Posts for: #Go

ポインタの学び直し、参照とは違う

2時に寝て7時に起きた。深夜に葬送のフリーレン10巻を読んでからなんとなく眠れなくて夜更かししてた。木曜日は会議がなくて自分のために時間を使える。機能開発に集中して実装していた。

go の学び直し

Gopher塾 #4 - 私も解説できるポインタ - DAY1 に参加した。

今日のテーマはポインタ。tenntenn さんが話すのだから深い内部実装の話しなどもあるのかな?と期待していたけれど、これは基本的な go のポインタの扱いを学ぶ講義だった。私にとっては9割は知っていることだった。それでも1割は知らないことがあったので参加して勉強にはなった。この歳になると本やイベントから1-2割学べたら十分だと思う。go は内部的にすべて値渡しで何でもコピーするするといった振る舞いをする。ポインタを渡すと、ポインタの値であるアドレスをコピーすることでプログラムが動く。コピーなので大きな構造体をそのまま渡すとその分のメモリのオーバーヘッドがあって遅くなったりする。ポインタならアドレスだけのコピーで済む。

go には参照の概念はないという説明があって、ポインタと参照は別の概念なんだと今更ながらに気付いた。gpt-4 にポインタと参照の違いを尋ねたりしていた。参照は初期化後に変更できなかったり、null を参照できなかったり、ポインタ演算のようなことができなかったりすることで安全にプログラミングするための言語機能と言える。一般的には参照はポインタを使って実装される。ポインタの方が低レイヤで制約が少ないと言える。参照はポインタの一部の機能を安全にプログラマーに提供していると言える。

次に go のポインタの特徴をまとめる。

  • 型付け
    • ポインタは型付けされていて、特定の型の変数のアドレスだけがその型のポインタに割り当てられる
  • アドレス演算子とデリファレンス演算子
    • これらを単項演算子と呼ぶ
    • アドレス演算子 (&) で変数のアドレスを取得してポインタを作成する
    • デリファレンス演算子 (*) でポインタが指すアドレスに格納されている値にアクセスする
  • ポインタ演算制限
  • ポインタ演算は許可されていない、メモリアクセスの誤りやセキュリティ上の問題が軽減される
  • new と make 関数
    • new 関数を使うと指定された型の新しい変数を作成してそのアドレスを返す
    • make 関数は、スライス、マップ、チャネルなどの複合データ構造を作成および初期化して、それらへのポインタを返す
  • ポインタの nil 値
    • ポインタは、無効なアドレスを表す特別な値である nil をもてる
    • nil ポインタにアクセスしようとすると、実行時に panic が発生する
  • メソッドレシーバとしてのポインタ
    • メソッドレシーバとしてポインタを使うとメソッド内でレシーバオブジェクトの変更ができる
      • 値レシーバは意図せぬ不具合を招く可能性があるから基本的にはポインタレシーバを使う方がよいのではないかといった話しもあった

キャッシュ機構の導入

1時に寝て7時に起きた。リリース延期を決めたので3月末へのストレスやプレッシャーからは解放されていつもよりよく眠れた気がした。あくまで気がしただけ。

キャッシュライブラリの選定

ある機能を作るための準備としてキャッシュ機構を作ることにした。avelino/awesome-go を眺めて適当なキャッシュライブラリを選定する。シンプルな用途向け、オンメモリで goroutine-safe で保守されていて実績があるものでのバランスを取るときむらさんのキャッシュライブラリになった。

ちょうどいまのお仕事を始める前に スカウトをもらった会社 のテックリードがきむらさんだったので、いまはその会社にいるんだと思い出したのが半年前。きむらさんとはリアルで何年も会っていないし、仲がよいわけでもないけれど、なぜか facebook でも繋がっているので存在を忘れるということもない。

閑話休題。ある特定の mongodb コレクションのデータをキャッシュしたい。

キャッシュの生成。

cache := gcache.New(128).Build()

特定の用途で一部の処理だけ使いたいのでこんな感じでキャッシュの処理を実装した。

