emahiro/b.log

日々の勉強の記録とか育児の記録とか。

Go の iterator を触ってみた

Overview

今月にもリリースされる予定の Go1.23 に同梱されている iterator package をだいぶ今更ながら触ってみました。
どういうものか、ということの概要は知っていましたが、まぁ一旦自分でも触ってみるか、ということで触ってみて、実際動かしながら触れることで思ったことを残しておきます。

実行方法

手元は Go1.22 なので GOEXPERIMENT=rangefunc を付けて Go のファイルを実行しました。

感想

先に感想だけ書いておくと、自分としては iterator は少し直感的じゃないなという印象を持ちました。

例えばシンプルな iterator 処理を書こうと思うと以下になりますが、

func TestSeq(t *testing.T) {
    for v := range seq {
        t.Log(v)
    }
}

func seq(yeild func(int) bool) {
    for i := range 10 {
        yeild(i)
    }
}

yeild が call されるたびに for-loop の処理に一旦処理が委譲され、再度 range over func で iterator のメソッドが実行されると前回中断した yeild の処理から再開する、というのが処理の流れになりますが、そもそもメソッドを2つ行き来しないと行けないとは、直感的に読みづらいなと感じました。
自分は Typescript も業務で利用してますが map や filter がメソッド2つに分割されていたら、やっぱり読みづらいと感じてしまいます。

この辺りは書いていくうちに脳内にマップが出来上がって多少読みやすくはなるのかもしれませんが、初見では分かりづらさが勝ります。

次に yeild での for-loop への処理の委譲についてですが、以下の2つの実行結果は実は同じです。

func TestEven(t *testing.T) {
    num := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    for v := range even(num) {
        fmt.Println(v)
    }
}

func even(num []int) Seq[int] {
    return func(yield func(int) bool) {
        for _, n := range num {
            if n%2 == 0 {
                if !yield(n) {
                    break
                }
            }
        }
    }
}
func TestEven(t *testing.T) {
    num := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    for v := range even(num) {
        fmt.Println(v)
    }
}

func even(num []int) Seq[int] {
    return func(yield func(int) bool) {
        for _, n := range num {
            if n%2 == 0 {
                yield(n)
            }
        }
    }
}

どちらも2で割り切れる値のときに for-loop の処理が走ります。違うのは yeild の評価方法です。

サンプルの実装でも if !yeild(...) { return } という loop を抜けると処理を完了する、という condition を書いてる物が多かったですが、どうして否定形で書くのかいまいちわかっていませんでした。

自分なりに納得したのは例えば以下のような呼び出す側(for-loop側)が途中で loop 終わるケースで、

func TestEven(t *testing.T) {
    num := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    for v := range even(num) {
        fmt.Println(v)
        break
    }
}

iterator 側の処理を止めていない(= yeild の評価をせずそのまま yeild を実行している)ときは、

panic: runtime error: range function continued iteration after exit

goroutine 1 [running]:
main.main.func2(0x2)
        $HOME/main.go:32 +0x30
main.main.iterFilter.func3(...)
        $HOME/main.go:24
main.main()
        $HOME/main.go:32 +0x8c
exit statu

上記のような panic が発生します。これは loop はすでに抜けているのに iterator がわの処理が続いてしまうことに起因します。

こういうケースで意図しない panic を防ぐためにも yeild を評価するときは否定形で確認する実装方法がもしかしたらいいのかもしれないな、と思いました。

まとめ

何にしても Generics 以来の Go の大きなアップデートなので触りながら慣れていきたいなと思います。

ポートフォリオサイト盆栽記録 2024年7月度

サマリ

個人で運用してるポートフォリオサイトの更新記録です。

emahiro.dev

今までなんとなく更新してましたが、更新ログをつけてみることにしました。

更新内容

依存関係をアップデートしました

毎回やってるやつですね。 emahiro.dev は next と firebase-functions で動いているのでそれぞれに npm audit fix をかけました。

これに合わせて

  • next を 13 -> 14 へアップデート
  • firebase を v2 にアップデート

をしました。

ImageResponse を next/server -> vercel/og に移管

Next13 のときは OG Image の生成に next/server の Image Response を使っていたのですが、Next14 ではこれが非推奨になっていたので vercel/og の利用に切り替えました。

vercel.com

@notionhq/client のバージョンを最新にしました

これが地味にめんどくさかったです。
依存モジュールのバージョン上げたら既存で使っていた Database の QueryResponse の中に入っていた値が取れなくなっていたので、API 書き直しでした。

具体的には Database Query で取得しても page の properties が一部入ってこないものがあった(Tag 情報とか)ので、DB を Query して Page の詳細取得する、という二段構えにする必要がありました。

developers.notion.com

Note に「しずかなインターネット」の記事を追加しました

「しずかなインターネット」に他愛もないことや、技術以外のことを書いていたんですが、自分の投稿一覧に追加しました。
このための API の利用を開始するためにスポンサーになっていたのですが、ようやく実装することができました。

sizu.me

ドキュメントも充実してていい感じです(endpoint 数も少ないし参照系しかないのでメンテしやすそう)

github.com

今は Note 一覧から一旦「しずかなインターネット」に飛ばしてますが、車輪の再発明したいので自前の markdown parser でも書こうかなと思っています。

今回やろうと思ってやらなかったこと

npm -> pnpm への移行

next と firebase functions が実質 monorepo 構成になっているのと、依存管理がしやすくなるならやろうかなーと思ったんですが、デプロイに使ってる GitHub Actions とかも変更しないといけないので、今回は pend しました。

ブログのロゴを変えてみた

ずっと自分の写真だったけど昨今のいろいろリスクも増えてきたのでブログ用のアイコンを作って favicon 等々に適用してみた。
なお、どういうロゴがいいかわからなかったのでとりあえず ChatGPT に「いい感じのロゴ作って〜」とお願いしていくつか出してもらった中で最もスッキリしたやつを選んだ。

ChatGPT 便利〜

ogen が v1 になっていたので変わったところも含めて試してみた

サマリ

