jsondiff: JSONの構造の一部を無視して差分をとれるGoのライブラリを書いた

github.com

背景

仕事でお世話になっているkayac/ecspressoの機能の中にローカルのタスク・サービス定義と現在使われている定義を比較して差分を出力してくれるものがある。

github.com

これから加えようとしている差分をプレビューできるだけではなく、たとえばデプロイしようとしているわけでもないのに差分があればローカルの定義が古びていることがわかるのでCIに組み込めると便利。

しかし実際に使おうとすると困る点が見つかった。

たとえばタスク定義にイメージタグを書く際に {{ must_env 'IMAGE_TAG' }} のように環境変数を参照している時に「イメージタグ 以外 に差分がない」ことを確認するのが難しいということ。

理想的には image を無視したJSONの構造を比較して差分が出せると良い。あるいは出力されるdiffをパースして image の差分は無視するとかが考えられる。

が、いずれもecspressoの機能とするにはだいぶ領分を外れているので、Pull Requestを送ろうにもましな実装が思いつかないのでどうしたものか。

ECSのタスク・サービス定義に限った問題ではなくJSONのdiffを取る時に一部構造を無視できれば良いので、JSONの差分を取るライブラリを作ることにした。 それがaereal/jsondiffになる。

紹介

ドキュメントのExampleを見てもらうとわかりやすい。

jsondiff.Ignore(query /* gojq.*Query: ".a" */) みたいに使う。

無視するJSON構造はgojqのクエリが使える。

github.com

gojqはid:itchynyさんによるjqのGo実装で、CLIコマンドだけではなくライブラリとしても使える。

他にJSONの構造を指定するクエリ言語のようなものというとJSON Pathが考えられたが、自分が普段JSONの構造を走査する用途ではjq (gojq) を使っているしライブラリ実装を提供していることを知っていたのでgojqを選んだ。

実装のポイント

.a, .b, .c みたいなクエリを .a = null | .b = null | .c = null のようなクエリに変換し、これを実行した結果を比較することで特定のキーを無視する機能を実現している。

元となる .a, .b, .c というクエリの構文木は以下のようになっている。

f:id:aereal:20220324001407p:plain

これを変換して .a = null | .b = null | .c = null というクエリにしたい。構文木としては以下の通り。

f:id:aereal:20220324001404p:plain

この木を作るには右端から作り、最後に処理したノードの左辺を更新していくのが合理的。

リストの右から処理していくといえばfoldRightである。しかしGoにそんな高級な関数はないのでfor文でデクリメントしていく実装になった。

see jsondiff/diff.go at 7e60f563b3601f48e6d4bf8c43210e4ac4614087 · aereal/jsondiff · GitHub

これを可視化すると以下のようになる。

f:id:aereal:20220324001415p:plain f:id:aereal:20220324001412p:plain f:id:aereal:20220324001409p:plain

こう淡々と書くと「ふーん」という感じだけれど、 len(xs) - 1 から開始してデクリメントしていけば末尾からイテレートできることに気がついた時はちょっと気持ち良くなれた。

むすび

当初欲しいと思えたものが作れて満足。

これをecspressoやlambrollに入れるかというと、どうしようかなと思っている。

というのもCLIコマンドも作ったので、 ecspresso render させてそれを比較させるのでも良いのではと思っている。

いずれにせよライブラリとCLIコマンドの両方を作っておくことで、外部からも単体でも利用しやすくなるというのはgojqのお世話になって実感したので良い物作りができた。

ソフトウェアエンジニアリングですべてを薙ぎ倒す2022

これまで

色んなチームにヘルプで入ってプロジェクトやチームを良い感じにする手伝いをすることが主で、他に基盤サービスを作って運用したり、あとは開発者ブログ編集部をやったりなどなど。

現職での1年半くらいのキャリアにおいてソフトウェアエンジニアリングは高々3割くらいで、あとはプロジェクトやピープルをマネジメントするような仕事が占めていた。

振り返り

前職でもWevoxを使っていて、その当時から振り返ってもありえないくらい低い「自己成長」スコアがここ最近ついていてクソワロタ (真顔) 状態だった。

このままだと退職 or dieしかないと感じたので、上司には「もうしばらくプロジェクトマネジメントはやりたくないでござる 絶対にやりたくないでござる」と上申して新しいチームへの配属を希望した。そしてそれは叶えられた。

Webサービスを作って届ける過程でプロジェクトやピープルをマネジメントすることの必要性は理解しているし、特に 今の 現職がそれら領域への梃入れを求めていることも理解している。

それでも 自分が やりたくないとは感じていたものの、義務感が勝っていたので受け入れていたが、我慢できなくなったという次第。

まずもって自分は人間を取り扱うのがとても苦手で、平均程度にはできるとは思うものの心的負担がめちゃくちゃ高い。

また自分はソフトウェア構築の道を究めたいと考えており、プロジェクト・ピープルマネジメントは近接する分野であるがそのものではない。 このため有限の時間を使いたい分野に割けていない状況に強いストレスを感じており、それがWevoxのスコアにも表れていたということだろう。

ソフトウェア構築のエキスパートとありたいのであって、プロ労働者になるつもりもなったつもりもない。 仕事は選びたいし仕事を選べる立場でありたいと思うし、自分がやりたくないことをやっているがためにこんな損失がありますよという主張に説得力を持たせるだけのスキルを備えておきたいと考えている。

そういうありかたを望んでいる自分にとってスキルを研鑽できない状態に陥ることは、まったく大仰ではなく自身の人生が破壊されることと同義である。

新しいチームでやりたいこと

掲題の通りソフトウェアエンジニアリングですべての障害を破壊していきたい。

「運用でカバー」に相当する作業を自分から徹底的に排除していきたい。 結果的にそうなるとしても、まずソフトウェアエンジニアリングで解決できないか問いたいし、それを端から諦めるのはエキスパートとしての怠慢だと叱咤する。

これがDigital Transformationや!!!! というのを見せ付けていきたい。

