Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Goで気に入っているテストの書き方と関数をモックするjest.fnのようなライブラリを作った話

以前からずっとある方法だけど、関数の内部で xxxInternal という形で実装を分けて、単体テストでは xxxInternal を使ってテストする書き方を気に入っています。どういうことかといえば、例えばKinesisにPutする以下のようなメソッドがあったとき、

type Writer struct{ ... }

func (w *Writer) BulkPost(ctx context.Context, metrics []*Metric) error {
    k := kinesisFromConfig(w) // Kinesisを初期化する
    records := make([]types.PutRecordsRequestEntry, len(metrics))
    for i, m := range metrics {
        records[i] = metricToRequestEntry(m)
    }
    input := &kinesis.PutRecordsInput{
        Records:    records,
        StreamName: aws.String("dummy"),
    }
    _, err := k.PutRecords(ctx, input)
    return err
}

このままでは k.PutRecords で本当のKinesisに投げてしまって困るので、本物のKinesisを参照する部分とそれ以外で、Writer.BulkPost()bulkPostInternal のように分けてしまいます。このとき、bulkPostInternalインターフェイスKinesisクライアントを受け取るようにしておきます。

type Writer struct{ ... }

func (w *Writer) BulkPost(ctx context.Context, metrics []*Metric) error {
    // 公開する実装は本物のKinesisクライアントを渡す
    k := kinesisFromConfig(w)
    return bulkPostInternal(ctx, metrics, k)
}

// recordsPutter はbulkPostInternalで必要なKinesisのメソッドだけ定義したインターフェイス。
type recordsPutter interface {
    // 本物のKinesisをそのまま渡せるようにKinesis SDKの型にあわせておく
    PutRecords(ctx context.Context, input *kinesis.PutRecordsInput, opts ...func(*kinesis.Options)) (*kinesis.PutRecordsOutput, error)
}

func bulkPostInternal(ctx context.Context, metrics []*Metric, putter recordsPutter) error {
    records := make([]types.PutRecordsRequestEntry, len(metrics))
    for i, m := range metrics {
        records[i] = metricToRequestEntry(m)
    }
    input := &kinesis.PutRecordsInput{
        Records:    records,
        StreamName: aws.String("dummy"),
    }
    _, err := putter.PutRecords(ctx, input)
    return err
}

これで自由にモックできるようになったので、テストでは関数にメソッドを生やしてモックします。

type putterFunc func(metrics []*Metric) (*kinesis.PutRecordsOutput, error)

// PutRecords はrecordsPutterインターフェイスのPutRecordsを実装します。
func (f putterFunc) PutRecords(ctx context.Context, input *kinesis.PutRecordsInput, opts ...func(*kinesis.Options)) (*kinesis.PutRecordsOutput, error) {
    return f(input)
}

func TestBulkPost(t *testing.T) {
    metrics := make([]*Metric, 10)
    bulkPostInternal(context.Background(), metrics, putterFunc(func(metrics []*Metric) (*kinesis.PutRecordsOutput, error) {
        return nil, errors.New("error")
    }))
}

このように分割すると、

  • モックの差し替えも関数を書くだけなので簡単
  • テストしたい対象の近くにモックの実装があるので、モックが返している値がすぐ見える
  • パッケージの外からは必要なインターフェイスしか見えない

特に最後の項目は、モックのためのインターフェイスをエクスポートしていないので、モックのための関数や型が見えていないところがいいですね。

type Writer struct{ ... }

func (w *Writer) BulkPost(ctx context.Context, metrics []*Metric) error

ライブラリ作成のモチベーション

やりたいのは上記の内容なのだけども、テストのたびに func(metrics []*Metric) (*kinesis.PutRecordsOutput, error) を手書きするのも少し面倒になるので、jest.fn みたいなライブラリが欲しいなと思いました。上記で putterFunc を実装した関数でいうと、以下のように書きたい。

fn := mock.Fn().ReturnOnce(nil, errors.New("error"))
bulkPostInternal(context.Background(), metrics, putterFunc(fn))

なのでGoのモックライブラリをいくつか調べたけれど、少なくとも有名なライブラリはインターフェイスをモックするものしか見つけられませんでした。調べるのも飽きてきた頃にたまたま「MOck FUnctionでmofu」って可愛い名前を思いついたので、自分の欲しいものを作りました、というのが以下のライブラリです。

github.com

Goの型パラメータによる制約などがあるので上で書いた欲しいものとは多少違いますが、TestBulkPost の例は

m := mofu.MockFor[putterFunc]().ReturnOnce(nil, errors.New("error"))
fn, r := m.Make()
bulkPostInternal(context.Background(), metrics, fn)
// 呼び出し回数のテスト、gtは最近気に入っている github.com/m-mizutani/gt です
gt.Equal(t, r.Count(), 1)

のように書けます。他にも、モックを書くときに必要そうなものは組み込んでいて、呼び出した回数によって結果を変えたい場合は ReturnOnce を繋げて書きます。

fn, r := m.
    ReturnOnce(&kinesis.PutRecordsOutput{}, nil). // 1回目の呼び出し
    ReturnOnce(nil, errors.New("error")). // 2回目
    Panic("panic"). // 3回目以降はずっとpanic
    Make()

引数によって返す値を変える場合は When で条件を書きます。

m.When(mofu.Any, &kinesis.PutRecordsInput{}).ReturnOnce(...).Make()

