Posts for: #Design

モジュールは複数データソースに依存させない

デバッグしていて遅くなり、帰ってから慌ててスーパーに買いものへ行ったりしていて時間がなくなったので今日はバドミントン練習をお休み。

データベースとメッセージキューの整合性を考える

昨日の続き。トランザクションを導入したことで mongodb と mq でのデータフローが意図せず変わってしまっていることが調査してわかった。

従来は次のように動いていた。

  1. api サーバーがエントリー情報を受け取る
  2. api ハンドラーがエントリー情報を mongodb に保存する
  3. api ハンドラーがメッセージを管理するジョブ情報を mongodb に保存する
  4. api ハンドラー内の producer が rabbitmq にメッセージを送信する
  5. consumer がメッセージを受信する
  6. consumer が mongodb からジョブ情報を参照する
  7. api ハンドラーがレスポンスを返す
  8. consumer がメッセージを処理する

トランザクションを導入したことで mongodb にデータが保存されるタイミングが変わってしまった。

  1. api サーバーがエントリー情報を受け取る
  2. api ハンドラーがトランザクションを開始する
  3. api ハンドラーがエントリー情報を mongodb に保存する (この時点では未コミット)
  4. api ハンドラーがメッセージを管理するジョブ情報を mongodb に保存する (この時点では未コミット)
  5. api ハンドラー内の producer が rabbitmq にメッセージを送信する
  6. consumer がメッセージを受信する
  7. consumer が mongodb からジョブ情報を参照するが、トランザクションがコミットされていない可能性がある
  8. api ハンドラーがトランザクションをコミットする
  9. api ハンドラーがレスポンスを返す
  10. consumer でエラーが発生する

処理の流れを見直して次のように改修した。内部実装の都合があってやや煩雑な変更となった。

  1. api サーバーがエントリー情報を受け取る
  2. api ハンドラーがトランザクションを開始する
  3. api ハンドラーがエントリー情報を mongodb に保存する (この時点では未コミット)
  4. api ハンドラーがメッセージを管理するジョブ情報を mongodb に保存する (この時点では未コミット)
  5. api ハンドラーがトランザクションをコミットする
  6. api ハンドラー内の producer が rabbitmq にメッセージを送信する
  7. consumer がメッセージを受信する
  8. consumer が mongodb からジョブ情報を参照する
  9. api ハンドラーがレスポンスを返す
  10. consumer がメッセージを処理する

これは単純にトランザクションのコミットタイミングと mq へのメッセージ送受信のタイミングを見直せばよいという話しではない。本質的に mongodb で管理しているジョブ情報と rabbitmq へ送信しているメッセージの整合性を保証することはできないということを表している。producer はメッセージ送信に失敗する可能性があるから、そのときにジョブ情報を書き換える必要はあるが、その前にトランザクションをコミットしてしまっているため、api ハンドラー内でデータのコミットタイミングが複数になってしまう。トランザクションを導入したメリットが失われてしまい、Unit of work のパターンも実現できない。consumer の処理に必要な情報を mongodb にあるデータとメッセージの2つに分割しているところが整合性の問題を引き起こしている。アーキテクチャ上の設計ミスと言える。consumer の処理に必要な情報はすべてメッセージに含めてしまい、メッセージを処理した後に mongodb に結果を書き込むといった設計にすべきだった。

初期実装のときからジョブ情報を mongodb で管理する必要はあるのか?という懸念を私はもっていた。要件や機能が曖昧な状況でもあり、メンバーもなんとなく db に管理情報を残しておいた方が将来的な変更に対応できて安心といった理由だったと思う。当時は整合性の問題が起きることに、私が気付いていなかったためにこの設計を見直すように強く指摘できなかった。トランザクションを導入したことで consumer が必要な情報を db に保持すると、db とメッセージ処理のタイミングにおける整合性の問題が生じるという学びになった。

いまとなってはこのジョブ情報を使う他の機能もあるため、この設計を見直すことはできない。今後の開発プロジェクトで db とメッセージを扱うときはこの経験を活かすためにふりかえりとして書いておく。

非同期処理のキャンセルと中断待ち

バックエンドで非同期のバッチ処理を実装してサーバーが shutdown したときに実行中のバッチ処理も安全に止めないといけない。

context でキャンセルするのは簡単だけど、非同期で動いている goroutine が中断してから終了したことを main プログラム側で待つ同期をどう実装しようか悩んでいた。たまたま 【Go】Contextの魅力を感じる の記事をみたら sync#WaitGroup と組み合わせたサンプルコードがあって、これまでも sync#WaitGroup を使ったコードを何度も書いてきて知っていたはずなのに context と組み合わせるという発想はなかったなと気付きを得た。それからライブラリとして汎用のバッチ処理の controller を実装した。