value, err := cache.Get(id)
if err == nil {
    return value.(*myData), nil
} else if err != gcache.KeyNotFoundError {
    log.Error("failed to get value from cache", map[string]any{
        "key": id,
        "err": err,
    })
}

// 通常処理

cache.Set(id, setting)

念のため、キャッシュをすべてクリアするときは次を呼ぶ。これをキャッシュ制御 api から呼び出せるようにしておく。何らかの理由やバグなどでキャッシュをクリアする必要もあるだろう。

cache.Purge()

データを削除したときは id 指定でキャッシュを削除する。

cache.Remove(id)

キャッシュが使われているかどうかは内部的に統計を取っているのでそれをキャッシュ制御 api から取得することでわかる。 キャッシュが使われていれば HitCount が増え、mongodb にアクセスしていれば MissCount が増える。単体レベルでプログラムのデバッグにはなる。

return CacheStat{
    HitCount:    cache.HitCount(),
    MissCount:   cache.MissCount(),
    LookupCount: cache.LookupCount(),
    HitRate:     cache.HitRate(),
}

windows のサービス起動を go から制御する

2時に寝て3回ほど起きて7時に起きた。淡々と自分の機能開発をしていた。ある処理が失敗したときにローカルのファイルシステムに暗号化して書き込み、定期的にそのディレクトリを監視してファイルがあれば読み込んで復号化してリトライをするといった仕組みを実装した。とくに難しくなくすぐにできた。

晩ご飯に 大衆酒場 PING (ピン) というお店に行ってみた。1人でも入りやすく値段も安く食べ応えもあっても私のイメージする居酒屋さんの印象にぴったりでよかった。また出張したら行こうと思う。そろそろ出張でバテてきたので今日は夜にお仕事せずに晩ご飯食べてからもテレビをみながらだらだらしてた。

windows のサービス起動

準標準パッケージである golang.org/x/sys パッケージを使ってアプリケーションの起動と停止をサービス管理画面から操作できるようにする。サンプルコードは次の場所にある。

サービスから呼ばれたかどうかを判定をすることでエントリーポイントを切り替えられる。

inService, err := svc.IsWindowsService()
if inService && err == nil {
    if err := runService(serviceName, false); err != nil {
        log.Error("failed to run service", map[string]any{
            "err": err,
        })
        return
    }
}

そして、次のような Execute() メソッドをもつ windows サービスから呼ばれる構造体を定義して、そのメソッド内でサービス管理画面からのステータス変更に対応する状態遷移のコードを実装すればよい。ここではサービス開始してから stop/shutddown で停止するぐらいしか必要ないのでシンプルに実装した。一時停止や再開も必要ならもう少し複雑なコードになる。

type myService struct{}

func (m *myService) Execute(
	args []string, reqCh <-chan svc.ChangeRequest, statusCh chan<- svc.Status,
) (ssec bool, errno uint32) {
	ctx, cancel := context.WithCancel(context.Background())
	ch := startMyService(ctx, getConfig())
	statusCh <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
	stop := false
	for {
		select {
		case c := <-reqCh:
			switch c.Cmd {
			case svc.Stop, svc.Shutdown:
				stop = true
				cancel()
				break
			}
		}
		if stop {
			break
		}
	}
	statusCh <- svc.Status{State: svc.StopPending}
	<-ch // wait until MyService would be completed
	return
}

暗号化と復号化を実装してみる

不定期に寝て7時半に起きた。起きてからシャワー浴びたらしゃんとした。昨日と同様に昼間働いて、ホテルに戻ってきて2-3時間寝てから夜に2-3時間かけて次のコードを書いた。

go での暗号化/復号化

ある機密情報を扱うので暗号化して普通には「みえない」ようにしたい。まったくわからないのでググったら次の記事がみつかった。

まずはこの記事にあるサンプルコードを動かしてみながら内容を理解する。暗号技術そのものは理解できないが、コードの振る舞いそのものは動かしながら理解できる。その後、いろいろ調べているとこのサンプルコードの大半は crypto/cipher パッケージにあるサンプルコードと同じであることに気づく。標準ライブラリのドキュメントでは iv (initialization vector) を暗号化対象の文字列の一部を切り取って生成している。iv は一意である必要はあるが、セキュアでなくてもよいとある。よくあるやり方とあるのでサンプルコードをみながら同じように実装した。