上記では MockFor で型を指定しましたが、具体的な関数があってそれをモックしたい場合のために MockOf もあります。

now, _ := mofu.MockOf(time.Now).Return(time.Date(...)).Make()

メソッドが複数の場合どうするか

ここまで恣意的にメソッドが1つだけの例を挙げていましたが、実際はメソッドが複数必要な場合もあります。mofu では、インターフェイスの実装に必要なだけ関数を作って合成する方法を採用しました。

read := mofu.MockOf(io.Reader.Read)
write := mofu.MockOf(io.Writer.Write)
m := mofu.Implement[io.ReadWriter](read, write)

ここで mio.ReadWriter を動的に実装しています。reflect だけでは動的なメソッド実装できないのですが、karamaruさんのポストを読んでいて知った go-dyno というパッケージが unsafego:linkname コンパイラディレクティブなども使って動的なインターフェイス実装を実現しています。

github.com

具体的には go-dyno では Dynamic という関数が func(meth reflect.Method, args []reflect.Value) []reflect.Value というシグネチャでコールバックを受け取り、メソッドが呼ばれたときにこの関数で動的に挙動を切り替えるようになっています。もともとはovechkin-dm/mockioというモックライブラリのために作られたようで、mofu と用途が被っていて気まずいなと思いますが、mockioインターフェイス主体でこちらは関数主体なので、まあいいかな。

明日から松山でRuby Kaigi 2025が開催されるので地元の好きな食べ物紹介する

Ruby Kaigi 2025が開催されるようです。

rubykaigi.org

自分自身は過去に社内ツールとしてRedmineやGitlabを運用したくらいしかRubyとの関わりはありませんが、せっかく松山で開催される*1ので他県の人にもおすすめできる食べ物を紹介しようかなと思いました。

私自身は松山よりももう少し南の出身なので宇和島由来のものが多いですが、松山でも食べられます。

鯛めし(宇和島風)

鯛めしは、鯛ごと炊き込んだものと、鯛の刺身がご飯の上に乗っている2種類あって、宇和島風は後者の刺身が乗っている方です。

www.jalan.net

確か松山空港の2階にも食べられる店があったと思います。炊き込むのもおいしいけれど個人的にはこっちの方が好きですね。

じゃこ天

何の魚なのかは知らないけれど、なんか魚の身をすりつぶした練り物です。

www.maff.go.jp

味が付いているのでそのまま食べるのが好みですが、醤油をつけても美味しいと思います。だいたい蒲鉾と同じ扱いなので料理に入れてもいいですね。駅でも空港でもだいたい売ってます。

どら一(いち)

ハタダという地元の企業が展開している和菓子です。最後の「一」は長音記号ではなくて漢数字の1。

www.hatada.co.jp

餡とクリームが入っていて甘そうなんだけど、そこまで甘くなくないので甘いものが苦手という人でなければ食べやすいんじゃないかなと思います。毎年春(5月?)から秋くらいまで販売停止するのだけど、今はまだ売っているのでちょうどいい機会ですね。これも松山空港などで普通に売っています。

3日間のお供にぜひ。

*1:交通事情あまり便利じゃないと思うけどなんで松山なのか全然分かってない

GitHubでコミットの署名を必須にする

先日、比較的広く使われているGitHub Actionsであるtj-actions/changed-filesに不正なコードが混入された問題があった。インシデントの発生した原因は後で詳しい人が書いてくれると思うけれど、少なくとも今(2025-03-16)の理解では、bot用のPersonal Access Token(PAT)が適切に管理されていなかったことによるものらしい。

なので対策としてはPATの管理方法に向くのが筋だとは思うのだが、オープンなPRとその作者のPATがあれば悪意のある変更を入れられるんじゃないか、というのが気になってしまった。例えば過去に何度もコントリビュートしてくれている人のPRに自動生成ファイルが含まれていたとき、その人が作成した repo の権限を持ったPAT*1が運悪く漏洩していたなら、第三者が後からコミットを書き換えられるのではないか。レビューするときに自動生成ファイルも全部見るかというと、疲れているときは読み飛ばすこともあると思う。

現実には、運悪くPATが漏洩することは多くないと思うけれども、起きてからでは遅いので、自分のコミットには署名を必ず付けよう、@lufia のコミットに署名がなければ弾いてほしい、という気持ちで手順を調べた。GPG鍵の運用については以下の記事を参考にした。

準備

まずは gnupg パッケージ(Arch Linuxの場合)が必要になるが、pacman の依存に入っているので普通はインストールされていると思う。

run0 pacman -S gnupg

XDG Base Directoryにこだわりがあるなら、GNUPGHOME を設定して ~/.local/share/gnupg に変更する。この環境変数が未設定の場合は ~/.gnupg にファイルが作られる。

export GNUPGHOME="${XDG_DATA_HOME:-~/.local/share}/gnupg"
mkdir -m 700 -p $GNUPGHOME

鍵を作成するためには gpg-agent が実行されている必要があるのだが、GNUPGHOMEディレクトリを変更すると、エージェントと通信するUNIXドメインソケットのパスが以下のように変わってしまう。

$ gpgconf --list-dirs | grep agent-socket
agent-socket:/run/user/60331/gnupg/S.gpg-agent

$ export GNUPGHOME=~/.local/share}/gnupg
$ gpgconf --list-dirs | grep agent-socket
agent-socket:/run/user/60331/gnupg/d.i11o9nniqjp5zmemejdfxw8f/S.gpg-agent

