私が歌川です

@utgwkk が書いている

utgw.netをNext.js App Routerに移行した

移行しました。といっても元々ペライチのページだし、とくに自明じゃないことはやっていないと思います。

前提

やったこと

ライブラリを最新にする

ライブラリが古びていたのでまずはバージョンを上げました。メジャーバージョンアップ上等で、ぱっと見で明らかに壊れていないならいいでしょう、という方針で進めました。VSCodeのformat on saveにもprettierを使っているはずですが、format on saveと prettier --write でコードの整形結果が違う*1のを目撃したけど、見なかったことにします。

github.com

Node.jsのバージョンを .node-version ファイルで16にpinしていたら古いよ、と言われてNetlifyでのビルドに失敗したので .node-version ファイルを消したらNode.js 12 (!?) が使われてしまいました。ビルド時に NODE_VERSION 環境変数をセットすることで解決しました。なんかデフォルトでいい感じのLTSバージョンが使われてほしいけどこういう感じなのか?

docs.netlify.com

target: "serverless" は使えない、と言われたので消したけどふつうに動いていそうです。

App Routerに移行する

ライブラリが最新になったので移行をやっていきます。とはいっても公式ドキュメントのガイドに従っていけばおおむね移行できます。最初はこの移行ガイドも見ていなくて、雰囲気でファイルを用意していったけどなんとかなりました。

nextjs.org

github.com

next.config.jsで output: "export" を指定すると手元環境向けのリダイレクト設定を redirects フィールドに書けないのでどうしたものか、と思っていたのですがビルドフェーズを見ることで手元環境向けの設定とビルド時の設定を分けて書けるようでした。リダイレクトはNetlify側の設定でやっているので手元で動けばじゅうぶんです。

nextjs.org

CSSをimportするのもふつうに動いていそうです。

感想

pageコンポーネントが非同期関数になっていて、おもむろにfetchできるのがかっこいいですね。

*1:format on saveだと末尾カンマが入らない

mockgenのコード生成を1回にまとめて高速化するツールbulkmockgenを作った

tl;dr

表題のようなツールを作りました。go install コマンドでただちにインストールできます。

$ go install github.com/utgwkk/bulkmockgen/cmd/bulkmockgen@latest

従来の //go:generate コメントから移行するツールもあります。

$ go install github.com/utgwkk/bulkmockgen/cmd/mockgen-to-bulkmockgen@latest

課題感

モックを使った開発と問題

Goでinterface定義のモックを生成するツールとしては、mockgen (gomock) がよく知られています。最近 github.com/golang/mock のほうがarchiveされてforkされましたね。

mockgen (gomock) については以下の記事が詳しいので、そちらを参照してみてください。

zenn.dev

Goでgomockを使った開発をすることを考えると、mockgenでモックのコードを生成し、テストではモックを使ってアサーションを記述する、というのがよくある開発フローだと思います。たとえば以下のようにinterface定義を書いたのち go generate コマンドでモックを生成できるように //go:generate コメントを書くでしょう。

package interfaces

import "context"

//go:generate mockgen -package mock_interfaces -destination mock_interfaces/iface1.go . Iface1

type Iface1 interface {
  Meth1(ctx context.Context, arg int) error
}

ところで、開発が進むにつれてinterface定義の数が多くなり、//go:generate コマンドの数も多くなると思います。こうなってくるとよくある問題として、mockgenのコード生成にかかる時間がどんどん長くなります。ひどい場合は go generate ./... を実行している間に長めに休憩できるかもしれません。

xkcd: compiling (コンパイル中に遊んでいる様子)
xkcd: compiling*1

mockgenのコード生成に時間がかかる

mockgenのコード生成に時間がかかる理由としては、大きく2つのことが挙げられるでしょう。

  • //go:generate コメントに書いたコマンドは逐次実行される
  • mockgenのreflect modeが遅い

前者は go help generate にも書いてあるように、 go generate コマンドがそのように設計されているためです。