そういえば5年くらい前にも似たようなこと書いていたなあと思い出したので置いておく: 「ついカッとなって……」取り組んだ 開発者のための開発 で業務効率を改善させた話 - エンジニアHub|Webエンジニアのキャリアを考える!


以上の文章は社内esaに書いた文章を加筆修正しました。

定期的にプロ労働者になりたくないと言っているなと気付いたのでその再確認。

injecuetにTerraform stateを参照する機能を追加した

injecuetとは: injecuet: CUEに環境変数を注入する便利CLIツールを書いた - Sexually Knowing

これまでCUEに埋め込むデータソースとして環境変数だけだったのですが、新たにTerraform stateを参照する機能を追加しました。

こういうCUEがあって:

@inject(tfstate,stateURL=./terraform/ok/terraform.tfstate)

name: string @inject(tfstate,name=output.user.name)
age: int @inject(tfstate,name=output.user.age)

terraform/ok/terraform.tfstate がこういうかんじだとして:

{
  "version": 4,
  "terraform_version": "1.1.6",
  "serial": 2,
  "lineage": "3124ddff-8837-9bb1-a0d6-fe4fd14969aa",
  "outputs": {
    "user": {
      "value": {
        "age": 17,
        "name": "aereal"
      },
      "type": [
        "object",
        {
          "age": "number",
          "name": "string"
        }
      ]
    }
  },
  "resources": []
}

injecuet ./tfstate.cue を実行するとこんな風に解決されます:

{
    @inject(tfstate,stateURL=./terraform/ok/terraform.tfstate)
    name: "aereal" @inject(tfstate,name=output.user.name)
    age:  17       @inject(tfstate,name=output.user.age)
}

attributeの形式を変更

before:

@injectenv(USER)

after:

@inject(env,name=USER)

Terraform state対応を入れるにあたり、データソースを識別できるようより拡張性のあるフォーマットに変えました。

古い形式も現在サポートしていますが、そのうち消えるかもしれません。

実装の話を簡単にすると、元々は (*Attribute) Contents() string という関数でattributeの中身を得て、それを strings.Split していました。 Contents()@some_attr(ここだよ)ここだよ を返します。

が、よくよく *Attribute のメソッドを見て Arg(int) などが生えていることに気がつきます。 Arg reports the contents of the ith comma-separated argument of a. という説明の通り、attributeの括弧の中身をカンマ区切りのリストとみなす実装です。

このようにライブラリのサポートがより受けやすい形式にしようということで変えました。

tfstate-lookup最高

Terraform stateから値を取る処理はtfstate-lookupにおんぶにだっこです。とても助かっています。

fujiwara/tfstate-lookup: Lookup resource attributes in tfstate.

CUEとJSONにおける数値の扱い

ちょっとハックが必要だったのが数値の扱いです。具体的にはfloatとintで、ちょっと込み入ったややdirtyな実装になっています。

具体的にはここ: github.com

CUEはintとfloatという区別があるのですが、JSONはnumberというGoやCUEでいうintとfloatをまとめた型しかありません。 これは元になったECMAScriptの仕様に由来するものですね。

で、Goのencoding/jsonのunmarshalはデフォルトでJSONのnumberをfloat64に変換します。

Goのfloat64をCUEがintを求める式に埋めようとすると型の不一致でエラーになりますが、Terraform stateに数値が含まれているとCUEに埋められないのも不条理です。

なので以下の条件をすべて満たす際にint64へキャストするようにしています。

  • CUEの式がintを受け入れる
  • CUEの式が floatは受け入れない
  • Terraform stateから得た値 (interface{}) がfloat32かfloat64である

理想的にはTerraform stateから得た値の小数部が0であることまで見るとよいのでしょうが、だいぶ大変なのでここらへんで諦めています。

良い実装方法があればPull Requestしてもらえると嬉しいです。

金沢に引っ越して1年が経とうとしている

この記事は地方在住 Advent Calendar 2021の10日目の記事です。

筆者について

……と移り住んでいます。

大阪府は進学で、京都市は就職でそれぞれのタイミングに合わせて移住しました。

金沢移住は転職と機をほぼ同じくしていますが、勤務先は金沢近郊ではなくリモートワークです。このあたりの背景は以前に書いたのでそちらをご覧ください。 端的にいえば観光で訪れた金沢に惚れて住んでみたくなったからです。

this.aereal.org

変わったところ

車に乗るようになった

f:id:aereal:20210930140428j:plain

これは必要になったからという理由が2割くらい、趣味に目覚めたからという理由が8割くらいです。元々、自動車免許を持っていなかったのでまず教習所に通い、秋に交付されました。

教習所に通っていたころの日記はこちらをご覧ください: aereal カテゴリーの記事一覧 - karimenの日記

後述するように金沢は降雪・寒冷地帯です。また冬に雨がとても多い地域ですので、京都時代に日常の足としていたロードバイクでは勝手が悪くなります。

また、地下鉄やJR, 各種私鉄網が発達していた京阪神に比べると公共交通機関カバレッジは北陸三県に広げて見てみても低いと言わざるをえません。 身軽な旅行が好きな身としてはより強く時刻表に縛られるようになり窮屈に感じます。

最後の趣味というのは車それ自体を楽しむことが目的になった、ということです。元々、ロードバイクでツーリングやヒルクライムは好きだったのでスポーツ走行に対する関心の芽はあったのだと思います。

そんな折にMAZDA3という車を知り、一目惚れしたので免許を取ることを決意し最近乗りはじめました。

d.aereal.org

車が無くとも一人暮らししている分にはさほど困りませんが、地域の特徴を見るに少なからず不便は被るでしょう。

雪が降る、冷え込む

f:id:aereal:20211210224934j:plain

降雪・寒冷地帯ですと述べました。京都市は年間を通して比較的温暖です。冬は緯度の割に冷えたり雪がちらつくことはありますが、少なくとも市街地においては大しことはないです。 京の底冷えという表現がありますが、少なくともここ最近では際立って低温というほどでもありません。

ただし夏はとても蒸し暑い地域で、そのため住宅の構造もそれに応じたものになっています。二重窓はまず市街地では設定されていません。なので、実は屋内の体感温度は金沢の方が温かく感じます。

