責任を扱うコミュニケーションの在り方

水曜日から継続していたメンバーのコードレビューをお昼頃に完了して、それからは自分の時間に集中して開発に取り組めた。途中、晩ご飯休憩もしながら翌2時頃までコードを書いていて issue を2つ fix した。

隔週の雑談

顧問のはらさんと隔週の打ち合わせ。ここ1-2ヶ月はあまり議題がなくて近況について雑談した。いまの開発フェーズが終わらないと、私が開発以外にリソースを割いていないので他のことに取り組む余裕はないかもしれない。開発プロジェクトにおける、議題の1つで次のインタビュー記事の内容について雑談した。

責任をすぐに相手に投げてしまわないこと。責任をこちらで負えるように日頃から信頼貯金を貯めていくことが大事です。責任を負うこと自体はたいへんだけど、仕事は楽になるんです。

でも、人は責任を負いたくないと考えてしまうんですね。気持ちは分かりますよ、責任を負うのには胆力が必要ですから。そして責任を負いたくないから「いつまでですか?」と聞いてしまう。その途端、スケジュールを決めるのは相手の裁量になる。責任から降りて、自分から立場を下にして、受発注の関係にしてしまっています。

何が事業貢献なのか分からなくなっていた伊藤直也さんが再認識したユーザーエクスペリエンスへのコミット

私自身、このインタビュー記事を読むまで相手に仕様や納期を決めてもらうことを、相手に責任を負わせるという視点をもっていないかった。しかし、その通りでもあると新たな気付きを得た記事でもあった。うちらはプログラマーという、システムを作る専門家なのでビジネス課題や解決したい業務課題について、その課題意識をもっている人 (ビジネスオーナー) から教えてもらうのを至極当然のように考えてしまう。そして、それ自体はいまの業務の在り方としてそうならざるを得ないところもある。

システム開発はそれぞれの専門職が分業によって行う。とくに規模が大きくなればそうなる。この構造そのものは一般的といえる。しかし、一緒にプロジェクトをやっていく働き方や考え方によってはその責任を分散させられるというのがこの記事に書いてあることだとわかる。そして、私自身これまで意識せずにそういった行動をいくらか取ってきているところもあり、それは「気付き」のレベルによって起こるものだとずっと考えていた。気付くからより多くの、より本質的な、より優れた改善のための行動ができる。そして、私の考え方も間違ってはいないと思うが、私は他人よりリスクを取りがちな性格があるから責任をこちらで負うという意識をあまりもっていなかった。つまり、私は自分の価値観でこうしたいと思って勝手にやっていたことを、別の見方では相手から責任を受け取って進めているという解釈もできる。

ちょうど 兵庫県知事の百条委員会の答弁 もみていて感じたことだが、私はこういったコミュニケーションを本質的に好んでいない。自分の責任ではないという議論をしても、モノゴトは前に進まない。世間では百条委員会の後も知事の説明は十分ではなかったとみられている。その所以である。誰かが責任をもってモノゴトに取り組む必要がある。その責任の所在を明確にすることも大事ではあるが、自身の責任ではないという主張だけでは物足りなさを感じる。普段の業務においてもそういう姿勢やコミュニケーションを取る人が少なからずいる。はらさんの経験でもその点においては同意していた。よくある状況だと思える。自身に責任を負うことをためらわないコミュニケーションを取る人とそうではない人の2通りがあるのだと気付いた。そして、私は後者の人とコミュニケーションを続けていると疲弊したり苛々したりすることがある。それゆえに私自身も結果に対して潔くあろうと努めるし、潔い人たちとウマがあうのだろうともわかってきた。

課題管理の文脈においては、コミュニケーションのやり取りから責任の綱引きがどのような場所でどのぐらい起きるのか。人間であれば読み取れるが、ai はその意図を解釈できるか。そういった業務の責任という概念を見える化することに意味はあるかもしれない。責任の押し付け合い、または責任分散、本質的にどうあるべきだったかをなんらかの指標をもって数値化できればおもしろいのではないかと思えた。

go における簡単な式の評価

テスト自動化のツールを作っていて、テストデータでちょっとした式の評価をやりたくて調べたらまさに次の記事で解決した。

この記事では go/types パッケージに定数や式の評価を行う機能がその使い方が紹介されている。簡単に使える。ふとサードパーティのパッケージならどうなるんだろう?とインターネットを検索したものの、自分ではみつけられなかった。chatgpt に問い合わせたら次のようなコードを紹介してくれた。そして、たしかにほとんどは正しくて意図したように動いた。次のコードでは http フレームワークの echo の定数を参照している。types.Package を生成するためのモジュールの読み込み方法について標準ライブラリはそのためのユーティリティが用意されているが、サードパーティのパッケージは用意されていなかった。

import (
    "fmt"
    "go/token"
    "go/types"
    "golang.org/x/tools/go/packages"
)

func eval(expr string) (types.TypeAndValue, error) {
	cfg := &packages.Config{
		Mode: packages.NeedTypes | packages.NeedImports,
		Fset: token.NewFileSet(),
	}
	pkgs, err := packages.Load(cfg, "github.com/labstack/echo/v4")
	if err != nil {
		return types.TypeAndValue{}, err
	}
	if len(pkgs) == 0 || pkgs[0].Types == nil {
		return types.TypeAndValue{}, fmt.Errorf("failed to load echo package")
	}
	mainPkg := types.NewPackage("main", "main")
	mainPkg.Scope().Insert(types.NewPkgName(token.NoPos, mainPkg, "echo", pkgs[0].Types))
	return types.Eval(
		cfg.Fset,
		mainPkg,
		token.NoPos,
		expr,
	)
}