Within a package, generate processes the source files in a package in file name order, one at a time. Within a source file, generate runs generators in the order they appear in the file, one at a time.

go generate を並列実行できるようにしたい、というproposalが提出されたことがありますがrejectされています*2

後者はmockgenのreflect mode特有の事情です。

mockgenにはsource modeとreflect modeという2つの動作モードがあります。mockgenのreflect modeは、大まかには以下のように動作します:

  1. 指定されたinterfaceの情報を得るためのプログラムを書き出す*3*4
  2. 書き出したプログラムをビルドして実行する*5
  3. 得られたinterfaceの情報をもとにモックを生成する*6

重要なことは、reflect modeではmockgenコマンドを実行するたびにプログラムのビルドが行われている、ということです。つまり、素朴に //go:generate コマンドを都度書いていくとその数だけプログラムのビルドが実行されるということになります。これによってモックの生成に時間がかかってしまいます。

別の話題として、mockgenを go run で実行するとmockgenのコードが都度コンパイルされるので余計に時間がかかる、というものがあります。

//go:generate コマンドを1行で書くと人間に優しくない

mockgenコマンドが一度で実行されるようにすればよいのではないか、ということを思いつくでしょう。実際、以下のように複数のinterfaceのモックを一括で生成することもできます。

//go:generate mockgen -destination mock_interfaces/mock_interfaces.go -package mock_interfaces . Iface1,Iface2,Iface3

これでひとまずコード生成にかかる時間は短縮できますが、一方で //go:generate コメントの末尾がどんどん長くなっていくことが予想されます。開発が佳境になって、どんどんinterfaceを足したいというときに複数人が //go:generate コメントを編集するとコンフリクトの温床となってしまうでしょう。なんとかコンフリクトを解消したと思ったらモックが生成されなくなってしまっていた、という事故が起こりえます。

//go:generate コメントを複数行にわたって書けるようにする、というproposalが提出されたことがありましたが取り下げられています*7。ということで、2023/7/8 時点では //go:generate コメントの仕組みに乗った上でmockgenだけを使ったまま高速化するにはコメントを1行で書ききらないといけないようです。

bulkmockgen

先述したような課題感をなんとかするためにbulkmockgenを作りました。以下のように、モックしたいinterfaceをまとめたスライスを定義しつつbulkmockgenを実行するような //go:generate コメントを書いたファイルを用意します。

package interfaces

//go:generate go run github.com/utgwkk/bulkmockgen/cmd/bulkmockgen MockInterfaces -- -package mock_interfaces -out ./mock_interfaces/mock_interfaces.go
var MockInterfaces = []any{
  new(Iface1),
  new(Iface2),
  new(Iface3),
}

この状態で go generate すると ./mock_interfaces/mock_interfaces.go ファイルに MockInterfaces に列挙されたinterface (Iface1, Iface2, Iface3) に対応するモックが生成されます。

内部的には、MockInterfaces に列挙されたinterface名をカンマで結合して、mockgenコマンドを一度だけ実行しています。こうすることによって//go:generate コマンドを1行で書いたのと同等の効果を得ることができています。-- 以降のコマンドライン引数はそのままmockgenに渡ります。

モックしたいinterfaceが増えたときは MockInterfaces に足せばよいです。1行に1つのinterface名を書けばよいので差分が人間にも優しく、コンフリクトの解消も比較的やりやすいのではないかと思います。

mockgenから移行する

さて、もう既にbulkmockgenを使ってみたくなっていると思いますが、一方で既存のコードには大量の //go:generate コメントがあることでしょう。これらのコメントを取り除きつつモック対象を列挙していく、というのはなかなかに骨の折れる作業かもしれません。

ということで移行ツールも用意してあり、こちらも今すぐにインストールしてもらえます。

$ go install github.com/utgwkk/bulkmockgen/cmd/mockgen-to-bulkmockgen@latest

以下のように移行したいファイルがあるディレクトリを指定して実行することで移行できます。