また京都市より明らかに降雪量がピーク・平均ともに多いです。が、それゆえに公営の除雪車や融雪設備は比較的充実していますし、車両のスタッドレスタイヤ等の装備率も高いとされているので交通が麻痺するほどではないようです。

ただ北海道出身なのであまり積もらずシャーベット状の路面が広がる様は新鮮です。どちらが良いというようなものではありませんが、根雪になる北海道の方がさまざまな点で歩きやすいようには思います。

変わらないところ

荷物の配達

北陸といえど本州なので、各種運送業のみなさまのおかげで特段の不自由を感じずに暮らしています。

ただ2021年1月の大雪などで交通が麻痺した時はさすがに遅れが生じていましたが、これは仕方のないことですし、予想できたことなので大事ではありません。

良いところ

のと里山海道という最高の自動車専用道がある

石川県/のと里山海道の紹介

のと里山海道金沢市能登半島を結ぶ自動車専用道です。無料化された自動車専用道というと片側一車線ずつで線形もよろしくない一般道と大差ないようなところを想像しがちですが、日本海沿岸を走るおよそ35kmの区間は二車線ずつで速度制限は80km/hのかなり高規格な道路です。

晴れた日に走ると視界の片隅に日本海と水平線を入れながら穏やかにドライブできとても快適です。前述したように免許とりたて・マイカーを買いたての身なので運転に慣れる格好の機会です。

元々有料道路だったのですが、北陸新幹線延伸に伴い無料化されました。

土日は兼六園に入り放題

毎週末は石川県民であることを公的証明書で示せば入園料が免除されます。

兼六園の県民観賞の日(毎週土・日曜日) 石川県民は、毎週土・日曜日は入園料免除となります。

ご利用案内|兼六園より

のと里山海道と併せて住んでいる自治体に税金を払う甲斐があるというものです。

微妙なところ

舗装が荒れている道路が散見される

特に157号線・159号線のそれぞれ尾山神社付近や尾張町付近が荒れていてロードバイクで走っていると振動で手が痺れてきますし、ハンドルがとられそうになります。

車通りが多いエリアでもあるのでかなり緊張感があります。できれば走るのは避けたいほどです。

冬に冷え込み、夏は猛暑日にもなるので寒暖差が激しく厳しい気候ではありますが安全のためメンテナンスは十全に行ってほしいです。

総括

1年近く金沢に住んでみて、概ね予想していた体験が得られています。 公共交通機関を頼った生活はできなくなることは想定済みですし、スーパーやコンビニ、そしてAmazonがあれば変わりのない生活が送れるだろうという期待もほぼその通りです。

意外な点としては、自身が実感を持てるほどにこの地域に愛着が湧いているということです。ひとえに払った税金が体感できるかたちで還元されているからでしょう。 そういった意味では京都市は「どうせインバウンドの観光客のために費されるのだろうな」という怒りとも諦めともつかない感情で見るしかありませんでした。

ふるさと納税のような制度が敷かれていますが、けっきょくのところ人生の時々の曲面において長大な時間を費す居住地域に気持ちよく納税できることが最も望ましいと考えているので、そういった意味でも今の金沢の生活は気に入っています。

一方、金沢に骨を埋めたいかというとはっきりとした答えはもっていません。 せっかくフルリモート勤務でそれなりにやっていけるということがわかりましたし車を持ったので、もっといろんな地域に住んでみたいという気持ちがあります。

転職から1年が経った

this.aereal.org

this.aereal.org

所属組織についての感想とか意気込みみたいなのは社内のesaに書いたので、転職体験に関する個人的な感想を書く。

意外とやれている気がする

ノンバーバルコミュニケーションなどに重心があった人々は、特に最近限界を迎えたり消耗しきったりなどしている声を聞くようになっているけれど、自分はかなり適応できている……というか自分の性質にあった環境に周りが変わってきていると思う。

自分は、おそらく考えていることが顔に出やすいほうなのでイラついてくるとあからさまに相手に伝わりやすい……と思う。 オンラインミーティングやテキストチャットへの偏心は、この自分の性質をうまいこと補ってくれるので助かっている。

また、自分は簡潔さよりも誤解を招かないことを重視したコミュニケーションをとる傾向があるので、口頭で同期的に話していると長くなりがち。 その点、テキストベースだと非同期にやりとりしやすくて「あ〜長い話してすまん〜」って気持ちが減ってやりやすい。

一方、自分ひとりのパフォーマンスが良くても仕方がないのでうまいこといくと良いなと思っている。いいなと思っているけど、現状、自分が能動的になにか手を打つつもりはあまりない。 自分より得意な人が考えてくれているし、そもそういうのは得意じゃないどころか下手まである。それに、自分が得意でかつやりたいことでまだまだやるべきことがあるのでそちらに目を向けたい。

「元同僚」ができて嬉しい

今回が初めての転職なので「元同僚」がたくさんできてとても嬉しい。

前職の同僚は頼れるし話していて楽しいけれど、同じ会社で働く同僚となるといろいろ思うところがあったり、そも目にしたり耳にしたりするものが同質になりがちな人とだけ話していて、本当にこれでいいのかな? と感じることがあった。 特に組織とかの話をする際がそう。

転職して所属も変わり、いろいろ見聞きするものごとが違ってくるにつれ、前職の同僚とたまに話すことが良い刺激になっていると感じる。 もともと交友範囲が広くなかったのですごくありがたい。

もちろんやりようによっては転職という手段をとらずともなんとかできたとは思うけれど、自分の場合はこうなったという話。

次のN年

前職に入社した時は30歳までには転職するだろうという漠然とした見立てがあった。

今はどうかというと、よくわからない。ただまた10年所属しつづけるかというと、あまりそういう未来は見えない。 所属組織がどうこうではなく、自分が10年以上同じ場所でなにかをやりつづけるイメージが持てないだけ。

this.aereal.org