type Controller struct {
	ctx    context.Context
	cancel context.CancelFunc
	wg     *sync.WaitGroup
	mu     sync.Mutex
}

func (c *Controller) Register() (context.Context, *sync.WaitGroup) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.wg.Add(1)
	ctx := context.WithValue(c.ctx, ctxKey{}, 0)
	return ctx, c.wg
}

func (c *Controller) Stop() {
	c.cancel()
}

func (c *Controller) Wait() <-chan struct{} {
	c.mu.Lock()
	defer c.mu.Unlock()
	quit := make(chan struct{}, 1)
	go func() {
		c.wg.Wait()
		slog.Debug("completed to wait group in batch.Controller")
		quit <- struct{}{}
	}()
	return quit
}

func NewController(parent context.Context) *Controller {
	ctx, cancel := context.WithCancel(parent)
	return &Controller{
		ctx:    ctx,
		cancel: cancel,
		wg:     new(sync.WaitGroup),
	}
}

parent の context でキャンセルさせることもできるし、この controller の api からキャンセルさせることもできる。

この controller を使うアプリケーションのコードは次のようになる。

c := batch.NewController(ctx)

func() {
	ctx, wg := c.Register()
	defer wg.Done()

    for {
	    // do something
        select {
        case <-ctx.Done():
            slog.Debug("canceled", "err", ctx.Err())
            return
        default:
        }
    }
}()

timer := time.NewTimer(10 * time.Second)
select {
case <-timer.C:
	slog.Debug("expected to complete waiting, but occur timeout")
case <-c.Wait():
	slog.Debug("completed to wait")
}

context.Done() に習って Wait() が channel を返すことで timer と組み合わせて、バッチ処理の中断を待たずに終了するといった制御もできる。後になってふりかえると、timer の制御も Wait() メソッドに入れてしまってもよい気もするが、それは呼び出し側の要件によって変わってくるからこのままの方がライブラリのコードの見通しがよくてよい気もする。朝から設計して、昼間に実装して夕方にテストを書いて、帰りの新幹線でアプリケーションに組み込みしていた。1日でちゃちゃっと作ったわりにはよく出来たと思う。

pipe による関心の分離

go の Pipe は go 言語プログラミングのよいところをいくつか同時に実感できる、実用度の高いユーティリティだと思う。go 言語は writer や reader をインターフェースとして定義している。細かいメソッドの違いでいくつかの writer/reader インターフェースを区別していたりもするが、基本的には write/read のメソッドをもっていればよい。

type Writer interface {
	Write(p []byte) (n int, err error)
}

type Reader interface {
	Read(p []byte) (n int, err error)
}

この汎用的な writer/reader のインターフェースに従っていれば、さまざまな要件に対して抽象化ができる。オンメモリでデータを扱うこともあるし、OS のファイルシステム上のファイルとして扱うこともあるし、オブジェクトストレージ上のオブジェクトとして扱うこともある。そういった具象オブジェクトの如何によらず writer/reader のインターフェースに従うことでデータ処理とリソース管理を分離できる。これはプログラミングパラダイムにおける 関心の分離 と呼ばれていることを実現する。

バッチ処理において複数データを読み込みながら処理を行い、処理中にエラーが発生したら、そのエラーデータを特定のファイルに書き込みしたい。エラーが発生したときだけ特定ファイルを生成する。そういった要件は次のように実装できる。

func write(w io.WriteCloser) {
	defer w.Close()
	/*
		if true {
			return
		}
	*/

	records := [][]string{
		{"first_name", "last_name", "username"},
		{"Rob", "Pike", "rob"},
		{"Ken", "Thompson", "ken"},
		{"Robert", "Griesemer", "gri"},
	}

	cw := csv.NewWriter(w)
	for _, record := range records {
		if err := cw.Write(record); err != nil {
			log.Fatalln("error writing record to csv:", err)
		}
	}
	cw.Flush()
	if err := cw.Error(); err != nil {
		log.Fatal(err)
	}
}

func read(r io.ReadCloser) {
	rr := bufio.NewReader(r)
	line, _, err := rr.ReadLine()
	if err == io.EOF {
		return
	}
	/*
		writer, err := os.Create("./test.txt")
		if err != nil {
			log.Fatal(err)
		}
		defer writer.Close()
	*/
	writer := os.Stdout
	if _, err := writer.Write(append(line, []byte{'\n'}...)); err != nil {
		log.Fatal(err)
	}
	if _, err = io.Copy(writer, rr); err != nil {
		log.Fatal(err)
	}
}