$ mockgen-to-bulkmockgen -in_dir ./benchmark/interfaces -out ./benchmark/interfaces/0_bulkmockgen.go

mockgenの //go:generate コメントを発見して、モックの生成先packageごとに別の変数にinterface一覧を格納して、bulkmockgenでコードを生成する //go:generate コメントを書いたファイルを生成してくれます。便利ですね。移行ツールでコードを一括で書き換えた上で、コマンドライン引数をちょっと調整してあげればすぐに使えるのではないでしょうか。

ベンチマーク

ベンチマークとして、20個のメソッドを持つinterfaceを50個定義し、モックを1つずつ生成する場合とまとめて生成する場合とを比較します。

bulkmockgenのディレクトリにベンチマーク用のファイルを用意してある*8ので、これを使いましょう。go generate ./benchmark/interfaces/... にかかる時間を計測します。

before (1つずつ生成する)

まずは元のコードのまま、interface定義ごとに //go:generate コメントを書いて go generate を実行してみます。

% time go generate ./benchmark/interfaces/...
go generate ./benchmark/interfaces/...  22.95s user 10.13s system 119% cpu 27.643 total

50個のinterfaceのモックの生成に20秒ほどかかっています。

after (まとめて生成する)

bulkmockgenでモックを生成するようにコードを修正した上で go generate コマンドを実行してみましょう。

% time go generate ./benchmark/interfaces/...
go generate ./benchmark/interfaces/...  1.62s user 0.48s system 151% cpu 1.380 total

50個のinterfaceのモックの生成がわずか数秒で終わりました。よかったですね。

bulkmockgenの制約

2023/7/9 時点では、bulkmockgenには以下のような制約があります。

  • mockgenのsource modeに対応していない
  • 指定したパッケージの外部のinterfaceのモックが生成できない

後者について、具体的には以下のようなコマンドに対応するモック生成ができません。

2023/7/29 14:37 追記: generator: interface mocking for external packages by utgwkk · Pull Request #4 · utgwkk/bulkmockgen · GitHub を取り込んだので対応しました。移行ツールについては未対応です。

$ mockgen database/sql/driver Conn,Driver

ひとまず自分が関わっているプロジェクトでのユースケースを網羅できればよい、という方針で実装したのでこのようになっています。真にmockgenを代替したいのであれば、いずれは解決する必要がありそうですね。

他の方針

gomockhandler

mockgenのコード生成を高速化する試みとして、専用のツールで並列にコードを生成するアプローチのgomockhandlerがあります。source modeや依存ライブラリのinterfaceのモックにも対応しており、またコードが最新になっているか確認する機能があることなどからもgomockhandlerのほうが機能は揃っていると思います。

gomockhandlerがあるのにも関わらずbulkmockgenを作ったのは、もっと素朴な方法でもコード生成を高速化できるのではないかと考えたことや、go generate コマンドの仕組みから逸脱せずにコード生成を高速化できないかと思ったためです (そして実際に高速化できました)。ほかにも、1プロジェクト内でレイヤードアーキテクチャ的に実装する際のモックを用意するのにひとまず特化して作れるのではないか、というのを検証したかったのもありました。

強いてgomockhandlerに対する優位性を挙げるとするならば、-use_go_run コマンドライン引数を使って go run 形式でmockgenを実行できることや、モックの追加・削除にCLIを使わなくてもよい点などがあるでしょうか。

Makefileやシェルスクリプトで生成する

素朴にはMakefileやシェルスクリプトで並列に生成することもかもしれません。が、筆者の環境ではMakefileを使って並列にmockgenコマンドを叩いてモックを生成しようとしたところ、途中でビルドエラーが発生してうまくモックを生成できませんでした。並列にモックを生成しているという点ではgomockhandlerも同様の問題に当たったと思いますが、どうやって解決したのでしょうか? 今度コードを読んでみます。

