Posts for: #Go

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
			}
		}
	})
}

コンテナー間のデータ通信はやはり 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")
}

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 を実装することを選ぶ開発者が多いのではないかと推測する。

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
}

ぱっとしない休日

9月に入ってしまった。8月は開発者の生活を思い出すための試行錯誤の月だった。よくもわるくもかな。

キャンセル料の支払い

この前の金曜日は しみん福祉スポーツセンター の体育館を借りてバドミントンを行う予定だった。木曜日は戻ってこれない可能性 があったし、前日の天気予報では金曜日の夕方が神戸に台風が来る予報になっていた。そこで木曜日の夜に参加者も少なかったしキャンセルすることに決めた。キャンセルは1週間前でないとできないため、これは自然災害だから仕方がないと体育館のキャンセル料金3,000円を支払うことにした。直接、しみん福祉スポーツセンターの窓口でないと支払いできない。窓口へ行って paypay で支払いしてきた。しみん福祉スポーツセンターの体育館を借りるのは値段が高いので参加者数が増えてから借りる運用を変えようと思う。

X-Forwarded-For ヘッダーの制御

先日の作業の続き 。本当は土曜日にやろうと思っていて、全然やる気がなくて、なにもやらないよりはちょっとでもやった方がよいかと、今日やり始めたら集中できて2-3時間で調査と対応を完了した。もともと構築しているリバースプロキシとしての nginx には次のヘッダーを扱う設定が追加されていた。

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;

追加で remote_addr を置き換える設定を入れてみれば、リバースプロキシ経由でリクエストを受ける go の http Request が参照する RemoteAddr の値も置き換わるのかな?と検証してみた。

set_real_ip_from 172.29.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
proxy_set_header REMOTE_ADDR $remote_addr;

結論としては RemoteAddr の値は置き換わらなかった。nginx の real_ip_header モジュールは nginx 環境における remote_addr の値を変更する設定であり、転送するときにパケットの値を置き換えるものではないようにみえる。そこで api サーバー側で X-Forwarded-For ヘッダーを参照するのが正しい対応方法だと理解できた。このヘッダーを参照することは一般的な用途に思えるので調べたら echo の IP Address のドキュメントにその設定方法やユーティリティの扱いなどが書いてあってすぐに参照できることもわかった。

X-Forwarded-For ヘッダーはクライアントが任意で偽装できる。デフォルトでは内部ネットワークから転送されたヘッダーの値のみを信頼するように設定されている。それが次の IPExtractor の設定になる。デフォルトの制約を変更することもできるが、コンテナで運用するとすべての通信はコンテナネットワークの gateway からリクエストされているようにみえるのでアクセス制御という側面ではこの設定そのものにあまり意味はない。

e := echo.New()
e.IPExtractor = echo.ExtractIPFromXFFHeader()

X-Forwarded-For ヘッダーの値を実際に参照するときは c.Request().RemoteAddr ではなく c.RealIP() を使う。api サーバーはこのぐらいの変更で実際のクライアントから転送されてきた ip アドレスを知ることができた。

multipart/form-data リクエストの扱い

9時に起きて2度寝して10時頃に起きたものの、お昼までだらだらしていた。それからオフィスへ行って溜まっていた日記をまとめて書いて、逃げ上手の若君 をみながら作業をしていた。来週は出張の週だから事前にやっておくことがいくつかある。

ストレッチ

先週は実家へ帰っていた ために2週間ぶりのストレッチになる。とはいっても、いまは運動をしているので筋肉痛もない。しかし、机に向かってデスクワークする時間が増えている分の負荷がかかっている。トレーナーさんからも腰から背中にかけて硬いと指摘された。今日は硬くなっているカラダ全体をほぐしてもらった感じだった。おかげで今後も体調よく机に向かえる。今日の開脚幅は開始前149cmで、ストレッチ後155cmと普段通りの数値だった。

multipart/form-data リクエストを扱う結合テスト

昨日の夜に汎用のファイル操作の api を実装した。今日はその結合テストを追加してマージリクエストを作成した。multipart/form-data のリクエストを作るのはちょっと面倒くさい。go のライブラリのテストコードや stackoverflow のサンプルコードなどを調べながら自分で実装した。次のような multipart/form-data リクエストを扱う writer や buffer を生成するためのユーティリティを作る。一通りの結合テストを実装するのに3時間ほどかかった。