このコードで expr に次のように echo の定数を指定するとその値を参照できる。

echo.MIMEApplicationJSON

動的型付けのノリで関数も実行できたりするのかな?と試してみたら (当たり前だが) できなかった。chatgpt になぜ関数実行できないかを尋ねたら次であるとのこと。静的型付けのコードを実行するには、本来コンパイルしないといけないのだから式の評価よりもずっとやることがある。

packages.Load を使用してサードパーティパッケージをインポートできているにもかかわらず、expr からそのパッケージの関数を呼び出しても結果が取得できない原因は、go/types パッケージがサポートしているのは、型や定数のチェック、構文解析、式の評価であって、関数の実行そのものはサポートされていないためです。

go/types の Eval 関数はあくまでコンパイル時の型検査や式の評価を行うもので 関数の実行や実行時の評価 (ランタイムの処理) は行いません。これは、Eval が式を評価して型と値を返すだけであり、動的な関数の実行などはできないためです。

つなぎの雑多な一日

目次

昨日の続きでメンバーのコードレビューをしながら、openldap スキーマの validator のバグ修正をしつつ、チーム勉強会で雑多な仕様や設計の議論をしていたらそれにも時間を取られて、1日としてはまあり開発を進捗させられなかった。コードを書くことに集中できる時間を2-3時間連続で確保できないとモチベーションにも影響が出てくる。それだけ取り組んでいる課題が複雑だったり煩雑だったりしているのもある。

openldap スキーマの validator と go のマルチエラー制御

目次

今日もメンバーのコードレビューに半日以上の時間を割いたので自分の時間をあまり取れなくて開発が進捗しなかった。

隙間時間で openldap スキーマの情報 を使って validator を実装していた。バリデーションのようなものはまとめてエラーを返せるのが望ましい。go でマルチエラーを制御する仕組みが少し前に追加されていたことを思い出した。Go 1.20 Wrapping multiple errors のチュートリアル記事を読みながら openldap スキーマの validator を実装して複数のエラーを返すようにした。まだ抜け・漏れはあるかもしれないけど objectClass の validator は次のようになる。

type ObjectClassValidator struct {
	objectClassMap map[string]ObjectClassDescription
}

func (v *ObjectClassValidator) Validate(attr map[string][]string) error {
	var errs []error
	var objClassDescs []ObjectClassDescription
	hasStructuralKind := false
	allAttrMap := map[string]struct{}{}
	lowerAttrNameMap := make(map[string]struct{}, len(attr))
	for k, values := range attr {
		lowerName := strings.ToLower(ParseAttribute(k))
		lowerAttrNameMap[lowerName] = struct{}{}
		if lowerName == "objectclass" {
			objClassDescs = make([]ObjectClassDescription, 0, len(values))
			for _, objcName := range values {
				lowerObjcName := strings.ToLower(objcName)
				ocd, ok := v.objectClassMap[lowerObjcName]
				if !ok {
					msg := fmt.Sprintf("'%s' objectClass is not defined", objcName)
					errs = append(errs, NewErrorValidation(msg))
					continue
				}
				if ocd.Kind != nil && *ocd.Kind == Structural {
					hasStructuralKind = true
				}
				objClassDescs = append(objClassDescs, ocd)
				for mustAttr := range ocd.GetMust() {
					allAttrMap[strings.ToLower(mustAttr)] = struct{}{}
				}
				for mayAttr := range ocd.GetMay() {
					allAttrMap[strings.ToLower(mayAttr)] = struct{}{}
				}
			}
		}
	}

	// require at least 1 structural objectClass
	if !hasStructuralKind {
		errs = append(errs, NewErrorValidation("no structural objectClass"))
	}

	// must in objectClass requires must attributes
	for _, ocd := range objClassDescs {
		for mustAttr := range ocd.GetMust() {
			if _, ok := lowerAttrNameMap[strings.ToLower(mustAttr)]; !ok {
				msg := fmt.Sprintf("'%s' objectClass requires '%s'", ocd.GetName(), mustAttr)
				errs = append(errs, NewErrorValidation(msg))
				continue
			}
		}
	}

	// all attribute keys are allowed may/must in objectClass
	for attrName := range lowerAttrNameMap {
		if _, ok := allAttrMap[attrName]; !ok {
			msg := fmt.Sprintf("'%s' is not allowed by objectClass", attrName)
			errs = append(errs, NewErrorValidation(msg))
			continue
		}
	}
	return errors.Join(errs...)
}

func NewObjectClassValidator(
	objectClasses []ObjectClassDescription,
) *ObjectClassValidator {
	ocm := make(map[string]ObjectClassDescription, len(objectClasses))
	for _, v := range objectClasses {
		for _, n := range v.Name {
			ocm[strings.ToLower(n)] = v
		}
	}
	return &ObjectClassValidator{
		objectClassMap: ocm,
	}
}

次のように複数のエラーを返すテストケースを定義する。

