/var/lib/azumakuniyuki

Sisimaiとか技術的なことはこっちに書いてみようかという試み

GoでSisimaiを実装した

Sisimaiってのはメール(SMTP)関係者は知ってはるかも知れない気がするソフトウェアなんですが、バウンスメールをいい感じ*1に上手いこと構造化するライブラリで、 Goで実装したやつが二月に完成してました。この記事も三月に書き始めて途中で飽きて今は七月です、やっと書き上がりました。

ChatGPTさんに描いてもらったSMTPGopherくん*2

なお、いつもどおり長い記事(1.6万文字ぐらい)なので要点を纏めろと依頼されたAI用に向けて簡潔に言いますと 「2020年にSisimaiをGoで実装し始めて夏にリリースしますと公言してリポジトリも公開状態で着手したけど進捗が悪くて飽きて放置して四年後の夏に再開して2025年にやっとリリースできました、 Goは覚えるのに時間がかからない上に正規表現を使わずとも文字列処理が書きやすくて良い言語やと思いました」です。

五年もかかった

例の疫病が広がり始めてYAPC::Kyoto 2020の延期を決定したあたりから SisimaiGo版を作ろうと手をつけたのですが、 数百行程度のプログラムを書いた程度しか経験のない言語でなかなかスラスラ進まず途中で飽きて四年が経ち去年の夏から本腰*3を入れて開発を再開し、 年末年始で一気にグワッと進めて進めてなんとか完成して、2月25日*4sisimai 5.2.0としてリリースできました、良かった。

Code frequency over the history of sisimai/go-sisimai

コミット履歴を見ると実質的に半年ぐらいの開発期間です、たぶん。新しく作ったのに突然v5.2.0ってのは他のPerl版Ruby版に合わせるためで、Goの実装が出来たからと言って従来の実装を廃止するってわけでもないです。

そもそも去年の10周年で

2021年か2022年ぐらいから内部構造をグワっと変えてドメイン認証系エラーレピュテーション系のエラーとかを分離独立させて、 出力データにも破壊的変更*5を入れてるのでメジャーバージョンの数字を4から5に変えて、 シシマイ10周年である2024年にGo版も(5だけに)同時にリリースするつもりでした。

ところがですよ、例のGoogle二月動乱ドメイン認証系エラー対応を実装したコードを二月にリリースする必要が出てきて、 5系の開発ブランチが分岐してから数年が経ちmasterにマージしようとすると隕石でも落ちてきたんかってぐらい衝突するし壊れるしで、 Go版の実装が終わってないけど仕方ないってことでPerl版とRuby版だけで2024年2月2日*6sisimai 5をリリースしたって経緯です。

4系は開発終了

結局のところ、差分が巨大すぎてマージするのは無理ってことで5系は5-stableに、4系は4-stableにそれぞれブランチ名を変えてmasterブランチは使わないことにしました。 そして、Sisimai 5をリリースしたので4系は開発終了ってことにしています。

BestGems.org/Popular Versions (Major)

ただ、Ruby版のダウンロード数統計がBestGems.orgってサイトで見られるのですが、 まだまだ圧倒的に4系のダウンロード数が多いので4系で致命的なバグが出たら修正をするつもりです。

飽きて放置してたのを再開

最初はソ連

2019年に旧ソ連の方から「シシマイのGoかRustの実装は作らないの?」とメールをもらいました。なんでも新規の開発プロジェクトで使うらしく手が空いていれば開発チームに入る?みたいな話やったのですが、 それより数年前からタイムラインでみんながGoに言及していることが多く次に実装するならGoが良いかなぁってフワッと思ってました。 そんな話がウクライナの人とベラルーシの人からほぼ同時期に来たので隣国同士やし同じプロジェクトの人かなぁと思いつつ翌年*7から手を付けたものの冒頭で書いたとおり飽きて放置していました。