現職で OpenAPI からのコード生成ツールとして ogen を採用して使っているのですが、この ogen がしばらく見ないうちに v1 (現時点の最新は v1.2.1) に進んでいたので、v1 以前との違い等々を調べてみました。

github.com

v1 以前と変わったところ

設定ファイルができた

ogen を実行するディレクトリと同階層に ogen.yml という設定ファイルが必要になりました。
この設定ファイルでは後述する生成するファイル(server や client) の対象を選択する設定や、コード生成時に発生するエラーを ignore したりすることができます。

オプションが変わった

まず最初に気づいたのはここでした。v1 以前とオプションが変わっています。

特に現職ではコード(特に API のリクエストやレスポンスの部分の)生成に ogen を利用しており、server や client といったコードの生成は利用していなかったのでのでちょっと困りました。

例. 元々使っていたオプションからなくなったのは以下

  • -no-client : Client のコードを生成しないオプション
  • -no-server : Server のコードを生成しないオプション

その他 -allow-remote-debug.noerr といったオプションもなくなっていました。

現在利用できるオプションは以下です。

flag provided but not defined: -no-client
Usage: ogen [options] <spec>
  -clean
        Clean generated files before generation
  -color
        Enable color logging (default true)
  -config string
        Path to config file
  -cpuprofile string
        Write cpu profile to file
  -loglevel value
        Zap logging level
  -memprofile string
        Write memory profile to this file
  -memprofilerate int
        If > 0, sets runtime.MemProfileRate (default -1)
  -package string
        Target package name (default "api")
  -target string
        Path to target dir (default "api")
  -v    Enable verbose logging
  -version
        Print version and exit

では従来のような Client や Server のコード生成をしない(抑制する)ための方法はどうするのか?というと、config ファイルに生成するファイルを enable する設定をかくという方式でした。
ちなみに再掲ですが、現職では Clinet や Server といった生成対象のコードをプロダクションでは利用しておらず、もっぱら Req/Resp のインターフェースの管理としての自動生成のみを使っていたので、新規で生成されるファイルは必要ありませんでしたので、最初オプションがなくなったときは困りました。

v1 以降は以下のような設定ファイルを書けばまずはデフォルトの生成対象を残した状態で、余計なコード生成はスキップさせることができます。

generator:
  features:
    disable_all: true

oneOf が正式にサポートされた

もともと oneOf はサポートされていましたが、プリミティブな型のみだったりしてたのがどうやら正式にサポートされたようです。

Sum type | ogen

ogen 拡張ができた。

Extentions というところを見るとわかりますが、OpenAPI ドキュメントを記載するときに、ogen のオプションを付けることができます。

個人的に便利だなと思ったのは以下です。

  • Custom Type Name をつけることができること。

    • ogen が生成するコードの schema を使うときに、適切にコンポーネントが切られていないと ogen 側での独自の命名規則(OperationId 等々を使っている)に沿って Struct が生成されてしまうのが少し気持ちわるかったのですが、このオプションを使うと名前を割り当てることができます。
  • Struct Field Tags で生成する schema に任意の tag を割り当てることができる。

    • 特に Go だと ORM を使うときにマッピングのための tag を打つケースが多いですが、OpenAPI の定義側に tag を指定することで、tag ごとコードを生成することができます。
    • ORM とかの定義を1から作らずともよいのでこれも良い機能だなと思いました。ORM のための Entity を Response でそのまま使うのか?という点は若干議論が分かれそうですが。

まとめ

OpenAPI を使うって言うとちょっともの好き感ありますが、実際枯れている仕様でもあるので選択肢に上がることも多いかと思います。
OpenAPI を使って自動生成を考えるときに、適切なライブラリ選定が難しい問題がありますが、その中でも ogen は注目していた一つで実際に商用環境でも使っていて十分使えそうだなと思っていたところで、 v1 になり色々便利な機能も増えていたので今回はその学習のためにブログを残しました。

また使っていく中で気づいたことがあったらまとめようと思います。

Go Conference 2024 に参加してきた

Go Conference 2024 に参加してきました。

個人としてはだいぶ久しぶりに大きなソフトウェアのカンファレンスに参加してきました。

コロナ禍でのオンライン開催での現地の熱気を感じれなくなったり、その後子供が生まれてそもそも週末に1人でなにかのイベントに行く、なんてことをあまりできなくなったのもあって久しくこういったイベントへの参加は控えていたのもあります。
そもそも共働きで、かつ奥さんが土日休みではないうちの場合、週末は原則ワンオペで、そのためにほとんどの予定を週末にいれること自体がなかなか難しく、週末や遠方のイベントというのは更に参加が難しくなってきているのもあります。

個人的に頭に残ったトピックは以下

  • イテレーターの話
    • Go を書き始めて以降、for loop で回してあれこれ処理する、っていうのが当たり前だと思っていたのでパラダイムの大きな変化を感じました。
    • 現職社内の勉強会でも何度かキャッチアップの機会はあったのですが、いまいち理解できていなかった自分がいて、そんななか tenntenn san の発表してもらったスライド (※1) を読んでようやく理解できました。(そもそもイテレーターというものへの理解が自分は浅かったのかなとも思います)

※1. 以下のスライドです。

https://audience.ahaslides.com/cl965inb88/review?lookback-tab=slides

  • slog の話

    • そろそろ使いたいなーと思っていたので、具体的な Handler の実装方法のライブコーディングは面白かったです。
    • LTSV の形式に対応した Handler を自作しないとなーと思って幾星霜。
  • swissTable の話

    • 社内でも話題に上がっていたので気になっていたトピックでした。
    • こういう深いところの知識がまだまだないのである程度自分の脳みそにインデックスを張っておける状態にしたいとも思いました。

子どものお風呂と晩ごはん準備があったのですべてのセッション、LT は聞かずに早めに離脱したのですが、結論としてだいぶ久しぶりだったけど行ってみてたくさん刺激をもらえたので頻繁に行くことはできないけど年に数回くらいであれば参加していきたいなと思いました。ブースでも何人か知り合いに挨拶できたのも良かったです。やっぱりリアルな場というのはオンラインにはない価値があって大事ですね。
こういうイベントに適度に参加すためにも、日々出かけさせてもらえる(特に週末)ように徳を積まないと行けないなとは思いますが。。。