そうすると、gnupg パッケージが用意してくれている /usr/lib/systemd/user/gpg-agent.socket%t/gnupg/S.gpg-agent へのアクセスを扱う構成なので、GNUPGHOME 変更後のパスと食い違いが生じてしまって意図した動作をしない。なので systemctl edit --usergpg-agent.socket ユニットのパラメータを更新する。

[Socket]
ListenStream=
ListenStream=%t/gnupg/d.i11o9nniqjp5zmemejdfxw8f/S.gpg-agent

GPGのマスターキーとサブキー

GPGにはマスターキーと、マスターキーによって署名されたサブキーというものがある。雑にいえばUnixにおける root と一般ユーザーみたいなもので、普段はサブキーを利用する運用が安全らしい。マスターキーが漏洩してしまうともう何もできることは無いが、この運用なら仮にサブキーが漏洩してもマスターキーは無事なのでサブキーの失効も可能になる。

以下ではマスターキーと、コミット署名用のサブキー1つを作成するための手順を紹介する。

マスターキーの作成

対話モードでは以下のような流れとなる。デフォルトは ECC (sign and encrypt) だけど証明(Certify)のみ必要なので --expert オプションを与えて署名(Sign)を無効化した。それ以外は好きなものを選べば良い。

$ gpg --full-generate-key --expert
Please select what kind of key you want:
   (1) RSA and RSA
   (2) DSA and Elgamal
   (3) DSA (sign only)
   (4) RSA (sign only)
   (7) DSA (set your own capabilities)
   (8) RSA (set your own capabilities)
   (9) ECC (sign and encrypt) *default*
  (10) ECC (sign only)
  (11) ECC (set your own capabilities)
  (13) Existing key
  (14) Existing key from card
Your selection? 11

Possible actions for this ECC key: Sign Certify Authenticate
Current allowed actions: Sign Certify

   (S) Toggle the sign capability
   (A) Toggle the authenticate capability
   (Q) Finished

Your selection? S <--- Signを無効化

Your selection? Q <--- 完了

Please specify how long the key should be valid.
         0 = key does not expire
      <n>  = key expires in n days
      <n>w = key expires in n weeks
      <n>m = key expires in n months
      <n>y = key expires in n years
Key is valid for? (0)

対話モードが不便な場合、gpg --batch オプションを使うと非対話モードで実行することもできるらしい。バッチ処理で必要となるファイルの内容は公式のマニュアルを参照。

鍵のリストを表示

gpg --list-keys コマンドを使う。うまく作れている様子がみえる。

$ gpg --list-keys
/home/lufia/.local/share/gnupg/pubring.kbx
------------------------------------------
pub   ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
uid           [ultimate] example <user@example.com>

$ gpg --list-secret-keys
/home/lufia/.local/share/gnupg/pubring.kbx
------------------------------------------
sec   ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
uid           [ultimate] example <user@example.com>

コマンド実行結果の BF6A9... がマスターキーのフィンガープリント(指紋)と呼ばれる。また、[C] と書かれている部分は鍵が持っている役割を表している。C 以外には以下のフラグが存在する。

  • S: Signing (署名)
  • C: Certify (証明)
  • E: Encrypt (暗号化)
  • A: Authenticate (認証)

上記の例は --full-generate-key のとき証明(Certify)のみを選んだのでマスターキーの秘密鍵と公開鍵の2つしか作られていないが、暗号化(Encrypt)を選んだ場合は追加で暗号化(E)の機能を持ったサブキーも作られる。

マスターキーをバックアップ

普段使う環境に置いておくと紛失した場合に困るので、マスターキーは安全な場所に置いておき必要なときにだけ取り出したい。そこでまずはマスターキーの秘密鍵をバックアップする。--armor オプションはテキスト形式で出力する。

gpg --armor --export-secret-keys -o secret-key 'user@example.com'

コマンドが完了すると secret-keyPGP PRIVATE KEY BLOCK ヘッダを持つ秘密鍵が出力されるので、これは物理的に安全な場所へ保管する。

参考: --export-secret-keys オプションはマスターキーだけではなく、サブキーの秘密鍵も全てエクスポートする。上から順に実行している場合は、この時点ではマスターキーしかないので問題ないけれど、他の鍵がある状態でマスターキーの秘密鍵だけバックアップしたい場合は困る。その場合はフィンガープリントの末尾に ! を付けて実行すればいい*2

gpg -a --export-secret-keys BF6A9F34814124AE28BD01597C63237ED4C24B72!

Git署名用サブキーの追加

次に、コミットの署名をするためのサブキーを追加する。サブキーの追加は gpg --edit-key を使う。--edit-key はユーザーIDを要求するが、ユーザーIDはArchWikiのGnuPGによると、鍵のフィンガープリント、メールアドレス、名前の一部などが使えて、結果的に鍵が一意に特定できればなんでもいいらしい。試した限りでは大文字小文字も区別しない。

$ gpg --edit-key 'user@example.com' --expert

gpg> addkey
Please select what kind of key you want:
   (3) DSA (sign only)
   (4) RSA (sign only)
   (5) Elgamal (encrypt only)
   (6) RSA (encrypt only)
  (10) ECC (sign only)
  (12) ECC (encrypt only)
  (14) Existing key from card
Your selection? 10
Please select which elliptic curve you want:
   (1) Curve 25519 *default*
   (4) NIST P-384
   (6) Brainpool P-256