そもそも10年先も現役でソフトウェアエンジニアをやりつづけられるのか、けっこう怪しいとも思う。 自分が世間で通用しつづけられるか、という視線を持つために転職というエコシステムは意識しつづけるだろう。

一方で、背中を追い続けるだけではなく、背中を見せるということもそれなりに意識しなければならない気配は漂ってきている。 いいかんじの環境を得るためにとる自分の動きのレパートリーを増やすというのも当然考えなければいけないだろう。

まあ遠い将来のことはよくわからないので、元気に楽しくやっていきたいですね。以上です。

生きているのならシェルスクリプトにだってなってみせる、そうPerlならね

シェルスクリプトを書くのをやめる - blog.8-p.info

これを見て:

Shell - run shell commands transparently within perl - metacpan.org

use Shell qw(cat ps cp);
$passwd = cat('</etc/passwd');
@pslines = ps('-ww'),
cp("/etc/passwd", "/tmp/passwd");

Synopsisより。

もうちょっとそれっぽく書くとこう:

cp '/etc/passwd', '/tmp/passwd';

仕組み

Shell.pm - metacpan.org

コメントを除いて150行に満たない小さいコードです。答えを言うとメタプログラミングで実現されています。

オリジナルはLarry WallによってPerlの強力さをデモンストレートするために書かれたそうです。興味深いですね。

use

Perlを知らない人向けに解説すると、Perluse Shell qw(cp mv);BEGIN { require Shell; Shell->import('cp', 'mv'); } のsyntax sugarです。 qw(cp mv) はリストリテラルの一種で ('cp', 'mv') と等価です。

requireはパス上からShellというパッケージを探して読み込む処理で、それを行った後にimportというサブルーチンの呼び出しを行います。

呼び出されるShellパッケージでこのimportサブルーチン *1 を定義しておくと、読み込まれた際に任意の処理を実行できます。

シンボルのインポート・エクスポートとシンボルテーブル

ところでこういうPerlのコードを見たことがあるかもしれません:

use Carp qw(croak);

croak('oops');

Carpというモジュールをuseするとcroakというサブルーチンが使えるようになりました。不思議ですね。

これは専用の言語機能があるわけではなくて上述のimportで何かをしています。

Perlにはシンボルテーブルというものがあり、他の言語でいうスコープとか環境 (environment) と呼ばれるものとだいたい一緒です。

変数やサブルーチンはこのシンボルテーブルに属しており、パッケージごとにシンボルテーブルが別れています。言い換えるとシンボルテーブルの境界をなすのがパッケージです。

Perlのおもしろいところはこのシンボルテーブルをランタイムにわりと自由に触れるということです。触れるというのは読み出すだけではなく、書き換えることもできます。

MyPackage::my_symbol など パッケージ名::シンボル名 という風にアクセスできます。

Shellのコードでいうと *{"${callpack}::$sym"} = \&{"Shell::$sym"}; がシンボルテーブルを操作するコードになります。

$callpack は呼び出し元のパッケージ、つまり use を書いたパッケージにあたるので、呼び出し元のシンボルテーブルに対して $sym の値を \&{"Shell::$sym"} に書き換えています。

$symimport の引数リストの要素なので、 'cp''mv' になります。つまり Shell::cp などのサブルーチンを指すのですが、これらの定義はどこにあるんでしょうか。

AUTOLOAD: Perl版method_missing

答えはAUTOLOADという関数です。これはRubyでいうmethod_missingで、AUTOLOADが定義されたパッケージに存在しないシンボルを参照しようとした時に呼ばれます。

ShellのAUTOLOADは呼び出そうとしたシンボル名を取り出し、 _make_cmd に渡しています。この _make_cmd がコマンドを実行するサブルーチンを作るので、それをシンボルテーブルに書き加えることで任意のコマンドを実行できるサブルーチンが無から湧き上がったようにみえるのでした。

補足

シンボルテーブル操作とか、スコープによるガードレールを破壊するような危険な行為じゃん! と思ったあなた。そうですね。

Perlはpragmaという仕組みで、一部の言語機能の使用を制限したり警告することができます。strict refs でこのシンボルテーブル操作を制限できます。

use strict; とだけ書くとこのstrict refsも有効になります。

ちなみにこのpragmaのon/offはレキシカルに行えるので、これらをどうしても使いたい時には no strict 'refs'; と書くと一時的に無効にできたりします。

Perl後方互換性を保つだけではなく書き手をある程度信頼する作りであるとも感じます。 これをもって大規模チームでの開発に向かないと評価する向きは理解できますが、2021年だしこういう「大いなる力には責任が伴う」を地で行くようなファンキーな言語がひとつくらいあっても良いのではないでしょうか。

おわり

scrapbox.io

私はちょっとした処理もGoで書くようになりました。

シェルスクリプトの一番の問題点はテスタビリティに欠ける言語設計だと思っているので、GoじゃなくてPerlでもRubyでもなんでも良いがテスタビリティが担保される言語ならなんでも良いと思います。

テストのないプロダクションコードはおぞましいと言う人が多くコモンセンスと言って差し支えないと思いますが、それが開発用の便利スクリプトとかになると途端に緩むのはどうしてなのでしょうか。 認証情報などが環境変数に露出していて、間違えると本番データを消してしまったり数百万の請求がやってくるかもしれない環境で、テストもない・dry-run機構が壊れているかもしれないスクリプトを実行することが恐しくないのでしょうか。

私は他人の書いた古びたシェルスクリプトほど怖いものはないです。

以上です。

Rubyのshellについて (2021-09-17追記)

miyagawaさんから情報提供いただきました。

Ruby演算子オーバーロードができるのでよりそれっぽい見た目になって最高ですね。

*1:関数のこと

prpl: AWS SSMパラメータストアの値を環境変数に設定するツールを作った

github.com

作った。

prpl = parameters pull toolです。

使い方

go run github.com/aereal/prpl/cmd/prpl -path /app/staging env

こういう風に使う。 -path はパラメータストアのパラメータパス。このパス以下のパラメータをすべて取得し、環境変数として設定、コマンドを実行する。 SecretStringもdecryptされて設定される。

