Unengineered Weblog

PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND

Unengineered Weblog のテーマを自作した

このブログのスクリーンショット

このブログのテーマを自作してみました。

このテーマはお堅い(?)ソフトウェア技術書から発想しました。「プログラミング言語C」とかちょっと前のオライリーの本とかをイメージしています。背景色に書籍の紙をイメージしたクリーム色、フォントは明朝体、ボーダーラインは少なめ、文章中の強調とセクションタイトルは太字のゴシック体といった感じです。 ちなみに書籍に使われているの淡いクリーム色の紙を淡クリームキンマリというそうです。初めて知りました。

シンタックスハイライト

コードのシンタックスハイライトを無くしてみました。技術書にはシンタックスハイライトがついていないことも多く、それを真似してみました。ただコメントには色をつけました。

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func main() {
    for i := 0; i < 10; i++ {
        dur := time.Duration(rand.Intn(1000)) * time.Millisecond
        fmt.Printf("Sleeping for %v\n", dur)
        // Sleep for a random duration between 0-1000ms
        time.Sleep(dur)
    }
    fmt.Println("Done!")
}

go.dev/play の 「Sleep」サンプルを引用。

シンタックスハイライトを無くしてもそんなに読みづらくはないんじゃないかなと思っています。以前 Go の公式サイトにシンタックスハイライトが無いことが話題になりました。これも意識しています。とはいえ僕はコーディングするときはシンタックスハイライトが使えるなら使っています。シンタックスハイライトがあるとクオートの閉じ忘れの発見が簡単ですね。

blog.stenyan.jp

ただ diff の出力だけは着色によって見やすさが大きく変わるので、色をつけました。