// The IV needs to be unique, but not secure. Therefore it's common to
// include it at the beginning of the ciphertext.

最終的な暗号化と復号化のコードは次になった。標準ライブラリにあるサンプルコードと基本的には同じ。

func Encrypt(secret string, plainText []byte) ([]byte, error) {
	block, err := aes.NewCipher([]byte(secret))
	if err != nil {
		return nil, fmt.Errorf("failed to create chiper: %w", err)
	}
	cipherText := make([]byte, aes.BlockSize+len(plainText))
	iv := cipherText[:aes.BlockSize]
	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
		return nil, fmt.Errorf("failed to read for iv: %w", err)
	}
	stream := cipher.NewCFBEncrypter(block, iv)
	stream.XORKeyStream(cipherText[aes.BlockSize:], plainText)

	buf := make([]byte, base64.StdEncoding.EncodedLen(len(cipherText)))
	base64.StdEncoding.Encode(buf, cipherText)
	return buf, nil
}
func Decrypt(secret string, text []byte) ([]byte, error) {
	block, err := aes.NewCipher([]byte(secret))
	if err != nil {
		return nil, fmt.Errorf("failed to create chiper: %w", err)
	}

	dbuf := make([]byte, len(text))
	n, err := base64.StdEncoding.Decode(dbuf, text)
	if err != nil {
		return nil, fmt.Errorf("failed to create chiper: %w", err)
	}
	cipherText := dbuf[:n]

	if len(cipherText) < aes.BlockSize {
		return nil, fmt.Errorf("ciphertext is too short: %d", len(cipherText))
	}
	iv := cipherText[:aes.BlockSize]
	cipherText = cipherText[aes.BlockSize:]
	stream := cipher.NewCFBDecrypter(block, iv)
	// XORKeyStream can work in-place if the two arguments are the same.
	stream.XORKeyStream(cipherText, cipherText)
	return cipherText, nil
}

いろいろな変換

前日は0時ぐらいまでオフィスでお仕事していて、家に帰ってお風呂入って荷造りして、そのまま寝ないで5時過ぎに出かけて新神戸駅から6時10分発の始発に乗ってから2時間ほど寝た。昼間働いて18時にホテルに戻ってまた2-3時間ほど寝て晩ご飯食べてから2時間ほどお仕事していた。寝付けなくて3時ぐらいに起きて吐いた。吐いたら気分よくなってその後はよく眠れた。

windows の内部の文字の扱い

前日の日曜日に実装したツールをメンバーに windows server検証してもらう。ある変換処理で windows の内部では文字を utf16le で扱っていて、文字コード変換の処理が漏れていることに気付いた。go の準標準ライブラリを使って、次のようなコードで utf16le から utf8 への変換ができる。

import "golang.org/x/text/encoding/unicode"

decoded, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder().Bytes(b)

LDAP の DN の仕様

LDAP の distinguished name (DN) を調べた。例えば、windows で dn を powershell の Get-ADUser を使って取得すると次のような値を取得できる。

dn="CN=Test,CN=Users,DC=example,DC=com"

CNDC は大文字/小文字どちらでもよいが、それが違うと dn の値として別の値になってしまう。できれば小文字に統一したい。その変換処理を実装しようと思って仕様を調べていたら RFC-4514 Lightweight Directory Access Protocol (LDAP): String Representation of Distinguished Names で定義されていることがわかった。ここで定義されている特殊な文字、例えばカンマや等号などはエスケープして値としても使えるように書いてある。キーの値を変換するにはこれらの仕様を理解してパースして変換しないといけない。わりと大変なことを理解して go-ldap のライブラリのコードを読んでいたら正に ParseDN() がそんな実装になっていた。

func ParseDN(str string) (*DN, error)

このツールを使って dn インスタンスにパースして文字列表現を取得すると自動的に正規化されて小文字に変換してくれる。