Alfred -> Raycast に移行した

Overview

タイトルのとおりですが、PC のランチャーアプリを長年愛用していた Alfred を一旦辞めて流行りの Raycast に移行してみました。

www.raycast.com

特にこれ!というものはなかったのですが、流行りに乗ってみようという単純な動機です。

便利なところ

これはいいな!と思ったのは専用の Clipboard の History 機能と Note 機能があるところですかね。 これ自体は Alred にも合ったので別に Raycast である必要も特にないんですが。Note 機能については CotEditor を入れていてちょっとしたメモとかであれば都度開いてコピペしてたりしたのでそういう手間は省けそうでした。

あと Alfred だと有料版でフルに利用しないとできないことが Raycast だと無料版でもできる?あたりは魅力かなと思いましたが、自分の利用範囲では特に違いはないかなと思いました。

残念なところ

Alfred のときは Alfred の検索バーでスペースから始めると Finder のなかのディレクトリ、ファイル検索になる、という仕様があって、かなり重宝していたのですが、Raycast だとこの振る舞いを再現する事が自分が調べた範囲ではできませんでした。
ex. 例えば Download のディレクトリを一発で開くのに Alfred は downloadスペース + {対象のディレクトリ} と打ち込むと Finder のダウンロードディレクトリまで一発で飛べましたが Raycast では Search Files だけでディレクトリへのジャンプではなくダイレクトにファイルにジャンプしてしまいます。

その他にも Raycast ならではの便利な機能もあるので使い続けてみようかなと思いましたが、Alfred でできることはだいたい Raycast でもできると思っていただけにちょっと残念。もしかしたらやり方あるんですかね?

結果

自分の使ってる範囲で使用感は特に変わりませんでした。

See Also

zenn.dev

Prezto をやめて sheldon に移行して zsh の起動速度を改善してみた

これはなに?

zshプラグインマネージャーとして Prezito を使っていましたがこれをやめて sheldon に移行したログです。

sheldon とは?

Rust 製のシェルのプラグインマネージャー。
Rust 製だけあってそもそも高速です。

brew でインストールできますが cargo でインストールできるのもいいですね。

ドキュメントも丁寧にまとめっていて良きです。

sheldon.cli.rs

動機

zsh の起動に大体 600ms 程度かかっていたのがだいぶ気になってはいたものの、高速化するのもだいぶ手間だな〜とか思っていたときに以下のエントリを読んだのがきっかけです。

karamaru-alpha.com

実際にはここで紹介されているうちの1つ(sheldon の採用)のみしか行っていませんが、体感でもだいぶ早くなりました。

やったこと

  1. prezto を削除して sheldon を入れる。
  2. zsh-defer をいれて遅延ロード処理を追加。
  3. sheldon source 読み込み結果をキャッシュする。
  4. zshrc の設定を更新する。

これらはすべて参考にしたサイトに記載されていた内容と 公式の Docs を参考にして進めました。

結果

sheldon 周りの設定を置き換えたのみの Before/After はこんな感じでした。

# before
zsh -i -c exit 
0.11s user 0.15s system 59% cpu 0.635 total

# after
time zsh -i -c exit
zsh -i -c exit  0.07s user 0.13s system 52% cpu 0.377 total

大体 600~700ms かかっていたのが 300~400ms まで改善しました。

元記事では 20ms まで落とせたらしいですがそこまでは行きませんでした。もっと早くできそうな気もしないでもないですが、zshrc で eval $(...env init -) というやってる処理が重たく、この辺はもう少し改善の余地がありそうだなと思いました。

というのも zsh の plugin 読み込みが遅いのかなと思っていたんですが、そこだけ切り取ると 30ms 程度で終わっていたので zshrc の読み込みに時間がかかってそう、というところに気がついたのがきっかけです。

time sheldon source
source "/Users/hiromichiema/.local/share/sheldon/repos/github.com/romkatv/zsh-defer/zsh-defer.plugin.zsh"
zsh-defer source "/Users/hiromichiema/.local/share/sheldon/repos/github.com/zsh-users/zsh-autosuggestions/zsh-autosuggestions.zsh"
zsh-defer source "/Users/hiromichiema/.local/share/sheldon/repos/github.com/zsh-users/zsh-syntax-highlighting/zsh-syntax-highlighting.plugin.zsh"
sheldon source  0.01s user 0.01s system 60% cpu 0.027 total

ちなみに rbenv とかそのへんを削除してみたら 100ms 弱早くなったのでやっぱり zshrc の読み込みもある程度少なくしておく必要がありそうで、試しにいつ設定したか忘れていた pyenv の設定をまるっと削除したら 100ms 以下まで起動を高速化できました。
env 系の読み込み処理を入れてる場合は遅延実行や断捨離を検討してみてもいいかもしれません。

time zsh -i -c exit
zsh -i -c exit  0.02s user 0.03s system 76% cpu 0.077 total

はまったところ

github からの プラグインを DL を指定するときは ssh でデフォルト設定していないと Git の認証で落ちる。

.ssh/id_rsa を使っていないとこのエラーが発火します。 これの回避のためには .gitconfig にて sshCommand を指定する必要があります。以下のような設定を追記します。

[core]
    sshCommand = "ssh -i /path/to/your/private/key"

特に GitHub に登録している鍵をデフォルトの id_rsa とは別で分けてるケースで必要になります。
これに引っかかると sheldon source で設定を更新しても反映されず、 GitHub の認証で落ちるエラーが出力されます。

remote (https 経由)でプラグインを DL するときは raw の URL を指定しないといけない

これも結構ハマりました。GitHub を指定しないときは remote で repository の URL (正確には *.plugin.zsh のファイルまで)を指定すると HTTP 経由でプラグインを clone してくれるのですが、URL は GitHub のURL ではなく、raw の URL が必要でした。
これは issue にも起票してありましたけど結構罠でした。

zsh-defer を使う場合には zsh-defer を最初に読み込む