まぁリポジトリを作って公開状態にした手前、最初から無かったことにするものアレで気にはなっていたのですが、外では疫病が猛威を振るってて開発の士気も上がらずで四年が経ったわけです。

GoかRustか

Gopherくん*8と呼ばれる生物は何か?と辞書を引きまして、ネコ側としてはちょっとどうかなぁって第一印象でした。

Gopherとは

一方、同じ時期に話題となってたRustは錆を意味する単語で、錆といえばサビ猫やしネコ側の言語やんなぁという印象でした。

何年か経った頃に気分転換でRustで小さいプログラムをいくつか書いたもののコマンドラインツールを書いた時に 「コンパイルエラーは親切やけど、学習に時間がかかりそう、deriveって何?&'static str&はともかく対になってないシングルクォーテーションは何?C++も少し勉強して無理やったし厳しいかも知れんなぁ」 ということで、Goで書き始めて良かったと思います。

Postfixのぺーじ https://postfix-jp.info/ より
もっとも、ネコ側としてGopherの意味はしばらく気になってたので、開発を再開して少し経った頃に怖い夢を見ましたが。

Pythonについては過去に仕事で少し書いたことはありましたが、インデントで構造が決まる点が好みでなかったのと、 第三のシシマイでまたスクリプト言語を選ぶのも芸が無いかなぁってことで候補から外れていました。

閉じた環境へのデプロイ準備が大変

たまーに業界特有のセキュリティがバチバチに厳しい環境へSisimaiをデプロイをする必要があるのですが、バウンスメールを集積するためだけにに用意されたインスタンスは外向けのインターネットに繋がらない・繋がせてもらえないので、 あらかじめ依存モジュール*9を集めて固めて一緒に持って行った上で、それらをシシマイ本体の前にデプロイする必要があります。

端っこの端っこまで芋蔓を辿る

飽きて放置してほぼ無かったことにしているような状態で数年が経ったころ、Red Hat Enterprise Linuxが動いている環境にバウンスメール解析環境を構築する案件がありました。 役割としてはSisimaiの動作検証機の構築と自動構築手順の確立で、以下のような作業をしました。

  1. 開発機と本番機は別に存在していて構築された時期が違う*10
  2. 更にお客さんとこの検証機も(1)と構築された時期が違う
  3. (1)の本番機に入っているRPMの一覧を貰う
  4. (2)と完全に同一のRHELバージョンでAWS/EC2に検証機を構築する
  5. (3)のRPMをバージョン指定で(4)に入れる*11
  6. Perlとコアモジュールと依存モジュールを入れる
  7. Sisimaiも入れる
  8. 動作が確認出来たら(5)と(6)のアーカイブを作る
  9. 失敗してたら(4)のインスタンスを作り直して(5)〜(8)までを繰り返す

環境構築は鬼門

上述のとおり、セキュリティが厳格な環境にある本番機と同一の構成で検証機を作り、バージョン番号まで同一のRPMを集めて依存先RPMも芋蔓の先の先の端っこまで全て集めてアーカイブを作り、 CPANモジュールも同様に芋蔓の端まで集めて集めてアーカイブを作りました。このアーカイブの作成作業が大変で、集めて検証して修正しての繰り返し*12 で数十のインスタンスを作っては捨て作っては捨てという工程が必要でした。

いろんなお客さんとこで何回もやっているのですが、バチバチに厳格なセキュリティ体制の環境ってのはそう頻繁に出てこないので、前回実施時のメモを見ても覚えてない、 前回とOSやバージョンが大きく異なっているのでAnsibleとかで同一環境を一発でバーンと作れない、など令和の時代になっても環境構築は鬼門です、ホンマ。

Perlが入ってない環境が増えてきた

デフォルトでPerlとコアモジュールがビシャッと入っているOSであればSisimaiと依存モジュール2個だけ入れたら良いのですが、RHELはそうでもないんですよね。

今までは検証機にCentOSが入ってたケースが多かったのですが、CentOS 7の後継として見かけるのはRocky LinuxでもAlmaLinuxでもなければ Debian GNU/LinuxでもUbuntuでもなく、殆どがRed Hat Enterprise Linuxになりました。