--- main.go  2024-06-17 18:00:56
+++ main_new.go   2024-06-17 18:01:22
@@ -8,7 +8,7 @@
 
 func main() {
    for i := 0; i < 10; i++ {
-       dur := time.Duration(rand.Intn(1000)) * time.Millisecond
+       dur := time.Duration(rand.Intn(1000)) * time.Second
        fmt.Printf("Sleeping for %v\n", dur)
-       // Sleep for a random duration between 0-1000ms
+       // Sleep for a random duration between 0-1000s
        time.Sleep(dur)

横に長いコードの表示

横に長いコードを表示した時の挙動を次のようにしてみました。

  • ブラウザのウィンドウの横幅が長いときは全部表示

このブログのページのスクリーンショット。ブラウザのウィンドウの幅が広い。

  • ブラウザのウィンドウの横幅が短いときは横スクロール表示

このブログのページのスクリーンショット。ブラウザのウィンドウの幅が狭い。

全部表示にするとサイトのレイアウトが崩れることもありますが、見やすさを優先しました。ユーザーがブラウザのウィンドウを横に広げたときは広く表示されてほしいだろうからです。

長いコード列のサンプルです。

func QueryScan(ctx context.Context, db *sql.DB, query string, args ...any) iter.Seq2[scanfunc, error]

つくりかた

このテーマは hatena/Hatena-Blog-Theme-Boilerplate から改変して作りました。GitHub で公開しています。

github.com

つかいかた

テーマストアで公開しています。どうぞご利用ください。

blog.hatena.ne.jp

curl でステータスコードだけを得る

ある URL からのレスポンスのステータスコードだけ知りたいときがある。404 かどうかだけわかればよいときだったり。

そういうときは curl--write-out / -w オプションが使える。このオプションには %{http_status} などの変数を含んだテンプレートを渡す。このオプションを指定して実行すると、レスポンスボディが出力したあとにテンプレートを展開した文字が出力される。

everything.curl.dev

たとえば、つぎのように使う。

curl -w 'status code: %{http_code}\n' example.com
<!doctype html>
<html>
... omitted
</html>
status code: 200

レスポンスボディのあとに -w に渡したテンプレート status code: %{http_code}\n が展開されたものが出力された。ステータスコードが 200 であることがわかった。

テンプレートに使える変数はいっぱいある。

everything.curl.dev

シェルスクリプトステータスコードを変数に入れるときは次のようにする。

code=$(curl -sSL -o /dev/null -w '%{http_code}' example.com)

Rust で書かれたプログラムを Plan 9 上で動かす (Wasm で)

Rust は人気なプログラミング言語である。 しかしながら今は Rust は Plan 9 での実行をサポートしていない。

doc.rust-lang.org

したがって Plan 9 で Rust で書かれたプログラムを実行するには、コンパイラを移植するといった大変な作業が必要かと思っていた。しかしながら Wasm を使うことで、特に苦労なく実行することができたので紹介する。

この試みは id:lufiabbPlan 9 でも Rust を動かせるようにしようと言っていたがきっかけである。

環境

Ubuntu 23.10 がインストールされた AMD64 マシン上で作業している。Plan 9QEMU上で実行している。 Plan 9 の OS イメージと Plan 9 向けの Go (Go 1.21 binaries) は 9legacy.org からダウンロードしている。

9legacy.org

準備

Rust コードを Wasm へコンパイル

wasm ファイルを生成するのはホストの Linux 上で行った。僕はあまり Rust と Wasm について詳しくないので、下の説明をそのまま実行した。

wasmbyexample.dev

ここでつくったプログラムは次のことをする。

  • 標準出力に Hello world! と書き出す。
  • /helloworld/helloworld.txt を生成して、そのファイルに Hello world! と書き出す。

生成された wasi_hello_world.wasm を QEMU 上の Plan 9 へ送る。

wazero

Plan 9 に wazero をインストールする。

wazero は Pure Go で記述された Wasm runtime である。 Pure Go のおかげで何も修正せずに Plan 9 で動いた。 go.sum が空っぽなのがすごい。

wazero.io

普段 LinuxMac で Go のアプリケーションをインストールするのと同じように、 Plan 9 上で次のコマンドを実行する。

go install github.com/tetratelabs/wazero/cmd/wazero@latest

実行

さて実行しよう。 以下は Plan 9 のターミナル上で行ったもの。

% mkdir helloworld


% wazero run -mount `{pwd}^:/ wasi_hello_world.wasm
Hello world!


% cat helloworld/helloworld.txt
Hello world!

このようにとくにコードにパッチを当てずにそのまま実行することができた。

考察

Rust コードを Wasm へのコンパイルLinux 上でやっているのが、微妙な感じである。できれば Plan 9 上でコンパイルしたい。

Plan 9 はネットワークなどもファイルシステムで表現されているので、すでにいまの WASI (preview 1) でネットワークも扱えるのではないか。

このWasm を使った方法によって Plan 9 をサポートしない他の言語で書かれたアプリケーションも Plan 9 で動くのではないか。

Go の新機能 Range Over Func を使って database/sql の Query を楽にする

Go 1.22 から実験的機能として Range Over Func が実装された。 このブログでは Range Over Func とは何か、どういうものなのか、といった説明はしないので、知らない人は次のページを見てほしい。

go.dev

標準パッケージの database/sqlRDB を Query するのはボイラープレートまみれになる。

database/sql のドキュメントにあるサンプルコードは次のようになっている。

rows, err := db.QueryContext(ctx, "SELECT name FROM users WHERE age=?", age)
if err != nil {
    log.Fatal(err)
}
defer rows.Close()

names := make([]string, 0)
for rows.Next() {
    var name string
    if err := rows.Scan(&name); err != nil {
        log.Fatal(err)
    }
    names = append(names, name)
}
// Check for errors from iterating over rows.
if err := rows.Err(); err != nil {
    log.Fatal(err)
}

https://pkg.go.dev/database/sql#example-Rows から引用。

このコードは長い。 error チェックを3回もしているのが大きな要因だ。さすがに SELECT 文の実行という定型的なことに毎回こんなコードを書きたくない。面倒だ。

だから、このサンプルコードのような (*DB).QueryContext(*Rows).Next(*Rows).Scan の一連の流れを、関数として独立して、いかなるクエリでもボイラープレートを書かずに済ましたい。以下このような関数の名前を QueryScan にする。

よし、 QueryScan を書こう!

func QueryScan(ctx context.Context, db *sql.DB, query string, args ...any) ... {
    ...???
}

…って、あれ?この関数の実装ってそんな簡単じゃない?

そう、自明ではない問題がいくつかある。 もっとも大きな問題は (*Rows).Scan の引数がクエリごとに違うこと。型も引数の数もクエリごとに違う。これらの情報をどうやって渡すの?そして返り値の型はどうするの? reflection つかうの?あれ面倒くさいなこれ。となる。

普通の開発ではサードパーティSQL ライブラリを使うのがいいんだろうけど、今回それは置いておこう。また reflect パッケージで頑張ってつくるのも今回はしない。QueryScan 関数は画面に収まる程度にしたい。

このブログではそれらの問題を Range Over Func を使うことで、まあまあ解決した関数 QueryScan を提案する。それは (*DB).QueryContext(*Rows).Next の一連の流れを、関数としてまとめたものだ。

Range Over Func を使った実装

提案する関数は次のように定義される。

type scanfunc func(dest ...any)

func QueryScan(ctx context.Context, db *sql.DB, query string, args ...any) iter.Seq2[scanfunc, error]

この関数の実装は後で紹介する。

そして QueryScan 関数を使うと、このブログで最初に提示したサンプルコードは次のようになる。

names := make([]string, 0)
for scan, err := range QueryScan(ctx, db, `SELECT name FROM users WHERE age=?`, age) {
    if err != nil {
        log.Fatal(err)
    }
    var name string
    scan(&name) // scan には返り値がない。返り値を無視しているわけではない。
    names = append(names, name)
}

うおおおおおお

error のチェックが3回から1回に減って、コード行数も16 行から 9行に減っている。 コードが短い分読みやすいのではないだろうか。

この関数のポイントとしては (*Rows).Scan 自体は結局手で書かないといけないところだ。エラーの返り値がないラップされた関数 scan を使っているので多少は楽になっているものの、この部分の面倒さは残る。しかしながら、 (*Rows).Scan の引数がクエリごとに違うという問題は解決している。

では QueryScan はどのような実装になっているのだろうか。中身を見てみよう。

type scanfunc func(dest ...any) // 再掲

func QueryScan(ctx context.Context, db *sql.DB, query string, args ...any) iter.Seq2[scanfunc, error] {
    return func(yield func(scanfunc, error) bool) {
        rows, err := db.QueryContext(ctx, query, args...)
        if err != nil {
            yield(nil, err)
            return
        }
        defer rows.Close()

        for rows.Next() {
            var scanErr error
            if !yield(func(dest ...any) {
                scanErr = rows.Scan(dest...)
            }, nil) {
                return // ここは困る!scanErr が渡せないからだ。後述する。
            }

            if scanErr != nil {
                yield(nil, scanErr)
                return
            }
        }

        if err := rows.Err(); err != nil {
            yield(nil, err)
            return
        }
    }
}

QueryScan の実装は、最初に引用した pkg.go.dev にあるサンプルコードと似ていることに気がつくだろうか。むしろほとんど一緒である。違うのは (*Rows).Scan (をラップした関数)と error を yield に渡しているところだ。

yield 関数が呼ばれるたび、 range over func の for 文の中身が1回実行される。言い換えると、for のループが1回まわる。QueryScandb.QueryContext とそのエラー処理をしているところに注目してみよう。つぎの部分だ。

    rows, err := db.QueryContext(ctx, query, args...)
    if err != nil {
        yield(nil, err)
        return
    }

yield が1回だけ実行されるので、 QueryScan のユーザー(呼び出し側)から見ると、ループが1回だけまわって終了しているように見える。このループで渡される値は、(nil, err) だ。つまりエラーだけを渡している。

yield を呼び出している別のところも見てみよう。たとえば、 rows.Scan が失敗するとどうなるのか? あるループの中で、 scan が失敗すると、次にもう1回だけ scanErr を渡すためのループが実行される。yield(nil, scanErr)の部分だ。そしてループは終了する。

この QueryScan の問題点

提案しておいてなんだが、この QueryScan は面白いものの、Range Over Func のハックだなぁとは思う。オーバーエンジニアリングかもしれない。また問題点もある。

大きな問題点は、for で error が渡されたときだけ break すると仮定していることだ。 error が渡されたループで break するのは問題はない。(というか error が渡されたときは最後のループなので、 break してもしなくても同じだ。)しかしながら、正常系つまり nil error が渡された時に break されると困ることがある。なぜなら次のループで、 error が渡されるかもしれないが、それを無視することになるからだ。 前述した QueryScan の実装で「ここは困る!」とコメントした部分だ。

for scan, err := range QueryScan(...) {
    if err != nil {
        log.Print(err)
        break  // ここの break は問題がない。 return でもよい。
    }
    ...

    // もしかすると、次のループで error が渡ってくるかもしれない。
    // ここで break するとその error チェックができない。
    break
}

このような break をするケースはほとんどないだろうけど。

一応解決策はある。それは break ができてしまう range over func をやめること。この記事の前提を崩すように見えるが、そんなこともない。QueryScan の返り値を iter.Seq2 すなわち

func(yield func(scanfunc, error) bool)

ではなく、

func(yield func(scanfunc)) error

で書くということだ。 Range Over Func は yield の返り値の bool で break かどうか分岐していた。 yield の返り値がなくなったので、 break できなくなった。また error をかえす場所を変えた。後者をかえす QueryScan の実装は宿題とする。

for 文では書けなくなるが、考え方自体は変わっていない。たとえば次のように呼び出すことになる。

err := QueryScan(ctx, db, `SELECT name FROM users WHERE age=?`, age)(func(scan scanfunc) {
    var name string
    scan(&name)
    names = append(names, name)
})

こっちの方が好きな人がいるかもしれない。こちらはbreak の問題がなくなり、エラーの渡し方も標準的だ。僕もこっちの方が好きかもしれない。

まあこのブログは新機能の range over func をせっかくなら使ってみたという欲張りがあったので、最初は range over func を使った手法を紹介したのだった。

Go スライスのソート順を維持したまま要素を追加する

スライスのソートを維持したまま要素を追加する関数 appendSorted の実装

func appendSorted[S ~[]E, E cmp.Ordered](s S, e E) S {
        i, _ := slices.BinarySearch(s, e)
        return slices.Insert(s, i, e)
}

挙動

s = []int{}
s = appendSorted(s, 4)  // [4]
s = appendSorted(s, 1)  // [1 4]
s = appendSorted(s, 9)  // [1 4 9]
s = appendSorted(s, 5)  // [1 4 5 9]
s = appendSorted(s, 3)  // [1 3 4 5 9]
s = appendSorted(s, 11) // [1 3 4 5 9 11]

計算量

配列の長さを n とすると、BinarySearch は  O(\log n), Insert は  O(n) だから、 appendSorted は  O(n) 。一方、単純に append と sort する実装だと、  O(n \log n) だから、早くなっている。平衡二分木を使えば、もっと早くなりそうだけど、コードも短いし十分実用的でしょう。

Mackerel をファイルシステムにした

この記事ははてなエンジニア Advent Calendar 2023の 12月36日 2024年1月5日の記事です。

developer.hatenastaff.com

Mackerel をファイルシステムにしてみましょう。 Mackerel でファイルシステムを監視するのではありません。 Mackerel をファイルシステムにするのです。

じゃん

mackerelfs と言います。よろしくおねがいします。 github.com

/home/rmatsuoka/mackerel ディレクトリに mackerelfs をマウントしましょう(マウントの方法は後半説明します。)最初は ctl ファイルだけがあります。

$ ls -l 
total 0
--w--w--w- 1 rmatsuoka rmatsuoka 0 Jul 14  2042 ctl

さて Mackerel を操作するときは API キーが必要です。 mackerelfs にも API キーを登録しましょう。ここでは私のオーガニゼーション rmatsuoka のものを使います。登録するには ctl ファイルに以下のように書き込むだけです。

$ echo "new $MACKEREL_APIKEY" > ctl

すると mackerel ディレクトリに rmatsuoka というディレクトリが登場しました。

$ ls -l 
total 0
--w--w--w- 1 rmatsuoka rmatsuoka 0 Jul 14  2042 ctl
dr-xr-xr-x 1 rmatsuoka rmatsuoka 0 Jul 14  2042 rmatsuoka
$ ls -1F rmatsuoka/
hosts/
service/

rmatsuoka/hosts 以下にはホストがディレクトリとして一覧になっています。

$ ls -1F rmatsuoka/hosts/
ctl
rmatsuoka-desktop/
Ryuichis-MacBook-Pro.local/

このオーガニゼーションでは私用の端末 rmatsuoka-desktop, Ryuichi-MacBook-Pro.local を監視しています。

rmatsuoka-desktop のメトリック一覧は hosts/rmatsuoka-desktop/metrics を readdir すれば、ファイル(ディレクトリ)として見ることができます。

$ ls -F rmatsuoka/hosts/rmatsuoka-desktop/metrics/
cpu.guest.percentage/
cpu.idle.percentage/
cpu.iowait.percentage/
cpu.irq.percentage/
cpu.nice.percentage/
cpu.softirq.percentage/
cpu.steal.percentage/
cpu.system.percentage/
cpu.user.percentage/
ctl
custom.linux.context_switches.context_switches/
custom.linux.disk.elapsed.iotime_sda/
...

<metric_name>/1hour 以下にあるファイルを読むと過去一時間分のメトリックが取得できます。ホストのカスタムメトリックと同じ形式になっています。

ホストのカスタムメトリックを投稿する - Mackerel ヘルプ

$ cat rmatsuoka/hosts/rmatsuoka-desktop/metrics/cpu.system.percentage/1hour 
cpu.system.percentage   5.542648  1704379920
cpu.system.percentage   5.857077  1704379980
...

metrics/service を readdir しましょう。サービス一覧が見ることができます。このオーガニゼーションには devices というサービスがあります。

$ ls -1F rmatsuoka/service/
ctl 
devices/

devices のロール(ここでは desktop と laptop)とサービスメトリックは rmatsuoka/service/devices 以下にあります。

$ ls -1F rmatsuoka/service/devices/
ctl
desktop/
laptop/
metrics/
$ ls -1F rmatsuoka/service/devices/desktop/
ctl
memo
rmatsuoka-desktop/

ちなみにディレクトリの至るところにある ctl ファイルに reload と write するとそのディレクトリの情報を reload してくれます。

$ echo reload > rmatsuoka/service/devices/ctl

見終わったので、 rmatsuoka オーガニゼーションを mackerelfs から取り除きましょう。mackerelfs のルートにある ctl に書き込むだけです。

$ echo delete rmatsuoka > ctl
$ ls
ctl

ファイルシステムの形をしたアプリケーション

mackerelfs はファイルシステムですが、データを収納するストレージではありません。Mackerel の情報を取得するアプリケーション / インターフェースです。 古くから Unix の「ファイル」とは単にデータの塊を指すだけではなく、デバイスのインターフェースでもありました。今の Unix-like な OS でも /dev 以下にデバイスファイルがありますね。これによってユーザーからはファイルもデバイスopen, read, write など同じ方法で扱うことができます。この考えを更に推し進めた Plan 9 はコンピュータ上のプロセスや、ネットワーク、アプリケーションまで様々なリソースを(ファイルではなく)ファイルシステムとして表現しました。これによって「ネットワークにアクセスする」、「プロセスを見る」といった処理は専用のシステムコールではなく open, read などのファイル用のシステムコールで賄えるようになります。 Linux でもプロセスは /proc 以下にファイルシステムで表現されています。/proc があるおかげで Linux, Plan 9ps コマンドはシンプル(つまり /proc 以下にあるファイルを読むだけ)になっています。一方 /proc がない他の Unix-like では ps コマンドは SUID がついていて強力な権限でゴニョゴニョしている。

mackerelfs は Plan 9 のアプリケーションから着想を得てつくりました。Mackerel のホストやサービスといった情報はファイルにマッピングされ、ユーザーはファイルを操作するシステムコールだけで Mackerel をの情報を取得することができます。

ところでアプリケーションの UI / UX という観点から mackerelfs を見てみましょう。このブログの前半では mackerelfs を使ってみた様子を紹介しました。そこでは lscat, echo のように Unix の基本的なコマンドで完結しています。ファイルシステムなので当然ですね。Unix に慣れた人なら、すぐ使えるのではないかなと思います。 ctl ファイルの挙動は独特ですが。

Mackerel には mkr というCLI アプリケーションがあります。これもシンプルな操作で CLI から Mackerel を操作することができますが、一定程度使い方を学ぶ必要があります。

mackerel.io

さらに mackerelfs は、ファイルIO をつかって簡単にスクリプトを組み、操作を自動化させることができます。コマンドラインで行ったことをそのままスクリプトに書き起こせばよいので、手動と自動の間に連続性があります。また http リクエストができない AWK をつかってスクリプトを組むこともできます。

#!/usr/bin/awk -f

BEGIN {
    while (getline < "rmatsuoka/hosts/rmatsuoka-desktop/metrics/cpu.system.percentage/1hour")) {
        ...
    }
}
...

このようにファイルシステム型のアプリケーションは基本的なコマンドのみで操作が完結したり、自動化しやすかったりの特徴をもっており、使いやすいツールのカタチとしての可能性があると思います。

mackerelfs をマウントする方法

mackerelfs は

func FS() fs.FS

という関数だけが用意されたライブラリです。 fs.FS は Go の標準パッケージ io/fs に定義されたファイルシステムを抽象化したインターフェースです。

pkg.go.dev

fs.FS は標準パッケージの定義ですから、 fs.FS をコンピュータにマウントするサードパーティライブラリはいくつか存在すると思います。 例えば僕が作った ya9p は fs.FS をPlan 9ファイルシステム通信プロトコル 9P2000 によって操作できます。mackerelfs にはこの ya9p を使ったコマンド mackerel9p が用意してあります。

github.com

Linux, macOS などは 9P2000 は 9fans/plan9port にある 9pfuse を使えば FUSE 経由でマウントできます。(macOSFUSE を使うには macFUSE も必要です。)

$ go install github.com/rmatsuoka/mackerelfs/cmd/mackerel9p@latest
$ mackerel9p
$ mkdir mackerel
$ 9pfuse 'tcp!localhost!8000' mackerel

注意事項

  • mackerelfs はこのアドベントカレンダーに向けて大急ぎで作ったので機能が足りてなかったり挙動が不安定です。
  • mackerelfs は私 id:rmatsuoka 個人のプロジェクトとしてつくったプロダクトで、 Mackerel 公式として提供するものではありません。