私が歌川です

@utgwkk が書いている

Go Conference 2024でgomockの話をします #GoCon

Well done! Your session Dive into gomock has been accepted for Go Conference 2024!

ということなので、よろしくお願いします。「Dive into gomock」というタイトルで、gomockの内部実装に深入りする話をします。

gocon.jp

6/8に渋谷で会いましょう。今日の12時から参加登録が始まるので全員来てください。

トークの説明

みなさまは、interfaceに依存するコンポーネントのテストを書いていますか? また、テストで使うinterfaceの実装はどうやって用意していますか? interfaceにメソッドが追加されたらどうしますか? 意図したメソッドが呼び出されていなかったら? メソッドに渡される引数の比較方法を柔軟にしたくなったら?

interfaceのモックを用いたテストを簡単に記述するためのフレームワークの1つに、gomockがあります。
gomockを使ったモック実装を使ってテストすることで、interfaceのメソッド呼び出しが適切に行われていることを検査し、意図しないメソッド呼び出しがあればテストを失敗させることができます。
また、gomockを使ったモック実装を生成するためのツールとしてmockgenが用意されています。mockgenを使うことで、interfaceの定義が変わってもgo generateコマンドで簡単にモック実装を修正することができます。

本セッションでは、gomockがどのようにinterfaceのメソッド呼び出しを検査しているのか解説します。
主に以下のトピックについて取り上げる予定です。

  • mockgenが生成するコードの内容
  • 期待するメソッド呼び出しを記述し、記録する
  • 期待するメソッド呼び出しがなければテストを失敗させる
  • 引数の比較方法をカスタマイズする

このセッションが、Goのinterfaceモックを用いたテストの仕組みへの理解を深める一助となれば幸いです。

選択しなかった技術判断に気を払う

技術選択の場面において、ある技術を選ぶということは選ばれなかった技術があるということである。なぜその選択にしたのか、に目が向きがちだけど、選ばなかった技術についても述べるべきだろう。

今回の企画・要件に対して、この技術はこういう制約が見合わないので採用を見送った、ということを書き残すことで、技術判断の根拠になる。技術選択はその場限りの出来事ではなく積み重ねなのだから、あとから振り返り可能な形になっているべき。逆に、選ばなかった技術がフィットする要件もあるだろう。そういう技術を選ぶ機会を逃さないように注意するべきだろう。

ということを考えたんだけど、だいたいDesign It! に書いてあった。つまり、Design It! を読んだらいいと思います。

ところで、アーキテクチャ俳句ってぜんぜん俳句じゃなくない??

ログ

ログ*1、油断すると絶妙に情報量が足りなかったり、欲しいものがdebugレベルでしか出ていなくて本番環境で見れなかったりしがち。ここでこういうログが欲しくなるだろう、という感覚を鍛えるには、実際にログが足りなくて困る経験をするしかないのか、もうちょっと高速道路が整備されているものなのか。

外部APIにリクエストを送る部分はとりあえずリクエスト・レスポンスの内容を (ユーザー個人情報などには気をつけながら) ログ出力しておくと調査に役に立つ、というのはありそう。GoのgRPCクライアントのミドルウェアにはまさにそういうのがあるし、HTTPクライアントにログ出力の仕組みを差し込むのもすぐできる。自分のシステムに閉じていない部分はこういう仕組みが整っていそう。

ほかにはアプリケーションごとに固有の事情に合わせることにもなるだろう。何が重要で、調査をしたくなるか、そしてアプリケーションログではなくもっと永続的なデータとして残したくなるか? ログとログデータの区別はどこに生じるのか?

*1:ここではWebアプリケーションやサブシステムなどのアプリケーションから出力するログを指す

GraphQL Cursor Connectionにおけるedgeのnodeフィールドの型は必ずしもNode interfaceを実装していなくてもよい

タイトルが全てです。

GraphQL Cursor Connectionの仕様の3.1.1節には以下のような注意書きがあります。

The naming echoes that of the “Node” interface and “node” root field as described in a later section of this spec. Spec-compliant clients can perform certain optimizations if this field returns an object that implements Node, however, this is not a strict requirement for conforming.

https://relay.dev/graphql/connections.htm#note-e0232

注意書きをざっくり訳すと以下のようになります。

  • この node という名前は、GraphQL Object IdenrificationにおけるNode interfaceや node クエリを想起させる
  • が、connectionのEdgeの node フィールドの型は、必ずしも Node interfaceを実装していなくてもよい
    • もちろん、Node interfaceになっているほうが最適化が効きやすいだろう

多くの場合はEdgeのnodeがそのまま Node interfaceを実装するようになっていても支障はないだろうけど、必ずしも Node interfaceである必要はないよ、というのが伝えたいことでした。

アイコンのステッカーを支える技術

以下のツイートが全てです。

この記事では、ツイートで触れていないトピックについてメモします。

大きさ