func main() {
	r, w := io.Pipe()
	go write(w)
	read(r)
}

Pipe を使うことで読み込みの処理と書き込みの処理を分離できる。ここでいう読み込みはバッチ処理、書き込みはエラーデータの保存処理に相当する。普通に実装すると、バッチ処理中にエラーデータの保存処理も記述することになる。業務のコードだと本質ではないリソース管理が煩雑になってしまう。2つの処理に分割するため、どちらか一方は非同期で実行する必要がある。ここで goroutine のシンプルさが際立つ。さらに pipe を使えば、reader 側は writer が Close するまでブロックするから終了処理の同期も自然なコードで実現してくれる。

非同期/並行処理の難しいところを go のインターフェースによる抽象化、簡潔な goroutine 記法、pipe に隠蔽された自然な同期処理の3つで簡潔に表現している。複雑さに対してこれほど簡潔なコードはそうそうないと思う。このコードを他の言語で実装しようとしたらもっと複雑になるはず。このサンプルコードとともに若いメンバーにリソース管理のリファクタリングをコードレビューで指摘したものの、そのメンバーには理解できなくて実装できなかった。そして、その処理を私が作り直すことになったというのが、今日のお仕事だった。本当は難しい非同期/並行処理をいくら簡潔にしても、その経験がないと人間には理解できない。まさに本質的複雑さを表しているように思える。

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
}

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年もとくになにもせず私は見守っていただけだった。しかし、今回は事前にポートフォリオを見直したり、空売りでリスクヘッジしていたりと急落の場面にも対応できた。以前よりは経済の背景を学んで資産運用の行動に反映できるようになってきた。これは経営においてもよいことだと思う。

mongodb も一応は外部結合できる

今日は出張中にたまっていた日記をまとめて書いていた。昨日筋トレしたから運動はおやすみ。

mongodb における外部結合

出張中のふりかえりやプロジェクトマネジメントの合間に mongodb の $lookup (aggregation) 機能を調査していた。一言で言えば、rdbms で言うところの外部結合 (join) を mongodb で実現する機能と言える。しかし、aggregation の機能として実装されていることから rdbms の外部結合とはパフォーマンスの側面で大きく違うのでは?と推測する。mongodb のような kvs では基本的に外部結合のようなことは行わず、結合済みのコレクションを定義するやり方がプラクティスとされる。一方で整合性を保証するには外部結合は有効なデザインパターンの1つなので使いたい場面があってもおかしくない。実際に mongodb に $lookup が実装されているのだから、そのニーズは大きいのだと思われる。

ちょうどいまやっている開発でその要件があったので mongo-go-driver の次のチュートリアルをみながら実装してみた。

例えば、次のような2つのコレクションがある。

type Group struct {
	ID          string      `bson:"_id"`
	Name        string      `bson:"name"`
	UpdatedAt   time.Time   `bson:"updatedAt"`
}

type MyData struct {
	ID           string    `bson:"_id"`
	GroupID      string    `bson:"groupID"`
	RegisteredAt time.Time `bson:"registeredAt"`
}

MyData の GroupID フィールドが Group コレクションの ID を保持して外部参照している。MyData コレクションに対する aggregate に渡すパラメーターとして pipeline を渡す。

myPipeline = mongo.Pipeline{
	{{
		Key: "$lookup", Value: bson.D{
			{Key: "from", Value: "group"},
			{Key: "localField", Value: "groupID"},
			{Key: "foreignField", Value: "_id"},
			{Key: "as", Value: "foreignGroup"},
		},
	}},
	{{
		Key: "$unwind", Value: bson.D{
			{Key: "path", Value: "$foreignGroup"},
			{Key: "preserveNullAndEmptyArrays", Value: false},
		},
	}},
}

$lookup の操作の後に $unwind で配列を flatten する処理がセットになる。result set にマッピングするためには flatten が必要になるのだと推測する。aggregate() を呼び出すと cursor が返ってきて、その cursor に外部結合される値の配列を渡すことで result set を取得できる。デバッグはややこしいし、コード上もちょっとわかりにくいけど、これは mongodb のお作法だと割り切ってユーティリティ化してしまえばよいものだと思う。

type joinedMyData struct {
	ID           string    `bson:"_id"`
	Group        Group     `bson:"foreignGroup"`
	RegisteredAt time.Time `bson:"registeredAt"`
}
...