MakefileやシェルスクリプトではなくGoのコード中にモック対象のinterfaceを列挙することで、renameに強いという効能が得られています。あとは go generate の仕組みから逸脱していないので、従来の暮らしからのジャンプが比較的小さいと思います。

おわりに

mockgenコマンドを一度だけ実行すればよいようにラップすることで、モックの生成にかかる時間を大幅に短縮できました。モックを使った堅牢な開発と素早い実装を両立するのに役立てられればと思います。

READMEがぜんぜん書けていないので誰か助けてください。

Goのstructを別のstructに変換する関数を自動生成するツールを書いた

すでに世の中にありそうな気がするけど作りました。もうあったらそっちを使いたいので教えてください。READMEがぜんぜん整備されていないので手があいたときになんとかしたい。

github.com

2023/6/19 11:53 追記

社内でcopierを教えてもらいました。

github.com

やりたいことにはおおむね合っていそうで機能も揃っていそうな印象? ただ、静的に変換できることが分かっているstructなら静的に (エラーなしで) 変換されたいかな〜という気持ちがあります。

モチベーション

以下のような2つのstructがあったとします。CreatedAt UpdatedAt はDB上でしか管理しないフィールドなので *foo.FooModel 上に対応するフィールドがないとしましょう。

package foo

type FooModel struct {
    Id   string
    Name string
    Age  int
}
package bar

type BarModel struct {
    Id        string
    Name      string
    Age       int
    CreatedAt time.Time
    UpdatedAt time.Time
}

*bar.BarModel*foo.FooModel に変換する関数あるいはメソッドを生やすことを考えると、フィールド名が一致するフィールドをマッピングして新しいstructを作って返せばよいだけなので、とくに難しいことはありません。

しかしながら、後からフィールドを足したり、そもそもフィールドの数が多くなったりすると、structのフィールドをマッピングする手間が大きくなったり、マッピングし忘れてゼロ値が引き回されていた!! という事故が起こりやすくなったりします。

go-transform-struct-genは、そういったstructの変換処理を都度手書きする手間をできるだけ減らしたい (減らせるかどうか実験したい) というモチベーションから生まれました。あとは go/ast に入門したかったとか……。

使い方

先述したようなstructが適当なパッケージにあるとして、以下のコマンドで変換関数 (メソッド) を生成できます。

% transform-struct-gen -src-struct '*github.com/utgwkk/go-transform-struct-gen/internal/fixtures/bar.BarModel' -dst-struct '*github.com/utgwkk/go-transform-struct-gen/internal/fixtures/foo.FooModel' -name ToFoo -type method
// This file is generated by github.com/utgwkk/go-transform-struct-gen
package bar

import "github.com/utgwkk/go-transform-struct-gen/internal/fixtures/foo"

func (src *BarModel) ToFoo() *foo.FooModel {
        return &foo.FooModel{
                Age:  src.Age,
                Id:   src.Id,
                Name: src.Name,
        }
}

変換元のstructのフィールドにtagを書くことで、どのフィールドにマッピングしたいかを指定することもできます。- を指定するとマッピング対象から除外することを明示できます。

package bar

type BarModel struct {
    Id        string    `transform_struct:"dst_field=Id"`
    Name      string    `transform_struct:"dst_field=Name"`
    Age       int       `transform_struct:"dst_field=Age"`
    CreatedAt time.Time `transform_struct:"-"`
    UpdatedAt time.Time `transform_struct:"-"`
}

transform_strut tagを既存のstruct定義に埋め込むツールも用意してあります (READMEで一切言及していないので書き足したい!!)。変換元のstructのフィールドと変換先のstructのフィールドのレーベンシュタイン距離を計算して、最も小さいフィールド同士を変換するようにstruct tagを埋め込むようにしています。あまりフィールド名が違いすぎるとマッピングされないので、そのときは手でstruct tagを埋めてくださいということにしています。

とりあえず手元で使ってみて、なにか進展があったらまた記事を書こうと思います。ヒマができたら……