dn, err := ldap.ParseDN(s)
if err != nil {
    return fmt.Errorf("failed to parse dn: %w", err)
}
dn.String()

イベントログに書き込む

1時に寝て2時ぐらいから吐き気で3時間ほど苦しんで寝て9時に起きた。バテてきた。10時過ぎからオフィスへ行ってだいたい丸一日お仕事してた。

go のマルチプラットフォーム対応

いま作っているモジュールは windows/linux の両方で動くモジュールにする必要がある。go は標準でそういったモジュールの開発に対応していて Build Constraints にビルドタグの仕様が書いてある。例えば、ログ出力を linux では標準出力に windows ではイベントログに書き込みたいとか。windows のイベントログに書き込むのも準標準パッケージである golang.org/x/sys パッケージを使ってすぐにできた。私がイベントログの仕様をよく知らないのでイベントログそのもののインストール作業をどうするかという課題がある。これは詳しい人に教えてもらおう。ビルドタグには unix という設定が使える。これで linux/macos 対応になる。例えば、log 出力のインターフェースをそれぞれ分けたいときに次のように実装できる。windows は標準ロガーに加えてイベントログにも書き込むようにしてみた。この log パッケージを使う呼び出し側のコードはマルチプラットフォームの違いを意識する必要はない。とても簡単にマルチプラットフォームのコードを管理できる。go のビルドやコンパイラの仕組みを学ぶたびに感心する。

//go:build unix

package log

func Error(msg string, fields map[string]any) error {
	return mylogger.Error(msg, fields)
}

func Warn(msg string, fields map[string]any) error {
	return mylogger.Warn(msg, fields)
}
//go:build windows

package log

import (
	"golang.org/x/sys/windows/svc/eventlog"
)

const (
	// this logName should be installed in advance
	logName = "mylog"
	eventID = 1 // TODO
)

var elog *eventlog.Log

func init() {
	var err error
	elog, err = eventlog.Open(logName)
	if err != nil {
		mylogger.ErrorExit(err)
	}
}

func Remove() {
	eventlog.Remove(logName)
}

func Error(msg string, fields map[string]any) error {
	elog.Error(eventID, getMessage(msg, fields))
	return mylogger.Error(msg, fields)
}

func Warn(msg string, fields map[string]any) error {
	elog.Warning(eventID, getMessage(msg, fields))
	return mylogger.Warn(msg, fields)
}

お土産探し

いつもは東京出張のタイミングにあわせてお店やお菓子探しから時間をかけて、自分でも買って試食してみて選択している。今回は2週間前から土日もずっと開発していて余裕がない。前回は 本髙砂屋の金つば を持って行った。本髙砂屋は金つばの老舗なのだけど、洋菓子も販売していて和菓子のお店の隣に洋菓子のお店も構えている。 今回も本髙砂屋にあやかって洋菓子の エコルセ の季節限定で売っている詰め合わせバージョンを購入してみた。「貧すれば鈍する」とよく言ったものでお土産探しの時間すらかけられなくなってしまっている。15時半頃から出かけて本髙砂屋さんで購入した。今回は試食できていないけど老舗だからあまり心配はいらないとは思う。

http クライアントのリトライ機能

1時に寝て7時に起きた。今週末もお仕事をがんばる。今週中に開発タスクを完了させたいという思いがある。まだ今週は続いている。

ストレッチ

今日の開脚幅は開始前154cmで、ストレッチ後157cmだった。今週も座っている時間が長かったせいか、あまり数値がよくなかった。ここ2-3週間前から右すねの外側の筋に張りがあったが、これが左にも転移して左すねの外側の筋も張りが強くなってきた。両方とも張りがあるという意味ではバランスが取れたと言えるのかもしれない。いずれにしてもいままで張りがなかったところに違和感があるので引き続き注視していきたい。歩くときにやや気になるけれど、日常生活に支障が出るほどの障害ではない。少し前からオフィス通勤に自転車をやめて、通勤とお昼ご飯を買いに行くのが自転車から歩きになったことが影響しているのかな?言うても徒歩3分ぐらいの距離でそんなこと起きるかなぁと思ったり。

http クライアントのリトライ機能