injecuetと組み合わせるために作った。もちろんこれに限らずSSMパラメータストアと連携させてコマンドを実行するのにも使える。

this.aereal.org

ちなみに横着してバイナリの配布はしていない。自分の用途では go run で問題ないので。気が向いたらバイナリ配布します。

環境変数命名規則

READMEにも書いてあるけど、 -path に指定した文字列を取り除いた残りを使う。英数字以外はすべてアンダースコアにして大文字になる。

なぜこうしているかというと、 /app/staging/creds/id/app/production/creds/id のようにパスの先頭に環境などを含めているケースを想定しているため。 環境変数を参照するアプリは環境を意識せずに CREDS_ID と参照し、prplを実行する際に -path を切り替えて実行することを想定している。

ssmwrapとの違い

非常によく似たツールにssmwrapがあって、というかもともとssmwrapを使おうとしていたけれどバグがあり修正PRを送ったものの応答がなかったのと、上記のようなもっと合理的な命名規則を採用したく、では別のものを作ろうと思い立った。

ssmwrapと違いオプションは -debug-path だけで、コマンドを実行する機能しかない。 正直、ssmwrapのオプションは -paths とか -names とか -prefix とか -env とかかなりややこしくて所望する結果を得るのにどういうオプションを渡したら良いのかかなりとっつきにくかったので、しょっちゅうオプションを変えるわけではないとはいえ、UNIX的世界観のツールらしくもっとsimpleかつeasyにしようという思いもあった。

また、ファイルへの書き出し機能は、おそらくECSやDockerのenvironmentFileのために追加されたのだろうけれど、これって env(1) で良いよなという思いもあり削りたかった。

いかがでしたか?

最近ちまちましたツール作りが捗っていて楽しい。どうぞご利用ください。

ISUCON 11予選に参加して敗退した

id:karupaneruraid:Sixeight とチームを組んでISUCON 11予選に参加した。
再試験スコアは25746点、ベストスコアは記録をちゃんと残せてなかったけど3万ちょっとくらい。

去年の思い出:
ISUCON 10の予選に参加しました - Sexually Knowing

使ったリポジトリ:
GitHub - aereal/isucon11-qualifier: 一発あてるぞ


id:karupaneruraさんの: ISUCON11予選に参加しました - 時計を壊せ

id:Sixeightさんの: ISUCON 11でISUCONに初出場して予選敗退した - ちなみに

参加するまで

仕事でバタバタしているうちにチーム編成がおぼつかず応募が締め切られたので、今年はお流れかなーと思っていたら既に応募していたid:karupaneruraメン募していたので声をかけ組んだという流れ。
そこにid:Sixeightも合流してチームにゃんこ選抜になった。

縁がなければ参加していなかっただろうから感謝です。

前日まで

過去問を解く練習を2回、過去問の講評を一緒に眺める回を1回やった。

AWSアカウントの準備とかはid:karupaneruraがサクサク進めてくれて助かる、ありがとうございます。

ISUCON 10の予選問題を解いたりして、まあまあ調子よくスコアを出せたりしたものの自分とid:karupaneruraは参加していたので当然といえば当然でもある。

また、集まってやる練習日以外にもScrapboxプロジェクトを作ってそこにカンペを溜めていくなどした。これはISUCON 10の時にもやっていて、いわゆるWiki的な使い方ができて本当に助かっています、ありがとうScrapbox.

当日

やったことは二人が既に書いてあるとおり。

自分は、conditionの挿入をバックグラウンドのGoroutineにやるように書き換えたり、trendのクエリ改善をしたり。

trendのクエリ改善は、order byの条件をミスって出してはfail続きでスコアを伸ばしきることができず、前述のスコアでフィニッシュ。

感想と反省

writeとreadにそれぞれボトルネックが設定され、かつ相互に関係しあうので思考停止キャッシュやKVS退避では解決しない解きごたえのある内容でとても楽しかった。

それだけに競技中に自分が納得のいくパフォーマンスを発揮できなかったことは悔しいし、はっきりと実力の不足を感じた。

競技後に自分が最後まで担当していたN+1クエリを解決させて、それがブロックしていた他の小さい変更を入れたら予選通過ボーダーくらいにまで上がったのでなおさら悔しい。

それだけではなく、その他のコード修正でもチームメイトにGoのコードレビューをしてもらった時に知らないことが多かったし「window関数を使ったらいけそう」って言われても書けなかった。

自惚れるほど自信があったわけではないけれど実力は足りていないことは改めて実感したし、この調子だとまた来年のISUCON (あれば) の1〜2ヶ月前から準備してもぜんぜん足りなさそうなので、今からしこしこ準備することにした。
とりあえず講評で出てきた知らない新機能や使用の自信のない機能とかは一通り調べていくことにした (MySQLのlateral句とかnginxのtry_filesやinternal redirectとか)。

ISUCONという競技自体は楽しめたけれども、これまでよりずっと悔しいし反省点の多い回だったので次回は絶対に本戦に出場するぞ。

injecuet: CUEに環境変数を注入する便利CLIツールを書いた

CUEとは

CUEJSONYAMLのスーパーセットのような構文を持ちながら、データ・スキーマ検証などが行える言語のこと。 KubernetesYAML生成にも使われているそう (そのシーンで使ったことはない)。

軸となるコンセプトはTypes are valuesと表される。言い換えると型と値はを成す。 束は半順序集合で、CUEでは型のとりうる範囲を表現している。値は要素がただひとつそれ自身の束とされる。ちなみにCUEにおいて値を得ることを解決 (resolve) と呼んでいる。

ここらへんの話はすべてThe Logic of CUE | CUEからの引用・言い換えなので詳しくはそちらをあたるのがよい。

The Logic of CUEにある下記の図がわかりやすい。

{x, y}
{x}
{y}
{}

もうちょっと実用上の特徴について触れると、CUEにおいて制約も型表現のひとつとなる。たとえば >0 は「ゼロより大きい数値」という型を表す。 さらにこれら制約 (= 型) は合成可能で、 >0 & <10 だと「0より大きく10未満の数値」になる。

