newmo 技術ブログ

技術で地域をカラフルに

まずはイテレータ(range over func)の仕様を学ぼう - Goのイテレータ深堀りNight

はじめに

こんにちは。newmoでソフトウェアエンジニアをやっている@tenntennです。 本稿では、2024年9月24日(火)にファインディ株式会社主催の「Goのイテレータ深堀りNight」というイベントで登壇してきましたので、その報告と内容について紹介します。

findy.connpass.com

「Goのイテレータ深堀りNight」は、2024年8月にリリースされたGo1.23の機能の1であるrange over func(通称イテレータ)について、6人の登壇者がさまざまな角度で10分のライトニングトーク(LT)を行うイベントです。筆者は、トップバッターということで「まずはイテレータ(range over func)の仕様を学ぼう 」という発表を行いました。

登壇に用いた資料は次のリンクから閲覧ができます。

docs.google.com

イテレータの導入経緯

イテレータの導入経緯

イテレータは単独で導入を検討されていた機能ではありません。Go1.18でジェネリクス(型パラメータ)が導入される際の議論からうまれた機能です。図のように、Go1.18ではジェネリクス(型パラメータ)について言語仕様の追加や宣言済み識別子(anyとcomparable)の追加は行われたものの、標準ライブラリはgoパッケージなどを除いて公開されているAPIはほとんど更新されませんでした。

Goコミュニティにおいて、ジェネリクスを使ったコードの経験がある程度貯まるまでは、標準ライブラリへに追加しないという理由で、slicesパッケージやmapsパッケージはx/expパッケージ以下で実験的に公開されていました。実際にslicesパッケージとmapsパッケージの一部の機能は変更され、constraintsパッケージは公開されず、一部の機能がcmpパッケージとして標準ライブラリに追加されました。

そして、Go1.21でついにslicesパッケージとmapsパッケージは標準ライブラリに追加されました。しかし、maps.Keys関数やmaps.Values関数など、スライスを返す関数は、x/exp/mapsパッケージでは提供されていたものの、標準ライブラリとしては追加されませんでした。これらの関数はスライスで返すのではなく、新たに議論が始まっていたイテレータ型として返すべきだという議論になったからです。

次のリンクから当時の議論を追うことができるでしょう。

github.com

github.com

有志が作成しているGoの歴史をまとめているサイトgolang.design/historyによれば、イテレータに関連する提案はかなり前からされていたようです。

golang.design

議論が活発になったのは、ジェネリクスがGo1.18でリリースされ、次のGitHub Discussionが作成されてからでしょう。

github.com

このDiscussionでは、次のようなインタフェースを満たす型の値がfor range文でNextメソッドを呼び出されていくことでイテレーションができるというものでした。

// Iter supports iterating over a sequence of values of type `E`.
type Iter[E any] interface {
    // Next returns the next value in the iteration if there is one,
    // and reports whether the returned value is valid.
    // Once Next returns ok==false, the iteration is over,
    // and all subsequent calls will return ok==false.
    Next() (elem E, ok bool)
}

この議論は後方互換性の観点からインタフェースでイテレータの仕組みを提供するのは難しいという結論になり、次に示したDiscussionにあるように関数を用いた方法に議論が移っていきました。

github.com

そして議論の末、ついにGo1.23でイテレータ(range over func)がリリースされました。しかし、型パラメータの時と同じように標準ライブラリの変更は最低限になっており、slicesパッケージやmapsパッケージにイテレータを返す関数が少し追加されただけです。

それでもGopher(Goのユーザ)たち念願のmaps.Keys関数が導入されました。なお、maps.Keys関数はfor range文でマップ型の値をイテレーションしてキーを取得した場合と同様にキーの並び順がランダムになるため、次のようにslices.Sorted関数かslices.SortedFunc関数を使用すると良いでしょう。