言われると当たり前ですが遅延読み込みをする場合には遅延読み込みをするライブラリを先に読み込んで後続のライブラリを遅延読み込み行う事が必要です。

shell = "zsh"
apply = ["defer"]

[templates]
defer = "{% for file in files %}zsh-defer source \"{{ file }}\"\n{% endfor %}"

[plugins.zsh-defer]
github = "romkatv/zsh-defer"
apply = ["source"]

上記のように apply=["defer"] を使う場合には zsh-defer を先に読み込んでおきます。

theme の設定

theme の設定は Examples に記載されていますが、選択肢は少ないです。 p10k だけアレばある程度色々できるので自分は p10k を利用しています。

sheldon コマンドでインストールしたら p10k configure で設定が可能です。

参考にしたサイト

zenn.dev

zenn.dev

zenn.dev

zenn.dev

github.com

github.com

社会人10年目が終わった

社会人 10 年目が終わって、11年目になった。

2014年から社会に出たときには、先のことなんか何も考えられてなかったけど気づいたら10年が過ぎてしまった。

10年目まではちゃんと数えておくか、と思っていたのもあり、来年以降は「社会人N年目」みたいなカウントはもうしなくなるとは思う。

特に社歴や社会人歴が意味を持つとは考えていないけれど、それでも10年は大きいな〜と思うともに「10年間なんやかんや体も壊さずに生き延びたのか〜」と思うとちょっと感慨深いものもあったりする。

30中盤なんて世間一般ではまだまだ若手だと思うけど、この業界では若手とは見られないし、見られ方も変わってくるのでそういう意味では自分の見方も見られ方も変わっているということは意識しないと行けない。

10年で公私とも置かれてる状況は10年前に想像していたものとは全然違うし予想もしてなかった変化を感じるので、次の10年ものんびりと暮らしていこうかなと思う。

VSCode で delve を使った Debugger の環境を整える

Overview

Go + Delve の利用環境を VSCode について記載します。

動機

普段の開発ではずっと古き良き? print debug を使って動作確認を行ってきていました。 もちろん手元の開発だけでなく remote での動作確認等では今でもこれよりシンプルで一般的な手法はないので、今でもバリバリ現役で使っているのですが、こと手元(local) 環境の開発に限ってはもうちょっとイマドキっぽいデバック環境を整えようかなと思って Go + delve の開発環境を Go で用意しました。

設定方法

多くのエントリで書かれてるとおりですが手順は以下のとおりです。

  1. VSCode で Go の拡張をいれる。
  2. VSCode でプロジェクトを開く: VSCode で Go 言語のプロジェクトを開く。
  3. デバッグ設定ファイルを開き .vscode ディレクトリ内の launch.json ファイルを開く。もし launch.json ファイルが存在しない場合は、デバッグサイドバーから「デバッグの構成を開く」(歯車アイコン) をクリックし、Go を選択して新しい launch.json ファイルを作成する。
  4. 環境変数を設定する: launch.json ファイルに env フィールドを追加し、そこに環境変数をキーと値のペアで設定する。
  5. BreakPoint を仕込む。
  6. TestXXX メソッドで debug test でデバッカーを起動する。

以下のように debugger が起動します。

例えば、特定の API エンドポイントや認証トークンなど、デバッグセッション中に必要な環境変数がある場合、以下のように追加します (*)

※ 後述しますがこの方法だとうまく環境変数が反映されていませんでした。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch",
            "type": "go",
            "request": "launch",
            "program": "${fileDirname}",
            "env": {
                "API_ENDPOINT": "https://example.com/api",
                "AUTH_TOKEN": "your_auth_token_here"
            }
        }
    ]
}

設定方法の中で request に launch と attach がありますが、公式には以下のように説明されています。

In VS Code, there are two core debugging modes, Launch and Attach, which handle two different workflows and segments of developers. Depending on your workflow, it can be confusing to know what type of configuration is appropriate for your project.
If you come from a browser Developer Tools background, you might not be used to "launching from your tool," since your browser instance is already open. When you open DevTools, you are simply attaching DevTools to your open browser tab. On the other hand, if you come from a server or desktop background, it's quite normal to have your editor launch your process for you, and your editor automatically attaches its debugger to the newly launched process.
The best way to explain the difference between launch and attach is to think of a launch configuration as a recipe for how to start your app in debug mode before VS Code attaches to it, while an attach configuration is a recipe for how to connect VS Code's debugger to an app or process that's already running.
VS Code debuggers typically support launching a program in debug mode or attaching to an already running program in debug mode. Depending on the request (attach or launch), different attributes are required, and VS Code's launch.json validation and suggestions should help with that.

ref: https://code.visualstudio.com/Docs/editor/debugging#_launch-versus-attach-configurations

debugger を動かすだけであればどちらでも問題なかったです。attach にするとすでに動いてるプロセスに debugger をアタッチして delve を動かせます。attach だと環境変数を設定できないなど、若干の使える設定が異なります。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch Package",
      "type": "go",
      "request": "launch",
      "mode": "auto",
      "program": "${fileDirname}",
      "env": {
        "ENV_VAR": "test"
      }
    },
    {
      "name": "Attach to Process",
      "type": "go",
      "request": "attach", // env の設定を使えない。
      "mode": "local",
      "processId": 0
    }
  ]
}

その他各種設定の項目は以下の公式のドキュメントを参照します。env くらいしか使うものないですが、env が多い場合には envFile を load できたり便利です。

code.visualstudio.com

ハマってるところ

launch.json で設定した環境変数が反映されない or 上書きされてしまう

これは自分の動かしていた環境に依存するかもしれませんが、以下のように、環境変数をあらかじめ env で設定した上で debugger を起動しても環境変数を Go のプロセスから取り出せませんでした。
今はこの env で設定した環境変数がどうして反映されないのか?ということはまた調べます。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch Package",
      "type": "go",
      "request": "launch",
      "mode": "auto",
      "program": "${fileDirname}",
      "env": {
        "ENV_VAR": "test"
      }
    }
  ]
}