技術イベントの名札に貼れるぐらいのサイズにすると取り回しがよいです。40mm x 40mmはラクスルで発注できるバラ四角カットシールの最小サイズです。

貼った様子は以下のツイートにある写真を見てください。これぐらいのサイズだと、小さな名札に貼った上で名前も書けるぐらいに収まります。

配置

アイコン画像に余白がない場合は、印刷保障線を少しはみ出るぐらいにサイズを調整して入稿するほうが見栄えがよいです。アイコンの端が途切れるのを恐れずに入稿しましょう。先述したツイートの画像にあるシールを発注したときは日和ってしまったので余白があります。

納品形態

他の納品形態を試したことがないけど、バラ四角カットにするとかさばらず持ち運びやすいし、1枚ずつ配りやすいです。アイコンの形をアピールしたいときはバラ台紙カットするのがいいのかな。

部数

多ければ多いほど単価が安くなると思うけど、量が増えるし値段も上がるのでいいバランスを見極めましょう。

自分はとりあえず50部で注文しています。単価は 2,805円 / 50 = 56.1円で、まあこんなもんなのか?

同人イベントとかで配りまくるならもっと部数があってもよさそう。

納期

納品までの日数が多ければ多いほど安くなるので、余裕を持って発注しましょう。7営業日は1週間よりも長いので注意してください。

画像形式

1200 x 1200ピクセルのPNG画像で入稿したけど、印刷の粗とかはとくに気になっていないです。自分が気にしていないだけかも。


ほかにも話題がありそうだけどいったんこれで。インターネットにいると人をアイコンでしか認識できないことがよくあるので、オフラインカンファレンスなどで名札にアイコンのステッカーを貼ると認識してもらいやすくて便利だと思います。

Goの特定のパッケージだけGitHub Actionsのジョブを分けてテストする

生きてると、テストがどんどん遅くなりがちです。実際にDBに接続するタイプのテストとか……。そしてそういう種類のテストに限って並列化しやすいように実装されていないこともありがちですよね。

GitHub Actionsを使っている場合*1、そのようなテストが複数ファイル・パッケージにわたる場合は独立したジョブでテストを走らせることでCIの高速化を見込むことができます。

ということで、特定のパッケージのテストだけ独立したジョブで走らせる・残りのパッケージのテストは単一のジョブで走らせる という仕組みを作りました。実物を見たほうが早いと思うので、以下のリポジトリを見てください。

github.com

鍵となるのは以下3つのファイルです。応用すればGoに限らずさまざまな言語のテストフレームワーク向けに使えるはず。

  • separated-test-pkgs.txt
  • separate-test-pkg.pl
  • .github/workflows/ci.yml

separated-test-pkgs.txt

このテキストファイルに、独立したランナーで走らせたいパッケージ名を1行1エントリで書きます。パッケージ名の形式は go list ./... コマンドで出力されるものに揃えてください。

separate-test-pkg.pl

いきなりPerlのコードが出てきましたね。PerlはGitHubが提供するGitHub Actionsのrunnerに入っている*2ので、おもむろに小さなスクリプトを書くのに便利です。順に読んでいきましょう。

use strict;
use warnings;
use JSON::PP qw(encode_json);

JSON::PPはPerl v5.13.9からコアモジュール*3になっているので、追加でモジュールをインストールする必要なく使えます。よかったですね。

# separated-test-pkgs.txt から、個別にテストを実行したいパッケージを取得する
my @separated_test_pkgs = do {
    open my $fh, '<', 'separated-test-pkgs.txt' or die $!;
    chomp (my @xs = <$fh>);
    close $fh;
    @xs;
};
# package名 => 個別にテストを実行したいなら true
my %is_separated_test_pkg = map { $_ => 1 } @separated_test_pkgs;

先述した separated-test-pkgs.txt ファイルから、個別にテストしたいパッケージの一覧を取得しています。 %is_separated_test_pkg 変数はあとで使います。

# このモジュール下のパッケージ一覧を取得する
my @all_pkgs = split "\n", `go list ./...`;

# 同時にテストを実行してよいパッケージのみを抽出する
my @independent_pkgs = grep { !$is_separated_test_pkg{$_} } @all_pkgs;

バッククォートで囲んだ文字列はシェルコマンドとして実行され、実行結果の文字列が入ります。 go list ./... コマンドの実行結果をsplitすることで、全パッケージ一覧を取得しています。

先ほど組み立てた %is_separated_test_pkg 変数を使って、個別にテストしなくてよいパッケージの一覧を抽出しています。

# JSON形式で出力する (フォーマットは __END__ 以下を参照)
my $outputs = [
    (
        map {
            +{
                name     => $_,
                packages => [$_],
            }
        } @separated_test_pkgs
    ),
    +{
        name     => 'Others',
        packages => [@independent_pkgs],
    },
];
print encode_json $outputs;
__END__
(省略)

GitHub Actions workflowのジョブのoutputとして使いやすいように、JSON形式で出力しています。{"name":"テストの名前","packages":[パッケージ一覧]} というオブジェクトの配列を作っています。