{
	name:       "multiple errors",
	objClasses: objectClasses1,
	attr: map[string][]string{
		"objectClass":           []string{"top", "posixAccount", "myObjeClass"},
		"uid":                   nil,
		"mail":                  nil,
		"mail;lang-ja;phonetic": nil,
		"employeeType;lang-ja":  nil,
		"homeDirectory":         nil,
		"GIDNumber":             nil,
		"street":                nil,
	},
	expected: []error{
		openldap.NewErrorValidation("'employeetype' is not allowed by objectClass"),
		openldap.NewErrorValidation("'mail' is not allowed by objectClass"),
		openldap.NewErrorValidation("'myObjeClass' objectClass is not defined"),
		openldap.NewErrorValidation("'posixAccount' objectClass requires 'cn'"),
		openldap.NewErrorValidation("'posixAccount' objectClass requires 'uidNumber'"),
		openldap.NewErrorValidation("'street' is not allowed by objectClass"),
		openldap.NewErrorValidation("no structural objectClass"),
	},
},

テストコードで次のように返ってくる error を unwrap することで検証できる。

for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) {
		t.Parallel()
		validator := openldap.NewObjectClassValidator(tt.objClasses)
		err := validator.Validate(tt.attr)
		if actual, ok := err.(interface{ Unwrap() []error }); ok {
			t.Log("\n" + err.Error())
			if diff := cmp.Diff(tt.expected, actual.Unwrap(), opts...); diff != "" {
				t.Error(diff)
				return
			}
		}
	})
}

結果に対する潔さ

目次

今日はマイルストーンの最終日で定例会議での確認事項が長引いたり、メンバーとのやり取りをしていたらあまり自分の時間を取れなかった。たまにはそういう日もあるか。

はてブですら兵庫県知事のパワハラ問題の記事がホットエントリーになる。自分の住んでいる自治体なのでちょくちょく読んでいる。先週に県議会の百条委員会の動画がアップされていた。すべてみたわけではないが、答弁のやり取りを少しみた。

知事は元官僚なので自身の責任を問われないよう、答弁はうまいように聞こえる。基本的に自身の過失を認めず、微妙な問題の判断は議会に委ね、自身の対応に問題はなかったという姿勢を貫いていたと思う。自身の保身を図るという側面ではこういった姿勢は適切だろうし、知事の優秀さも伺えるし、知事の視点からは理解できる。一方で政治家として結果責任をとるという視点では、自殺者が2人も出ていて、百条委員会が開かれる状況になっていることを自身の責任だと受け入れる必要はあると思う。

たまたま今日もはてブでホットエントリーになっていて議会で不信任案に全会一致という状況になっていて悲惨にみえる。

兵庫県議会の議員86人全員が斎藤知事に「辞職」を求めるという異例の事態となっています。

斎藤知事に兵庫県議全員86人が「辞職要求」へ 全会派に無所属4人も 知事は改めて辞職否定「来年度予算の議論含めてしっかりやっていきたい」

現時点で知事は辞職しないという姿勢をとっている。過去の事例を調べてみると、都道府県議会で不信任案が可決されたのは過去に4回しかなくて、さらに知事が辞職しなかった事例は1度もないという。その文脈を踏まえると、どこかで政治的な妥協案が取られて知事は辞職するのかもしれない。

都道府県議会で実際に可決されたのは岐阜(昭和51年)、長野(平成14年)、徳島(15年)、宮崎(18年)の4回。いずれも知事が辞職か失職を選び、議会が解散されたことはない。

知事の不信任案可決、過去には「脱ダム」田中氏ら4例のみ 議会側の最終手段も高いハードル

私は人を評価する視点の1つに「潔さ」をみている。かっこいい人は潔い。とくに失敗したときに潔いかどうかをみている。うまくいかなかったときにどのような行動を取れるか。百条委員会の答弁は知事の視点から自己弁護すること自体はわるくないと思う。しかし、実態はどうあれ、議会の議員全員が不信任案に賛成するという状況において辞職を決断できないのは潔くないという点のみで、私は知事を支持できない理由になっている。

コンテナー間のデータ通信はやはり http プロトコル

目次

過去に コンテナー間のデータ通信に named pipe を使って実装 した。機能的には問題なく運用できていたが、環境構築時に named pipe の設定がわかりにくいという課題があった。また docker compose は volumes に設定したパスが存在しないときに歴史的経緯でディレクトリを作成してしまう。初期設定時に named pipe を期待するリソースがディレクトリになってしまっているところの、エラー制御のわかりにくさもあった。またこの運用はオンプレミスの環境でしか利用できず、将来的にクラウド対応 (k8s) で運用することを考慮すると、http 通信できる方がよいと考えを改め、データ通信の仕組みを再実装した。http サーバーと比べて named pipe の方が優れているのはセキュリティとして物理的にその OS 上からしかアクセスできないところ。

go でフレームワークを使わず、シンプルな http サーバーを実装してみた。セキュリティの制約さえなければ、このぐらいの手間を惜しんで named pipe を実装することもなかったかと、いまとなっての学びとなった。

func newMux(cfg config.MyConfig) *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("/version", handler.HandleVersion(cfg))
	return mux
}