cursor, err := collection.Aggregate(ctx, myPipeline)
if err != nil {
	return nil, err
}
var joined []joinedMember
if err = cursor.All(ctx, &joined); err != nil {
	return nil, err
}

また時間があるときにある程度のデータ量でどのぐらいの負荷に対してパフォーマンスが出るのかを測ってみたい。

ストレッチ

昨日1週間ぶりに筋トレをしたせいか、今朝から筋肉痛になっていてカラダが痛い。その影響もあって今日の開脚幅は開始前146cmで、ストレッチ後152cmとカラダが硬かった。足も腰も肩周りも全身にわたって軽い筋肉痛だった。それだけよい感じの負荷をかけて筋トレができていたと言えるのかもしれない。あちこち筋肉痛はあったものの、ストレッチを受けていての辛さのようなものは感じなかった。ちょうどよい感じにほぐされているような印象を受けた。もしかしたらトレーナーさんが気を遣っていつもよりは弱めにストレッチしてくれたのかもしれない。トレーナーさんが立候補していた店長選挙は落選してしまったらしい。もし店長になったらよそのお店へ転勤になって担当者が変わってしまうのかなとも思っていた。トレーナーさんは私が通っているお店の副店長を務める方向で調整しているらしい。待遇をあげていくにはキャリアアップをしていかないといけないといった背景もあるようにみえる。

テストの依存関係をステージで表現する

今日の運動は腹筋ローラー,腕立て,縄跳び(両足跳),散歩,ジョギング,ハンドグリップをした。統計を 運動の記録 にまとめる。

qa テストの設計

システムや機能が複雑になった後の qa テストをどう設計するか。先日 Full Stack Testing を読んで考察 したりしていた。テストでもっとも工数がかかるのはデータや設定も含めてテストできる環境を構築すること。gitlab ci/cd で3つのテスト環境を構築しているものの、それらと qa テストのテストケースとどうやって関連させるか、またはテストの依存関係をどのように表現するかといったことを設計の重要事項として考察していた。

これまで google sheets の1ファイルですべてのテストケースを管理していた。しかし、機能が増え、テストケースが増えていくにあたり、この1ファイルにより管理は破綻しかけていた。id 連携という、複数のシステムまたはモジュールを介してデータをやり取りするというアプリケーションの特性上、テストの依存関係は google sheets の1ファイルでは表現できない。そこで「ステージ」という概念を導入し、google drive のフォルダや階層構造で依存関係を表現することにした。ステージ1のテストが成功しない限り、ステージ2のテストが成功することはない。業務における本質的な難しさである依存関係を人間にとってどう管理していくかが重要になる。急にこれらの qa テストをすべてできるわけではないが、ステージを3つまで定義した。最後の手動探索テストを issue の創発という、課題管理にとって重要な位置づけにおさめたのが個人的にお気に入り。実際に手動探索テストをどのように実施できるか?というのはまだまだこれからの課題ではあるが。

  • ステージ1
    • 2つのシステム間におけるテスト
    • 特定モジュールの振る舞いの検証
    • ui 周りのテスト
  • ステージ2
    • 複数のシステムをまたがるテスト
    • 機能横断的な要求テスト
      • 可用性に関するテスト
      • エラー制御に関するテスト
    • アプリケーションの機能が動作した次にある高度な要件
      • セキュリティテスト
      • パフォーマンステスト
      • アクセシビリティテスト
  • ステージ3
    • 手動探索テスト
      • issue の創発

テスト設計についての考察

飲みにいっていたので今日の運動はお休み。

都営浅草線

宿泊したホテルの近くにある三田駅から五反田まで都営浅草線で乗り換えなしで通勤できることに気付いた。山手線でも行けるが。芝公園は運動にもよいスポットなので五反田の京王プレッソイン以外にスーパーホテルに泊まるプランも織り交ぜてよさそうに思える。

QA テストのテスト設計

次のマイルストーンから QA テストが始まる。その前に今回の開発した成果物を踏まえてテストケースなどを整備し直さないといけない。さらに機能が増えてシステムが複雑化したことでテストのカテゴライズなども見直したいと考えていた。そういった一連のテスト設計を再構築しようと考えている。一般的なやり方としてテストケースをスプレッドシートでまとめる手法がある。それ自体は悪くないし、デフォルトのやり方だとは思うが、もっとよいやり方はないかと模索している。試しにオライリーのサブスクリプションでみつけた Full Stack Testing の、関心のあるところだけ斜め読みした。