結論から言うと go-retryablehttp を使ってすぐできた。自分で作ってもよかったのだけど、retryablehttp がシンプルで私の求めている機能そのものにみえたのでこのライブラリを採用した。既存の http クライアントの機能を次の2箇所変更するだけで retryablehttp が使える。こういうインテグレーションの簡単さも嬉しい。ちゃんとバックオフとジッターの機能も実装しているし、デフォルトのそれらの機能をカスタムで置き換えることもできる。最低限のリトライ機能があればいいといった http クライアントならこれでよいと思う。こういうシンプルで使い勝手のよいライブラリをみつけると嬉しくなる。

-	res, err := c.raw.Do(req)
+	_req, err := retryablehttp.FromRequest(req)
+	if err != nil {
+		return []byte{}, fmt.Errorf("failed to create retryable request: %w", err)
+	}
+	res, err := c.raw.Do(_req)
+func NewRetryableClient(cfg config.Client) *retryablehttp.Client {
+	c := retryablehttp.NewClient()
+	c.Logger = log.GetLogger()
+	c.RetryWaitMin = cfg.RetryWaitMin
+	c.RetryWaitMax = cfg.RetryWaitMax
+	c.RetryMax = cfg.RetryMax
+	return c
+}

-		raw: http.DefaultClient,
+		raw: NewRetryableClient(cfg),

zeromq のメッセージングを go でやり取りする

0時に寝て何度か起きて7時に起きた。たぶんよく眠れたと思う。

zeromq のライブラリ選定

zeromq のクライアントをサンプル的に実装している。windows と linux の両方で使う予定なので pure go 実装の go-zeromq/zmq4 を使ってみることにした。zeromq のライブラリはいくつかあるが、大きく2つに分けられる。

  • c 言語の zeromq ライブラリのラッパー
  • zeromq のプロトコルを pure go 実装

c 言語のライブラリのラッパーだとビルド環境をプラットフォームごとに用意しないといけない。pure go ならクロスコンパイルも簡単。例えば、linux 上で windows のバイナリをビルドするには次のようにする。go ならたったこれだけでよい。

GOOS=windows GOARCH=amd64 go build -o bin/myapp.exe ./cmd/myapp/main.go

但し、zeromq の pure go 実装は開発があまり活発ではないし production ready でもない。まだまだベータ版というか、stable な実装にはなっていないようにみえる。うちの用途はとてもシンプルなメッセージングに使うだけなので動けば問題ないだろうという想定で最初の選択肢として go-zeromq/zmq4 を試してみる。これはまだチュートリアル的に動かしているレベルなのでコードが誤っているかもしれないが、ひとまずメッセージのやり取りができるところまで確認した。windows でも動く。これから1ヶ月ほどかけて実運用レベルのデータやテストを実施して本当に使えるかどうかの検証をしていく。

メッセージを送る側は Push というソケットを使う。送信は chan を使って非同期に送る必要性はないのだけど Pull にあわせて汎用性をもつ実装にするとこんな感じかな。

func Push(
	ctx context.Context, cfg config.Queue, msgCh <-chan zmq4.Msg,
) (<-chan error, error) {
	push := zmq4.NewPush(ctx, zmq4.WithDialerRetry(time.Second*3))
	if err := push.SetOption(zmq4.OptionHWM, cfg.SendHWM); err != nil {
		return nil, fmt.Errorf("failed to set socket option: %w", err)
	}
	endpoint := "ipc://" + cfg.Path
	if err := push.Dial(endpoint); err != nil {
		return nil, fmt.Errorf("failed to dial: %w", err)
	}
	if addr := push.Addr(); addr != nil {
		return nil, fmt.Errorf("dialer with non-nil addr")
	}

	errCh := make(chan error, messageChanSize)
	go func() {
		defer func() {
			push.Close()
			log.Debug("push queue was closed", nil)
			close(errCh)
		}()
		for msg := range msgCh {
			if err := push.Send(msg); err == nil {
				errCh <- nil
				continue
			} else if errors.Is(err, context.Canceled) {
				log.Info("push queue is closing ...", map[string]any{
					"err": err,
				})
				return
			} else {
				errCh <- err
			}
		}
	}()
	return errCh, nil
}