そして値は「ちょうどその値だけを表す型」とみなされる。言い換えると要素数が1の集合が値となる。

injecuetを作るきっかけ

ecspressoで使うサービス定義・タスク定義中にIAMロールのARNなどをtfstateプラグインで埋めていたが、使い勝手が悪く・拡張性にも乏しいので別の方法でサービス定義やタスク定義を生成したいと考えた。

というのもtfstateプラグインはTerraformが出力するtfstateの情報すべてにアクセスできてしまい、Terraformモジュールのカプセル化を破壊するおそれがあることがひとつ。 Terraformのリファクタリングをしている中でモジュールへ分割したところtfstate内の識別子が変わったことによりecspressoからの参照が壊れたことが直接のきっかけだった。

加えてコレクションの扱いが弱いことも挙げられる。 ecspressoは内部でtext/templateを使用している。range 構文が使えるが、validなJSONで配列を出力しようとすると strings.Join を使うか「ループの最後だったらカンマを出力しない」という記述が必要になる。 text/template内で strings.Join のような関数を使うにせよ、ループの最後かを検出するにせよ、テンプレート処理する時にカスタム関数の定義を渡す必要がある。

そしてそのカスタム関数の定義はecspressoの実行時に渡せるようにはなっていない。 tfstateプラグインはあくまでecspresso内に閉じた機構により動的に有効になるだけであって、外部から機能を追加できるような純然たるプラグイン機構は持っていない。

ecspressoにそのような機能を追加することも考えられたがいささかオーバーキルだし、JSONを良いかんじに出力するという点に関しては別のツールに任せるのが良いように思えた。

そこで先に紹介したCUEを利用することを思い付いた。とはいえCUE自体では実用上足りない機能があった。injecuetはその欠けていた機能を足すことを目指している。

injecuetの使い方

github.com

さてinjecuetについて。injecuetはCUE文書を受け取り、 @injectenv という属性がついたフィールドに対応する環境変数の値を注入した新しいCUE文書を出力する。

文章だけだとわかりづらいのでsynopsisを引用する。

cat src.cue
# name: string @injectvar(USER_NAME)

env USER_NAME=aereal injecuet ./src.cue
# name: "aereal" @injectvar(USER_NAME)

より実践的な使い方はこう:

cat complex.cue
# import "strconv"
# 
# #varAge: string @injectvar(AGE)
# age: strconv.Atoi(#varAge)

env AGE=17 injecuet -output ./out.gen.cue ./src.cue

cat out.gen.cue
# #varAge: "123"
# age:     123

cue export ./src.cue ./out.gen.cue
# {
#     "age": 123
# }

cue exportJSONYAMLを出力できるコマンド。引数にCUE文書を渡すことができ、複数渡すこともできる。 複数渡すとすべての制約を満たすよう合成される。

cue export は合成されたCUE文書が解決されている必要がある。言い換えるとすべて値になっていなければいけない。 age: >=0 のような型は要素数が2以上の集合なので値ではない。これが age: 17 だと値なのでOK.

injecuetの実践的な使い方

タスク定義中の taskRoleArn を外部から渡すことを考えてみる。 refs. Task definition parameters - Amazon Elastic Container Service

まずCUEによる記述:

family: "my-app"
taskRoleArn: string

これをexportしてみる:

[2021-08-17 22:31:31] ✘╹◡╹✘ < cue export testdata/task-definition.cue
taskRoleArn: incomplete value string

想定通り怒られる。

injecuetを使って環境変数を注入し、タスク定義のJSONを出力したい。 まず属性を付与する。

family: "my-app"
taskRoleArn: string @injectenv(ECS_TASK_ROLE_ARN)

injecuet実行時に環境変数として渡す:

[2021-08-17 22:37:14] ✘╹◡╹✘ < ECS_TASK_ROLE_ARN='aws:arn:iam:123456789012:role/task-role' injecuet ./task-definition.cue > resolved.task-definition.cue
[2021-08-17 22:37:26] ✘╹◡╹✘ < cat resolved.task-definition.cue
{
        family:      "my-app"
        taskRoleArn: "aws:arn:iam:123456789012:role/task-role" @injectenv(ECS_TASK_ROLE_ARN)
}

期待通り taskRoleArn に値が注入されている。

最後にexportする。

[2021-08-17 22:38:24] ✘╹◡╹✘ < cue export ./resolved.task-definition.cue | jq .
{
  "family": "my-app",
  "taskRoleArn": "aws:arn:iam:123456789012:role/task-role"
}

完成。ちゃんとvalidなJSONが出力されている。

正規表現を使う

さらにCUEのTypes are valuesという特性について思い出してほしい。以下の例を見るとCUEの強力さが伺えると思う。

[2021-08-17 22:41:14] ✘╹◡╹✘ < cat task-definition.cue
family: "my-app"
taskRoleArn: =~"^aws:arn:iam:.+" @injectenv(ECS_TASK_ROLE_ARN)
// ↑stringという定義から変わっていることに注目
[2021-08-17 22:41:56] ✘╹◡╹✘ < ECS_TASK_ROLE_ARN='aws:arn:ecs:...' \  # 間違ってIAMロールではない別のサービスのリソースのARNを渡してしまった
  injecuet ./task-definition.cue > resolved.task-definition.cue
[2021-08-17 22:42:03] ✘╹◡╹✘ < cat resolved.task-definition.cue
{
        family:      "my-app"
        taskRoleArn: =~"^aws:arn:iam:.+" @injectenv(ECS_TASK_ROLE_ARN)
        // ↑環境変数が展開されていないことに注目
}
[2021-08-17 22:45:32] ✘╹◡╹✘ < cue export ./resolved.task-definition.cue # 当然exportは失敗する
taskRoleArn: incomplete value =~"^aws:arn:iam:.+":
    ./resolved.task-definition.cue:3:15

CUEは正規表現を型に指定できる=~"^aws:arn:iam:.+" は「^aws:arn:iam: で始まり任意の文字が1つ以上続く文字列」という型を表す。