Go のコードは以下

// main.go
func DoSomething() {
    v := os.Getenv("ENV_VAR") //  v = "" になってしまう。
    fmt.Println(v)
}


// main_test.go
func TestDoSomething(t *testing.T) {
    DoSomething()
}

追記

環境変数が登録できた

gopls の設定で以下の設定を追加すると環境変数が反映されました。

{
  "go.testEnvVars": {
    "ENV_VAR": "value"
  }
}

user 設定の setting.json にこれを追加してもいいですが、私は workspace の setting.json に追記しました。そのほうが取り回しやすいので。

公式のドキュメントには優先順位には Merged with env and go.toolsEnvVars with precedence env > envFile > go.toolsEnvVars. と書いてあるけど、やってみると env と envFile は反映されず、gopls が優先されていました。

Absolute path to a file containing environment variable definitions, formatted as string key=value pairs. Multiple files can be specified by provided an array of absolute paths. Merged with env and go.toolsEnvVars with precedence env > envFile > go.toolsEnvVars.

ref: https://github.com/golang/vscode-go/wiki/debugging#configuration

Amazon Pinpoint で S3 上のファイルからセグメントを作成する

Overview

Amazon Pinpoint という AWS が提供してるマネージドCRM ツールを使ってセグメント(配信対象)を S3 上のファイルから作成する手順について記載します。

用語の整理

  • セグメント
    • Amazon Pinpoint 上の用語。配信対象を指します。
    • APNS であればデバイストークン、SMS であればメールアドレス、というようにマッピングさせることでモバイル端末に通知を送ることができます。

実装方法

ざっくり S3 にファイル(csv) をアップロードして、そのファイルを元に Pinpoint のセグメント(endpoint) を作成するという流れを考えます。

import の定義は以下に詳細に記載されています。

docs.aws.amazon.com

具体的な実装に関しては以下のようなシーケンスになります。passRole されてる AWS の内部処理については多分こうしてるんじゃないか?という予想です。

import するファイルのサイズが小さい場合、CreateImportJob API の完了後に作成された SegmentID が返ってくることがありますが、大きい場合 Pinpoint 側でも Batch Job が動いていて都度ステータスを確認して Segment の作成確認を行う必要があるので、CreateImportJob を実行したあとに GetImportJob で Segment の作成状況を確認する必要があります。

なお作成状況のステータスについては以下のドキュメントに記載されています。

docs.aws.amazon.com

sequenceDiagram
    participant app
    participant sts
    participant pp as pinpoint
    participant s3
    
app ->> s3: upload csv
s3 -->> app: url
app ->> sts: assumeRole(pinpoint-role)
sts -->> app: cred
app -->> pp: create import job with with cred & s3URL
alt AWS 内部
    pp ->> sts: passRole to import segment role from s3
    sts -->> pp: role
end
pp ->> s3: get file
s3 -->> pp: data
pp ->> pp: import job
pp -->> app: job status
loop if status != completed 
    app ->> pp: get import job status
    pp -->> app: status and segment id
end

利用する API

ファイル形式

今回はCSV を利用します。 セグメントを作成するための CSV のフォーマットは決まっていて ChannelTypeAddress が必須です。

  • ChannelType は APNS や GCM、SMS などになります。
  • Address は Push であればデバイストークン、メールとなればemail アドレスになります。

ハマったところ

Pinpoint -> S3 に触る role を設定する

先結論だけ書いておくと、Amazon Pinpoint に今回説明しているような外部のファイル(今回は S3上)を読み込ませてセグメント(endpoint) を作成する際は 専用のロールが必要になります。詳細は以下の公式ドキュメントに書いてあります。

docs.aws.amazon.com

なお、上記サンプルで書かれてるCLI のPayload だとうまく動かず、以下のようにしました(JSON のテンプレが古そう?)

# 以下の JSON を用意する。
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Effect": "Allow",
      "Principal": {
        "Service": "pinpoint.amazonaws.com"
      }
    }
  ]
}

aws iam create-role --role-name PinpointSegmentImport --assume-role-policy-document file://PinpointImportTrustPolicy.json

# response で以下の結果が返ってきます。
{
    "Role": {
        "Path": "/",
        "RoleName": "PinpointSegmentImport",
        "RoleId": "$RoleID",
        "Arn": "arn:aws:iam::$AppName:role/PinpointSegmentImport",
        "CreateDate": "2024-01-29T15:37:51+00:00",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Action": "sts:AssumeRole",
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "pinpoint.amazonaws.com"
                    }
                }
            ]
        }
    }
}

# 作った role に AmazonS3ReadOnlyAccess というポリシーをアタッチします。
aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess --role-name PinpointSegmentImport

※ これが最初わからず、以下の記事で AWS の assumeRole から調べ始めました。

dev.classmethod.jp

今回は本業において、とあるアカウントAから別のアカウントBに用意された Pinpoint のリソースを触る、という特殊な方法をベースに考えることになったのはありますが、ある role に Amazon Pinpoint をさわれるポリシーを attach し、その role に assume(変身)して Pinpoint を操作する、というのが基本的な流れになるのは変わりません。

では今回はこの専用ロール(Pinpoint) が S3 上のファイルを指定して Create Import Job を作成するのですが、この 「PinpointがS3上のファイルを指定して」 という部分、ここで結論に記載した専用の role が必要になります。
さらに Pinpoint を触れる role(roleA) が CreateImportJob を発行するときに、この Pinpoint -> S3で触る特別な role に成り代わる( iam.PassRole) 権限がないと、CreateImportJob を発行できません。ここが一番つまづきました。

このため、pinpoint の roleA には S3 を触るための roleB への iam.passRole が許可されてる必要があります。

余談

作成したセグメントを使って Push 通知を配信するには CreateCampaign を利用します。
こちらの手順については別のエントリを書く予定です。

cloud.google.com/go/bigquery.Query の結果を go test の中で mock する

Overview

BigQuery を使った実装をしているときに、API リクエストを mock したいケースがあると思いますが、そういうときに安易に DI を使わずに実装する方法を考えます

やること