メッセージを受け取る側は Pull というソケットを使う。Recv() でキューからメッセージの到着をブロックする。context をキャンセルすると Recv() が即時でエラーを返すので終了処理も制御しやすい。

func Pull(ctx context.Context, cfg config.Queue) (<-chan zmq4.Msg, error) {
	pull := zmq4.NewPull(ctx)
	endpoint := "ipc://" + cfg.Path
	if err := pull.Listen(endpoint); err != nil {
		return nil, fmt.Errorf("failed to listen: %w", err)
	}
	if addr := pull.Addr(); addr == nil {
		return nil, fmt.Errorf("listener with nil addr")
	}

	ch := make(chan zmq4.Msg, messageChanSize)
	go func() {
		defer func() {
			pull.Close()
			close(ch)
			log.Debug("pull queue was closed", nil)
		}()
		for {
			log.Debug("-- waiting messages ...", nil)
			msg, err := pull.Recv()
			if err == nil {
				ch <- msg
			} else if errors.Is(err, context.Canceled) {
				log.Info("pull queue is closing ...", map[string]any{
					"err": err,
				})
				return
			} else if err == io.EOF {
				log.Debug("got EOF", nil)
			} else if err != nil {
				log.Error("failed to recieve", map[string]any{
					"err": err,
				})
			}
		}
	}()
	return ch, nil
}

go 1.20.1 を使い始めた

0時に寝て何度か起きて7時半に起きた。あまりうまく眠れなかった。

go 1.20.1 へのアップグレード

リファクタリングの区切りがついたらやろうと思っていて遅れた。すでに 1.20.1 がリリースされている。先日 Go 1.20 リリースパーティ に参加して、いろいろ聞いていると改善されているところや新しい機能を試してみたいものがいくつかみつかった。単純にアップグレードするだけでも最大でビルド時間が10%短縮される可能性がある。1.18 と 1.19 で generics 対応でビルド時間が遅くなっていたのが 1.17 相当に改善されたらしい。やっぱり generics はコンパイラを複雑にするものなのでコンパイル時間が長くなる傾向があるんやなと話しを聞いていて思ったりもした。ついでに依存ライブラリなどのバージョンアップもまとめてやった。単体テストと結合テストが普通には揃ってきたのでバージョンアップなども安心して実行できる。

rocky linux のネットワーク設定

テスト環境に使っている rocky linux の ip アドレスを変更する必要があったので調べてみた。ちゃんとした公式のガイドが出てきてすごいなと感心した。

ip アドレスを確認するコマンドが ifconfig から ip に変わっていたり、ネットワーク設定のためのツールも私が知っているものとは全然変わってしまっている。

$ ip addr

Network Manager のサービスで設定されているようでそのための設定ファイルは次の場所に保存されていた。

/etc/NetworkManager/system-connections/my-device.nmconnection 

これを書き換えるツールとして nmtui という tui ツールと nmcli という cli ツールの2つがある。tui とか懐かしいなとか思いながら操作していた。ssh 経由で設定していたので cli でいきなり設定を反映するよりも tui で設定ファイルを書き換えて os を再起動するのがよいだろうと考えてやってみた。ドキュメントに書いてある通りに操作したら意図した ip アドレスを設定して変更できた。

リファクタリング一段落

1時に寝て7時に起きた。前日も21時頃までオフィスにいて、今日も午後からコードを書いていて22時ぐらいまでやってた。コードを書いていると時間がどんどんなくなる。本当は三宮.dev の勉強会があったんだけど、このリファクタリングは週末にやってしまわないとやばいという野生の勘でキャンセルした。

ストレッチ