Your selection? 1
Please specify how long the key should be valid.
         0 = key does not expire
      <n>  = key expires in n days
      <n>w = key expires in n weeks
      <n>m = key expires in n months
      <n>y = key expires in n years
Key is valid for? (0) 1y
Key expires at Mon Mar 16 01:45:11 2026 JST
Is this correct? (y/N) y
Really create? (y/N) y

最後に save を忘れると保存されない。逆に中止したい場合は quit とする。

gpg> save

マスターキーを普段の環境から取り除く

マスターキーを削除する方法はいくつか存在するようだったが、簡単そう*3なのでKeygripを使う方法で取り除く。Keygripは --with-keygrip オプションを付けると出力内容に含まれる。

$ gpg --with-keygrip --list-key BF6A9F34814124AE28BD01597C63237ED4C24B72
pub   ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
      Keygrip = 1D3FDCE6C25A225890E03665FA3EBEFE927D3337
uid           [ultimate] example <user@example.com>
sub   ed25519 2025-03-15 [S] [expires: 2027-03-15]
      Keygrip = 312EB9F5918CF8CDD8EAA1A0CDFCA906D1B67037

マスターキーのKeygripが分かったら、Keygripを使って鍵を削除する。

gpg-connect-agent 'DELETE_KEY 1D3FDCE6C25A225890E03665FA3EBEFE927D3337' /bye

これで --list-secret-keyssec# と表示され、利用できない状態となる。公開鍵の方は変わらない。

$ gpg --list-keys
/home/lufia/.local/share/gnupg/pubring.kbx
------------------------------------------
pub   ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
uid           [ultimate] example <user@example.com>

$ gpg --list-secret-keys
/home/lufia/.local/share/gnupg/pubring.kbx
------------------------------------------
sec#  ed25519 2025-03-15 [C]
      BF6A9F34814124AE28BD01597C63237ED4C24B72
uid           [ultimate] example <user@example.com>

secpub の意味は次の通り。

  • sec: 秘密鍵(Secret key)
  • ssb: サブキーの秘密鍵(Secret Subkey)
  • pub: 公開鍵(Public key)
  • sub: サブキーの公開鍵(Public Subkey)
  • sec#: 秘密鍵だけど鍵がローカルに存在しない状態

Gitのコミットに署名する

GitでGPGを使って署名するためには、サブキーのIDが必要なので調べる。

$ gpg --list-keys --keyid-format=long
...
sub   ed25519/773E2B27856EDC91 2025-03-15 [S] [expires: 2027-03-15]

ここで表示された 773E2B27856EDC91 はサブキーのフィンガープリントから末尾8バイトだけを取り出したもの*4で、これをGitの設定に追加する。

git config --global user.signingkey 773E2B27856EDC91
git config --global commit.gpgsign true

公開鍵を以下のコマンドで出力してGitHubに登録する。

gpg --armor --export 773E2B27856EDC91

以上でサブキーを使ったコミットへの署名が行われる。

コミットの署名は --show-signature オプションで見れる。

git log --show-signature

GitHubリポジトリでコミットの署名を強制したい

署名されていないコミットを弾きたい場合は、GitHubの保護ブランチ機能に Require signed commits オプションがあるので有効にするといい。これを設定しておくと、未署名のコミットを保護されたブランチにpushしたとき以下のようなエラーで弾かれる。

$ git push
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: Review all repository rules at https://github.com/lufia/plug/rules?ref=refs%2Fheads%2Fmain
remote: 
remote: - Commits must have verified signatures.
remote:   Found 1 violation:
remote: 
remote:   b254e04b07d138e4bfbe6a9743a7e1941d1be5bc
remote: 
To https://github.com/lufia/plug.git
 ! [remote rejected] main -> main (push declined due to repository rule violations)

同様に、署名のないコミットがプルリクエストに含まれていたときも、以下のようなエラーでマージがブロックされる。

未署名のコミットが混ざっているのでマージがブロックされている様子

*1:HTTPでGitHubにpushしている場合は該当する

*2:付けない場合はマッチする鍵を広く選択するらしい

*3:後から知ったけど gpg --delete-secret-keys の方が簡単かも

*4:フルサイズを表示したければ --with-subkey-fingerprint オプションを使う

sd-journalライブラリでsystemdのジャーナルログを読む

systemd の管理するログは /var/log/journal または /run/log/journal 以下に出力されていますが、これらのログは独自のバイナリ形式で保存されているため、プログラムからログを読みたい場合は以下のような手段を経る必要があります。

  1. journalctl -o exportJournal Export Formatとしてログを読む
  2. systemd-journal-gatewayd.serviceを経由してJournal JSON Formatとしてログを読む
  3. Native C API(sd-journal)を使ってログを読む

公式にJournal File Formatというドキュメントでバイナリ形式のフォーマット仕様を読めますが、ドキュメントの最初に

Or, to put this in other words: this low-level document is probably not what you want to use as base of your project. You want our C API instead! And if you really don't want the C API, then you want the Journal Export Format instead! This document is primarily for your entertainment and education.

のように書かれていて、バイナリログを直接読むことは推奨されていません。そこで、この記事では sd-journal というCのライブラリを使ってジャーナルを読む実装を紹介します。ここではCを使っていますが、Goからcgoを使ってもいいですし、他の言語からCを呼び出すときにも参考になるでしょう。

コードのコンパイル方法