これまでは手動テストと自動テストの2つのカテゴリ程度でしか分類されていなかったテストに関するコンテキストを、著者は10個のスキルに分割して体系化し、これらをまとめて full stack testing と呼んでいる。適切な名前をつけられることから著者のテストに関する知識や経験の深さが伺える。

いくつか斜め読みした中で私が最も関心をもったところに手動探索テストの章がある。著者は「手動テスト」と「手動探索テスト」はまったく別の概念であると定義している。前者はあらかじめ決められたテスト仕様にしたがってテストするだけ。後者はアプリケーションの詳細を掘り下げ、現実的なシナリオを考えて、それらをシミュレートするようにテストしていく。

この開発後の探索的テストを実施するために、別個の担当者を配置する必要はないかもしれない。しかし、そのアプローチの方が、アプリケーションに関する知識が蓄積され、鋭い観察力と分析力を持つ人が必要になるため、より良い結果が得られるかもしれない。コストや稼働率の問題でこれができない場合は、既存のチームメンバーが、各イテレーション期間中、総当り方式で探索的テストを実施する責任を負うべきである。実際、探索的テストのスキルを開発することは、すべての役割のパフォーマンスを向上させるのに役立つかもしれない。

探索テストをやるときはその人の経験に応じて感覚的にあちこち触ってテストをしているように思う。次の図は、その分類を明確にすることでチェックポイントやテストのコツやプラクティスの共有をやりやすくなっていくかもしれないと思えた。

雑談会

夜はやぎさんとおがわさんと久しぶりに会って雑談してきた。おがわさんとは1年以上会っていなかったと思う。実家が大阪なので帰ってきたら声をかけてくださいとよく伝えているものの、最近は実家に帰っておられないようだ。うちは最低でも年に1回お正月に帰るのが当たり前の家だったから何年も実家に帰っていないという家族の形態もあるんだなという所感。それぞれの家の文化がある。ホットクックで 麻婆なす を作ろうという話題になって、私も作ったことがないことに気付いた。今度うちでも作ってみようと思う。あと、まったく飲んだことのなかった焼酎で 十割そば焼酎粋蕎 (いっきょう) がおいしかった。近所の酒屋でも売っているか探してみよう。

健康診断前は安静に

今日の運動は腕立て,スクワットをした。統計を 運動の記録 にまとめる。明日は健康診断があるのでよい数値を得るために今日は休養して安静にしようと思う。

オフィス移転祝

お手伝い先の会社が6月1日にオフィス移転をする。感覚的に引っ越ししたら胡蝶蘭を玄関に並べてあるイメージがあって、ちょうどよい機会なのでうちも送ってみることにした。

この記事によると、次の2つのパターンのときは移転祝いを送らない方がよいらしい。

  • 事業縮小による移転
  • 移転祝いを辞退している

お手伝い先はどちらにも該当しないから送って大丈夫だと思う。初めて胡蝶蘭を購入して送付する手続きをする。適当にオンラインで検索して PREMIER GARDEN というサイトから手配をした。取引関係のある法人だと2-5万円ぐらいが相場になるらしい。適当にみて27,000円の白の胡蝶蘭 (3本立て) にした。立て札も法人なら縦書きがよいらしい。胡蝶蘭を送る慣習は花言葉が「幸せが飛んでくる」となっていて、さらに胡蝶蘭には「根付く」という意味があって「幸せが根付く」という意図も込められているらしい。そんなことも知らんかった。機会があってよい学びになった。

歯科検診

17時から歯科検診へ行ってきた。今日はベテランっぽいスタッフの方が担当でとても効率よく巧く歯のお掃除をやってくれた気がする。時間も早かったし、口をあけた状態で作業される不快さもいつもより少なかった気がする。機械を使った作業でも、人間の手際のよさで大きく違うものやなと思った。

svelte の情報収集

19時から Svelte Japan Online Meetup #3 にオンライン参加した。もう私はほとんどフロントエンドの開発に携わっていないけれど、アーキテクチャや大きな開発の方針などをメンバーに指導していくためには svelte を取り巻くエコシステムの流れを把握しておかないといけない。こういったコミュニティのミートアップはそういった情報収集に役に立つ。前回から都合がつけば参加するようにしている。

svelte/kit を使っている会社の情報をまとめているという話しを聞いたので骨髄反射で pr を投げておいた。

svelte 5 はいま rc になっているそうでもう少しでリリースされそう。うちらは次の開発フェーズで移行することになると思うけれど、大きく機能や構文が変わっているようにみえるのでまたこのタイミングで私も再勉強するのがよさそうに思えた。