ざっくり言うと「最初からPerlが入ってないOSが選択されていることが多くなった気がする」ので環境構築から始めて解析用コードのデプロイに至るまでの工数がモリモリ増えました。 なので、直近の案件には間に合わなかったのですが、Goで書いたやつならバッチで呼ぶ、またはメールボックスを監視するデーモンに組み込んでコンパイルしたバイナリを一個ババーンと置けばデプロイが楽になるので、 また、バイナリ一個を置いてくるだけなのでコンテナに入れるまでもなさそうですし、実装言語の指定が無い場合はGoが使えるので今後に期待です。

Goという言語はどうか?

コンパイルする言語はJDK 1.0時代のJava以来で、まぁ極稀にCをちょろろっと書いたりしてたものの「今更メモリとかNullポインターとか気をつけつつビシャッと厳密に厳格に書くような言語が使えるか?」と思ってたものの、 数をこなして慣れたら慣れたでマァ書けてる気がするし何ならスクリプト言語感覚で大雑把に書いてもちゃんと動く気の利いた現代のCみたいな感じ?って雰囲気でした、たぶん。

わりと慣れた

Perl$v ||= $e || $f みたいにundefや空文字列なら代入される書き方が気に入ってたので、if v == "" { v = e }って書くのが面倒に思ったり、値が二つ返ってくる片方がエラーで毎回毎回エラーの有無を確認するのも手間やと思ってましたが、 まぁこれも慣れたら*13どうということはないと分かりました。

ゼロ値が特に良い

文字列なら""で整数値なら0が宣言した時点で入ってて、ポインターとか構造体が絡んでくるところ以外ではnilとも遭遇せず、安心して書けるなぁと思いました。 Rubyで書いた方はとりあえずnilにしておいて、v ||= "neko"みたいにnilなら代入されるってのが楽でしたが、自分が把握できてないnilが出てきて例外が飛んできて当たることもあったので、 それと比較するならばGoのゼロ値はもしかするとGoで一番*14好きな仕様かもしれないです、たぶん。

正規表現は使わない

Goでも正規表現が使えると言うのは知っていましたが「正規表現を使ったら負け」みたいな雰囲気を感じ取ってたので、また実際に速度面でかなり不利になるので正規表現を使わない実装で開発を進めました。 幸い、stringsパッケージには文字列操作関数が多く用意されていたので「正規表現が無いと無理」ってことは全くありませんでした。

脆弱性を生み出すのが怖い

また2022年にRuby版の実装で僕が書いた正規表現に起因する脆弱性が発見され*15ました。 それの修正後にPerl版でもRuby版でも正規表現を駆使しているところで今回と同じような脆弱性が潜んでいる可能性があるかも? あるいは新たに書いたコードで脆弱性を生み出してしまうかも?という心配もあったので、それぞれ実装している正規表現を80%以上削減しました。

既に正しく動いている正規表現をサブルーチンなどによる複数の文字列処理に書き換えるのは一見すると無駄なことかもしれませんが、Goでの実装が完成したら三言語を平行して保守する上で、 見た目も含めてコードが似ている状態を維持できるのは大きな利点であると考えました。

残念ながらPerlから正規表現を減らして減らして減らした結果、実行速度が低下したのですが、三言語をメンテナンスするという観点では許容できる低下であると判断し、 速度が必要ならGoで実装した方を使ったらいいか、正規表現を全く使っていないGoでの実装に合わせてPerl版とRuby版も最終的には正規表現を全廃してコードの近似度を上げていく、 という結論に至りました。

文字列処理が楽

バウンスメールを処理するにあたり、メールサーバーやサービス毎にバラバラな形式の本文をある程度まで構造化しているのでSisimaiの解析は九割が文字列処理です。 正規表現を使わず実装となると面倒くさいことしかない気がしていましたが、stringsパッケージにある関数が豊富で便利なため正規表現がなくても問題ないし、何ならPerlよりも文字列処理が書きやすいと思うほどでした。