Cコンパイラにはリンクするライブラリを与えるフラグがあると思うので、それで libsystemd を指定してください。

$ gcc -lsystemd -o journalread main.c

複数のアーキテクチャ用にクロスコンパイルする場合はGitHub ActionsでC言語のコードをクロスコンパイルするを読んでください。

ログを読む

ファイルと同様に、ジャーナルログを読むときはログを開く手続きが必要です。

#include <systemd/sd-journal.h>

int
main(void)
{
    sd_journal *j;

    if(sd_journal_open(&j, SD_JOURNAL_LOCAL_ONLY|SD_JOURNAL_SYSTEM) < 0)
        fatal("failed to open journal: %m\n");

    /* ここに j を操作してログを読むためのコードを書く */

    sd_journal_close(j);
    return 0;
}

sd_journal_openに渡している SD_JOURNAL_LOCAL_ONLY フラグは /var/log/journal 以下を参照するフラグです。/run/log/journal 以下を参照するための SD_JOURNAL_RUNTIME_ONLY フラグもあります。

もうひとつ、SD_JOURNAL_SYSTEM フラグを渡すとシステムサービスやカーネルのログを対象とします。ユーザーサービスのログを扱うための SD_JOURNAL_CURRENT_USER フラグもあります。journalctl コマンドでいえば、それぞれ --system オプションと --user オプションに相当します。

ログを読み進める

1行ずつログを読むときはsd_journal_nextsd_journal_get_dataを使います。

int rv, e;
char *s, *m;
size_t n;

sd_journal_set_data_threshold(j, 0);
while((rv=sd_journal_next(j)) > 0){
    e = sd_journal_get_data(j, "MESSAGE", (void*)&s, &n);
    if(e == -ENOENT) /* フィールドが無い場合はENOENTが返る */
        continue;
    if(e < 0)
        fatal("failed to get MESSAGE: code=%d\n", -e);

    /* sは "MESSAGE=xxx" のようにフィールド名も含んでいるので8文字飛ばす */
    printf("message = %*s\n", n-8, s+8);
}
if(rv < 0)
    fatal("failed to move next: %m\n");

デフォルト設定の場合、sd_journal_get_data は64KBを越える長さのテキストを途中で切り詰めます。切り詰めるサイズを変更したい場合は sd_journal_set_data_threshold で上限となるサイズを設定します。0を設定すると無制限となります。

あとは sd_journal_next でカーソルを進めて、sd_journal_get_data でカーソル位置のログを読むことになりますが、このとき1つのログには複数のフィールドが存在しています。例えば以下のような用途でフィールドがあります。

  • MESSAGE: ログ出力したメッセージそのもの
  • PRIORITY: 1(alert)、6(info)のようなエラーレベルの数値、0が最高で7が最低
  • UNIT: ログを発行したユニット名
  • _SYSTEMD_UNIT: ログを発行したユニット名

このようなフィールド名を指定して、ログから必要なデータを読んでいきます。このとき、sd_journal_get_data が返したデータは、次の sd_journal_next で上書きされてしまうので、別の場所で参照したい場合は自分でコピーを作らなければいけません。フィールド名は上記の他にもいっぱいあるので、詳細はsystemd.journal-fieldsのマニュアルを見てください。

sd_journal_get_data が返すエラーの種類や詳細はマニュアルに書かれています。

名前がアンダースコア(_)で始まるフィールド

アンダースコアが1つだけの場合、そのフィールドは systemd によって保護されたフィールドです。これらの値はユーザーのコードから変更できません。

アンダースコアが2つ続いている場合、ログのアドレスをシリアライズしたものを意味します。これらのフィールドは、以下で紹介するフィルタの条件には使えません。

色々なUNITフィールド

ところで、上で UNIT_SYSTEMD_UNIT を挙げましたが、多くのsystemサービスでは

_SYSTEMD_UNIT=dbus-broker.service

とだけ設定されるものが多いのですけれど、btrfs-scrub@-.timer の場合は

_SYSTEMD_UNIT=init.scope
UNIT=btrfs-scrub@-.timer

のように2つ設定されます。また、pipewire.service の場合は、

_SYSTEMD_UNIT=user@60331.service
_SYSTEMD_USER_UNIT=pipewire.service

となります。最後に user サービスの gvfs-metadata.service が記録するログエントリは

_SYSTEMD_UNIT=user@60331.service
_SYSTEMD_USER_UNIT=init.scope
USER_UNIT=gvfs-metadata.service

です。欲しいユニット名がどこに出現するかを確認したうえでフィールドを読むことをおすすめします。

カーソル位置を記憶する

カーソルはsd_journal_get_cursorで取得できます。

char *cursor;

if(sd_journal_get_cursor(j, &cursor) < 0)
    fatal("failed to get cursor: %m\n");

ここで取得したカーソルはただの文字列なので、そのままファイルに保存すればいいでしょう。次の実行でカーソル位置まで移動したい場合はsd_journal_seek_cursorで移動します。

if(sd_journal_seek_cursor(j, cursor) < 0)
    fatal("failed to seek to the cursor: %m\n");
if(sd_journal_next(j) < 0)
    fatal("invalid cursor: %m\n");

// カーソル位置に移動できたか調べる:
// >0: カーソル位置に移動した
// =0: カーソルそのものは無かったが近くに移動した
// <0: なんらかのエラーが起きた
if(sd_journal_test_cursor(j, cursor) < 0) // 0の場合は一致していないが近くには移動した
    fatal("invalid cursor: %m\n");