func main() {
	ctx, stop := signal.NotifyContext(context.Background(),
		syscall.SIGINT,
		syscall.SIGTERM,
	)
	defer stop()

	cfg := config.MyConfig{}
	server := &http.Server{
		Addr:    ":7099",
		Handler: newMux(cfg),
	}
	go func() {
		if err := server.ListenAndServe(); err != http.ErrServerClosed {
			slog.Error("got an error providing http service", "err", err)
		}
		slog.Info("stopped http server normally")
	}()

	<-ctx.Done()
	slog.Info("caught the stop signal")
	ctxTimeout, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := server.Shutdown(ctxTimeout); err != nil {
		slog.Error("failed to shutdown normally", "err", err)
		return
	}
	slog.Info("done graceful shutdown")
}

キャリア談義

たまたまだけど、お昼にキャリアセミナーの動画をみて、夜にキャリア相談にのった一日だった。

キャリアセミナーと転職

あんちぽサロン に参加されているメンバーがキャリアセミナーで話されている動画を紹介されていたのでみた。その方も5回転職していて、私と似たようなキャリア形成をされてきたようにみえた。そのせいもあって価値観も共感できるところが多かった。私もそうだが、一定回数以上の転職をする人は自身の好きなことに価値を見出す人が多いのかな?とその動画をみていて思った。転職について私自身の考えを考察してみる。

私の考える転職で得られるメリットは次になる。

  • どんな組織にも良いところと悪いところがあるとわかる
  • 多様な人間関係や価値観に触れるきっかけになる
  • 新しい知識やスキルを身につけるきっかけになる
  • コンフォートゾーンを抜け出すことで変化に対応するスキルが身につく
  • 年収をあげやすい

一方でデメリットもある。

  • 10年とか時間のかかる知識やスキルを身につけられない
  • 1度転職すると、その後も転職を繰り返しがちになる
  • 転職後の変化に対応するストレスが大きい
  • 転職が嫌なことからの逃げ道になってしまう
  • 一定数を超えると長期のキャリア形成を望む組織に信頼されない

いまの時代、転職におけるもっとも大きなメリットは変化に対応するスキルが身につくことだと個人的には思う。誰もが慣れた環境や人間関係、同じ業態・業種のお仕事をずっと続けていると、それが当たり前になってずっと続くような錯覚をしてしまう。そして、実際には3年、5年、10年と世の中は変わっていって普段しているお仕事がなくなっていく。個人と比べて組織はずっと変化に疎い。同じ組織にいると世の中の変化に鈍感になる傾向はあると思う。しかし、転職はリスクのある判断で一概に奨められるものでもない。とくに年齢が高くなると自身が求める待遇と先方から求められるスキルセットのマッチングが難しくなっていく。そしてマッチングで失敗するとキャリアにおけるダメージも大きい。私は20代の頃から転職してきたからリスクも折り込み済みで働いてきたが、過去を振り返るとよかった転職とわるかった転職は五分五分だと思う。そして40代になって転職先がなくなって自分で会社を経営するしかなくなった。その選択に後悔はないが、あまり他人へ奨められるようなキャリアではない。キャリアや働き方を大きく左右する転機の1つとして、若いときに転職するかどうかがあるように思う。

キャリア相談

夜にコミュニティの仲のよいメンバーと飲みに行った。急に誘いがきたからなんとなくそういう系の話しかな?という予測はついていた。そのメンバーはスタートアップで働いていて、あまりうまくいっていないという話しを以前から聞いていた。どうやら創業者が体調不良で会社を清算するらしい。事業を他社に売却するものの、従業員はどうなるか分からないらしい。黒字化していない事業だからあまりよい値段がつくとも思えないことから従業員までは雇わないのではないかと推測する。予定通り事務手続きが進めば今月末に会社がなくなるらしい。

そこで個人事業主でやっていくか、転職するかの相談にのっていた。まだ30代の若い方なのでどうしてもやりたいことがあるなら自分でやるのも1つの手だけど、個人事業主はいつでもなれるから転職して経験を積んだ方がよいとお勧めした。一番起業の成功率の高い年齢は、40代半ば にもあるように、起業して成功する確率が高いのは統計的に40代になる。当たり前の話しだけど、経験のある方がうまく経営したり失敗を避ける上での知恵が働くのだと推測する。うちの会社が受託開発できるのも世の中にシニアエンジニアは少なく、30代で身につけたスキルの貯金で食いつないでいるといえる。

あと貯金が減っていくのは精神的によくないという実体験を話した。起業するなら3年ぐらいは収入がなくても生活できる貯金を用意した方がよいとアドバイスした。生活費がなくなると目先のことしか考えられなくなって判断を誤るというのは「貧すれば鈍する」と昔からの格言にある。脱サラした経営者がキャッシュフローをみてないといった失敗談もたまにみかけるが、想定外のことが起こっても資金繰りに困らないだけの十分な余力として3年ぐらいの生活費をみた方がよいと考えている。

集中力の正体

昨日は20時半頃にお仕事を終えて帰った。スーパーで買いものして、家に帰って晩ご飯を作って食べて、休んだらオフィスへ戻るつもりがそのまま寝てしまった。

メッセージ作成