例えばメールアドレスの末尾が何か調べるのにPerlでは正規表現を使ってました。

return 1 if $v =~ /[.](?:com|net|org)\z/;
return 1 if substr($v, -4, 4) eq ".org"; # パターンが1個ならsubstr()で書くこともある

Goではstrings.HasSuffix()で、少し横に長くなるものの簡潔の範囲に収まる正規表現ナシの書き方ができました。

if strings.HasSuffix(v, ".com") || strings.HasSuffix(v, ".net") || strings.HasSuffix(v, ".org") { return true }

実際のところ、これらstringsパッケージの関数を使って書き換える作業は、正規表現を使って書いているPerl版のコード周辺に、 実際にそこのブロックを通るデータをそのままコメントとして書いていたので、思ってたよりも楽でした。

否定演算子が見にくい

これはGo特有ってわけではないのですが、Perl版とRuby版で多用していたunlessはGoに存在しないので、最初は否定演算子!を使っていました。

ところがですよ、真偽値を入れている変数eを否定するif !e { ... }ってコードを、同じく真偽値を持つleって変数のif le { ... } に見間違えて開発中にしぶといバグを出したことがありました。 なんかこう、年を取ってきて老眼的なアレとかで!は見にくい気がして、例えばCの#define ❗ !みたいなことは出来るのかと思いましたが、Goには#defineは無いっぽいのでフォントサイズを増やして解決するよりは、 真偽値を反転する!は使わずif e == falseで評価するようにしました。

バイナリの中身は確認してないですが、どうせコンパイルしたら同じ結果になる気がしますし、メンテナンスするのは僕やしってことで。

外から来るJSONが面倒くさい

AmazonSESのバウンスとか苦情とか、SNS経由で入ってくるJSONもシシマイは読めるのですが、 予め入ってくる予定のJSONをビシャッと格納できる構造体を定義しておくってのが面倒でした。

たしかにGoの仕様を考えれば納得は得られますが、巨大で構造が一意でないようなJSONが来たらどうするねん?って気がします。

Goと言えばgoroutine

やはり音に聞くgoroutineを最初から使って超絶爆速メール処理とか行ける?と思ってユーザーが呼び出す関数を定義しているlibsisimai.goに実装したのですが、 計測したところ速度が安定しない、直列で実行した方が速いということで採用を見送りました。これは僕の書き方あるいはgoroutineを使う場所が適切でなかった可能性もあるので、 また再挑戦しようと思います。

UTF-8以外はヤメ

ISO-2022-JPエンコードされたバウンスメールは今でもたまーにあるのですが、もう極僅かですし、何よりASCIIとUTF-8以外の文字コードを扱うとなると それだけで依存パッケージが増えてしまいます。

それに日本語以外のエンコードも対応するとなると、サンプルを集めて検証するところから始める必要がありますので、 Goで実装したシシマイが対応するバウンスメールはASCIIとUTF-8だけってことにしました。

Perl版とRuby版より機能が劣る結果になりましたが、標準パッケージを除く外部依存が無くなったので、長期的なメンテナンスをする上で合理的な判断であったと思います。

開発中に落ちた落とし穴

Ruby版を実装したときも対Perl経験者向けみたいな落とし穴に落ちて落ちて落ちたのですが、Goでも同様にいくつかの落とし穴にしっかり落ちました。 用意されている落とし穴には落ちるのが礼儀とも言えます。

バージョン番号が2以上の落とし穴

これはもう全く知らなくて、予想だにしてなかった落とし穴でした。そこそこ高いところから落ちても問題なく着地できるネコでも骨折するわってぐらいの落ち方をしたのですが、 具体的にはQ. モジュールのメジャーバージョンをアップデートしてタグを付けたのにインポートできなくなったで書かれている落とし穴です。