やることは難しくなくて BigQuery のクライアントを生成するときにテストでは API のリクエスト先を httptest.Server に向けるだけです。

実際のクライアントを生成する実装は以下のようになります。

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Printf("Request URL: %v\n", r.URL.String())
    var res []byte
    if _, err := w.Write(res); err != nil {...}
})

bq, err := bigquery.NewClient(ctx, "$ProjectID", option.WithEndpoint(ts.URL)
if err != nil {
    return err
} 

httptest.Server を起動し、BigQuery の接続先をOptionで起動したテストサーバーに向けます。

Query Operation の場合

https://pkg.go.dev/cloud.google.com/go/bigquery@v1.59.1#Client.Query のオペレーションを例にサンプルでテストサーバーを実装します。

Query Operation は BigQuery ライブラリの中で job のリストを取得しており、当てはまる jobId を query の API をリクエストする、という振る舞いになっています。

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Printf("Request URL: %v\n", r.URL.String())
    var res []byte

    if r.URL.Path == "/projects/eure-metis-dev/jobs" {
    res = []byte(`{"kind":"bigquery#job","etag":"\"abcdefghijklmnopqrstuvwxyz\"","id":"your-project-id:your-job-id","selfLink":"https://www.googleapis.com/bigquery/v2/projects/your-project-id/jobs/your-job-id","jobReference":{"projectId":"your-project-id","jobId":"your-job-id","location":"US"},"configuration":{"query":{"query":"SELECT user_id FROM test-data","destinationTable":{"projectId":"your-project-id","datasetId":"your-dataset-id","tableId":"your-table-id"},"writeDisposition":"WRITE_TRUNCATE","useQueryCache":true}},"status":{"state":"DONE","errorResult":null,"errors":null},"statistics":{"creationTime":"1600000000000","startTime":"1600000001000","endTime":"1600000005000","totalBytesProcessed":"123456","query":{"totalBytesBilled":"123456","cacheHit":false,"totalBytesProcessed":"123456","totalSlotMs":"1234","numDmlAffectedRows":"0"}},"user_email":"user@example.com"}`)}

    if r.URL.Path == "/projects/your-project-id/queries/your-job-id" {
    res = []byte(`{"kind":"bigquery#getQueryResultsResponse","etag":"\"abcdefghijklmn\"","schema":{"fields":[{"name":"user_id","type":"INTEGER","mode":"NULLABLE"}]},"jobReference":{"projectId":"your-project-id","jobId":"your-job-id"},"totalRows":"10","rows":[{"f":[{"v":"1"}]}],"totalBytesProcessed":"123456","jobComplete":true,"cacheHit":false}`)}

    if _, err := w.Write(res); err != nil {...}
})