Goのテスト結果をtparseで整形する・GitHub ActionsのJob Summaryと組み合わせる

あらすじ

go test が出力するログはシンプルです。シンプルなのはいいんですがテストサマリ的な情報が乏しくて、ログを全部見返さないとどのテストが落ちたのか分からなくなりがちという問題があります。

tparseというツールを使うと、go test を使いつつテストサマリが見れるようになるので紹介します。

tparseについて

tparseは、go test の出力をパースして人間に分かりやすいサマリを生成してくれるツールです。

github.com

最も簡単な使い方としては、READMEにあるように go test の出力をそのままパイプする (あるいは一時ファイルを経由する) ことで動作します。

% set -o pipefail && go test ./... -json | tparse -all

# あるいは
% go test ./... -json > fmt.out
% tparse -all -file=fmt.out

tparseとGitHub ActionsのJob Summaryを組み合わせる

こっちが本題です。

GitHub ActionsにはJob Summaryという機能があり、ジョブの結果をまとめたレポートをMarkdown形式でジョブに添付することができます。詳しくはGitHubのブログ記事を読んでください。

github.blog

tparseはテストサマリをいくつかの形式で生成することができます。対応している形式の中にはMarkdownがあります。

ということで、tparseが生成するMarkdown形式のテストサマリをJob Summaryとして出力できるようにしてみましょう。Job Summaryを生成するステップを分ける場合は、以下のように go test の出力を tee コマンドで一時ファイルに出しつつ引き回したり、if: always() でテストがコケたときも実行されるようにしたりなどいくつか気をつけるポイントがあります。

- name: Test
  run: |
    set -o pipefail
    go test ./... -json | tee ./go-test.out | go run github.com/mfridman/tparse -all
- name: Add job summary
  if: always()
  run: |
    go run github.com/mfridman/tparse -file ./go-test.out -format markdown >> $GITHUB_STEP_SUMMARY

うまくいくと以下のようなJob Summaryが生成されると思います。テストの数が多くなると見慣れた表形式でテストの状況を俯瞰できて便利そうですね。

GitHub Actions Job Summaryの例

おわりに

tparseで go test のテストサマリを生成し、GitHub ActionsのJob Summaryと組み合わせる例について紹介しました。普段から使っているテストランナーは差し替えずに、テストの出力結果をパースして整形してくれるのがUNIX的でいいですね。

Goのテストが出力するログが見づらいと思っている方はぜひ使ってみてください。

rowstructgenにテーブル名を表す定数を出力する機能を追加しました

rowstructgenについては以下の記事を参照してください。

blog.utgw.net

以下のように -table-name-const という引数付きでrowstructgenを実行することで、テーブル名を表す定数を含めてコード生成できます。

$ rowstructgen -schema schema.sql -table users -struct User -package dbrow -out dbrow/user.go -table-name-const
package dbrow

import "time"

const TableUsers = "users"

type User struct {
    Id        uint64     `db:"id"`
    Name      string     `db:"name"`
    DeletedAt *time.Time `db:"deleted_at"`
    CreatedAt time.Time  `db:"created_at"`
    UpdatedAt time.Time  `db:"updated_at"`
}

goquなどのクエリビルダにテーブル名を渡すとき、テーブル名が長いと何度もタイプするのが憂鬱になるし、油断するとtypoすることがあります。だいたいは文字列リテラルをコピペすることになるでしょう。

テーブル名を表す定数があれば、補完が利きやすくなるし、テーブルを参照している箇所を定数の利用箇所検索で探しやすくなると思います。

長いテーブル名をコピペしつづけている方はどうぞご利用ください。

linterを導入していなかったプロジェクトにlinterを導入しようとするときにまずやること

今どきlinterを使っていないプロジェクトなんて、と思われるあるかもしれませんが、歴史の長いプロジェクトだとそういうこともあるでしょうし、あるいは一部のDSLやスキーマ*1に対してのみlinterを走らせたいということもあるでしょう。