aws:arn:ecs:... という文字列はマッチしないので解決されず、JSONへのexportもできない。 環境変数の注入はありふれたアイデアだが、JSONは数値や配列など豊かな型を持っており、文字列として表現される環境変数からそれらへの変換は自明ではない。 制約を表現できるならそれに越したことはない。

式を書いて文字列を他の型へ変換する

さらにCUEは簡単な式を書くことができる。

import "strconv"

_varPort: =~"^[0-9]+$" @injectenv(PORT)
port: strconv.Atoi(_varPort)

これをexportしてみる:

[2021-08-17 23:24:54] ✘╹◡╹✘ < PORT=8080 injecuet ./port.cue > resolved.port.cue
[2021-08-17 23:25:04] ✘╹◡╹✘ < cue export ./resolved.port.cue
{
    "port": 8080
}

PORTに制約を満たさない値を渡した場合:

[2021-08-17 23:25:41] ✘╹◡╹✘ < PORT=abc injecuet ./port.cue > invalid.port.cue
[2021-08-17 23:25:48] ✘╹◡╹✘ < cat invalid.port.cue
import "strconv"

_varPort: =~"^[0-9]+$" @injectenv(PORT)
port:     strconv.Atoi(_varPort)
[2021-08-17 23:25:50] ✘╹◡╹✘ < cue export ./invalid.port.cue
error in call to strconv.Atoi: non-concrete value string:
    ./invalid.port.cue:4:11
    ./invalid.port.cue:3:11

便利。pkg · pkg.go.devに標準で利用できるパッケージがあるので、たとえば strings.Split を使ってカンマ区切りの文字列をリストに展開できる。

ちなみに _ 始まりのフィールドはHidden fields を呼ばれ、export時には公開されない。 なのでこういう風に、環境変数を束縛するが最終的な出力に含めたくない一時変数として利用することができる。

おわりに

シンプルながらCUE自体の強力さもあって複雑なJSONの構築に役立てる便利なツールができた。

どうぞご利用ください。

自動車教習所の学科教習のスケジュールを立てるのに便利なSPAを作った

教習スケジュール

ちょっと前から普通自動車免許を取ろうと思い立ち教習所に通いはじめた

技能教習はWeb上から予約できて、基本的に順番にやっていくだけなので難しいことはない。

問題は学科教習で、月ごとにスケジュールが配布されるのだけれどもExcelで作った表をPDFにエクスポートしたもので、とてもじゃないが機械的に扱うのに向いていない。 一応、PDFをパースしてテキストを取り出したらなんとかならないかなと思ったけれど、開講されていない時限に空白が入る関係上、取り出したテキストデータからセルの位置を同定できないことがわかったので諦めた。 テキストデータ以外の、たとえば位置情報とか (とれるのか?) を頑張って計算したらなんとかなりそうな気もするけれど大変そうだし、そもそもやりたいことは取り出した後にあるので手で入力することにした。

さてデータは頑張って手でインポートするとしてスケジュールを立てる際、どんなことができてほしいか。

  • 常勤の仕事がある関係上、できるだけ半休で済む単位で履修したい
    • 未履修の科目が集中しているからといって全休を取って履修するようなスケジュールは避けたい
    • そもそも1日中ずっと開講されているわけではなくて、たとえば午前と午後の間に2時間以上の間隔が空く場合は持て余してしまう
  • 同じ科目が別の日・別の時限に開講されているが、受講は1回だけで良いので「履修済みか」「別の日に履修予定があるか」を俯瞰したい
  • 全26時限のうち、未履修の科目・履修予定を立てた科目・履修済みの科目を一覧したい

……あたりができてほしい。

というわけで作ったのが冒頭の教習スケジュールアプリ。

f:id:aereal:20210804102907p:plain

見た目はこんなかんじ。

f:id:aereal:20210804103530p:plain

こんな風に、同じ科目の予定が別の日に入っていたらそれとわかるようになっている。加えてここからチェックボックスを操作するとこの日に予定を変えることもできる。

特徴は:

  • 科目ごとに色分け
  • 「未履修」「履修予定が入っている」「履修済み」が一目でわかるようアイコンと科目の明度を分ける
  • 同じ科目が別日に予定が立っている場合、予定を入れ替えられる
    • 例: に科目12が入っている状態で、のスケジュールを開いてこちらに変更できる
  • 前日以前の背景をグレーにし、これから予定が入れられる範囲を視覚化
  • 履修状況ページで科目ごとに「未履修」「履修予定がある」「履修済み」を可視化

色分けについて、科目が多い関係上、どうしても色相が近く特に色覚異常のある方にとってかなり厳しい見た目になってしまったし、なんなら色覚について (おそらく) 正常である自分でもたまたま近い色相の科目が並ぶと混乱することがあるのであまり良い筋ではないかも、とも思う。 しかし色分けしなかったら数字だけで判別しなければいけないので、これはこれで厳しいからやむをえない。

また、予定が入っていたらそれとわかる。上記スクリーンショットだとたとえば8/6の5〜7時限に予定を入れたので、午前の1〜2時限を取ろうとすると全休しないといけないし、そもそも3〜4時限ぶん空いてしまうな、とかがわかる。

賢いテクノロジーでスケジュールを自動で立てようかと思ったけど、仕事の都合とか自分の体調その他の都合で急遽別の日にしたいこともあるので、自分でスケジュールを立てること前提で使いやすいものにした。

技術的な詳細は:

……というかんじ。さっと作ってさっと動かしたかったのでいつものグッズにした。

おかげさまで無事にあと3時限履修したら卒業テストが受けられる状態になったので、あとは技能教習をがんばります。サンキューです。

OpenAPI定義に沿ってバリデーションをしてくれるGoのライブラリを書いた

GitHub - aereal/go-openapi3-validation-middleware: net/http middleware to validate HTTP requests/responses against OpenAPI 3 schema using kin-openapi.