注意点として、カーソルを移動しただけではログエントリの読み込みをしていないので sd_journal_get_data 等が使えません。なので移動した後は必ず sd_journal_next または同等の処理を行いましょう。また、ここで読めるログは「sd_journal_get_cursor を取得した時点のログ」と同じものです。なので「カーソルの次に書かれたログ」を読みたい場合は sd_journal_next が2回必要です。

ログをフィルタする

sd_journal_get_data でフィールドを取得してからプログラム上でフィルタしてもいいのですが、sd-journal にはsd_journal_add_matchというライブラリ側でフィルタしてくれる仕組みが用意されています。

/* エラーの場合は負数を返しますが、ここではエラー処理を省略します */
sd_journal_add_match(j, "SYSLOG_FACILITY=9", 0);
sd_journal_add_match(j, "PRIORITY=5", 0);
sd_journal_add_match(j, "PRIORITY=6", 0);

とてもシンプルな関数なんですが、動作は非常に難解です。この関数を複数回実行した場合は次のルールに従います。

  • 異なるフィールドの条件を与えると、それぞれ AND として結合する
  • 同じフィールドの条件があれば、それぞれを最も高い優先順位の OR で結合する
  • 完全に同じ条件は1つにまとめる

これを上のコードに当てはめると、次のように解釈できます。

SYSLOG_FACILITY=0 AND (PRIORITY=0 OR PRIORITY=1)

任意のOR条件を追加する

ここで sd_journal_add_disjunction を呼び出すと、それまでに構築した式を OR で繋げて、新しい式の構築を開始します。

sd_journal_add_disjunction(j);

結果は次のような式になります。

(
    SYSLOG_FACILITY=9 AND (PRIORITY=5 OR PRIORITY=6)
) OR (
    -- まだ何もないが、以降sd_journal_add_matchするとここに入る
)

SYSLOG_FACILITY=0 を追加してみましょう。

sd_journal_add_match(j, "SYSLOG_FACILITY=0", 0);

結果です。

(
    SYSLOG_FACILITY=9 AND (PRIORITY=5 OR PRIORITY=6)
) OR (
    SYSLOG_FACILITY=0
    -- 次にsd_journal_add_matchすると、ここに新しく式が追加される
)

任意のAND条件を追加する

最後、sd_journal_add_conjunctionAND 条件を追加します。OR よりも優先順位が高いので、より大きな範囲で AND を構築します。

sd_journal_add_conjunction(j);
sd_journal_add_match(j, "_SYSTEMD_UNIT=systemd-timesyncd.service", 0);

最終的なフィルタ式です。

(
    (
        SYSLOG_FACILITY=9 AND (PRIORITY=5 OR PRIORITY=6)
    ) OR (
        SYSLOG_FACILITY=0
    )
) AND (
    (
        _SYSTEMD_UNIT=systemd-timesyncd.service
        -- 次にsd_journal_add_matchすると、ここに新しく式が追加される
    )
    -- 次にsd_journal_disjunctionすると、ここに新しくOR式が追加される
)
-- 次にsd_journal_conjunctionすると、ここに新しくAND式が追加される

GitHub ActionsでC言語のコードをクロスコンパイルする

GitHub ActionsではARM64ランナーも公開されつつありますが、ここでは gcc を使ったクロスコンパイルを説明します。この記事ではホスト*1アーキテクチャx86_64、ターゲット*2アーキテクチャarm64 としていますが、他のターゲットでも同様の手順となるでしょう。また、C言語を前提に書いていますが、他の言語でもライブラリをリンクする場合は参考になるんじゃないかなと思います。

aptリポジトリの準備

まずはターゲットとなるアーキテクチャをパッケージ管理システムに追加します。

sudo dpkg --add-architecture arm64

GitHub Actionsのubuntuランナーにはx86パッケージのリポジトリしか設定されていないので、ARMパッケージがあるaptリポジトリのURLを/etc/apt/sources.list.d/arm64.listに設定します。

deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted

また、GitHub Actionsランナーの /etc/apt/sources.listアーキテクチャを制限していないので、このままだと arm64 パッケージも探してしまって警告が出力されます。どのみちデフォルトのリポジトリarm64 パッケージは用意されていないので、探さないよう arch= オプションを設定しておきます。

sudo sed -i -E '/^deb(-src)? ([^[])/s/ / [arch=amd64,i386] /' /etc/apt/sources.list

sources.list の各項目がどんな意味なのかは以下の記事が分かりやすいと思います。

kujira16.hateblo.jp

コンパイラとライブラリのインストール

ここまで終われば、コンパイラと必要なライブラリをインストールしましょう。

sudo apt update
sudo apt install -y gcc-aarch64-linux-gnu
sudo apt install -y libsystemd-dev libsystemd-dev:arm64 # libsystemdをリンクしたい場合の例

このとき、 gcc では aarch64 の部分がターゲーットのアーキテクチャ名になります。また、リンクするライブラリはパッケージ名の後ろに :arm64 のようにアーキテクチャ名を追加します。

arm64とaarch64の関係

ここまでで、 arm64aarch64 といった名称を使いましたが、何が違うのでしょうか。

これらの名前は、aarch64 は命令セットの名前に、arm64 はARMプロセッサの64bitアーキテクチャに由来します。クロスコンパイルする状況においては結局どちらも64bit ARMを意味していますが、歴史的な事情によって使っている名称が異なります。