年一ゲストとして出演している terapyon channel100回記念公開収録 があった。いつもお世話になっているので私は参加できないがカンパ枠で応援していた。昨日てらださんから音声メッセージを作ってほしいと依頼され、本当は昨日の夜に作ろうと思っていたものの、疲れて寝てしまったので朝起きてから慌ててオフィスへ行って作成した。ubuntu の apt でインストールできるツールとして audacity を使って録音した。簡単に使えた。お祝いのメッセージを文章に書き出して、マイクテストも含めて最終的には7回撮り直した。文章を書いてから、話してみると、流れが悪かったり、語呂が悪かったり、途中で詰まったり、かんでしまったりと、なにか気になるところをみつけて、文章を推敲して録音しながら1時間ほどやってた。録音して聞き直すと、いかに自分の話し方が下手であるかがよくわかる。話し方を練習しないとうまくならないのだろうな。

みなとのもりの運動

前回の所感 。14時頃に作業を終えてストレッチまで1時間ほど時間が空いた。ふと運動してからストレッチへ行くとよさそうだと思い立ってジョギングと縄跳びをしてきた。前回が7月24日なので1ヶ月半ほど運動していなかった。1kmほどジョギングしてから縄跳びを15分間した。以前と比較したら軽めに流したにも関わらず、久しぶりに運動したら体力が落ちていてかなりバテた。習慣的に運動していないとすぐに衰える。久しぶりに運動できたので、また再開するきっかけになるかもしれない。

ストレッチ

今日の開脚幅も開始前148cmで、ストレッチ後152cmとあまり数値は出なかった。直前に運動して行ったのでよく伸びるかな?と予測したものの、測ってみるとそうでもなかった。それでも運動した後にストレッチを受けるのはカラダによさそうにも思える。ここ1ヶ月半ほど運動できていないものの、体重は67kg台を維持していて体脂肪率や筋肉量はあまり変わっていない。トレーナーさんとそういう話をしていたら、筋肉は落ちにくくつきにくいから1ヶ月ではそんなもんとのこと。

プログラミングの集中力と生産性への考察

ストレッチを受けているときにトレーナーさんと話していて思うことがあったのでまとめておく。いまの開発フェーズは開発者としてプロジェクトに参加している。平均すると1日10時間ぐらいお仕事していて、早く帰る日もあるから遅い日は日付が変わるぐらいまではコードを書いていることがある。家に帰ると休んでしまうからオフィスで晩ご飯を食べることも多い。なぜプログラミングに集中していると運動ができないのか?を考察してみた。

  • 他の余分なことを排除すると集中力が増す

いまやっている開発のお仕事は1日で完了するような簡単な開発ではない。1年半開発しているので残っている課題は厄介で複雑な問題への対応であることが多い。新規機能を追加するときも既存機能や仕様や依存関係を考慮しないといけない。そうすると2-3日かけて課題を解決することが多い。そのときに他の余分なことを排除した方が脳のリソースを課題解決に割り当てられる。通勤しているときも、寝ているときも、ご飯を食べているときも取り組んでいる課題のことを四六時中考えている。他のことに脳のリソースを割くと課題解決の品質が下がる可能性が高い。ずっと考え続けることが複雑な問題の課題解決に重要となる。

  • システム開発とはタイムアタック競技である

システム開発のプロジェクトマネジメントにおいてもっとも重要な概念はタイムボックスだと私は考えている。適度な期間 (うちは2週間) を設け、モノゴトに取り組む最初と最後を作ること。認知心理学の研究からも記憶の仕組みからも期間の最初と最後がもっとも学習効果が高いことを示唆している。このことは私の経験則においてもシステム開発で当てはまる。作業期間が適切でないと人間はだらだらしてしまう (パーキンソンの法則) 。プログラミングにおいて動くのは当たり前で、動いた上でいかに品質をあげられるかが腕の見せ所になる。エラー処理を適切に制御できているか、他人がコードを読んでも理解しやすく保守しやすいか、将来の拡張性を考慮して設計されているか、など品質をよくするための取り組みには答えがない。仕事ができる・できないを分ける行動の1つとして、答えのない問いにどれだけ準備できるか、考え続けられるかと言い換えられるかもしれない。答えがないからいくらでも品質をあげるための施策がある。しかし、現実の業務には期限があるため、期限内に最善の品質を目指すことになる。そのため、タイムボックスでシステム開発をマネジメントする限り、いつもタイムアタック競技をしているのと同義になるから時間が足りない。

プログラミングはいつも妥協を強いられる。完璧で最高のシステムなどありえない。その時々で開発途中における最善の動くものをスナップショットとしてバージョン管理しているに過ぎない。この妥協するレベルが私にとってはマネージャーと開発者という役割で大きく異なるのだろうと思う。マネージャーとしての遊撃なら先送りできても、開発者としては多少の負荷があっても解決してしまう。自身の基準を満たす働き方に違いがあるのではないかと思う。言語化してみると、この考え方はプログラミングに限らず、そのときに取り組む対象によって何にでも応用できそうに思う。

ldap スキーマの情報を返す web api の実装

目次

一昨日昨日 と ldap スキーマの parser について調査して実装した。最終的には構文解析した値をそのまますべて返すわけではなく、実際に ui や内部の処理で必要な情報のみを使いやすい構造にして web api として実装する。マージリクエストを作成してメンバーにコードレビューをしてもらっていて、スキーマ情報だけでは足りない仕様に1つ気付いた。たとえば、次の cn という属性の定義には SUP (super の略語で親の定義から派生していることを表す) として name が定義されている。