今日の開脚幅は開始前156cmで、ストレッチ後160cmだった。だいたいいつも通り。先週から右すねの外側の筋に張りがある。先週よりちょっとよくなった気もするけれど、まだ張りが継続している。あと今週は右腰に張りがあった。今週はリファクタリングしていて座ってきる時間がいつもより長かったためだろうと推測する。どんなに忙しくてもストレッチだけは休まないようにしている。ストレッチに通い始めて2年以上経つが身体的に体調が悪いということは記憶にほぼない。日記にはその週のどこそこに張りがあるとか調子が悪いといったことを書いたりしているが、それは日常生活を送る上で支障が出るようなレベルではない。そうならないように予防している。健康を維持する上でストレッチは大きな影響を与えているため、中長期の展望から忙しくても継続するようにしている。

機能拡張とリファクタリング

今日は休日出勤して go のコードを書いていた。ある機能を作るときに内部的には汎用の api にしてしまって他のコレクションのデータ型でも再利用できるようにしたい。先週ずっと go の generics を使って mongodb 周りのコレクションとそのクライアントのリファクタリングをしていた。go の generics の理解も進んで crud なインターフェースを generics でどのように定義して実装すればいいかわかってきた。その過程で web api のアプリケーション層と mongodb のインフラ層 (データ層) の役割分担も明確になりつつある。どちらも generics を駆使して型チェックされた上でソースコードを共通化し、汎用 api としてリファクタリングしながら設計している。その集大成としてアプリケーション側で汎用的な機能を追加するときに、理想的には1つのコードを追加・変更すれば、別のデータ型でもすべて同じように動くといった機能として実装した。4つのコレクションのデータ型で同じ振る舞い (機能) を共通化する。その実装も丸1日やれば完了できるぐらいに設計の効率化ができてきた。

go はオブジェクト指向言語ではないので generics を駆使しても、java でいうところの抽象既定クラスを用いたテンプレートパターンの実装ができない。それぞれの構造体で基本的にはコピペとなる構造体のメソッドを定義しないといけない。もちろん別のヘルパーに移譲するといったことはできるけど、状態をもっていないコードの再利用はたしかに安全ではあるけれど、状態を参照できないからそのために値をコピーするといった整合性の懸念やボイラープレート的なコードを書く必要がある。これは設計におけるトレードオフになるので go の処理系の設計に不満があるわけではない。但し、generics でできることにはまだ機能不足がある。とくに直和型の扱い。できないのかな?とググると proposal の issue がみつかるので今後の機能拡張に期待したい。

正直マネージャーが開発に工数使っていて何やってるんだとみられているかもしれない。1-2週間集中してリファクタリングしてみて、いまのアプリケーションの設計の勘所が以前よりも理解が進んだ。コードレビューだけではわからないフィードバックがある。結合テストもいくつか追加したので今後の開発で役に立つはず。これで私の (リファクタリング) 開発は終了しようと思う。

今日作ったマージリクエストの diff が次になる。

51 files +1314 -648

diff の行数だけ数えると今週だけで1万行近くは変更したと思う。

mongodb のトランザクションの考え方

1時に寝て8時に起きた。3時頃に気分悪くて起きて少し吐いてそれからまた寝た。丸1日機能拡張とリファクタリングのために go のコードを書いていた。

mongodb のトランザクション管理

2つの web api から同じコレクションの異なるフィールドを更新したい。mongodb で厳密なトランザクションを管理するようなアプリケーションではないけど、なるべく整合性を維持できるように努めることはやっておきたい。mongodb でトランザクションに近いことを実現する方法として次の記事が参考になった。

この記事では findOneAndUpdate() という api を使って、更新時に必ず変更されるフィールドを含めることで find したときのそのフィールドの値が変わっていればエラーになってくれることでトランザクション相当の機能が提供されると書いてある。必ず変更されるフィールドとして ObjectId を使えば他の更新処理を検出するのに役立つだろうとある。いま私が開発しているアプリケーションでは同じフィールドを複数の web api から更新するわけではないのでここまで厳密なトランザクション管理は必要にない。

既存の処理が Replace を使って実装されていたのを Update を使うように変更する。Replace と Update の違いはドキュメント全体を更新するのか、一部のフィールドのみを更新するのかの違いになる。具体的には go のドライバーにおいて次のメソッドの使い分けになる。

これらのメソッドを使うことで find と replace/update の操作を1回の処理でできるから効率もよいらしい。