kin-openapiというOpenAPI 3定義を読んでリクエスト・レスポンスのバリデーションをしてくれるGoのライブラリがあるんだけど微妙に使い勝手が悪い。 素朴に使おうとするとHTTPハンドラ内でバリデーションに関するコードを書く必要があって関心を分離させるという目的を果たすにはちょっと弱いし、得られたエラーを一貫して取り扱うにはエラーをHTTPレスポンスに加工して返すところまで一気通貫で取り扱いたい。

Goでnet/httpを使ってHTTPサーバを書く時は、RackやPlackのように、ミドルウェアあるいはサンク (thunk) を組み合わせてリクエスト/レスポンスの参照や加工を行えるので、この仕組みに乗りたい。

というわけでREADMEのsynopsisから引用:

import (
    "net/http"

    "github.com/aereal/go-openapi3-validation-middleware"
    "github.com/getkin/kin-openapi/routers"
)

func main() {
    var router routers.Router // must be built with certain way
    mw := openapi3middleware.WithValidation(openapi3middleware.MiddlewareOptions{Router: router})
    http.Handle("/", mw(http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) {
        // this handler is called if validation succeeds
    })))
}

見てわかるように、http.Handlerを扱えるライブラリならnet/http以外でも使える。 実際に自分はhttptreemuxで使った。

WithValidation はリクエストとレスポンスどちらも検証する。 用途に合わせてリクエストのみを検証する WithRequestValidation, レスポンスのみを検証する WithResponseValidation というエントリポイントをそれぞれ用意してある。

  • 自動テストやQAが十分であればレスポンスの検証は本番環境では不要という考え方もありうること
  • 実装の都合上、書き出されたレスポンスボディをすべてメモリ上に確保するためGoのio.Writerインターフェースの良さをスポイルしてしまっていること

……といった理由からレスポンスは検証せずリクエストのみ検証するというオプションを用意した。 ちなみに WithValidationWithRequestValidationWithResponseValidation を合成しただけ。ちょっとおしゃれで好き。

実装してのおもしろポイントといえばhttp.ResponseWriterを独自実装したところとか。

go-openapi3-validation-middleware/response_writer.go at main · aereal/go-openapi3-validation-middleware · GitHub

http.ResponseWriterはインターフェースなのでnet/httpがデフォルトで持っている実装以外を使うこともできる。

このライブラリではレスポンスボディとステータスコードを保持するための実装を書いた。 レスポンスを検証する際の最終的なレスポンスは、内側のHTTPハンドラが書き出すかもしれないし、このライブラリがエラー報告を書き出すかもしれない。 なのでbufferingResponseWriterのWrite()とWriteHeader()の書き込みインターフェースはそれらを呼び出した時点では引数を保持するだけにしてある。

レスポンスを書き換えるパターンのミドルウェアは初めて書いたので手札が増えてよかった。

as a builder

ソフトウェアエンジニアリングやると究極的には一行もコードを書かなければバグが混入することもないしなっていう気持ちになることがある。

それはテコを効かせるエンジニアリングの考え方として理に適っているので納得している。

早すぎた最適化とかは「やりすぎ」自体が問題なのではなく最適化という枝狩りの山はよく外れるからエビデンスを集めてからやれということだと思うし、最初から将来に渡ったユースケースもカバーしてめちゃくちゃ速いものを作れるならそれに越したことはないはず。

一方でソフトウェアコンストラクションという手段にこだわりたい自分もいて、この時折顔を見せる自分はなんだろう、とずっとモヤモヤしていた。

ふとこれはビルダーとしての自分だなと気がついた。作ることが手段であり目的でもある存在としてビルダーというラベルはしっくりくる。

常に便利なソフトウェアを作ってものごとを良くしていくというやりかたにこだわろうとする自分をビルダーという振る舞いにあてはめて認めてあげたい。

言葉を尽くす

大抵の誉め言葉を嬉しいとは感じない。どれもこれも社交辞令に聞こえる。なぜ社交辞令に聞こえるかといえば、着眼点がずれていたり、解像度が低かったりするから。

これも本当にズレている・見えていないだけのこともあれば、当人の認識は的を外していないけれどもそれが言語化する際にスキルが伴わず伝わらないだけということもあろう。

 

ポジティブ・ネガティブ問わずフィードバックする際に限らず、たとえばタスクひとつ振る際にも背景や要件だけではなく「なぜあなたを指名したか」「これを通してあなたが何を得ると期待しているか」といったことを過不足なく伝えることはこだわり徹底したいことのひとつ。

別に指名の理由が大仰なものと限らず「単に暇そうだったから」ということもあるだろうし、それはそれで良いと思う。その場合は正直にそう伝える *1。でも「他に頼れる人がいないのでお願いしたいと思っている」とそう伝えることはできよう。

 

大人数の群として大きな仕事をやっていこうという中でどうしたって個人の自尊心は後回しにされがちなのは否めない。

もちろんそれは当人の内的なものであって外から与えたり充たすような類のものではない。

ただ、不足や欠落があって少しずつ瑕がつき、いずれ大きなものとなるというのはよくあること。

何より自分にとっては言葉を尽くして伝えようとしてくれる行為そのものに好感を抱くし、群を動かし大きなことを成そうとする能力があると信頼に足る。

 

1on1を、会議よりも有意義な対話にするためには、もっと自然体で臨む必要があります。メンバーの言葉を聴き、感じた印象はストレートに伝えます。とりわけ、お互いの感情を織り交ぜながら対話するほうが伝わりやすくなります。

リーダーとメンバーがお互いの感情を伝え合い、感情面での共感や反発があって「ガチ対話」が生まれやすくなります。テクニックを身に付けていくことはもちろん重要ですが、テクニックを活かす土台作りとして、感情を共有しあうことを意識しています。

「ガチ対話」でエンジニアチームのエンゲージメントを高める1on1の工夫 - ZOZO Technologies TECH BLOG

最近インターネットで見かけた似た考えについて書かれている記事。

 

自分にとってはグレード昇格時の id:motemen さんからのコメント・フィードバックがとても嬉しくて印象に残っているので、目指すべき姿のひとつとなっている。

*1:さすがに「暇そうだったから」とそのままは言わないけれど