attributetype ( 2.5.4.41 NAME 'name'
       EQUALITY caseIgnoreMatch
       SUBSTR caseIgnoreSubstringsMatch
       SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )

attributetype ( 2.5.4.3 NAME ( 'cn' 'commonName' )
       DESC 'RFC2256: common name(s) for which the entity is known by'
       SUP name )

cn の定義には SYNTAX は定義されていないが、これは name の定義にある SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 (Directory String のこと) の oid を継承するという仕様になっている。自分で SUP を調べて継承元の情報を取得するのは面倒なので構文解析した後に SUP が指定されていれば Equality, Ordering, SubStr, Syntax を親から取得するように改善した。こういうところは自前で parser を実装しているので自分たちの都合にあわせてカスタマイズしやすい。

3日もかかってしまったが、これで ldap スキーマを返す web api の実装を完了できた。いまの開発フェーズにおける、私が機能拡張する issue はこれで完了にしようと考えている。来週からはテストや qa 向けの作業にのみ注力していこうと思う。本番導入前に少しでも運用トラブルの懸念を減らす、もしくは品質をあげるための取り組みをあと3週間やってからしっかり検証をしていく。ここ1ヶ月ほど深夜までコードを書いていることが多かった。終わってみれば、面倒で厄介な issue も大半を fix した。当たり前の話だけど、四六時中ずっとコードを書いていたら気付いたら成果として積み上がってきた。マネージャーを移管したことでその分の労力を開発にまわせているのもある。

go で parser を実装してみた

目次

昨日の ldap スキーマ parser 実装 の続き。parser generator は使わず自分で parser を実装してみることにした。go の標準ライブラリにある text/scanner を使って字句解析をしたら、次のカスタマイズを行うことでそれっぽく token に分解できた。

s.Mode ^= scanner.ScanFloats
s.IsIdentRune = func(ch rune, i int) bool {
	return ch == '.' ||
		unicode.IsLetter(ch) ||
		unicode.IsDigit(ch)
}

しかし、text/scanner はあくまで go のソースコードを字句解析するためのツールになる。ldap スキーマの字句解析も一応は意図したように分割できたが 'objectClass' のようなシングルクォートで文字列を囲むような構文を go のソースコードでは記述できないため、エラーをチェックすると invalid char literal として必ずエラーになってしまう。これでは字句解析のエラーチェックをできなくなってしまうため text/scanner の利用は断念した。

そこで bufio#Scanner を使って字句解析を実装した。字句解析を行うツールを lexer または tokenizer と呼ぶ。go ではこのツールを scanner と呼ぶ慣習になってるようにみえる。分割する token をカスタマイズするには SplitFunc を定義する。ldap スキーマの字句解析は次のように定義できた。

const (
	singleQuote byte = '\''
)

func tokenize(
	data []byte, atEOF bool,
) (
	advance int, token []byte, err error,
) {
	advance, token, err = bufio.ScanWords(data, atEOF)
	if err != nil {
		return
	}
	if len(token) > 0 && token[0] == singleQuote {
		since := 0
		if data[0] != singleQuote {
			since = bytes.IndexByte(data, singleQuote)
		}
		i := bytes.IndexByte(data[since+1:], singleQuote)
		if i == -1 {
			return 0, data, bufio.ErrFinalToken
		}
		pos := since + i + 2
		return pos, data[since:pos], nil
	}
	return
}

func NewScanner(src string) *bufio.Scanner {
	s := bufio.NewScanner(strings.NewReader(src))
	s.Split(tokenize)
	return s
}

次のような ldap スキーマの文字列は、

( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )

次のように token として分割される。

(
2.5.4.0
NAME
'objectClass'
DESC
'RFC4512: object classes of the entity'
EQUALITY
objectIdentifierMatch
SYNTAX
1.3.6.1.4.1.1466.115.121.1.38
)

あとはこれらの token から構文解析を行う parser を実装するだけ。AttributeTypeDescription の文法は次になる。