m := map[string]int{"dog": 1, "cat": 99}
fmt.Println(slices.Sorted(maps.Keys(m))

型パラメータのおさらい

型パラメータのおさらい

このようにイテレータは型パラメータ(ジェネリクス)と密接に関係した機能です。型パラメータを知らずにイテレータを理解することは難しいため、本登壇ではイテレータの説明の前に型パラメータについて、図のように簡単におさらいをしました。

パラメータという言葉は、ここでは「外から変更できるもの」という意味で使用しています。日頃からよく使う関数のパラメータ(引数)と型パラメータを対比させて解説しました。

関数パラメータ(引数)を使用しない場合、関数内で使用する値は関数内で宣言した定数や変数、リテラル表記した値を使用する必要があり、関数の外から変更できません。そこで関数に引数を設けることで外から値を指定できるようになります。また、次のように関数宣言に記述する引数のことを特に仮引数(parameter)と呼び、F(10)のように実際に指定した引数のことを実引数(argument)と呼びます。

func F(n int) {
    fmt.Println(n)
}

関数の引数を値のパラメータだとすると、外部から変更できるものを型にしたものが型パラメータになります。 たとえば、次のような関数において、引数の型を外部から指定できるようにしたい場合を考えます。

func G(v int) {
   fmt.Println(v)
}

次のように記述すると、引数の型を外部から指定できます。この時、Tは型パラメータと呼ばれ、anyは型制約(type constraint)となります。 型制約は、値に対する型のようなもので型パラメータTとして受け入れる型の条件でインタフェースを指定します。 なお、anyはどんな型でも受け付けることを表しています。 比喩的に表現すると、値に対する型や型パラメータに対する型制約は、求人における募集要項のようなものだと考えると良いでしょう。 条件に示したこと以上の働きはできないので、適切な条件を指定する必要がある点が似ています。

func G[T any](v T) {
   fmt.Println(v)
}

また、G[int](10)のように実際に指定した型のことを型引数(type argument)と呼びます。 関数における値の引数の仮引数・実引数は、型パラメータにおける型パラメータと型引数に対応しています。

さらにジェネリクス(型パラメータ)について学びたい場合は、公式チュートリアルや筆者のスライドを参考にすると良いでしょう。

docs.google.com

言語仕様のアップデート

イテレータ(range over func)

図のように、Go1.23ではfor range文のrange expressionに関数が使えるようになりました。どんな関数でも良いわけではなく、引数にyield関数と呼ばれる関数を取るような3種類の関数です。それぞれの違いは引数にとるyield関数の引数の個数になり、0個、1個、2個の3種類になります。yield関数はfor range文のボディに処理を移す役割があるため、これらの数および型はfor range文の左辺の識別子(または式)に一致しています。

range over funcと他のrange over

図のようにGo1.22までは、range expressionで扱える式の型は、配列、配列へのポインタ、スライス、文字列、チャネル(双方向、受信専用)、整数(Go1.22で導入)でした。これらの値に対するイテレーションは、型ごとに決められています。たとえば、スライスや配列であれば先頭から順にアクセスし、マップであればランダムな順番でアクセスします。そして、range over func(イテレータ)によって、任意のデータ構造を任意のアルゴリズムでシーケンシャルにアクセスできるようにするになりました。

重要な3つの仕様

図のようにイテレータには3つの重要な仕様があります。1つめは、for range文が実行される際に、range expressionに指定した関数が1回だけ実行される点です。イテレーションのアルゴリズムは、この関数に移譲されると考えると良いでしょう。2つめは、yield関数を呼ぶとfor range文のボディに処理が移る点です。for range文のボディが1回だけ実行され、yield関数はリターンされます。3つめは、breakなどでfor range文のイテレーションが途中で終了した場合、yield関数はfalseを返す点です。

簡単なイテレータの例を次に示します。Alphabetはアルファベット列を生成するようなイテレータです。 イテレータとは関係ありませんが、rune型は整数なのでfor c := 'A'; c <= 'Z'; c++で、c'A'から'Z'まで変化していきます。

func Alphabet(yield func(rune) bool) {
    for c := 'A'; c <= 'Z'; c++ { // 'A', 'B', 'C', …
        if !yield(c) { return }
    }
}

func usage() {
    // ABC
    for c := range Alphabet {
        fmt.Printf("%c", c)
        if c == 'C' { break }
    }
}

標準ライブラリの変更点

標準ライブラリの変更点

Go1.23では言語仕様の変更の他に、iterパッケージが導入されました。iterパッケージでは、図のようにSeq[V]型とSeq2[K, V]型が提供されています。 この2つの型のおかげで、次のようにイテレータをわかりやすく表記できるようになっています。

func Map[X, Y any](seq iter.Seq[X], f func(X) Y) iter.Seq[Y] {
    return func(yield func(Y) bool) {
        for x := range seq { if !yield(f(x)) { break } }
    }
}

func usage() {
    seq := Map(slices.Values([]int{10, 20}), func(x int) string {
        return fmt.Sprintf("0x%x", x)
    })

    // 0xa
    // 0x14
    for v := range seq { fmt.Println(v) }
}

Map関数は、あるiter.Seq[X]型のイテレータをiter.Seq[Y]型に変換する関数です。各要素は第2引数の関数fによって変換されます。 なお、slices.Values関数は、Go1.23で導入されたスライスの値からなるイテレータを生成する関数です。

注意点

注意点

図のように、yieldが宣言済み識別子(組み込み型)や予約語ではありません。ちなみに宣言済み識別子が増えることはありますが、予約語が増えたことはありません。 予約語は識別子にできない語なので、予約語を追加する場合は後方互換に気を使う必要があるからです。 Go1.21で互換性に関する機能の拡張が行われたため、予約語を追加しても後方互換性がなくなる可能性は低いですが、気軽に増やせるものではないでしょう。

また、yield関数がfalseを返した場合、さらに yield 関数を呼ぶとパニックが発生します。そのため、戻り値を if 文などでハンドリングしておく必要があります。

for range 文のrange expressionに値が nil の関数を指定した場合、パニックが発生します。これは通常の関数呼び出しの場合と同じ挙動になっています。標準ライブラリでは、引数でイテレータを受け取る場合でも nil かどうかのチェックは特別行っていないようです。一方で戻り値でイテレータを返す場合には、 nil を返さないように作られています。

おわりに

本稿では、Go1.23で導入されたイテレータについて、その仕様に紹介しました。標準ライブラリで本格的に活用されるのはGo1.24になるようですが、それまでにぜひ触ってみましょう。

PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

careers.newmo.me

go testの時だけ時刻を固定する

はじめに

こんにちは。newmoでソフトウェアエンジニアをやっている@tenntennです。 newmoには2024年8月に入社しました。この記事を書いているのは2024年9月なので、入社してだいたい1ヶ月ちょっとが経過したところです。 なお、筆者が入社した経緯などは次の記事を読んでください。

note.com

入社した当初、newmoのバックエンドコードのコードを眺めていると、次のように宣言された関数を見つけました。

func Now(_ context.Context) time.Time {
    return time.Now().In(time.UTC)
}

単にtime.Now関数を呼び出して、LocationUTCに設定しているだけです。 しかも、引数はブランク識別子になっているので使用していません。

しかし、筆者はこれを見て、これは後々のことを考えているなと感心しました。 ちなみに変数と違って関数内で使用してなくてもコンパイルエラーにはならないためブランク識別子(_)にする必要はありません。 それにも関わらず、あえてブランク識別子にしているのは、今は使ってないけど将来は使いますよと気持ちを暗示しているように見えました。 (意図は社内で確認してないので、考えすぎかもしれません。)

ちなみに、Goの言語仕様では次のようにブランク識別子すら使用せず、型名だけでも問題ありません。 ただし、この形を取る場合は、すべての引数の識別子を省略する必要があります。

func Now(context.Context) time.Time {
    return time.Now().In(time.UTC)
}

さて、この引数のコンテキストは何のためにあるのでしょうか? 実はこのコンテキストはテストの際に時刻をコントロールするためにあります。 特に時刻を特定の時間で固定するために使用することを想定しています。

その後、プロダクト開発していく中で時刻を固定してテストがしたくなり、筆者がコンテキストを使って時刻を固定できるように変更しました。 また、本稿を書くにあたって、ライブラリとして切り出しOSSとして公開してあります。

そこで、本稿ではコンテキストを使って時刻を固定する方法とその方法で実装されたライブラリについて紹介します。

コンテキストを使って時刻を固定する

テストのために時刻を固定する方法はいくつか存在します。 たとえば、筆者が書いた記事には次の方法が挙げられていました。

  • 引数に現在時刻を渡す
  • パッケージ変数やフィールドなどに現在時刻を返す関数やインタフェースを設定する
  • context.WithValue関数でコンテキストに現在時刻を設ける

スレッドセーフなテスト用の時間を固定するライブラリを作った - tenntenn.dev

この記事ではさらに筆者が開発したtesttimeというライブラリが紹介されていました。 testtimeは便利ですが、linknameや-overlayフラグなど、若干"やんちゃ"な機能を使用していました(詳細はtestimeの記事をご覧ください)。

テストのときだけとは言えども、できるなら"通常の"やり方で解決したいところです。 やはり、安全に時刻を固定するには、プロジェクトの初期からテストで時刻を固定したくなるときが来ることを考慮しておく方が良いでしょう。

冒頭に紹介したNow関数は、コンテキストを引数に取っているため、後から如何様にも実装を拡充できるようになっています。 たとえば、次のようなWithFixedNow関数を作成し、コンテキストに特定の時刻を紐づけることもできます。

func Now(ctx context.Context) time.Time {
    if testing.Testing() {
        return nowForTest(ctx)
    }
    return defaultNow(ctx)
}

func defaultNow(_ context.Context) time.Time {
    return time.Now().In(time.UTC)
}

func nowForTest(ctx context.Context) time.Time {
    now, ok := nowFromContext(ctx)
    if ok {
        return now
    }
    return defaultNow(ctx)
}

type ctxkey struct{}

func WithFixedNow(t *testing.T, ctx context.Context, tm time.Time) context.Context {
    t.Helper()
    return context.WithValue(ctx, ctxkey{}, tm)
}

func nowFromContext(ctx context.Context) (time.Time, bool) {
    tm, ok := ctx.Value(ctxkey{}).(time.Time)
    return tm, ok
}

WithFixedNowの第1引数に*testing.T型を指定する必要があるのは、テスト以外で呼ばれることを防ぐためです。 また、Now関数はtesting.Testing関数がtrueを返す場合のみ、つまりテストの時だけコンテキストから時刻を取得するようにしています。

テストの時だけ挙動を変える

前述の方法では、テストのときにだけ時刻を固定することができますが、Now関数を宣言したパッケージがtestingパッケージをインポートする必要があります。 そこで、次にテスト以外ではtestingパッケージをインポートせずに、テストの時だけ挙動を変える方法を考えてみましょう。

次のように、Now関数を宣言しているパッケージをctxtimeと名付け、テストで使用するWithFixedNow関数などは、サブパッケージのctxtimetestパッケージに移動させます。 また、ctxtimeパッケージがtestingパッケージに依存しないように、internalパッケージを用意します。

ctxtime                                                                                                            
├── ctxtime.go                                                                                                     
├── ctxtimetest                                                                                                                                                                                      
│   └── ctxtimetest.go                                                                                        
└── internal                                                                                                       
    └── now.go  

Goにおいて、internalパッケージは特別なパッケージです。 internalという名前のディレクトリ以下に配置したソースコードやパッケージは、そのinternalディレクトリが存在するディレクトリ以下でしか参照できません。 詳細は筆者が前職時代に書いた記事を参照してください。

qiita.com

それでは、配置し直した各ファイルの中身を見ていきましょう。 ctxtimeパッケージには、次のようにNow関数だけを配置します。

// ctxtime/ctxtime.go
package ctxtime

import (
    "context"
    "time"

    "github.com/newmo-oss/ctxtime/internal"
)

func Now(ctx context.Context) time.Time {
    return internal.Now(ctx)
}

大部分の処理はinternalパッケージに移動させます。 そして、internalパッケージでは次のように宣言しておきます。

// ctxtime/internal/now.go
package internal

import (
    "context"
    "time"
)

var Now = DefaultNow

func DefaultNow(_ context.Context) time.Time {
    return time.Now().In(time.UTC)
}

ctxtime.Now関数の挙動を変更できるように、internal.Nowは変数として宣言します。 デフォルト値としてinternal.DefaultNow関数が設定されています。

そして、次のようにctxtimetestパッケージのinit関数でinternal.Now変数の値を変更しています。

// ctxtime/ctxtimetest/ctxtimetest.go
package ctxtimetest

import (
    "context"
    "sync"
    "testing"
    "time"

    "github.com/newmo-oss/ctxtime/internal"
)

func init() {
    if testing.Testing() {
        internal.Now = nowForTest
    }
}

func nowForTest(ctx context.Context) time.Time {
    now, ok := nowFromContext(ctx)
    if ok {
        return now
    }
    return internal.DefaultNow(ctx)
}

type ctxkey struct{}

func WithFixedNow(t *testing.T, ctx context.Context, tm time.Time) context.Context {
    t.Helper()
    return context.WithValue(ctx, ctxkey{}, tm)
}

func nowFromContext(ctx context.Context) (time.Time, bool) {
    tm, ok := ctx.Value(ctxkey{}).(time.Time)
    return tm, ok
}

このようにすることでctxtimeパッケージは、testingパッケージに直接依存することなくテストの時だけ時刻を固定できるようになります。

テストごとのID

newmoでは、テストごとにtest idと呼ばれるID(通常はUUID)を採番しています。 この他にテストを一意に識別するには、*testing.T型のNameメソッドを用いる方法もあります。 しかし、テスト(サブテストも含む)でIDを複数用いたくなることを想定して、テスト名とは別にIDを付与することにしています。 また、テスト時にtest idをHTTPのヘッダやgRPCのメタデータに付与することで、どのテストからリクエストが来たか分かるようにしています。

testidパッケージは、以下のリポジトリでOSSで公開されています。

github.com

newmoで開発・使用しているctxtimeパッケージも前述したコンテキストとキーを使った方法ではなく、次のようにこのtest idを使用する方法を使用しています。 WithFixedNow関数の代わりにSetFixedNow関数が用意され、UnsetFixedNow関数で設定した時刻を削除できるようにしています。

なお、UnsetFixedNow関数を呼ばなくてもテスト終了時に(*testing.T).Cleanup関数で自動で削除されるように作られています。

// ctxtime/ctxtimetest/ctxtimetest.go
package ctxtimetest

import (
    "context"
    "sync"
    "testing"
    "time"

    "github.com/newmo-oss/ctxtime/internal"
    "github.com/newmo-oss/testid"
)

var fixedNows sync.Map

func init() {
    if testing.Testing() {
        internal.Now = nowForTest
    }
}

// SetFixedNow fixes the return value of ctxtime.Now.
// The fixed current time is set each test id which get from [testid.FromContext].
// If any test id cannot obtain from the context, the test will be fail with t.Fatal.
// The fixed current time will be remove by t.Cleanup.
func SetFixedNow(t testing.TB, ctx context.Context, tm time.Time) {
    t.Helper()

    tid, ok := testid.FromContext(ctx)
    if !ok {
        t.Fatal("failed to get test ID from the context")
    }

    t.Cleanup(func() {
        fixedNows.Delete(tid)
    })

    fixedNows.Store(tid, tm)
}

// UnsetFixedNow removes the fixed current time which was set by [SetFixedNow].
// If any test id cannot obtain from the context, the test will be fail with t.Fatal.
func UnsetFixedNow(t testing.TB, ctx context.Context) {
    t.Helper()

    tid, ok := testid.FromContext(ctx)
    if !ok {
        t.Fatal("failed to get test ID from the context")
    }

    fixedNows.Delete(tid)
}

func loadFixedTime(ctx context.Context) (time.Time, bool) {
    tid, ok := testid.FromContext(ctx)
    if !ok {
        return time.Time{}, false
    }

    v, ok := fixedNows.Load(tid)
    if !ok {
        return time.Time{}, false
    }

    tm, ok := v.(time.Time)
    if !ok {
        return time.Time{}, false
    }

    return tm, true
}

func nowForTest(ctx context.Context) time.Time {
    tm, ok := loadFixedTime(ctx)
    if !ok {
        return internal.DefaultNow(ctx)
    }
    return tm
}

Linterを使ったtime.Now関数の呼び出しの検出

ctxtime.Now関数を効果的に使うためには、プロジェクト全体でtime.Now関数ではなくて、ctxtime.Now関数を使用するというルールを設ける必要があります。 ルールを守っているかレビューでチェックするようにしていると、時間が経つにつれ形骸化しがちです。

そこでtime.Nowを使っている箇所を検出するctxtimechekというLinterも合わせて作成しました。 なお、次のようにgolang.org/x/tools/go/analysisパッケージ(以下、go/analysisパッケージ)を用いて作成しています。

// ctxtime/ctxtimecheck/ctxtimecheck.go
package ctxtimecheck

import (
    "go/types"

    "github.com/gostaticanalysis/analysisutil"
    "github.com/gostaticanalysis/ssainspect"
    "golang.org/x/tools/go/analysis"
)

const doc = "ctxtimecheck finds calling time.Now instead of ctxtime.Now"

// Analyzer finds calling time.Now instead of ctxtime.Now.
var Analyzer = &analysis.Analyzer{
    Name: "ctxtimecheck",
    Doc:  doc,
    Run:  run,
    Requires: []*analysis.Analyzer{
        ssainspect.Analyzer,
    },
}

func run(pass *analysis.Pass) (any, error) {
    in := pass.ResultOf[ssainspect.Analyzer].(*ssainspect.Inspector)

    timenow, _ := analysisutil.ObjectOf(pass, "time", "Now").(*types.Func)
    if timenow == nil {
        // skip
        return nil, nil
    }

    for in.Next() {
        c := in.Cursor()
        if analysisutil.Called(c.Instr, nil, timenow) {
            pass.Reportf(c.Instr.Pos(), "do not use %s, use ctxtime.Now", timenow.FullName())
        }
    }

    return nil, nil
}

Linterなどの静的解析ツールやgo/analysisパッケージについては、300ページ超えでちょっとだけ長いですが次のスライドが参考になります。

docs.google.com

time.Now関数はもちろん、time.Now関数をローカル変数に代入して呼び出している箇所も検出できます。 現在の実装ではパッケージ変数に代入した場合は検出できませんが、今後のバージョンアップで対応予定です。

ctxtimechekは、次のようにgo installを用いてインストールができます。

$ go install github.com/newmo-oss/ctxtime/ctxtimecheck/cmd/ctxtimecheck@latest

インストールした実行可能ファイルは、go vetコマンドの-vettoolフラグに絶対パスを指定することで利用できます。

$ go vet -vettool=$(which ctxtimecheck) ./...

なお、現在のctxtimecheckは筆者が開発しているcalledという静的解析ツールでも同様の動作をします。 calledを導入している方は、次のように指定することで同様の結果が得られるでしょう。

$ go vet -vettool=$(which called) -called.funcs="time.Now" ./...

github.com

おわりに

本稿では、newmoで活用しているgo test時に時刻を固定する方法とOSS化しているctxtimeパッケージとtestidパッケージの紹介をしました。 time.Now関数だけではなく、time.Ticker型を使ったコードなどもテストしにくいので、今後のアップデートで対応できたらと考えています。

newmoではスピード感を保ちながら新しいプロダクトを開発しつつ、技術へのチャレンジを惜しまず日々の開発を行っています。 これからもプロダクト開発の中で生まれたライブラリや知見は、惜しまず技術コミュニティにお返しできればと考えています。

PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

careers.newmo.me

GitHub ActionsのJobが落ちたときに何をするべきかを記述するPlaybookの仕組みを作って運用している話

newmoではGitHub Actionsを自動テスト、Lint、デプロイなどに利用しています。 また、newmoではmonorepoで開発しているため、1つのリポジトリに複数のチーム/複数のアプリケーションが存在しています。

GitHub Actionsではpathsを使うことで、特定のファイルが変更された場合のみ特定のWorkflowが実行できます。 newmoのmonorepoのworkflowでは基本的にpathsが指定されていますが、それでも普段は触らないファイルを変更して意図せずにCIが落ちることがあります。 GitHub ActionsのCIが落ちたときに、そのCIの仕組みを作った人やチーム以外だと何をすべきかわからないことがあります。

この問題の解決するを手助けするシンプルな仕組みとして、GitHub ActionsにCIが落ちたときに何をするべきかを表示するPlaybookの仕組みを導入しました。

Playbookの仕組み

Playbookといってもやっていることはとても単純です。

GitHub ActionsのWorkflowのJobが失敗したときに、そのJobが失敗した理由とその対処方法を表示するだけです。

具体的には次のようなcomposite actionを作成し、各Jobの最後にif: failure()で実行するようにしています。 Composite actionはGitHub ActionsのWorkflowから呼び出せる関数的なActionを定義する仕組みです。

次のようなComposite actionを作成します。

.github/actions/playbook/action.yaml:

---
name: "playbook"
description: "CIのJOBが落ちた時にどのように対応するべきかを書く"
inputs:
  message:
    description: "How to fix?"
    required: true
runs:
  using: "composite"
  steps:
    - name: How to Fix?
      uses: actions/github-script@v7.0.1
      env:
        INPUT_MESSAGE: ${{ inputs.message }}
      with:
        script: |
          const message = process.env.INPUT_MESSAGE;
          core.summary.addRaw(message, true);
          core.summary.write(); // Summaryへ出力
          core.setFailed(message); // Jobページ開いた時に自動的に開いた状態にするためFailedを設定し直す

そして、Workflowの各Jobの最後に次のように記述するだけです。

.github/workflows/ci.yaml:

name: CI Build

jobs:
  build:
    permissions:
      contents: "read"
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: 既存の色々な処理
        run: echo "既存の処理"
      # Jobが落ちた場合のみ、Playbookを実行する
      - name: Playbook
        if: failure()
        uses: ./.github/actions/playbook
        with:
          message: |
            # ${{ github.job }} が失敗しました

            ## 影響
            - 何が影響を受けるか

            ## 調査方法
            - Jobの落ちてるステップのエラーログを確認してください

            ## 修正方法 
            - どのように対応するか

            ## 修正後の確認方法
            - 修正後にどのように確認するか

これで、CIが落ちたときにGitHub ActionsのログやJob Summariesに対処方法が表示されるようになります。

PlaybookのJob Summaryの表示例

あとは、これを見て対処するだけです。

newmoのCIでは、各Jobに次のようなテンプレートでPlaybookを記述しています。

# ${{ github.job }} が失敗しました

## 影響
- 何が影響を受けるか
- e.g. デプロイが失敗しているので、本番環境に反映されていない

## 調査方法
- 原因となるエラーを特性する方法を記述する
- e.g. Jobの落ちてるステップのエラーログを確認してください

## 修正方法 
- どのように対応するか
- e.g. どのファイルを修正すればいいか

## 修正後の確認方法
- 修正後にどのように確認するか
- e.g. どのコマンドを実行すればいいか

CIのWorkflowを書いた人は、CI上に表示されるエラーを見るだけでわかるかもしれませんが、必ずしも他の人がわかるとは限りません。 そのため、インシデント対応のプレイブックのように、CIが落ちたときに何をすべきかを表示するPlaybookの仕組みを導入することで、CIの運用を円滑にすることができます。

実際にこの仕組みを導入してから、フロントエンドに関するCIが落ちた場合にも、普段はフロントエンドを触っていないバックエンドのエンジニアがCIが落ちた原因を修正できる事例もありました。

おわりに

newmoでは、GitHub ActionsのWorkflowが落ちたときに何をすべきかを表示するPlaybookの仕組みを導入しています。 仕組み的にはとてもシンプルで、if: failure()で何をすればいいかを書けるだけの仕組みです。 単純な仕組みですが、CIの運用を円滑にするためにはとても有効な仕組みだと感じています。

PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

monorepo内でのパッケージのバージョンを1つだけに統一するOne Version Ruleをpnpm catalogで実装する

newmoでは、フロントエンド、バックエンド、iOSやAndroidなどのモバイルアプリをすべて同じリポジトリで管理するmonorepoを採用しています。 monorepoを採用することで、アプリケーション間で共通のコードを共有することができたり、CIの管理が楽になったり、他のチームのコードを見るのにわざわざリポジトリをcloneする必要がなくなります。 また、monorepoを採用することで、アプリケーションが利用しているパッケージ(ライブラリやツール)のバージョンを1つだけにするOne Version Ruleが実装できます。

One Version Rule

One Version Ruleは、monorepo内のパッケージのパッケージのバージョンを1つだけにするルールです。

One Version Ruleでは、monorepo内のアプリケーションが依存するパッケージは1つのバージョンだけにします。 たとえば、アプリケーションAがpackageXのバージョン 1.0.0 を使っているとき、アプリケーションBもpackageXのバージョン 1.0.0 を使うように統一します。

これによって、あるパッケージが1つのバージョンだけに集約されるので、メンテナンス性がよくなったり、パッケージのアップデートに関するセキュリティ的な問題に対処しやすくなったり、Diamond dependencyのようなパッケージ同士の依存におけるバージョンのConflictが起きにくくなります。

また、副作用として新しいパッケージを入れることに慎重となるため、同じ機能を持つ異なるパッケージが入りにくい傾向があります。 雑にパッケージの依存を増やすコストが高くなるため、newmoではDesign Docを書いて議論してからパッケージを追加することが多いです。

一方で、One Version Ruleを実践するには、当たり前ですがパッケージのバージョンを1つに統一する必要があります。そのため、依存するパッケージのバージョンを1つに統一するための方法を考える必要があります。 このパッケージのバージョン管理方法は言語により異なるため、言語ごとに適切な方法を選択する必要があります。

この記事では、フロントエンドで利用するnpm Registryのパッケージのバージョンを1つに統一するOne Version Ruleを実装する方法について紹介します。

pnpm catalogを使ったOne Version Ruleの実装

newmoでは、npmのパッケージ管理ツールとしてpnpmを利用しています。 そして、pnpmのCatalogs機能を使ってOne Version Ruleを実装しています。

pnpm catalogとは

pnpm catalogは、pnpm 9.5で追加された機能で、依存する複数のパッケージに名前をつけて管理したり、パッケージの依存をカタログ的に一箇所で管理できる仕組みです。

pnpmのworkspace(複数のパッケージ = ここではアプリケーションを統合的に扱う仕組み)を定義するpnpm-workspace.yamlに、catalogを定義できるようになっています。 pnpm-workspace.yamlcatalog:には、パッケージ名とそのバージョンを定義できます。

pnpm-workspace.yaml:

packages:
  - packages/*

# Define a catalog of version ranges.
catalog:
  react: ^18.3.1
  redux: ^5.0.1

catalog: 直下のパッケージはデフォルトのカタログとして扱われ、アプリケーションのpackage.jsonからは catalog:(名前がない場合はデフォルトカタログという意味となる)で参照できるようになります。

アプリケーションのpackage.json:

{
  "name": "@example/app",
  "dependencies": {
    "react": "catalog:",
    "redux": "catalog:"
  }
}

さらにcatalogにはNamed Catalogsという機能もあり、複数のパッケージとバージョンをまとめたものに対して名前をつけて管理することができます。

次の例では、react17react18という名前のcatalogを定義しています。

catalogs:
  # Can be referenced through "catalog:react17"
  react17:
    react: ^17.0.2
    react-dom: ^17.0.2

  # Can be referenced through "catalog:react18"
  react18:
    react: ^18.2.0
    react-dom: ^18.2.0

同じようにアプリケーションからは catalog:react17catalog:react18 で参照できます。

{
  "name": "@example/components",
  "dependencies": {
    "react": "catalog:react18",
  }
}

この機能を使うことで、monorepoにある全てのパッケージの名前とバージョンがpnpm-workspace.yamlという一つのファイルで管理できるようになります。

One Version Ruleでは、monorepoでは1つのバージョンを扱うので基本的にデフォルトカタログ(catalog:)にほとんどのパッケージを記述することになります。

パッケージをアップデートするときに、アプリケーションのコードも必要な場合があります。 複数のアプリケーションが依存しているパッケージの場合、同時にアプリケーションを修正することが難しいケースもあります。 その場合は、例外的にNamed Catalogsを使ってバージョンを分けることで、段階的にアップデートを進めることも可能です。

ここまでは、pnpmのcatalog機能の紹介でしたが、実際にOne Version Ruleをやるにはこのルールに強制力が必要です。 newmoのmonorepoでは、pnpmのHooks機能を使ってこのルールに強制力を持たせています。

pnpmのHooksを使ったOne Version Ruleの実装

pnpmのHooks機能を使うことで、依存するパッケージのバージョンを1つに統一するOne Version Ruleを実装することができます。

pnpmのHooks機能は、package.jsonの依存を解析したタイミング(readPackage)と依存関係が全て解決したタイミング(afterAllResolved)にフックする処理を.pnpmfile.cjsファイルに記述できます。

実現したいOne Version Ruleをpnpmのレベルまで落とすと、次のようなチェックをすれば良いことがわかります。

  1. アプリケーションが依存するパッケージのバージョンは workspace:* または catalog: で指定する
    • アプリケーション側には直接バージョンを指定できなくします
  2. { "<name>": "catalog:" }とアプリケーションで指定されたパッケージの実際のバージョンが pnpm-workspace.yaml に記載されている
    • pnpm-workspace.yamlで全てのパッケージのバージョンを管理するようにします
    • 基本的にはデフォルトカタログ(catalog:)にパッケージとバージョンを定義して、1つのバージョンを使うようにします
    • 例外としてnamed catalogを使うことで、複数のバージョンが存在することは許容します
  3. monorepo内のパッケージとnode_modulesのパッケージを区別できるような名前をつける
    • これはreadPackageがmonorepo内外の両方package.jsonの解析のタイミングで呼ばれるため、区別するのに必要です
    • newmoでは @newmo-app/ で始まるパッケージをmonorepo内のパッケージとして扱っています
  4. バージョンは必ずPinされたバージョンを使うようにする
    • lockファイルでバージョンは固定はされますが、pnpm-workspace.yamlを見たときにバージョンがわかるようにPinされたバージョンを使うようにしています
  5. このルールを満たさない時は、自動的に修正コマンドをエラーに表示する
    • このルールを満たさないときに手動で修正することは可能ですが、自動的に修正コマンドを表示することで、修正の手間を減らすことができます
    • 一般的とは言えないルールなので、普通のパッケージ管理ツールを使うのと同じぐらい簡単に扱えるようにする必要があります

このルールを実装した.pnpmfile.cjsは、次のようになっています。 MONOREPO_PREFIXを変更すれば、大体そのまま利用できるようになっています。

.pnpmfile.cjs (クリックで開く)

/**
 * # One Version Rule Implementation
 * **社内Notionへのリンク**
 **/
const rootPkg = require("./package.json");
const fs = require("node:fs");
const path = require("node:path");
const rootDir = __dirname;
/**
 * Prefix for all monorepo internal packages
 * monorpo内部のアプリケーションやmonorepo内のworkspaceのパッケージは、このprefixで始まるようにする
 * @type {string}
 */
const MONOREPO_PREFIX = "@newmo-app/";
const pnpmCatalogYamlFile = path.join(rootDir, "pnpm-workspace.yaml");
const pnpmCatalogYaml = fs.readFileSync(pnpmCatalogYamlFile, "utf-8");
const isMonorepoPackage = (pkgName) => {
  return pkgName.startsWith(MONOREPO_PREFIX);
};
/**
 * Pin the version of the package
 * @example
 * pinVersion("^1.0.0") // => "1.0.0"
 * pinVersion("~1.0.0") // => "1.0.0"
 * pinVersion("1.0.0") // => "1.0.0"
 * @param {string }version
 * @return {string}
 */
const pinVersion = (version) => {
  if (version.startsWith("^") || version.startsWith("~")) {
    return version.slice(1);
  }
  return version;
};
const isPackageIncludedInCatalog = (pkgName) => {
  // "pkg": version
  // or
  // pkg: version
  const pkgAndVersionPattern = new RegExp(String.raw`"?${pkgName}"?: \d+\.\d+\.\d+`);
  if (pkgAndVersionPattern.test(pnpmCatalogYaml)) {
    return true;
  }
  // npm alias pattern
  // pkg: npm:...
  // e.g "@types/react": npm:types-react@19.0.0-rc.0
  const pkgAndVersionPatternWithNpm = new RegExp(String.raw`"?${pkgName}"?: npm:[\w-]{1,32}@`);
  return pkgAndVersionPatternWithNpm.test(pnpmCatalogYaml);
};
/**
 * Check if all packages are prefixed with {@link MONOREPO_PREFIX}
 */
const assertMonorepoPackageNameRule = (lockfile) => {
  // get all packages in the monorepo
  const pkgPathList = Object.keys(lockfile.importers).map(pathFromRoot => {
    return path.join(path.resolve(rootDir, pathFromRoot), "package.json");
  });
  // check if all packages are prefixed with
  for (const pkgPath of pkgPathList) {
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
    if (!isMonorepoPackage(pkg.name)) {
      throw new Error(`Found invalid package name: ${pkg.name}
            
Please make sure that all internal packages are prefixed with ${MONOREPO_PREFIX}

詳細は **社内Notionへのリンク** を参照してください。
`);
    }
  }
};
/**
 * Check if the package is not violating the One Version Rule
 * - All dependency versions should be pinned
 * - All `pnpm.overrides` versions should be pinned
 * @param pkg
 */
const assertRootPackage = (pkg) => {
  if (pkg.name !== rootPkg.name) {
    throw new Error(`Invalid argument: ${pkg.name}. This function only accepts root package.json`);
  }
  /**
   * @typedef {{depName:string; depVersion:string; depType: string; errorType: "invalid-semver-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  // all dependency versions should be pinned in root package.json
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // if depVersion is not pinned
        if (depVersion !== pinVersion(depVersion)) {
          errorPackageSet.add({
            depName, depVersion, depType, errorType: "invalid-semver-version"
          });
        }
      });
    }
  });
  // pnpm.overrides should be pinned
  if (pkg?.pnpm?.overrides) {
    Object.entries(pkg.pnpm.overrides).forEach(([depName, depVersion]) => {
      if (depVersion !== pinVersion(depVersion)) {
        errorPackageSet.add({
          depName, depVersion, depType: "pnpm.overrides", errorType: "invalid-semver-version"
        });
      }
    });
  }
  // throw error if there is any error
  if (errorPackageSet.size !== 0) {
    const commands = Array.from(errorPackageSet).map((errorPackage) => {
      if (errorPackage.errorType === "invalid-semver-version") {
        if (errorPackage.depType === "pnpm.overrides") {
          return `npm pkg set 'pnpm.overrides.${errorPackage.depName}'='${pinVersion(errorPackage.depVersion)}'`;
        }
        return `npm pkg set '${errorPackage.depType}.${errorPackage.depName}'='${pinVersion(errorPackage.depVersion)}'`;
      }
    }).join("\n");
    throw new Error(`Found invalid dependency versions in root package.json

This monorepo enforces all dependencies to be pinned version in root "package.json".
Please run the following commands to fix the issue:

\`\`\`
cd ${rootDir}
${commands}
pnpm install
\`\`\`
 
詳細は **社内Notionへのリンク** を参照してください。
`);
  }

};

/**
 * Check if the package is not violating the One Version Rule
 * @param pkg
 */
const assertWorkspacePackage = (pkg) => {
  /**
   * @typedef {{depName:string; depVersion:string; depType:string; packageName:string; errorType: "non-catalog" | "invalid-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // Skip monorepo packages
        // e.g. "@monorepo/package-name" -> "@monorepo/shared-lib"
        if (isMonorepoPackage(depName)) {
          return;
        }
        // check if the dependency is defined in catalog
        if (!isPackageIncludedInCatalog(depName)) {
          errorPackageSet.add({
            depName, depVersion, depType, packageName: pkg.name, errorType: "non-catalog"
          });
        }
        // check if the dependency version is defined as workspace:*
        if (!depVersion.startsWith("workspace:") && !depVersion.startsWith("catalog:")) {
          errorPackageSet.add({
            depName, depVersion, depType, packageName: pkg.name, errorType: "invalid-version"
          });
        }
      });
    }
  });

  if (errorPackageSet.size !== 0) {
    /**
     * @param {RuleError}
     */
    const fixCommand = ({ depName, depVersion, depType, packageName, errorType }) => {
      if (errorType === "non-catalog") {
        // If already defined as workspace:*, then get latest version from npm
        if (depVersion.startsWith("workspace:") || depVersion.startsWith("catalog:")) {
          // use yq to append `depName: {latest}}` to "catalog" field
          return `yq -i '.catalog += {"${depName}": "$(npm info ${depName} version)"}' "${pnpmCatalogYamlFile}" # ${packageName}`;
        }
        // If not `depName: version` to pnpm-workspace.yaml
        return `yq -i '.catalog += {"${depName}": "${pinVersion(depVersion)}"}' "${pnpmCatalogYamlFile}" # ${packageName}`;
      }
      if (errorType === "invalid-version") {
        return `npm --prefix=$(find . -name package.json | xargs grep -l '"name": "${packageName}"' | xargs dirname) pkg set '${depType}.${depName}'='catalog:'`;
      }
    };
    const commands = Array.from(errorPackageSet).map((errorPackage) => {
      return fixCommand(errorPackage);
    }).join("\n");

    const isAddNewCatalog = Array.from(errorPackageSet).some((errorPackage) => {
      return errorPackage.errorType === "non-catalog";
    })

    throw new Error(`Found packages that violate the rule

This monorepo enforces all dependencies to be defined in root "package.json" and pnpm catalogs.
Please run the following commands to fix the issue:

yq コマンドに依存しているので、 "brew install yq" で事前にインストールしてください

\`\`\`
cd ${rootDir}
${commands}
pnpm install
\`\`\`

${isAddNewCatalog ? "新しいパッケージがpnpm-workspace.yamlのcatalogに追加されます。コマンド実行後に、pnpm-workspace.yamlに追加された依存を適切なグループに移動してください" : ""}
詳細は **社内Notionへのリンク** を参照してください。

`);
  }
};

function afterAllResolved(lockfile, context) {
  assertMonorepoPackageNameRule(lockfile);
  return lockfile;
}

/**
 * Read package.json and check if it violates the Mono Version Rule
 * @param pkg
 * @param context
 * @returns {*}
 */
function readPackage(pkg, context) {
  // pnpm create does not have package name
  const isPnpmCreate = pkg.name === undefined;
  if (isPnpmCreate) {
    return pkg;
  }
  // SKip outside monorepo packages
  if (!isMonorepoPackage(pkg.name)) {
    return pkg;
  }
  // Check root package
  if (pkg.name === rootPkg.name) {
    assertRootPackage(pkg);
    return pkg;
  }
  // Check workspace package
  assertWorkspacePackage(pkg);
  return pkg;
}

module.exports = {
  hooks: {
    readPackage,
    afterAllResolved
  }
};

この.pnpmfile.cjsは、pnpm install時に実行され、依存するパッケージのバージョンを1つに統一するOne Version Ruleを実装しています。

たとえば、次のようにアプリケーションに直接バージョンを指定した状態で pnpm install を行うとエラーが発生します

  "devDependencies": {
    "@playwright/test": "catalog:",
    "@types/node": "catalog:",
    "typescript": "catalog:",
    "jquery": "^3.7.1"
  },

実行時のエラーメッセージには、このエラーを修正するコマンドも表示されるので、コマンドを実行することでエラーを修正することができます。

pnpm: Found packages that violate the rule

This monorepo enforces all dependencies to be defined in root "package.json" and force the version via pnpm catalogs
Please run the following commands to fix the issue:

yq コマンドに依存しているので、"brew install yq" で事前にインストールしてください

```
cd /path/tp/newmohq/newmo-app
yq -i '.catalog += {"jquery": "3.7.1"}' "/path/tp/newmohq/newmo-app/pnpm-workspace.yaml" # @newmo-app/application-x
npm --prefix=$(find . -name package.json | xargs grep -l '"name": "@newmo-app/application-x' | xargs dirname) pkg set 'devDependencies.jquery'='catalog:'
pnpm install
```

詳細は **社内Notionへのリンク** を参照してください。

pnpm installでOne Version Ruleのチェックが行われるので、自動的にCIでも落ち、また開発者のローカルでもすぐエラーがわかるようになっています。

これによってnewmoでは、monorepo内の全てのパッケージのバージョンを1つに統一するOne Version Ruleを実装しています。

Note: Sherifのようなmonorepoに特化したLinterなどもありますが、.pnpmfile.cjsで実装するメリットは他のツールを増やす必要がない点にあります。

参考: newmoのpnpm catalog

参考までに、newmoのpnpm-workspace.yamlに記載されているパッケージのカタログを紹介します。 ここに書かれているパッケージが、現在時点(2024-08-30)でのフロントエンドで利用しているパッケージの一覧です。

packages:
  - ... 色々な内部パッケージ ...
catalog:
  # ------------------------------
  # Browser Runtimeで利用するその他の依存
  # カテゴライズが難しいが、ブラウザのRuntimeに含まれるような依存
  # ------------------------------
  # LIFFのSDK
  # Design Docs: **Design Docへのリンク**
  "@line/liff": 2.24.0
  # アニメーションを再生するプレイヤーのライブラリ
  # https://rive.app/
  "@rive-app/react-canvas": 4.12.0
  # ------------------------------
  # React関係
  # ------------------------------
  react: 19.0.0-rc-935180c7e0-20240524
  react-dom: 19.0.0-rc-935180c7e0-20240524
  "@types/react": npm:types-react@19.0.0-rc.0
  "@types/react-dom": npm:types-react-dom@19.0.0-rc.0
  # ReactのUIコンポーネントライブラリではReact Ariaを利用している
  # https://react-spectrum.adobe.com/react-aria/index.html
  # Design Doc: **Design Docへのリンク**
  react-aria-components: 1.2.1
  # ------------------------------
  # Next.js関係
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  next: 15.0.0-rc.0
  "@next/third-parties": 14.2.5
  # ------------------------------
  # Panda CSS関係
  # https://pandacss.com/
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  "@pandacss/dev": 0.44.0
  # 直接postcssは利用していない、Panda CSSをNext.jsと連携させるために利用している
  postcss: 8.4.41
  # ------------------------------
  # Storybook関係
  # Design Doc: **Design Docへのリンク**
  # ------------------------------
  storybook: 8.2.9
  "@storybook/addon-essentials": 8.2.9
  "@storybook/addon-interactions": 8.2.9
  "@storybook/addon-links": 8.2.9
  "@storybook/addon-storysource": 8.2.9
  "@storybook/blocks": 8.2.9
  "@storybook/react": 8.2.9
  # VRTには https://www.chromatic.com/ を利用している
  chromatic: 11.7.1
  "@chromatic-com/storybook": 1.6.1
  # StorybookにはViteを利用しているため、ViteのReactプラグインを入れてる
  "@storybook/react-vite": 8.2.9
  "@vitejs/plugin-react": 4.3.1
  # ------------------------------
  # Vite関係
  # https://vitejs.dev/
  # ------------------------------
  vite: 5.4.2
  # Unit TestはViteを利用している
  vitest: 2.0.3
  # ------------------------------
  # Playwright関係
  # https://playwright.dev/
  # ------------------------------
  # Integration TestはPlaywrightを利用している
  "@playwright/test": 1.46.0
  # 静的なHTMLをテストで利用する時に、ローカルサーバとしてserveを利用している
  serve: 14.2.3
  # ------------------------------
  # GraphQL関係
  # https://graphql-code-generator.com/
  # codegenのpluginやfake serverなど
  # GraphQLのruntimeでも必要な依存はここに記載する
  # ------------------------------
  graphql: 16.8.1
  "@graphql-typed-document-node/core": 3.2.0
  # GraphQLのクラインアントはApollo Clientを利用している
  "@apollo/client": 3.10.6
  # GraphQLのdevDependenciesはここに記載する
  "@graphql-codegen/cli": 5.0.2
  "@graphql-codegen/client-preset": 4.2.5
  "@newmo/graphql-codegen-plugin-typescript-react-apollo": 1.2.2
  # Fake Serverはローカル開発時に利用している
  # Design Doc: **Design Docへのリンク**
  "@newmo/graphql-fake-server": 0.11.0
  "@newmo/graphql-codegen-fake-server-client": 0.11.0
  # ------------------------------
  # ESLint関係
  # https://eslint.org/
  # ESLintの設定は/eslint.config.jsにまとめられている
  # ------------------------------
  eslint: 8.57.0
  typescript-eslint: 7.13.1
  eslint-config-next: 15.0.0-rc.0
  eslint-plugin-playwright: 1.6.2
  eslint-plugin-prettier: 5.1.3
  eslint-plugin-react: 7.34.1
  eslint-plugin-react-hooks: 4.6.2
  eslint-plugin-storybook: 0.8.0
  "@vitest/eslint-plugin": 1.0.4
  "@pandacss/eslint-plugin": 0.1.9
  "@next/eslint-plugin-next": 14.2.4
  "@graphql-eslint/eslint-plugin": 3.20.1
  # ------------------------------
  # secretlint関係
  # https://github.com/secretlint/secretlint
  # 機密情報がコミットされたらCIで落とすために入れている
  # ------------------------------
  secretlint: 8.2.4
  "@secretlint/secretlint-rule-preset-recommend": 8.2.4
  # ------------------------------
  # TypeScript関係
  # https://www.typescriptlang.org/
  # TypeScriptのcompiler pluginやtsconfigに関係するもの
  # ------------------------------
  typescript: 5.5.2
  # ------------------------------
  # Formatter関係
  # ------------------------------
  prettier: 3.2.5
  # ------------------------------
  # スクリプト/ツール関係
  # script/やcmd/などで利用する開発用のスクリプトやツールで利用するもの
  # ------------------------------
  glob: 10.3.12 # TODO: Node.js 22ではネイティブで利用できるので不要になる
  # ------------------------------
  # その他の開発系(devDependencies)の依存
  # driver-webでcloudflare pagesへデプロイするのに利用
  # ------------------------------
  wrangler: 3.57.1
  # ------------------------------
  # その他の@types
  # グルーピングされないtypesはここに記載する
  # ------------------------------
  "@types/node": 20.12.7
  # ------------------------------
  # ここから↓↓↓は、まだカテゴライズされていない依存が列挙されています
  # yqで追加した時はここに記載されるので、適切なカテゴライズをしてください。
  # ------------------------------

すでに複数のウェブアプリケーションが稼働していますが、依存はかなり最小限にしていて、また新しいパッケージを追加する際にはDesign Docを書いて議論することが多いです。 Design Docでは、導入する目的/目的外、他の選択肢との比較、メリット/デメリット、Tier(フロントエンドの移り変わりは早すぎるのかを参考に)などを議論しています。 いわゆるArchitecture Decision Record(ADR)のようなものですが、新しいパッケージを追加する際には、このような議論を行うことでなぜそのパッケージを導入するのかを明確にしています。 カタログにDesign Docへのリンクを入れることで、後から入った人もなぜそのパッケージを使っているかをわかるようにしています。

こうした議論をするのは、一度入れたパッケージを削除するのが難しいからです。 パッケージの扱い(Tier)について話すのも、どれぐらい該当のパッケージに依存してアプリケーションを作るかを関係者で認識を揃えるためです。

まとめ

newmoのフロントエンドでは、pnpmのcatalog機能を使ってOne Version Ruleを実装しています。

One Version Ruleは、monorepo内の全てのパッケージのバージョンを1つに統一するルールです。 これによって、パッケージの一覧性がよくなり、パッケージのアップデートといったメンテナンスがしやすくなります。

一方で、基本的には1つのバージョンだけを扱うことになるので、アップデート時には自動テストが重要になります。 newmoのフロントエンドでは、PlaywrightとFake Serverを使ったIntegration TestsやChromaticを使ったVRT(Visual Regression Test)など自動テストを充実させています。 ライブラリを扱うコードに対しては、Unit Testが書きにくいことも多いため、Integration Testsなどユーザーが見るものに対するテストに比重を置いています。 (主なロジックはGoで書かれたバックエンドにあるため、バックエンド側もE2Eテストなどを充実させてカバーしています)

副作用として、新しいパッケージを追加する際には、通常のバラバラのバージョンで管理するよりは心理的な障壁が高くなります。 良い面としてはちゃんと議論してからパッケージを追加することができるということですが、逆を言えばパッケージを簡単には追加できないということでもあります。 これは、newmoではちゃんと検討してからパッケージを追加するような意思決定をしたということなので、全ての開発でこの方法が適切というわけではありません。

One Version Ruleから複数バージョンへ移行するのは簡単ですが、その逆は難しいです。 このOne Version Ruleで破綻するところまでは、この方法でやってみようということで、newmoではフロントエンドを含め、Go言語のバックエンドやSwiftのiOSなども同様の方法でパッケージ管理を行っています。

PR: newmoではフロントエンドエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

Appendix: pnpm 9.5未満でのOne Version Ruleの実装

pnpm catalogはpnpm 9.5で追加された機能ですが、pnpm 9.5未満でも同様のOne Version Ruleを実装することができます。

pnpm.overridesworkspace:* を使うことで擬似的にpnpm catalogと同様の機能を実現することができます。 pnpm catalogが出る前にこの仕組みを実装して使っていて、pnpm catalogがリリースされたときには、pnpm catalogを使うように移行しました。

こちらの方式は pnpm overridesを使うのでrenovatebotなども対応しています。

pnpm catalogはまだリリースされたばかりなので、renomatebotやdependabotなどのツールはまだ対応していません。

pnpmfile.js (クリックで開く)

 /**
 * ## One Version Rule
 *
 * - Root package.json should define all versions of child packages via `pnpm.overrides`
 *  - e.g. `"pnpm": { "overrides": { "package-name": "1.0.0" } }`
 * - Each child package should use `workspaces:*` version instead of specific version
 *   - It uses the version defined in the root package.json's "pnpm.overrides"
 *   - e.g. `"dependencies": { "package-name": "workspace:*" }`
 *
 **/
const rootPkg = require("./package.json");
const fs = require("node:fs");
const path = require("node:path");
const rootDir = __dirname;
/**
 * Prefix for all monorepo packages
 * @type {string}
 */
const MONOREPO_PREFIX = "@newmo-app/";
const isMonorepoPackage = (pkgName) => {
  return pkgName.startsWith(MONOREPO_PREFIX);
};
/**
 * Pin the version of the package
 * @example
 * pinVersion("^1.0.0") // => "1.0.0"
 * pinVersion("~1.0.0") // => "1.0.0"
 * pinVersion("1.0.0") // => "1.0.0"
 * @param {string }version
 * @return {string}
 */
const pinVersion = (version) => {
  if (version.startsWith("^") || version.startsWith("~")) {
    return version.slice(1);
  }
  return version;
};
/**
 * Check if all packages are prefixed with {@link MONOREPO_PREFIX}
 */
const assertMonorepoPackageNameRule = (lockfile) => {
  // get all packages in the monorepo
  const pkgPathList = Object.keys(lockfile.importers).map((pathFromRoot) => {
    return path.join(path.resolve(rootDir, pathFromRoot), "package.json");
  });
  // check if all packages are prefixed with
  for (const pkgPath of pkgPathList) {
    const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
    if (!isMonorepoPackage(pkg.name)) {
      throw new Error(`Found invalid package name: ${pkg.name}
              
  Please make sure that all packages are prefixed with ${MONOREPO_PREFIX}
  `);
    }
  }
};
/**
 * Check if the package is not violating the One Version Rule
 * - All dependency versions should be pinned
 * - All `pnpm.overrides` versions should be pinned
 * @param pkg
 */
const assertRootPackage = (pkg) => {
  if (pkg.name !== rootPkg.name) {
    throw new Error(
      `Invalid argument: ${pkg.name}. This function only accepts root package.json`
    );
  }
  /**
   * @typedef {{depName:string; depVersion:string; depType: string; errorType: "invalid-semver-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  // all dependency versions should be pinned in root package.json
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // if depVersion is not pinned
        if (depVersion !== pinVersion(depVersion)) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            errorType: "invalid-semver-version",
          });
        }
      });
    }
  });
  // pnpm.overrides should be pinned
  if (pkg?.pnpm?.overrides) {
    Object.entries(pkg.pnpm.overrides).forEach(([depName, depVersion]) => {
      if (depVersion !== pinVersion(depVersion)) {
        errorPackageSet.add({
          depName,
          depVersion,
          depType: "pnpm.overrides",
          errorType: "invalid-semver-version",
        });
      }
    });
  }
  // throw error if there is any error
  if (errorPackageSet.size !== 0) {
    const commands = Array.from(errorPackageSet)
      .map((errorPackage) => {
        if (errorPackage.errorType === "invalid-semver-version") {
          if (errorPackage.depType === "pnpm.overrides") {
            return `npm pkg set 'pnpm.overrides.${
              errorPackage.depName
            }'='${pinVersion(errorPackage.depVersion)}'`;
          }
          return `npm pkg set '${errorPackage.depType}.${
            errorPackage.depName
          }'='${pinVersion(errorPackage.depVersion)}'`;
        }
      })
      .join("\n");
    throw new Error(`Found invalid dependency versions in root package.json
  
  This monorepo enforces all dependencies to be pinned version in root "package.json".
  Please run the following commands to fix the issue:
  
  \`\`\`
  cd ${rootDir}
  ${commands}
  pnpm install
  \`\`\`
    `);
  }
};

/**
 * Check if the package is not violating the One Version Rule
 * @param pkg
 */
const assertWorkspacePackage = (pkg) => {
  /**
   * @typedef {{depName:string; depVersion:string; depType:string; packageName:string; errorType: "non-overrides" | "invalid-version"}} RuleError
   * @type {Set<RuleError[]>}
   */
  const errorPackageSet = new Set();
  ["dependencies", "devDependencies", "peerDependencies"].forEach((depType) => {
    const deps = pkg[depType];
    if (deps) {
      Object.entries(deps).forEach(([depName, depVersion]) => {
        // Skip monorepo packages
        // e.g. "@monorepo/package-name" -> "@monorepo/shared-lib"
        if (isMonorepoPackage(depName)) {
          return;
        }
        // check if the dependency is defined in root package.json
        if (!rootPkg.pnpm.overrides[depName]) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            packageName: pkg.name,
            errorType: "non-overrides",
          });
        }
        // check if the dependency version is defined as workspace:*
        if (!depVersion.startsWith("workspace:")) {
          errorPackageSet.add({
            depName,
            depVersion,
            depType,
            packageName: pkg.name,
            errorType: "invalid-version",
          });
        }
      });
    }
  });

  if (errorPackageSet.size !== 0) {
    /**
     * @param {RuleError}
     */
    const fixCommand = ({
      depName,
      depVersion,
      depType,
      packageName,
      errorType,
    }) => {
      if (errorType === "non-overrides") {
        // If already defined as workspace:*, then get latest version from npm
        if (depVersion.startsWith("workspace:")) {
          return `npm pkg set 'pnpm.overrides.${depName}'="$(npm info ${depName} version)"`;
        }
        return `npm pkg set 'pnpm.overrides.${depName}'='${pinVersion(
          depVersion
        )}'`;
      }
      if (errorType === "invalid-version") {
        return `npm --prefix=$(find . -name package.json | xargs grep -l '"name": "${packageName}"' | xargs dirname) pkg set '${depType}.${depName}'='workspace:*'`;
      }
    };
    const commands = Array.from(errorPackageSet)
      .map((errorPackage) => {
        return fixCommand(errorPackage);
      })
      .join("\n");
    throw new Error(`Found packages that violate the rule
  
  This monorepo enforces all dependencies to be defined in root "package.json" and force the version via "pnpm.overrides".
  Please run the following commands to fix the issue:
  
  \`\`\`
  cd ${rootDir}
  ${commands}
  pnpm install
  \`\`\`
  
  詳細は **社内Notionへのリンク** を参照してください。
  
  `);
  }
};