func createMultiPartFormData(
	fileBody []byte, formField map[string]string,
) (*multipart.Writer, *bytes.Buffer, error) {
	b := new(bytes.Buffer)
	mw := multipart.NewWriter(b)
	for k, v := range formField {
		fw, err := mw.CreateFormField(k)
		if err != nil {
			return nil, b, err
		}
		if _, err := fw.Write([]byte(v)); err != nil {
			return nil, b, err
		}
	}
	fw, err := mw.CreateFormFile("file", "path/to/file")
	if err != nil {
		return nil, b, err
	}
	if _, err := fw.Write(fileBody); err != nil {
		return nil, b, err
	}
	if err := mw.Close(); err != nil {
		return nil, b, err
	}
	return mw, b, nil
}

このユーティリティを使って http リクエストのテストデータを生成するのは次のようなコードになる。

u := &url.URL{
	Scheme: "http",
	Host:   "localhost",
	Path:   path,
}
mw, body, err := createMultiPartFormData(fileBody, formField)
if err != nil {
	return nil, fmt.Errorf("failed to create multipart form data: %s", err)
}
req, err := http.NewRequest(http.MethodPost, u.String(), body)
if err != nil {
	return nil, fmt.Errorf("http create new request error. err: %s", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())

あと結合テストでファイルをアップロードすると実際にファイルシステム上に保存されてしまうのでそれらを削除するには Cleanup を使うと簡単に後始末できた。

func TestFileUpload(t *testing.T) {
    ...
	t.Cleanup(func() {
		if err := os.RemoveAll(file.FilesDir); err != nil {
			t.Errorf("failed to remove files directory: %s", err)
			return
		}
	})
    ...
}

ファイルアップロードのインターフェース

今日も昼間は普通にお仕事で開発して、お仕事を終えて晩ごはんを買い出しへ行ってきて、オフィスで晩ごはんを食べてから夜のコーディングに戻るといった一日になった。昼間は会議や問い合わせ対応、他のメンバーの進捗をチェックしたりなど、ちょくちょく割り込みが入る。夜はそれらがないことがわかっているので3-4時間集中してコーディングできる。

echo のファイルアップロードのサンプル

メンバーがファイルを受け渡しする web api のインターフェースがよくわからないということで私がサンプルを作ってみることにした。echo には File Upload のサンプルも紹介されているからすぐにできる。歴史的にブラウザからのファイルアップロードを前提にすると、ファイルを扱うときは次の Content-Type を使う。

Content-Type: multipart/form-data

これを echo のコードで書くと次のようなインターフェースとなる。

type MyRequest struct {
	Type      string    `form:"type" validate:"required"`
	Operation string    `form:"operation" validate:"required"`
}

func handle(c echo.Context) error {
	req := MyRequest{}
	if err := c.Bind(&req); err != nil {
		return err
	}
	if err := c.Validate(&req); err != nil {
		return err
	}
	file, err := c.FormFile("file")
	if err != nil {
		if errors.Is(err, http.ErrMissingFile) {
			return echo.NewHTTPError(http.StatusBadRequest, "file is required")
		}
		return err
	}
    ...

ファイル情報を MyRequest で管理できるとリクエストパラメーターを一元管理できて望ましいが、次の issue によると現時点ではそれはできないらしい。

curl コマンドでリクエストするには次のような cli になる。

$ curl -i -X POST -F type=misc -F operation=add -F file="@myfile.data" ...

go の iterator 実装へのリファクタリング

mongo-go-driver を使った go の iterator 実装

先日 go 1.23 がリリースされた。このバージョンの目玉機能の1つに range-over function iterators がある。簡潔に言えば、ユーザー定義のイテレーターを言語機能の仕組みを使って簡単に実装できる。ある制約を満たして関数を実装すれば、for 文の range 構文を使ってイテレーターとして扱える。ユーザー定義のイテレーターを実装するのが、ほとんど関数を実装するだけで出来てしまうので実装の難易度が減ったと言える。どんなコードかをみるには次のチュートリアルがわかりやすいと思う。

昨日で私が抱えていたプロジェクトのボトルネックを解消できた。ここからはボーナスステージで相対的に自由に開発できる。手始めにプロダクトの依存バージョンを go 1.23 へアップグレードして mongodb からの fetch 処理をイテレーターに置き換えるリファクタリングをした。アプリケーションからみたら mongodb は Store という generic なインターフェースを定義していて、そこに Iter() メソッドを追加した。mongodb のコレクションは必ず Iter() メソッドを実装しなければいけないという制約を課す。

type Store[T any] interface {
    ...
    Iter(ctx context.Context, query query.Query, opt *sort.Option) iter.Seq2[*T, error]
}

mongo-go-driver はもともと cursor でイテレーター機能を提供しているため、これを range-over function iterators を使ってアプリケーションから使えるように間をつなげてあげればよい。具体的には generics を使って次のような汎用の iter() メソッドを mongodb client に実装する。

func (c *client[R, E]) iter(
	ctx context.Context, name string, filters queryFilters, sortOpt *sort.Option,
) iter.Seq2[*E, error] {
	if c.toEntity == nil {
		panic(fmt.Errorf("require toEntity() function"))
	}
	return func(yield func(*E, error) bool) {
		collection := c.raw.Database(c.db).Collection(name)
		opts := []*options.FindOptions{}
		if sortOpt != nil {
			opt := options.Find().SetSort(makeSortSet(*sortOpt))
			opts = append(opts, opt)
		}
		cursor, err := collection.Find(ctx, filters, opts...)
		if err != nil {
			if !yield(nil, err) {
				return
			}
		}
		defer cursor.Close(ctx)
		for cursor.Next(ctx) {
			var result R
			if err := cursor.Decode(&result); err != nil {
				if !yield(nil, err) {
					return
				}
			}
			if !yield(c.toEntity(&result), nil) {
				return
			}
		}
		if err := cursor.Err(); err != nil {
			if !yield(nil, err) {
				return
			}
		}
	}
}

Store インターフェースを満たすコレクションの実装は次のように型チェックのためのメソッド実装をもてばよい。

func (c *MyCollection) Iter(
	ctx context.Context, query query.Query, opts *sort.Option,
) iter.Seq2[*entry.MyEntity, error] {
	return c.iter(ctx, c.name, makeFilters(query), opts)
}

朝から iterator 実装の試行錯誤や使い方の勘どころを確かめながら、これも1日でほとんどリファクタリングして移行できた。python のジェネレーターもそうだけど、関数をイテレーターにできると直感的にわかりやすく簡潔に実装できる。すごくよい機能だと思う。1.23 以降の go のコードはイテレーターを使うよう、大きく変わっていくと思われる。

サーバーの拡張とフックポイント

今日は東京株式市場の歴史的な急落もあっていろいろ経済ニュースを読んでいた。

echo の Custom Binding

echo で開発していて気に入っているところの1つに、サーバーサイドの、ここをカスタマイズしたいという箇所にちゃんと拡張するためのフックが用意されているというのがある。例えば、リクエストデータとサーバー内部で使う構造体を紐付ける bind 処理もデフォルトの仕組みを拡張する Custom Binding が提供されている。

Echo 構造体の値に Binder というフィールドがあり、カスタマイズしたい処理を実装した Binder インターフェースの値をセットする。

e := echo.New()
e.Binder = binder.New()

Binder はリクエスト構造体の値と echo.Context の値を受け取る Bind() メソッドを実装する。基本的には echo.DefaultBinder を適用した後にカスタマイズしたい bind 処理を実装すればよいと思う。

type Binder struct{}

func (b *Binder) Bind(i any, c echo.Context) error {
	defaultBinder := new(echo.DefaultBinder)
	if err := defaultBinder.Bind(i, c); err != nil {
		return err
	}
	switch req := i.(type) {
	case *myRequest:
		if err := req.BindSomething(c); err != nil {
			return err
		}
	}
	return nil
}

func New() *Binder {
	return &Binder{}
}

bind 処理をどう実装するかはいろいろやり方があると思うが、echo.Context の値を渡せるので request 構造体にその実装を隠蔽するというのもいいんじゃないかと思う。

type myRequest struct {
}

func (r *myRequest) UnmarshalJSON(data []byte) error {
	type Alias myRequest 
	if err := json.Unmarshal(data, (*Alias)(r)); err != nil {
		return fmt.Errorf("failed to unmarshal ids request: %s", data)
	}
    // implement custom validation
	return nil
}

func (r *myRequest) BindSomething(c echo.Context) error {
    // implement custom binding
    return nil
}

日経平均株価の歴史的な急落

金曜日から2日連続 で株価指数が 5% 超も下落するのは世界的にも珍しいらしい。

金曜日に史上2番目の下落幅という見出しでメディアでは煽られていて、下落幅と下落率は違うからそんな不安に感じなくてよいと楽観していたら、今日は史上2番目の下落率 (-12.40%) となった。さらにストップ安になっている銘柄が相次いでいるから実体はもっと悪いと考えられるとのこと。一方でリーマンショックのような、金融機関が破綻しているような状況でもなく、実体経済が傷んでないのに売りが売りを呼ぶ急落になっているので今後どうなるのかはよく分からない。明日も下がるのか、反発するのか、誰にもわからない (素人はしばらく何もしない方がよい) 。注目していた任天堂の株価も -16.53% と日経平均株価以上に急落している。これだけ落ちると、信用取引の追証が2営業日以内となるため、任天堂は明後日「投げ売り」されてもう少し下がる可能性もあるかもしれない。いずれにしても、私は今週いっぱいぐらいかけて空売りの返済をしていく。買うときも売るときも一度に行うのではなく、タイミングや数量を分割してならしていく。

株価の急落について私の記憶にあるのは、2016年の米大統領選挙でトランプ氏が初当選したとき、2020年にコロナが流行り始めたときを思い出した。たまたまだけど、4年に1回は急落がやってきている。2016年も2020年もとくになにもせず私は見守っていただけだった。しかし、今回は事前にポートフォリオを見直したり、空売りでリスクヘッジしていたりと急落の場面にも対応できた。以前よりは経済の背景を学んで資産運用の行動に反映できるようになってきた。これは経営においてもよいことだと思う。

初めてのオープンチャット運営

週末にかけて体調を崩していたのと土日のイベント参加で疲れてしまったのか、ゆっくり休日を過ごしていた。

イテレーターの復習

Kyoto.go remote #51 Go Conference 2024 復習会 にオンラインで参加した。物理的に京都へ行くのは時間/お金のコストがかかるのでオンラインはありがたい。discord で自己紹介したり、グループディスカッションしたりといった軽い勉強会だった。先日参加したカンファレンス で聞いた発表を復習して、周りのメンバーに共有するといった取り組みだった。終わったら周りの人に共有するという制度設計をすることで目の前の課題に集中して取り組みやすいことが、複数の勉強会に参加して私自身もよくわかってきた。ただもくもく会で自分が勉強するだけでは足りないのだ。学習の時間が終わったら後で伝えないといけない、まとめないといけない、そのためには自分が理解しないといけない、こういった制約を人それぞれの無理のない範囲で作っていくことが大事なのだ。

line のオープンチャット開設

近所の体育館を借りて来月からいくつか予定が組まれている。その予約した日が近付いてきて実際にメンバーを確保できるかどうかがわからない。体育館は4ヶ月前に抽選申込みをして日程が決定される。抽選という仕組み上、メンバーを確定して日程が決まるのではなく、日程が決まってからメンバーを探すことになる。先週から三宮.devの運動部や雑談チャンネルで軽く呼びかけてみたが、反応がよくないので普通には参加者を集めるのは無理そう。そこで外部からも参加者を募れるように line のオープンチャットを作ってみた。すぐにはオープンチャットのサイトで検索してもまだ検索結果に出てこない。

すでにメンバーが8人になった。4人は私の知人ではあるけれど、知人の知人で3人新たに関心をもつメンバーを迎い入れることができた。体育館のスペースとしては4-6人来ればよい。

オフラインカンファレンスへの参加

今日の運動は腹筋ローラー,腕立て,スクワット,散歩,ハンドグリップをした。統計を 運動の記録 にまとめる。

神戸に戻ってきてから軽く公園へ行って運動しようと思っていたら22時頃から雨降りで行けなかった。オフィスで事務作業や片付けなどをしていた。

久しぶりのオフラインカンファレンス

たまたま出張の週末にかさなっていたので Go Conference 2024 に参加してきた。いつもはオンライン参加していたが、今回はオフラインでしかやらないという。朝ホテルでのんびりしていたら時間がぎりぎりになってしまって、10時半からの tenntenn さんの発表が始まる10分前ぐらいに会場へ着いた。時間的にはちょうどよかった。次の 1.23 でリリース予定のイテレーターについての話しを聞いた。さすがのクォリティだったと思う。

python の generator に近い概念のようにみえる。まだ自分でイテレーターを実装したことがないので seq 関数というのがちょっと腹落ちしていない。お手伝い先の次の開発フェーズで 1.23 にアップデートするだろうからそのときにいろいろ試してみることになると思う。イテレーターは重要な概念なのでチーム勉強会などでメンバーとしっかり情報共有してもよいかもしれない。

他の発表もどれもレベルが高くておもしろかったのだが、私が関心をもったものを1つあげると go module のバージョン管理の仕組みの解説が勉強になった。

依存先のモジュールのどのバージョンを使うかを決定するアルゴリズムを Minimal Version Selection (MVS) と呼ぶ。ライブラリをアップデートする経緯によってはサードパーティライブラリのバージョンの組み合わせは一意に決定されないという振る舞いを、あまり意識する機会はないだろうけれど、開発者として知っておく必要がある。

過去の人間ドックの結果

月曜日に手配しておいた 5年前の人間ドックの結果の冊子が届いていた。5年前の時点で体重は80kgあったことがわかった。それからコロナ禍になって一気に90kgまで太ってしまったようにみえる。これでスプレッドシートに過去の健康診断の数値はプロットできた。5年前までの数値を見比べてみると、体重は10kgぐらいの幅で増えたり減ったりしているものの、血液検査や他の内蔵の数値などはあまり変化がないようにみえる。あとは今回の結果が送られてくるのを待つのみ。楽しみ。