AttributeTypeDescription = "(" whsp
      numericoid whsp              ; AttributeType identifier
    [ "NAME" qdescrs ]             ; name used in AttributeType
    [ "DESC" qdstring ]            ; description
    [ "OBSOLETE" whsp ]
    [ "SUP" woid ]                 ; derived from this other
                                   ; AttributeType
    [ "EQUALITY" woid              ; Matching Rule name
    [ "ORDERING" woid              ; Matching Rule name
    [ "SUBSTR" woid ]              ; Matching Rule name
    [ "SYNTAX" whsp noidlen whsp ] ; Syntax OID
    [ "SINGLE-VALUE" whsp ]        ; default multi-valued
    [ "COLLECTIVE" whsp ]          ; default not collective
    [ "NO-USER-MODIFICATION" whsp ]; default user modifiable
    [ "USAGE" whsp AttributeUsage ]; default userApplications
    whsp ")"

AttributeUsage =
    "userApplications"     /
    "directoryOperation"   /
    "distributedOperation" / ; DSA-shared
    "dSAOperation"          ; DSA-specific, value depends on server

字句解析する scanner と組み合わせて実装した parser は次のようになる。

func ParseAttributeTypeDescription(
	src string,
) (*AttributeTypeDescription, error) {
	var err error
	d := &AttributeTypeDescription{}
	s := NewScanner(src)
	for s.Scan() {
		token := s.Text()
		switch token {
		case leftParenthesis:
			if s.Scan() {
				d.OID = OID{Value: s.Text()}
			}
		case rightParenthesis:
			// do nothing
		case "NAME":
			if s.Scan() {
				d.Name = ParseNAME(s)
			}
		case "DESC":
			if s.Scan() {
				d.Desc = Unquote(s.Text())
			}
		case "OBSOLETE":
			d.Obsolete = true
		case "SUP":
			if s.Scan() {
				d.Sup = NewWOID(s.Text())
			}
		case "EQUALITY":
			if s.Scan() {
				d.Equality = NewWOID(s.Text())
			}
		case "ORDERING":
			if s.Scan() {
				d.Ordering = NewWOID(s.Text())
			}
		case "SUBSTR":
			if s.Scan() {
				d.SubStr = NewWOID(s.Text())
			}
		case "SYNTAX":
			if s.Scan() {
				d.Syntax, err = ParseNOIDLen(s.Text())
				if err != nil {
					return nil, fmt.Errorf("failed to parse SYNTAX: %w", err)
				}
			}
		case "SINGLE-VALUE":
			d.SingleValue = true
		case "COLLECTIVE":
			d.Collective = true
		case "NO-USER-MODIFICATION":
			d.NoUserModification = true
		case "USAGE":
			if s.Scan() {
				usage := GetAttributeTypeUsage(s.Text())
				d.Usage = &usage
			}
		default:
			if strings.HasPrefix(token, "X-") {
				if s.Scan() {
					if d.Extension == nil {
						d.Extension = make(map[string][]string)
					}
					d.Extension[token] = ParseQuotedStrings(s)
				}
				break
			}
			slog.Warn("unsupported", "token", token, "oid", d.OID, "name", d.Name)
		}
	}
	if err := s.Err(); err != nil {
		return nil, fmt.Errorf("failed to scan: %w", err)
	}
	return d, nil
}

昨日の調査で go 製の parser generator をあまりみつけられなかったことに気付いた。その理由を理解できた。go で初めて parser を実装してみて簡単に実装できることに気付いた。標準ライブラリにある text/scanner や bufio#Scanner を使うと簡単に字句解析できるため、自分で parser を実装する労力が小さい。したがって parser generator を使って実装するよりも、自分で parser を実装することを選ぶ開発者が多いのではないかと推測する。

openldap スキーマと parser generator

この開発フェーズではあまり機能拡張を行わない方針としているが、できればやっておいた方がよい機能拡張の1つに openldap サーバーから ldap スキーマを取得する処理がある。ちょうどいま若いメンバーに実装してもらっている大きな機能のバリデーションにも ldap スキーマの情報があると便利そうなので私が対応することに決めた。

openldap サーバーで管理している ldap スキーマの定義は openldap サーバーの Schema Specificationrfc 4512 の2つをみると仕様がわかる。たとえば、次のように AttributeTypeDescription というスキーマの定義は Augmented Backus–Naur form (abnf) という記法で定義されている。rfc などのネットワークプロトコルの世界では abnf のフォーマットで仕様を説明することが多いらしい。

AttributeTypeDescription = "(" whsp
      numericoid whsp              ; AttributeType identifier
    [ "NAME" qdescrs ]             ; name used in AttributeType
    [ "DESC" qdstring ]            ; description
    [ "OBSOLETE" whsp ]
    [ "SUP" woid ]                 ; derived from this other
                                   ; AttributeType
    [ "EQUALITY" woid              ; Matching Rule name
    [ "ORDERING" woid              ; Matching Rule name
    [ "SUBSTR" woid ]              ; Matching Rule name
    [ "SYNTAX" whsp noidlen whsp ] ; Syntax OID
    [ "SINGLE-VALUE" whsp ]        ; default multi-valued
    [ "COLLECTIVE" whsp ]          ; default not collective
    [ "NO-USER-MODIFICATION" whsp ]; default user modifiable
    [ "USAGE" whsp AttributeUsage ]; default userApplications
    whsp ")"

AttributeUsage =
    "userApplications"     /
    "directoryOperation"   /
    "distributedOperation" / ; DSA-shared
    "dSAOperation"          ; DSA-specific, value depends on server

openldap サーバーに対して ldap スキーマを取得する方法は How can I fetch schema information from the server? の faq に書いてある。search base に対してサブスキーマサブエントリを返す dn を取得する。openldap サーバーの場合 ldap の root ツリーに対応するのは cn=Subschema がデフォルトとなる。

$ ldapsearch -H ldap://localhost -x -LLL -b dc=example,dc=com -s base subschemaSubentry
dn: dc=example,dc=com
subschemaSubentry: cn=Subschema

この dn にスキーマ情報の属性が格納されているのでそれらを取得する。このスキーマ情報は operational attributes として管理されているので + という記号が operational attributes 属性群をまとめて取得するキーワードになっている。

$ ldapsearch -x -LLL -b cn=Subschema -s base '(objectClass=subschema)' +

これでスキーマ情報を取得できる。先に書いた AttributeTypeDescription の実際のスキーマ情報は次のような内容になる。こういったスキーマのテキスト情報をたくさん取得できる。

( 2.5.4.0 NAME 'objectClass' DESC 'RFC4512: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )

go の parser generator 調査

この手のものは abnf から parser をコード生成するのが一般的なやり方かな?と、まずは go で使えそうな parser generator について調べてみた。意外と go 製の parser generator はみつからなかった。私が発見できたのは次の3つぐらい。

最後の antlr は java 製のツールだけれども、go のソースコードを出力できるので go 実装の parser をコード生成できるという意図で対象としている。goyacc は abnf の文法を yacc に変換しないといけない。yacc で実装した ldap スキーマの parser を公開しているのもみかけたが、yacc は違うなと思って除外した。antlr も abnf から専用の文法に変換しないといけないから不採用にした。

それで消去法的に bnf (ebnf) を扱える gocc で parser をコード生成できないかを調査してみた。しかし、半日ほど bnf を書いて実際にコード生成してみて不採用とした。ebnf の options 記法として [...] に gocc が対応していないようにみえた。この記法がないと ldap スキーマの文法定義が煩雑になる。issue のコメント をみると、lexer は対応したが、parser は対応していないといったコメントがある。コード生成しようとするとエラーになるので未対応なのかもしれない。bnf でこの options 記法相当のものを自分で文法定義すると、1つ2つなら簡単に変換できるが、数個の組み合わせがあると途端に文法の複雑さが大きくなるように思える。abnf から bnf に変換する過程で文法が複雑になってしまうとその後の保守ができなくなる懸念が生じるので断念した。

今回の gocc の採用は却下したが、軽く触ってみて gocc 自体の感触はよかった。シンプルな bnf で表現できるものであれば機会があれば採用してもよいと思える。gocc example をみればわかるが、bnf を書きながら単体テストのコードを実行して動作検証を小さく簡潔にできる。これは parser のコード生成の開発サイクルは速くできそうに感じた。こういう小さく単体で動くツールは好み。

最終的な結論としては parser generator は使わず、自前で parser を実装することを決めた。

case-insensitive という古くて厄介な問題

casemap によるリファクタリング

ldap プロトコル (v3) は rfc 2251 で定義されていて属性へのアクセスは case-insensitive (大文字小文字を区別しない) に扱うという仕様になっている。

ldap サーバーへの問い合わせや検索は case-insensitive となるため、ldap エントリーを扱う他システムの処理もすべて case-insensitive にしないと整合性が取れなくて混乱する。これは利用者だけでなく開発者も同様であり、一部が case-insensitive なのに、一部を case-sensitive (大文字小文字を区別する) にすると、比較処理がマッチしたりしなかったりという混乱を生じる。そういう面倒くさい issue が過去にいくつか散見されてきた。この問題は場当たり的な修正ではなく、本質的な対応が求められた。

結論としては、すべてを case-insensitive に扱う必要があって、そのためにキーを case-insensitive として扱う map like なデータ構造を実装した。オリジナルのキー情報も保持しつつ、case-insensitive にアクセスできるよう小文字変換しておいた名前変換テーブルを内部にもつ。最初からこういったデータ構造を定義しておけば開発時に混乱は起きないが、こういうものは後になってわかってくる。うちは1年半経ってからこの問題の本質を理解した。そのため、関連する処理のインターフェースを見直して置き換えることになった。こういうリファクタリングは時間がかかる割に成果も地味なので労力に対して評価されないことが多い。私もやっていてあまり楽しくはないが、こういうところをきっちり作ると品質に寄与するのを経験的に知っている。

type CaseMap struct {
	keyValue map[string][]string
	nameConv map[string]string
}

func (m *CaseMap) String() string {
	return fmt.Sprintf("%v", m.keyValue)
}

func (m *CaseMap) Values(name string) []string {
	origName, ok := m.nameConv[strings.ToLower(name)]
	if !ok {
		return nil
	}
	values, ok := m.keyValue[origName]
	if !ok {
		return nil
	}
	return values
}

func (m *CaseMap) Get(name string) ([]string, bool) {
	origName, ok := m.nameConv[strings.ToLower(name)]
	if !ok {
		return nil, false
	}
	values, ok := m.keyValue[origName]
	return values, ok
}

func (m *CaseMap) Set(name string, values []string) {
	m.keyValue[name] = values
	m.nameConv[strings.ToLower(name)] = name
}

func (m *CaseMap) Len() int {
	return len(m.keyValue)
}

func (m *CaseMap) Iter() iter.Seq2[string, []string] {
	return func(yield func(string, []string) bool) {
		for k, v := range m.keyValue {
			if !yield(k, v) {
				return
			}
		}
	}
}

type CaseName struct {
	Original  string
	LowerName string
}

func (m *CaseMap) IterWithLower() iter.Seq2[CaseName, []string] {
	return func(yield func(CaseName, []string) bool) {
		for lowerName, origName := range m.nameConv {
			name := CaseName{
				Original:  origName,
				LowerName: lowerName,
			}
			if !yield(name, m.keyValue[origName]) {
				return
			}
		}
	}
}

func (m *CaseMap) ToMap() map[string][]string {
	return m.keyValue
}

func New(length int) *CaseMap {
	return &CaseMap{
		keyValue: make(map[string][]string, length),
		nameConv: make(map[string]string, length),
	}
}

func NewFrom(attr map[string][]string) *CaseMap {
	m := New(len(attr))
	for k, v := range attr {
		m.Set(k, v)
	}
	return m
}