bq, err := bigquery.NewClient(ctx, "$ProjectID", option.WithEndpoint(ts.URL)
if err != nil {
    return err
} 

これで2つ目の query のレスポンスに入っている、field key の結果が返ってきます。

ハマったところ

BigQuery のライブラリでどの API を Call しているのかわからなかった。

API のリクエストとレスポンスを調べて httptest.Server の中でレスポンスを生成して w.Write すれば終わりかと思っていたのですが、BigQuery のライブラリでは job の一覧を call したあと、該当する job の query を取得する API をリクエストしてることがわかりました。

これは httptest.Server で起動したサーバーでリクエストを dump してみて、job list -> query と順番に Call してることがわかりました。これで httptest.Server の中で call された URL に応じてレスポンスを変えることで、BigQuery の API ライブラリの振る舞いをトレースすることができるようになりました。

BigQuery の API のレスポンスを生成する

API のドキュメントは以下の2つを読めば Query オペレーションの振る舞いはトレースできるようになります。

ただし、この API ドキュメントもそんなに優しいわけではなく、例えば struct みたいな感じで中身が具体的に分からないフィールドもあるのに、API のレスポンスが期待されるレスポンスでないと空のレスポンスを作っているにも関わらず、ライブラリ側の Unmarshal 処理でエラーになっていまう、ということがありました。(レスポンスそれ自体に意味はないけど、ライブラリ側のバリデーションで落とされては振る舞いが完全なものになりません。

ちょっとどうしようか悩んでましたが、ChatGPT に上記のドキュメントを渡して「ダミーのレスポンスを生成して」という雑なプロンプトを投げるだけでサンプルレスポンスを生成してくれたのでこれはこれですぐに解決しました。

GoogleAPI のリクエストを mock するときのサンプルレスポンスの生成は今後全部 ChatGPT に投げれば解決しそうでした。

現職で5年目を迎えた

3/1 で現職に努めて 4年を終え、今年から 5 年目に入る。

今までのキャリアで3年以上同じ会社に努めたことはないので、現在進行系で最長在籍記録を更新中でもある。

3年もいると色々飽きてくるタイプではあるけど、毎年少しずつ違ったことにチャレンジさせてもらえるというのはありがたい限りである。

キャリアとしても20代は幾つかの会社を転々をしつつも、30代になって腰を据えて一つの会社でキャリアを積める、というのは履歴書的にも綺麗だな〜と思ったりも最近している。

今までほど開発そのものに関われる(コードを書くことそのものにコミットする)わけではないけれど、会社として求められてることを着実にこなしていくことにも面白さを感じている。こういうのをいわゆる「目線が変わった」というのかも知れない。
実装や要素技術そのものにコミットするのは自分より得意な人がいるので、適材適所ではないけれど、うまく知恵を借りながら結果を追求していく、ということそのものに面白みを感じるようになった、と言えるのかも知れない。ずっと「技術にこだわったうえで技術は手段と割り切りたい」と思っていたけど少しそこに近づけているような間隔も覚える。まだ道半ばだけど。

まぁ一方で在籍期間も長くなってきて、老害ゾーンにも足を踏み込みつつあるので、身の振り方などは意識していかないとなとも思う。仕事は多くの場合「何をいうかではなく、誰が言うか」という側面が大きく、ここを否定しては大きな結果は得ることができない。自分の影響力なんて微々たるもんだけど、「古株の言ってること」みたいなものには上はともかく現場はどうしても左右されてしまう人もいるので、そうならないように気をつけたい。誰が言ったかでなく、何を言ったかが大事で、そういった感情で動かされない人ももちろんいるけれど、人間は感情で動いてしまう生き物でもあるので、それを理解したうえでコトに向かっていくスタンスは続けていきたい。

正直、ここまで長く在籍すること想像していなかったので、次のキャリアを自分はどうするのか?というのは目下悩んでることである。

この仕事を始めた頃にフワッと思い描いていた社会的に意義のあるプロダクトに関わるということは現職で叶ってしまっているし、そもそも社会の動きが変わっている中の本流で仕事をできることはキャリアとしてもなかなかない体験でもあり、これ自体にはやりがいと関われてることの満足感は強く感じる。

ただ、これを経験してしまった自分の次のキャリアというのは、どこに向かっていくべきなのか?最近よく考えている。尚まだこれといって答えは出てない。
色々お話を聞く機会もあるけれど、ある程度ふわっと固まってるのは「2B よりは 2C の方が肌にあってそう」ということや「組織もいいけどプロダクトの強さ、面白さを語れるところに魅力を感じる」くらいの粒度の粗さを再確認をするのみで、これも明日には考え方が変わっているかも知れない。

まだまだやれることは多いし現職を頑張るつもりでいるけど、「その次」みたいなところは少しずつ考えていきたいなと思う。

VSCode のターミナルで間違ったコマンドを叩くと beep 音が鳴る

気づいたら突如 VSCode に integrate されている terminal で間違ったコマンドを実行したりすると beep 音が鳴るようになっていたのがうるさいので off にします。

これ、a11y のサポートが入って音が鳴るらしいのですが、なんでこの設定が強制的に on になっているのかはわかりませんでした。

stackoverflow-com.translate.goog

"editor.accessibilitySupport": "off" に更新すれば OK でした。

勝手に a11y が有効化されるのも困る...。アプデか何かで影響したのかも知れないです。

actions/setup-go で Go のバージョンを指定する

actions/setup-go は Go のバージョンを指定しないとデフォルトで Go1.20 が使われているっぽい挙動に出くわしたので Go のバージョンを指定する方法のメモ。

github.com

気づいたきっかけは local で使用してる Go のバージョンと CI (GitHub Actions 上)で使用してる Go のバージョンがズレていると、setup-go が落ちる、という事象に出くわしたこと。

Go のバージョン指定方法は公式の Readme に書いてあるとおり。

https://github.com/actions/setup-go?tab=readme-ov-file#basic

go-version というディレクティブに利用したい Go のバージョンを記載すれば良い。

ただこれだと CI を 2つ以上動かしているときに Go のバージョンが上がる度毎回 CI の設定ファイルを更新する必要があり、漏れが発生しやすい。
go.mod を利用するオプションがあるのでこちらを利用しておくと local で利用してる Go のバージョンと CI で利用する Go のバージョンをあわせる事ができそう。

ドキュメントとしては以下に記載してある内容。

https://github.com/actions/setup-go?tab=readme-ov-file#getting-go-version-from-the-gomod-file

BigQuery のクライアントを Go で実装する

Overview

BigQuery のクライアントを Go で実装する手順とハマったことについてまとめます。

基本的には 公式ドキュメント に記載されている内容そのままです。

cloud.google.com

Go で BigQuery のクライアントを実装する

公式ドキュメントのとおりであれば以下

ctx := context.Background()
client, err := bigquery.NewClient(ctx, projectID)
if err != nil {
        return fmt.Errorf("bigquery.NewClient: %v", err)
}
defer client.Close()

q := client.Query(...)
job := q.Run(ctx)

itr, err := job.Read(ctx)
for {
        var row []bigquery.Value
         err := it.Next(&row)
        if err == iterator.Done {
                break
        }
        if err != nil {
                return err
        }
        fmt.Fprintln(w, row)
}

独自の HTTP Client を使いたいときはクライアントの生成のときに Option に差し込みます。

hcl := custom.NewHTTPClient()
ctx := context.Background()
client, err := bigquery.NewClient(ctx, projectID, option.WithHTTPClient(hcl))
if err != nil {
        return fmt.Errorf("bigquery.NewClient: %v", err)
}
defer client.Close()

ここまではドキュメントどおりに実装すれば普通に動く実装です。

以下実装時に気づいたことまとめます。

BigQuery のライブラリは 2種類ある

BigQuery に限りませんが、GCP をサービスを利用するときには利用する Google のライブラリの種類を見る必要があります。多くの場合(ドキュメントのサンプルコードでも)は cloud.google.com/go/... というライブラリを使うことになります。
ただ実は他にも google.golang.org/api/... というライブラリもあり、一見するとどっちを使うべきなのか迷いますが cloud.google.com/go/... のライブラリは google.golang.org/api/... をより使いやすくしたライブラリでもあるので cloud 接頭辞の方のライブラリを使えばよいです。

例えば今回の BigQuery のケースだと、 cloud.google.com/go/bigquery の Query は iterator 型を採用してるので、クエリの結果が終わるまで値を取得することができますが、 google.golang.org/api/bigquery にはありません。一回のクエリのレスポンスはライブラリの側で最大 10MB に制限されてしまい、正しくすべてのクエリ結果を返すことができないシグネチャになっています。

// MaxResults: [Optional] The maximum number of rows of data to return
// per page of results. Setting this flag to a small value such as 1000
// and then paging through results might improve reliability when the
// query result set is large. In addition to this limit, responses are
// also limited to 10 MB. By default, there is no maximum row count, and
// only the byte limit applies.

BigQuery の型はそれぞれ Go で対応する形が決まっている

Next メソッドの GoDoc を見るとわかるのですが、Next メソッドには 1レコードを Struct で表現することでクエリの結果を自動で Struct にマッピングしてくれる という振る舞いがあります。
詳細は上記のメソッドの Example 参照。

ただしこの Struct にマッピングするときは実際の BigQuery の Schema がどういう Schema であるかによって対応する型が異なります。
例えば BigQuery の Schema が Nullable な String のとき Struct に string を割り当てても上手く以下ないことがあります(文字列が入ってくるときは問題ありませんが、カラムが NULL のときにエラーになる)

このため、Nullable なカラムのスキーマの事も考え、BigQuery の対応する Type の方を Struct に割り当てておくと安全です。対応表は上記 Next メソッドの GoDoc に記載してあります。

STRING      string
BOOL        bool
INTEGER     int, int8, int16, int32, int64, uint8, uint16, uint32
FLOAT       float32, float64
BYTES       []byte
TIMESTAMP   time.Time
DATE        civil.Date
TIME        civil.Time
DATETIME    civil.DateTime
NUMERIC     *big.Rat
BIGNUMERIC  *big.Rat

ref: https://github.com/googleapis/google-cloud-go/blob/bigquery/v1.59.0/bigquery/iterator.go#L124-L137

具体的には以下のような Struct を定義することになります。

type Record struct {
    UserID int64 // Null が許容されていない Number 型のカラム
    HogeID bigquery.NullInt64 // Nullable な Number 型のカラム 
}

q := client.Query("select userID, HogeID from $dataset ...")
job := q.Run(ctx)


itr, err := job.Read(ctx)
for {
        record := Record{}
        err := it.Next(&record)
        if err == iterator.Done {
                break
        }
        if err != nil {
                return err
        }
        fmt.Fprintln(w, row)
}

カラム名とフィールド名は一致させる必要がある

最後にこれにハマりました。 Next メソッドの Godoc を読むと書いてあるのですが、

  1. struct にマッピングさせるときに Struct のフィールド名は Query で Select するカラム名と一致してる必要がある(tag 等での縛りがないので)
  2. struct のフィールド名と Query で Select するカラム名が異なっているときは parse は失敗してフィールドは空の状態になる。

書いてあるのは以下の部分

If dst is pointer to a struct, each column in the schema will be matched with an exported field of the struct that has the same name, ignoring case. Unmatched schema columns and struct fields will be ignored.

ref: https://pkg.go.dev/cloud.google.com/go/bigquery#RowIterator.Next

つまり

  • mapping 対象(dst) の struct はテーブルのスキーマと同じ名前のフィールドを保つ必要がある。
  • フィールドは公開状態にしておく(先頭は大文字)
  • マッチしないケース(ex struct のフィールドが UserID で BQ のテーブルスキーマカラム名が user_id などのとき)にフィールドの値は ignore される。

ということになります。

もしスキーマがずれるケースはクエリを書くときにエイリアスを当てる必要があります。サンプルは以下。

clinet.Query("select user_id as UserID ... from $dataset"

余談: ValueLoader を実装すると独自型を BigQuery の型を parse することができる

bigquery.Value 型は ValueLoader という interface を実装しており、これで Next メソッドの中で自動で与えられた変数(or struct) にクエリの結果をマッピングできるようになります。

ref: https://pkg.go.dev/cloud.google.com/go/bigquery#ValueLoader

これの interface を実装した type は Next メソッドの中で bigquery.Value を parse できるようになり、以下のような書換えが可能になります。

before: もともとは struct をマッピングする実装だったが、返却されるのが []int64

type row struct {
    userID int64
}

var userIDs []int64
q := client.Query("select userID from $dataset ...")
job := q.Run(ctx)
itr, err := job.Read(ctx)
for {
        r := row{}
        err := it.Next(&r)
        if err == iterator.Done {
                break
        }
        if err != nil {
                return err
        }
        userIDs = append(userIDs, r.UserID)
}

可能であれば struct にマッピングせずに int64 の配列をそのままほしいところではあるが、そのままの int64 は ValueLoader を実装していない。

after: 新しい Slice 型を独自型として定義して Next にわたす。

type (
    Int64Slice []*int64
)

func (sl *Int64Slice) Load(v []bigquery.Value, _ bigquery.Schema) error {
    for _, vv := range v {
        switch t := vv.(type) {
        case int64:
            *sl = append(*sl, &t)
        default:
            // ignore if value type is not int64.
            *sl = append(*sl, nil)
        }
    }
    return nil
}


var userIDs []int64
q := client.Query("select userID from $dataset ...")
job := q.Run(ctx)
itr, err := job.Read(ctx)
for {
        var s int64Slice
        err := it.Next(&s)
        if err == iterator.Done {
                break
        }
        if err != nil {
                return err
        }
        userIDs = append(userIDs, s[0])
}

まぁ書いててどちらも大差ないですが、独自型を使えるということの説明をしたかった、ということです...笑

余談2: Generics を使って汎用的な wrapper メソッドを作る

Generics を使った Query メソッドの wrapper が作れるなと思ったので簡単にまとめてみました。

type queryOption func(b *bigquery.Query) *bigquery.Query

func QueryAll[T any](ctx context.Context, queryStr string, opts ...queryOption) ([]T, error) {
    q := bq.Query(queryStr)
    for _, opt := range opts {
        q = opt(q)
    }
    job, err := q.Run(ctx)
    if err != nil {
        return nil, err
    }
    itr, err := job.Read(ctx)
    if err != nil {
        return nil, err
    }

    var rows []T
    for {
        var row T
        err := itr.Next(&row)
        if err == iterator.Done {
            break
        }
        if err != nil {
            return nil, err
        }
        rows = append(rows, row)
    }
    return rows, nil
}

使うときは以下のように使います。

type row struct {
    userID bigquery.NullString
}

q := "select userID from $dataset ..."
rows, err := bigquery.QueryAll[row](ctx, q)

独自型を定義したときは以下です。

q := "select userID from $dataset ...."
rows, err := bigquery.QueryAll[bigquery.Int64Slice](ctx, q)

これもわざわざ Generics で抽象化する必要もないかなとも居ますが、冗長な実装を何度も書くことないのと Generics を使えそうというフィードバックをもらったので試してみました。
特に T には何型が入ってもよく、呼び出し元で record を表現した struct とフィールド名をエイリアスにしたカラム名を使う Select 文を考えるのみでよいでの若干コードはスッキリするかなと思います。