そういったプロジェクトに対してlinterを導入する前に、デフォルト設定でlinterを走らせたときにどういう出力になるのかを一通り眺める、というのをやっています。

どういうエラーがどこで何件出ているのか、warningなのかerrorなのか、linterのルールがプロジェクトにとって妥当なのか、一通り吟味した上で設定を調整してからlinterを導入するようにしています。なぜなら、linterのノイズが多いとlinterの言うことに従うモチベーションが下がる (と自分では思っている) からです。

最初からlinterが入っているなら、とりあえずlinterの言う通りにする (あるいは設定を変える) ことはできると思いますが、途中からlinterを導入するとプロジェクトのこれまでのコードとlinterの出力が噛み合わないこともあると思います。linterの噛み合わせをよくする*2のがよいと考えています。

具体例として、Perlプロジェクトに対してperlcriticを導入するときにやったことをまとめた記事を紹介します。

blog.utgw.net

心構えの話をしておこうという気持ちに急になったので記事にしました。

*1:たとえばDBスキーマやGraphQLスキーマ

*2:この表現は今考えた

DBスキーマからGoのstruct定義を生成するグッズを書いた

GoでSQLを書いて実行するとき、素のdatabase/sqlだけだとさすがに心もとないのでsqlxなどのライブラリを使ってDBの行をstructにマッピングすると思います。db struct tagでマッピング元のカラム名を指定できるのが便利ですね。

一方で、このstructを定義する作業ですが、テーブルのカラムが多いと大変だし、typoしていたので直して再チャレンジする……ということが往々にしてあると思います。

こういうときのためにstruct定義を生成するツールを書いてみました。 go install github.com/utgwkk/rowstructgen@latest を実行したら使えるようになると思います。今のところMySQLにしか対応していません (普段はMySQLしか使っていないため)。main.goに全ての実装が書いてあってひどい感じなので、気が向いたらなおします。

github.com

以下のようなDBスキーマが schema.sql というファイル名で用意されていたとします。論理削除には目をつぶってください。

CREATE TABLE `users` (
  `id` BIGINT UNSIGNED NOT NULL,
  `name` VARCHAR(255) NOT NULL,
  `deleted_at` DATETIME NULL,
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4;

このようなスキーマに対して、rowstructgenで users テーブルに対応するGoのstruct定義を生成するには以下のようにします。

$ rowstructgen -schema schema.sql -table users -struct User -package row -out row/user.go

こうすると row/user.go に以下のようなファイルが生成されます。

package row

import "time"

type User struct {
    Id        uint64     `db:"id"`
    Name      string     `db:"name"`
    DeletedAt *time.Time `db:"deleted_at"`
    CreatedAt time.Time  `db:"created_at"`
    UpdatedAt time.Time  `db:"updated_at"`
}

ISUCONで用意されているようなDBスキーマならこれぐらいでうまく動くのではないでしょうか。ちゃんとやるならALTER TABLE文をシミュレートする必要がありそうですが、自分の目的を満たすにはこれでじゅうぶんかなーと思っています。

みどころ

sqldefをSQLパーサーとして使う

sqldefというDBスキーマの管理ツールがあります。sqldefについては以下のブログ記事などを読んでください。

k0kubun.hatenablog.com

sqldefはSQLのパーサーを内包しているので、これを使わせてもらいます。これによってDBスキーマをなめてコード生成を行うことに集中できました。

MySQLの型をGoの型にマッピングする

ベタッと書いてあってひどい感じですが、書いてあります。

https://github.com/utgwkk/rowstructgen/blob/bc3221a6d169a4f5c51a0538194bc7b8dfb49b10/main.go#L67

ENUMはstring型にしておくので、必要に応じて独自の型にマップさせましょう、というスタンスです (凝ったことをやりたくないから)。JSONはひとまずbyte型にマッピングしていますがこれも諸説あると思います。ところでJSONってbyteにマッピングできるんでしたっけ?