以下は各システムがどちらの表記を使っているかまとめた表です。せっかくなのでx86の表記も加えてみました。

システム x86 ARM
GCC x86_64 aarch64
Clang x86_64 aarch64 1
GNU x86_64 arm64
Debian/Ubuntu amd64 arm64
RHEL x86_64 aarch64
Plan 9 amd64 arm64
Go amd64 arm64
Windows x64 2 arm64

なので、gccパッケージのターゲット名は aarch64 となっているし、ライブラリのアーキテクチャ名はDebian/Ubuntu命名に沿うので libsystemd:arm64 表記が使われているわけですね*3

ソースコードをビルドする

ビルドするときはターゲット用の gcc を使えばいいだけです。 make を使っている場合は CC 変数にセットします。

make CC=aarch64-linux-gnu-gcc

ライブラリのリンク等は、ターゲット用の gcc がターゲット用のライブラリを探してくれるので、開発者が意識することはありません。

ワークフロー

ここまでのワークフローをまとめます。

steps:
  - name: Add an architecture to install packages
    run: |
      sudo dpkg --add-architecture arm64
      sudo sed -i -E '/^deb(-src)? ([^[])/s/ / [arch=amd64,i386] /' /etc/apt/sources.list

      source /etc/lsb-release
      o="$(mktemp)"
      url='http://ports.ubuntu.com/ubuntu-ports'
      echo "deb [arch=arm64] $url $DISTRIB_CODENAME main restricted" >>"$o"
      echo "deb [arch=arm64] $url $DISTRIB_CODENAME-security main restricted" >>"$o"
      echo "deb [arch=arm64] $url $DISTRIB_CODENAME-updates main restricted" >>"$o"
      sudo install -m 644 "$o" /etc/apt/sources.list.d/arm64.list
  - name: Build sources
    run: |
      sudo apt update
      sudo apt install -y gcc-aarch64-linux-gnu
      sudo apt install -y libsystemd-dev libsystemd-dev:arm64
      make CC=aarch64-linux-gnu-gcc

リポジトリの設定部分はlufia/workflows/.github.actions/setup-multiarchとして複合ワークフローにしておいたので、よければ使ってください。

steps:
    - uses: lufia/workflows/.github/actions/setup-multiarch@v0.5.0
      with:
        arch: arm64

  1. AppleバックエンドのことをARM64と呼んでいたがAArch64に統合された
  2. x86-64 表記もあるが、x64 の方が多いと思う

*1:ソースコードコンパイルする側

*2:ビルドされたバイナリを実行する側

*3:理屈は分かるけど紛らわしいので統一してほしい

Goで関数呼び出しを繋げてパイプライン演算子を再現する

最近、Goで関数呼び出しを無限に繋げる書き方を気に入っています。文字で書いても伝わらないと思うので実例を挙げると、例えばこういう書き方。

repeat(yield)("しか", 1)("のこ", 3)("こし", 1)("たん", 2)

どうやって実現しているのかというと、自身を参照する型を作ればいいだけです。

type Emitter func(s string, n int) Emitter

func repeat(yield func(string) bool) Emitter

完全なコード例は以下のGo Playgroundを見てください。

このような、関数呼び出しを繋げる方法でパイプライン演算子を再現するとどうなるか?と思って試してみた記事です。

パイプラインを作る

パイプライン演算子を使うと、 c(b(a(10))) という呼び出しを 10 |> a |> b |> c のように書けます。左から右に読めるので、処理の流れを追いやすくなりますね。

話は変わって、いま所属している企業では関数型ドメインモデリングの読書会が行われています。この書籍ではパイプライン演算子を多用していますが、Goにはパイプライン演算子がありません。無くてもそれほど困らないものの、パイプライン自体は関数を無限に繋げていくものなので、最初に紹介した方法を使ってパイプラインを実現できないかなと考えました。

// 空文字列ならエラー
func require(s string) (string, error)

// printしてsを返す
func tee(s string) string

// 以下のように書けると嬉しいが、このまま実現はできない
result := pipe("hello world")(require)(tee)(strings.ToUpper)

なんだけど、実際は色々な課題があって上記のようには実現できません。

  • パイプラインの初期値や、計算途中の状態を保存する場所がない
  • 関数の戻り値が関数なので、最後に結果を返す手段がない
  • requirestringerror を返すので型が異なる

試行錯誤の結果、dmmf-go/internal/pipeでは少し不恰好だけど近しいものを実現できました。

result, err := pipe.Value("hello world").Catch(require)(tee)(strings.ToUpper).ValueErr()

以下で、実現のためにやったことの一部を紹介します。

計算の状態を保存する

まず、ここで実装するパイプラインでは関数呼び出しを繋げたいので、パイプライン型の基底型は関数です。

type pipe[T any] func(f func(v T) T) pipe[T]

このように定義すると pipe(f1)(f2) のように連続して呼び出せるのですが、 pipe[T] は関数なので任意の値を持たせることができません。具体的には、pipe[T] 型に パイプラインを識別する情報 が追加できません。そういった制約があるため、計算の状態を残すには「実行時に取れる情報」から決める必要があります。例えば実行時のコールスタックやゴルーチンIDなどが考えられますが、今回は関数ポインタをパイプライン識別に利用しました。

どういうことかというと、一般的に関数ポインタは関数ごとに1つですが、無名関数の場合は記述する毎に作られます。例えば以下の場合、