.github/workflows/ci.yml

name: CI
on:
  push:
    branches:
      - main
  pull_request:

jobs:
  prepare:
    name: Prepare
    runs-on: ubuntu-latest
    outputs:
      separated-pkgs: ${{ steps.separate.outputs.out }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v4
        with:
          go-version-file: go.mod
      - id: separate
        run: echo "out=$(perl separate-test-pkg.pl)" >> $GITHUB_OUTPUT
  test:
    name: Test (${{ matrix.target.name }})
    needs:
      - prepare
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.prepare.outputs.separated-pkgs) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v4
        with:
          go-version-file: go.mod
      - run: go test -v ${{ join(matrix.target.packages, ' ') }}

prepareジョブで、どのテストを独立に実行するか・同時に実行するか を先述した separate-test-pkg.pl の出力によって決めています。

prepareジョブの出力を受けて、testジョブでは独立にテストを実行するパッケージのmatrixと、それ以外のパッケージのmatrixに分けてテストを走らせています。go test コマンドには複数のパッケージ名を渡せるので、これでテストを個別に実行できるわけですね。

fromJSONjoin はGitHub Actions workflowの組み込み関数です*4。 動的に組み立てたmatrixのフィールドは matrix.target オブジェクトから参照できます。

fail-fast には false を指定しておいて、1つのテストジョブがコケても他のジョブが即時終了しないようにしておくほうがよいでしょう。

実際に以上のworkflowをもとにCIを走らせると、以下の画像のようにジョブが分かれていることが確認できます。

gyazo.com

先行研究

gotesplit*5というツールを使うと、Goのテストを分割数を指定して独立したジョブとして走らせることができるようです。

songmu.jp

今回は、DBを使うテストだけ独立して実行する・DBを使わないテストは高速に終わるからまとめて実行する というふうに、分割の基準を自由に指定できるようにしたかったので、自前で仕組みを用意してみました。このような単位で分けておくと、DBを使うテストでだけDBの準備をするようにできるし、複数のパッケージのテストからDBにアクセスしても干渉しないようにできます*6

*1:他のCIランナーでもそうだろう

*2:具体的にプリインストールされているソフトウェアについてはactions/runner-imagesリポジトリにあるOSごとのドキュメントを参照すること

*3:Perl本体に同梱されるモジュールのこと

*4:Expressions - GitHub Docs

*5:名前がおしゃれ

*6:たまにTRUNCATE TABLEするテストとかがある

aws-sdk-go S3 PutObject MaxMessageLengthExceededエラー 何

tl;dr

PutObject のオプションの Bucket フィールドに空文字列を渡していませんか? 環境変数経由で設定する値がうまく渡っていない、などありませんか? もう一度点検してみましょう。

この記事はこれでおしまいです。検索しても全く情報が出てこなくてしばらく悩んでいたけど、しょうもない感じだった。以下はおまけです。

起こっていたこと

aws-sdk-goでS3にファイルをアップロードするクライアントを書いた。手元では動いたので開発環境にデプロイして動かしてみたらなんかエラーが出る。

operation error S3: PutObject, https response error StatusCode: 400, RequestID: (snip), HostID: (snip), api error MaxMessageLengthExceeded: Your request was too big.

MaxMessageLengthExceededと出ているけど、ファイルをアップロードするのになんか制限に引っかかったか? でもS3にそんなシビアな制限ないのでは?? と思って首をかしげていた。あんまり凝ったことはしてないS3クライアントの実装だし、サンプルコードと見比べても変なところはなさそうに見える。

世の中には1x1.pngという非常にいい画像ファイルがあるのでこれをアップロードしてみると、エラーメッセージが変わった。何???

operation error S3: PutObject, https response error StatusCode: 400, RequestID: (snip), HostID: (snip), api error MalformedXML: The XML you provided was not well-formed or did not validate against our published schema

この時点では全く心当たりがなくて*1、いろいろ試行錯誤してもよく分からない。情報が足りていないので、APIコールのログを吐くようにしてデプロイし直す。

解決編

手元と開発環境でのAPIコールのログが取れたので見比べる。よく見るとリクエストの発行先がおかしい。バケット名が入ってなくない??

  • 手元: /bucket-name/hoge.png?x-id=PutObject
  • 開発環境: /hoge.png?x-id=PutObject

ここまで見たところ全てが分かった。環境変数のセットを忘れてるじゃん……。

ということで、環境変数を入れて再度デプロイしたら直った。

必須の引数 (バケット名) にうっかり空文字列を渡してもAPIコール前にはエラーにならず、変なエンドポイントを叩いた結果見慣れないエラーが返ってくる、というのが難しいポイントだった。気をつけてください。APIクライアントのコンストラクタで必須な引数が空文字列だったらエラーを返す、みたいなことをするともうちょっとよかったのだろう。

*1:ここ伏線です