function afterAllResolved(lockfile, context) {
  assertMonorepoPackageNameRule(lockfile);
  return lockfile;
}

/**
 * Read package.json and check if it violates the Mono Version Rule
 * @param pkg
 * @param context
 * @returns {*}
 */
function readPackage(pkg, context) {
  // pnpm create does not have package name
  const isPnpmCreate = pkg.name === undefined;
  if (isPnpmCreate) {
    return pkg;
  }
  // SKip outside monorepo packages
  if (!isMonorepoPackage(pkg.name)) {
    return pkg;
  }
  // Check root package
  if (pkg.name === rootPkg.name) {
    assertRootPackage(pkg);
    return pkg;
  }
  // Check workspace package
  assertWorkspacePackage(pkg);
  return pkg;
}

module.exports = {
  hooks: {
    readPackage,
    afterAllResolved,
  },
};

Reviewed by ito.

iOSDC Japan 2024 にて「GraphQLとスキーマファーストで切り開くライドシェアの未来」について話しました! #iosdc

iOSDC Japan 2024 にスポンサーセッションで登壇しました

こんにちは。newmoのソフトウェアエンジニアの@kuです。

先週開催されたiOSDC Japan 2024にて、Day2の夕方に「GraphQLとスキーマファーストで切り開くライドシェアの未来」というタイトルで登壇させていただきました。(トーク情報

このブログでは、当セッションの登壇資料、補足・裏話をシェアします。

 

登壇資料

speakerdeck.com

 

本セッションでは、GraphQLのディレクティブを使ってスキーマにより多くの情報を持たせ、そこからコードを生成することで、異なるソフトウェア間で一貫性のある実装を安全に、高速に行う弊社の取り組みを紹介しました。

GraphQLのディレクティブはあまり紹介されないものの、様々な用途に利用できるので、iOS開発でもより広く使われるようになるといいなと思っています。

(セッションの動画は、後日iOSDC Japan公式YouTube Channelで公開されるはず。もう少々お待ち下さい。)

 

おわりに

iOSDCでは2021以来GraphQLのセッションはなかったので、もうみんなGraphQLに興味がないのでは?と心配だったのですが、満員御礼となって嬉しかったです。ありがとうございました。

 

また、1階のnewmoブースには3日間で約200名の方に来ていただきました。newmoのことを知っている人も、知らない人も居ました。とにかく、たくさんのエンジニアと交流できてとても楽しかったです。

 

セッションやブースで話を聞いて、newmoがどんな会社かちょっと気になる…そう思った方は、newmoのキャリアページnewmoの求人一覧をご覧ください。

 

youtrust.jp

 自分とのカジュアル面談もぜひ。

 

newmoは来月開催予定のDroidKaigi 2024にもブース出展するので、DroidKaigiに参加予定の方が居たら、来月の会場でお話しましょう!

newmo は「エンジニアの楽園 vim-jp ラジオ」を応援しています! #vimjpradio

こんにちは。newmo の TechPR 担当です。

newmo は、2024年にスタートした「エンジニアの楽園 vim-jp ラジオ」を応援しています。
newmo の vimmer も他のエディタ使いも、いつも楽しく vim-jp ラジオを聞かせていただいており、協賛できることを嬉しく思います。

協賛にあたり、以下日時のお知らせコーナーにて、newmo の情報が配信される予定です。

  • 9月9日  #10 @yusukebe さん回
  • 9月16日   #11 @uzulla さん回

vimmer の方も、そうでない方も。
vim-jp ラジオの audee ページと vim-jp X アカウント(@vimjpradio)を要チェックです!

audee.jp

 

newmo は iOSDC Japan 2024 にゴールドスポンサーとして協賛します!ブース出展 & Day2にセッションも(記事内チャレンジトークン有り) #iosdc

こんにちは。newmo の TechPR 担当です。

newmo は、2024年8月22日〜24日に開催予定の iOSDC Japan 2024 にゴールドスポンサーとして協賛します!
創業1年目の会社ですが、iOSコミュニティの発展に寄与できることを嬉しく思います。

記事内にiOSDCチャレンジトークンがあります。
※ iOSDCチャレンジトークンとは、公式が催している全員参加型企画「iOSDCチャレンジ」に使用するトークンのことです。詳しくはこちらの公式案内ブログをご覧ください。

iOSDC Japan 2024 開催概要

newmo セッション情報

Day2の昼過ぎに、newmo エンジニア @ku による以下セッションを実施予定です。

より安全に、高速にiOS開発を進めていくための弊社の取組みをご紹介します。ご興味ある方は、ぜひご聴講・ご視聴ください。

iOSDCチャレンジトークン

このセッションを聞いて、

#ライドシェアの未来

について、一緒に考えてみませんか?

newmo ブース情報

前夜祭からDay2まで、オフライン会場にてブースを出展します。

ブースお品書き

newmoのビジネス紹介ポスター

創業して半年。まだiOSアプリがリリースされていないので、newmoのビジネスについてご存じない方も多いと思います。そんな方のために、創業の背景・解決したい課題・newmoの特徴などをまとめたポスターを掲示 & newmo ブース担当者が口頭で説明します。

newmo iOS Design Blueprint ポスター

以下の情報をポスターにして掲示 & ブース担当者が口頭で説明します!

  • Multiple app architecture
  • the Composable Architecture
  • Usage of Google Fleet Engine
  • Interaction with GraphQL

ノベルティ

ロゴステッカー

まだ持っている人が少ない、レアなステッカー?!

フェイスタオル

汗をかく季節。タオルは何枚あっても嬉しいですよね。

塩分チャージタブレット

この季節は熱中症に注意!汗で流れた塩分を補給しましょう。

ブラックサンダー

会場内を動き回ったりセッションを聞いて頭を使ったら、脳と身体が糖分を欲しているはず。小休憩とともにどうぞ。

イベント直前・タイムテーブルチェック会

newmo-tech.connpass.com

開催3日前の 8/19(月)に、「iOSDC Japan 2024 のタイムテーブルをチェックする会」を開催します。「既にタイムテーブルはチェック済みだよ」という方も、そうでない方も。
登壇者の @noppe さんや @ooba さん、過去参加経験のある @d_date さんや @hcrane14 さんと一緒に、わいわいしましょう。
お昼の時間帯にオンライン配信するので、ご飯を食べながらの視聴もウェルカムです!

▼申込みはこちら
https://newmo-tech.connpass.com/event/327148/

最後に

現地の様子はX(旧Twitter)の @newmotech でもポスト予定です。ぜひこちらのアカウントもフォローしておいてください。

newmoがどんな会社かちょっと気になる…そんな方は、ぜひ事前にnewmoのキャリアページをご一読ください。 careers.newmo.me

ただ今 iOSエンジニアも募集中です。newmoの求人一覧はこちらhrmos.co

それでは、当日お会いできるのを楽しみにしています!