package main
func main() {
    f1 := func() { ... }
    f2 := func() { ... }
}

このとき、f1f2 は異なる関数ポインタを持ちます。内部的には、無名関数は main.main.func1main.main.func2 として作られるようですね。そしてGoは関数のインライン展開を行うので、以下の例でいえば pipe.Value の呼び出しをインライン展開できれば、関数ポインタをパイプラインの特定に使えます。

// pipe.Valueをインライン展開できれば、p1とp2の関数ポインタは異なるので識別できる
p1 := pipe.Value(10)
p2 := pipe.Value(20)

インライン展開されるためには、複雑な関数ではないことが条件です。

Go 1.22.5では、次のような複雑度ならインライン展開されます。

var states map[uintprt]*state

func Value[T any](v T) pipe[T] {
    s := &state{}
    var f pipe[T]
    f = func(g func(T) T) pipe[T] {
        s.current = g(s.current)
        return f
    }
    addr :=  **(**uintptr)(unsafe.Pointer(&f))
    states[addr] = s
    return f
}

ここで、本当は reflect.Value.Pointer を使いたいけれど、使ってしまうとインライン展開されなかったので、関数ポインタの取得を unsafe.Pointer で行っています。

エラーを扱う

Goでは型にメソッドを実装できるので、関数にメソッドを追加しました。計算の状態を保存できるようになったので、これはすぐに実装できます。

func (p pipe[T]) Catch(f func(T) (T, error)) pipe[T] {
    addr :=  **(**uintptr)(unsafe.Pointer(&p))
    s := states[addr]
    s.current, s.err = f(s.current)
    return p
}

pipe[T]func(f func(T) (T, error)) pipe[T] としてもいいのですが、エラーを常に求められるのも使いづらいなと感じたので、そのようにはしませんでした。

結果を返す

上記と同様に、こちらもメソッドを実装して対応しました。

func (p pipe[T]) ValueErr() (T, error) {
    addr :=  **(**uintptr)(unsafe.Pointer(&p))
    s := states[addr]
    delete(states, addr)
    return s.current, s.err
}

作ってみた感想など

上記の他にも、関数呼び出しを繋げるために色々と工夫をしています。

  • パイプラインの途中でエラーが発生した場合は後続の関数を呼ばない
  • パイプラインをコピーさせないように pipe[T] 型を公開しない
    • インライン展開された場所に依存するので、例えば再帰呼び出しされると関数ポインタが競合する
    • 他の関数引数や戻り値に pipe[T] を使えないのでコピーされるリスクを減らせる
  • 型の変換をするために別の関数を使って行う
    • Go 1.22.5時点ではメソッドに型パラメータを持たせられないので仕方なく

今回、パイプラインを作ってみてどうかでいえば、エラー処理を一箇所にまとめられるのは便利かなと思いました。次のコードは書籍の例ですが、エラーを最後に判定するだけになっていて若干すっきり記述できています。

func PlaceOrder(order *UnvalidatedOrder) {
    var (
        validateOrderConfig    ValidateOrderConfig
        priceOrderConfig       PriceOrderConfig
        acknowledgeOrderConfig AcknowledgeOrderConfig
    )
    p1 := pipe.Value(order)
    p2 := pipe.From(p1, validateOrderConfig.ValidateOrder)
    p3 := pipe.From(p2, priceOrderConfig.PriceOrder)
    p4 := pipe.From(p3, acknowledgeOrderConfig.AcknowledgeOrder)
    v, err := p4.ValueErr()
}

ただし、関数を繋げられる必要はあまりないかもしれません。関数呼び出しを繋げるために不要な制限を持ち込んでしまっているので、普通に構造体を返した方が扱いやすいと思います。まあ試してみた記事の結論としては、これで十分でしょう。

Plan 9とInfernoにおけるtar(1)の変化

小ネタです。以下の記事を読んでいて、

なぜ不要なのかは元記事を読んでもらうといいのだけど、ここではPlan 9ではどうなのか気になったのでtar(1)を調べてみた。ベル研UNIXの子孫なので当然だろうけど、Plan 9のマニュアルでは key の存在がそのまま残っている。

tar key [ file ... ]

The key is a string that contains at most one function letter plus optional modifiers.

なんだけど、そこで終わりではなく、Plan 9から派生したInfernoでは tar(1) コマンドが無くなっていて、代わりにgettar(1)で置き換えられている。他にも puttar(1)lstar(1) があって、それぞれ tar x, tar c, tar t に相当する。もともと、tar(1)crtx はサブコマンドのようなものだと言われていたけど、Infernoで再定義する際にサブコマンドではなく別のコマンドとして整理したのは「一つのことをうまくやる」の現れなのかなと思った。

余談だけど、1つの文字に固有の意味があって、それらを並べて一連の文字列で表現するものはPlan 9にいくつか残っている。例えばパーミッションlarwx もそうだし、以前使われていたファイルサーバ専用カーネルではディスクの構成も1つの文字列で表現していた。具体的には h は(S)ATAディスクを、 wSCSIディスクを意味して、それに続く数字で「どのディスクなのか」を識別する。これを組み合わせて (w1w2w3) なら3つのディスクを単純に連結する意味になるし、[w1w2w3]{w1w2w3}RAID 0RAID 1相当の意味となっていた。初見だとむちゃくちゃ混乱するけど、慣れるとこれはこれで使い易いと思うのですよね。