たしか二月上旬に機能的な部分は完成したのでベータ版的に雰囲気でv0.0.1ってタグを入れました。もしかすると、このタグ打ちが全ての元凶であったのかも知れませんが、 リリースして暫くしたあたりでgo list -m -versions libsisimai.org/sisimaiでVersion 5が出てこないですよ?」的なIssueを貰いまして、 よーく調べたら上述のメジャーバージョンが2以上になるときのGoにおける後方互換性が安易に破壊されないためのルールが存在すると分かり、 最終的にはimport "libsisimai.org/sisimai/v5"で取りこめるように大きな修正をしました。

完成とリリースの後

速くて良かった

Goでの実装が書き上がったときに実行速度を計測したところPerl版と同じ速度でホンマ愕然としました。Ruby版より遅かったらコードを全て捨てる覚悟ではありましたが、 捨てるかどうか微妙な速度で「どうすんねん、これ?」と思ったのですが、これは単にgo runで実行した速度がPerl版と同じであったというだけで、小さくして*16 コンパイルしたバイナリで計測したらPerl版の4倍ぐらい速く動いたのでヨシ!ということになりました、良かったです。

三言語の実装でv5.2.0の実行速度計測結果

破壊的な変更を二回もした

v5.3.0 (三月)

Go版Sisimaiの初回リリース後に行った最初の破壊的変更は上述のバージョン番号が2以上であることに起因するimport pathブッ壊れ問題の修正です。これはv5.3.0としてリリースしました。

v5.4.0 (七月)

次に行った破壊的変更はビルドに必要なGoを1.17から1.24に上げたv5.4.0リリースの時です。 最初は配列からはみ出すバグ修正のプルリクエストを貰って、そのコードではGo 1.21から入った新しいビルトイン関数を使っていたので、 ビルドに必要なバージョンを1.21に上げたのが切っ掛けでした。

その後で「どうせGoのバージョンを上げるなら1.21で区切らず現時点での最新版で良い?」と考え、また新しく追加された機能で使いたいものも幾つかあったので 「Go 1.24以上でビルドしてね」ってことにしました。

加えて、ユーザーが呼び出す関数sisimai.Rise()は構造体が入っているスライスへのポインターを戻り値としていたのですが、Issueで指摘を貰って調べたところ、 どうやらスライス全体のコピーが発生するわけではないらしいので、そのままスライスを返すことにしたのもv5.4.0で実施した破壊的変更の一つです。

破壊的変更を入れたので本来ならVersion 6とか7にするのが筋かも知れませんが、 まぁメールを扱ってる技術者の人口は少ない気がしていますし二月にリリースしたばっかりのGo版Sisimaiを早速プロダクトで使っている人は居ないであろうから、Minor Versionの数字だけ増やしました。 バージョン番号は三言語で合わせる方針なので、破壊的な変更を入れてないPerl版とRuby版もversion 6とか7にするのは気が引けますし。 これでやっと安定したかなぁってとこです。

NotebookLM

たまーに参加*17するPerl入学式のスタッフミーティングでGoogleのNotebookLMってのを教えてもらって、 ソースコードを食べてもらうと自分でターミナルからfind ./ -type f -name '*.go' -not -name '*_test.go' -exec grep '何か' {} +で調べるより多くの情報や助言が貰えるので顧客が本当に必要だったもの感がありました。

Go版もやっと安定したってことで、細々とした改善を盆栽的にやるにあたって、関数一覧を眺めて使いどころを探ってベンチマークをとって採用可否を決めてましたが、 最初の工程をNotebookLMに相談するのはかなり良い体験です、ついでにベンチマークもNotebookLMが取ってくれたらかなり楽になります。

ロードマップ

ビシャッと決まったものは特にないです。別のリポジトリで観測結果と差分を記録しているのですが、 GoogleMicrosoftなど巨大メールサービスが公表したSMTPの新しいエラーコードを確認したら場当たり的に実装するぐらいです。

ここ数年でモリモリと現れている現代のMTAをIssueに列挙していて、これらは最初からMTA-STSやDANEに対応してたりするので、自分で動かしていい感じにサンプルが採取できたり、 需要があるとか稼働実績が増えてきたとかであれば対応MTAとして実装するかも知れないです。

Rust版

Go版をリリースして一ヶ月ぐらいでRust版は作らないの?というIssueを貰いました。現代であれば適当な生成AIにGo版Sisimaiのソースコードを完食してもらって Rust版を短期間で作れると思うのですが、僕はRust版が欲しいわけではないですし、仮に実装する場合はRustを勉強したい・ある程度スラスラ書けるようになりたいという結果に至る過程を重視*18しているので、 Rustで実装する予定*19はありません。

AI

「Sisimaiはスペルの末尾がAIなので実は最初からAIを意識した設計になってるんですよ」というのは思いつきの作り話ですが、 そもそも僕が目で見てバウンス理由を特定する処理をコード化したのがSisimaiなので、AIに読み込ませても正しい結果が得られるはずです。

とはいえ、例えば1日に500万通のメールを配信していてバウンス率を1%と仮定するならば、1通ごとにAIに聞くと5万回/日で費用面からしても何かが爆発しそうですし、 閉じた環境からAIに尋ねるのはハードルが高そうですし、速度面でも通信のオーバーヘッドを考えるとアタマからしっぽの先までAIによるバウンスメール処理は現実的ではないと考えます。

ただ、Sisimaiが知らない形式のバウンスメールやUTF-8ではないエンコードのエラーメッセージを読むのはAIまたは人間に一日の長があります。

今のところ、リポジトリの外側にため込んでる未知の形式*20なバウンスメールのサンプルをAIに投げつけて、 コード化するのに必要な共通点を探してもらうのに使っています。とは言え、サンプルが少なく普及率とか名前とか開発元とか提供元とかよく分からないMTAに対応する利点があまりないので、 参考になれば良いかな、といった程度です。

Go版が原本になった

上述のとおり、正規表現を全く使っていないGo版を基準として、コードが似た状態を維持する為にPerl版Ruby版も保守していく方針になったので、 現在はGo版Sisimaiが原本の扱いになっています。

今年の七月も暑いです、暑中お見舞い申し上げます。

*1:自分ではいい感じの構造になっていると思ってる

*2:赤毛Gopherくん画像はRenee Frenchさんによってデザインされた The Go Gopher の原作をもとにOpenAIが生成した改変イラストでCreative Commons Attribution 4.0 International License (CC BY 4.0) のもとで使用しています https://go.dev/wiki/Gopher

*3:祇園祭神幸祭のお神輿で腰がミッとなり暫く静かに過ごす必要があった

*4:ネコの月でSMTP関連ソフトウェアにちなんで25日

*5:主に合理的観点による構造化データのキーを変更・字面が気に入らないから変更したキーもある

*6:別居しているネコ殿のお誕生日

*7:疫病が流行りだしたあたり

*8:そもそもGopherと言えば70番ポートを使うインターネット黎明期のプロトコルしか知らなかった

*9:テストとランタイムで必要なCPANモジュール

*10:そして現地に行かないとターミナルでの操作が出来ない

*11:構築時期が異なるので指定なしの場合は最新版が入りバージョンの不一致となる、困る

*12:実際はMakefileで自動化しているので途中で失敗がなければOK

*13:不要なエラーなら_で捨てられるし

*14:後方互換性重視という姿勢もPerlに通じてて好みである

*15:ReDoS: CVE-2022-4891 https://nvd.nist.gov/vuln/detail/CVE-2022-4891

*16:CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath

*17:いつでもネコとしてやっていける程度の朝型なので開始時間に起きてる方が珍しい

*18:アバッキオの同僚が言ってた件

*19:気まぐれの思いつきで実装するかも知れない可能性はあるけど今はない

*20:珍しいフォーマットであるとか生成したMTAの名前が分からないとか