every Tech Blog

株式会社エブリーのTech Blogです。

DELISH KITCHENのユニットテストで使用しているライブラリ

この記事は every Tech Blog Advent Calendar 2024(夏) 7日目の記事です。

はじめに

エブリーでソフトウェアエンジニアをしている本丸です。
Go Conference 2024もいよいよ明日開催ですね。
Goに関する話ということでDELISH KITCHENのユニットテストで使用されているライブラリを紹介したいと思います。

弊社ブログの過去の記事にテストの可読性についてのものがあるので興味があればぜひ読んでみてください!
Go testにおける可読性を保つ方法を考える

DELISH KITCHENのユニットテストで使用しているライブラリ

DELISH KITCHENではユニットテストを行うときに主に以下の4つのライブラリを使用しています。

  • gomock
  • testify/assert
  • go-cmp
  • httptest

gomock

名前の通り、ユニットテストの際にモックを提供してくれます。
https://github.com/uber-go/mock

gomockでは以下のようなinterfaceからmockを生成することができ、

type UserRepo interface {
    Insert(age int) User
    BulkInsert(ages []int) []*User
    Change(u User) *User
}

テストコードの中で下記のように使います。

   ctrl := gomock.NewController(t)
    repo := NewMockUserRepo(ctrl)
    repo.EXPECT().Add(20).Return(User{})

これだけでも便利なのですが、社内のコードに個人的に便利な機能だと思うものがあったので、いくつか紹介します。

Do()

ドキュメントからの引用ですが、下記のことを行ってくれます。

Doは、呼び出しがマッチしたときに実行するアクションを宣言します。後方互換性を保つため、関数の戻り値は無視されます。

社内で具体的にどのように使っているかというと

   repo.EXPECT().Change(gomock.Eq(u)).
        Do(func(user) { u.ID = 1 }).
        Return(&u)

構造体を受け取って、その構造体のフィールドを変更して返す関数のモックを含む時に使用しています。

InAnyOrder()

こちらもドキュメントからの引用ですが、下記のことを行ってくれます。

InAnyOrderは、順序を無視して同じ要素のコレクションに対して真を返すMatcherです。

社内で具体的にどのように使っているかというと

idMap := map[int]struct{}{
    19: struct{}{},
    20: struct{}{},
}
ids := make([]int, 0, len(idMap))
for id := range idMap {
    ids = append(ids, id)
}

repo.BulkInsert()

のような処理があり、BulkInsert()のmockを作りたい時に

   repo.EXPECT().BulkInsert(gomock.InAnyOrder([]int64{20, 19})).
    Return()

arrayの順番が保証されないためこちらを使用しています。

testify/assert

ある値がこうなるはずだというアサーションのチェックを行ってくれます。
https://github.com/stretchr/testify

testify/assertを使用してある関数のレスポンスが期待したものと一致するか確認したい場合は以下のようになります。

   want := true
    got := doAnything()
    assert.Equal(t, want, got)

様々なアサーションが用意されているのですが、Equal以外では下記に示したものがDELISH KITCHENだとよく使用されていました。

  • Contains()
  • Error()
  • Len()

go-cmp

オブジェクトを比較してくれるライブラリで、ユニットテストでもオブジェクトの比較のために使用しています。
https://github.com/google/go-cmp

   if diff := cmp.Diff(want, got); len(diff) != 0 {
        t.Errorf("got diff = %v", diff)
    }

go-cmpはオプションを使用することで様々なケースに対応することが可能です。 その中から社内で使われているものを一部紹介します。

IgnoreUnexported

IgnoreUnexportedをオプションとして指定すると、構造体の中のprivateなフィールドなどunexportedなものを無視して比較してくれます。

type SearchRequest struct {
    Client http.Client
    url    string
}

例えば、上記のような構造体があった場合はClientだけ比較されて、urlは無視されるといった挙動になります。

IgnoreFields

IgnoreFieldをオプションとして指定すると、構造体の中の指定したフィールドを無視して比較してくれます。

type User struct{
    ID        int
    CreatedAt time.Time
}

opts := []cmp.Option{
    cmpopts.IgnoreFields(User{}, "CreatedAt"),
}

上記のように指定すると、CreatedAtが無視されてIDだけ比較されるという挙動になります。

SortSlices

SortSlicesをオプションとして指定すると、指定したarrayのフィールドをソートした後に比較してくれます。

func GetUserIDs() []int {
    // 要素の順番がランダムなuserIDのarrayを返す処理
}

got := GetUserIDs()
want := []int{1, 2, 3}

opt := cmpopts.SortSlices(func(i, j int) bool {
    return i < j
})
if diff := cmp.Diff(got, want, opt); diff != "" {
    t.Errorf("GetUserIDs() = %v, want %v", got, tt.want)
}

例えば、GetUserIDs()という関数のテストをしたい時に、実際のコードではランダムな順序で問題ない場合でもテストでは順序も含めて比較を行うため失敗してしまうということが起こり得ます。このような時にSortSlicesをオプションとして指定すると任意の順番にソートした後に比較を行うため、配列の順番でテストが失敗するということは起こらなくなります。

httptest

標準ライブラリなので趣旨と少しズレるかもしれませんが、テスト用のモックサーバーとして利用しています。

func NewRequestHTTPRequestMock() (*httptest.Server, func() string) {
    var body string
    return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        b, _ := io.ReadAll(r.Body)
        body = string(b)
    })), func() string {
        return body
    }
}

func Test_Request(t *testing.T) {
    requestMock, getRequestContent := NewRequestHTTPRequestMock()
    _, _ = s.Search(requestMock.URL)
    got := getRequestContent()
    if diff := cmp.Diff(tt.want, got); diff != "" {
        t.Errorf("got diff (-want +got):\n%s", diff)
    }    
}

DELISH KITCHENではhttp.Clientを使用している箇所があり、利用箇所のテストを行うためにhttptestを利用しています。 上記のコードでは、NewRequestHTTPRequestMock()でモックサーバーを作成して、そこに対してリクエストを行うことでリクエストの中身が正しいのかのテストを行なっています。

まとめ

改めてまとめてみるとDELISH KITCHENではGoのテストではメジャーなライブラリが使われているといった印象でした。それと同時に、普段使用しているライブラリでも改めてドキュメントを読み直してみると、自分は使いこなせていない部分も多いと気付かされました。

Go Conference 2024 まで、あと1日!
https://gocon.jp/2024/

株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう!
https://gocon.jp/2024/sponsors/2/

Go 言語で行うメール解析

この記事は every Tech Blog Advent Calendar 2024(夏) 6 日目の記事です。

目次

はじめに

こんにちは!最近推しの配信が多くなってきて嬉しい@きょーです!DELISH KITCHEN 開発部のバックエンド中心で業務をしています。

業務でメール内容を解析、処理する機会があり、そこで経験した学びについて話していこうと思います。

イントロダクション

業務中にメールを解析メールヘッダーのカスタマイズメールの送信をするという場面に出くわしましたが、Go の標準パッケージである net/mail では解決が難しいことがわかり、苦労した経験があります。この記事では net/mail の基本的な使い方や遭遇した辛いところを紹介し、その辛さを解決してくれるパッケージ jhillyerd/enmimeについてお話しようと思います。

そもそもメールヘッダーとは

メールヘッダとは、メールの詳細情報が書かれている部分のことです。具体的には、メールが配送された経路や時間、経由したサーバーなどが記録されています。

以下は、一般的なメールヘッダーの例とその説明です。

From: 送信者のメールアドレスが記載されています。
To: 主な受信者のメールアドレスが記載されています。
Subject: メールの件名が記載されています。
Received: メールが経由したサーバーとその日時が記載されています。これはメールの配送経路を追跡するのに使われます。
Content-Type: メールの本文の形式(例:text/plain, text/html)が記載されています。
MIME-Version: メールが MIME(Multipurpose Internet Mail Extensions)規格を使用している場合、そのバージョンが記載されています。

メールヘッダーは、メールのトラブルシューティング、スパムの検出、セキュリティ分析などに使用されます。たとえば、Received ヘッダーを調べることで、メールがどのサーバーを経由してきたかを追跡し、スパムやフィッシングメールの出所を特定することができます。

net/mail パッケージ

pkg.go.dev

net/mail パッケージは、メールメッセージを解析するための機能を提供します。このパッケージを使用すると、メールのヘッダー情報やアドレスの解析、メッセージの本文の取得などが行えます。

net/mail パッケージの基本的な使用方法について紹介していきます。

メールの解析

net/mail パッケージを使用してメールを解析するには、まず mail.ReadMessage 関数を使用してメールデータを読み込みます。

   // メールのサンプルデータ
      rawEmail := `From: sender@example.com
To: recipient@example.com
Subject: This is a test email
Content-Type: text/plain; charset="utf-8"

This is the body of the email.` // ←がBody部分

    // io.Readerの作成
    reader := strings.NewReader(rawEmail)

    // ReadMessageを使用してメールを解析
    msg, _ := mail.ReadMessage(reader)

ヘッダーの取得

メールのヘッダーは Header 型で表され、これは下記のような map[string][]string の型定義です。

type Message struct {
    Header Header
    Body   io.Reader
}

type Header map[string][]string

ヘッダーの値は Header.Get(key)メソッドを使用して取得できます。このメソッドは指定されたキーに対応する最初の値を返します。

    // ヘッダーの取得
    header := msg.Header

    // Fromヘッダーの取得
    from := header.Get("From")
    fmt.Println("From:", from)  // From: sender@example.com

Body の取得

以下のようにMessage構造体の中にあるBodyからメールの本文を取得できます。

    // 本文の取得
    bytes, _ := io.ReadAll(msg.Body)
    fmt.Printf("Body: %s", string(bytes)) // Body: This is the body of the email.

net/mail パッケージのメール解析で辛いところ

net/mail パッケージは、基本的なメールメッセージの解析機能を提供しますが、いくつかの辛みポイントがあります。以下にその主な点を挙げます。

MIME マルチパートメッセージの解析が不完全

net/mail パッケージは MIME マルチパートメッセージの解析を直接サポートしていません。Message 構造体の Body フィールドには、メールの本文が含まれますが、MIME マルチパートメッセージの場合、下記のコードのような boundary 文字列(--000000000000abcdefg12345)や各パートのヘッダーなどがそのまま含まれてしまいます。これにより、メールの本文だけを簡単に取得することができない、という問題が生じます。

--000000000000abcdefg12345
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: base64

44GT44KT44Gr44Gh44Gv
--000000000000abcdefg12345
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: base64

PGRpdiBkaXI9ImF1dG8iPuOBk+OCk+OBq+OBoeOBrzwvZGl2Pg==
--000000000000abcdefg12345--

MIME マルチパートメッセージとは

MIME マルチパートメッセージテキストhtml画像などそれぞれ異なるパートに分け、それらを組み合わせ構成されたものです。この仕組みは複数のファイルを電子メールに添付するときなどに使用されます。

上記のメールの本文では、text/plaintext/htmlの部分が組み合わされ一つのメッセージとなっています。画像や動画を送る場合はimage/pngvideo/mp4などのパートがメッセージに追加されます。

developer.mozilla.org

デコード機能が不十分

net/mailパッケージにはほぼデコードの機能がありません。(ParseAddress 関数を除く) そのため、日本語で書かれたメールの件名本文エンコード方式(base64quoted-printableなど)に合わせ適切にデコードしなければ文字化けしてしまいます。

また、net/mailパッケージではHeaderではなくBodyの中にMIMEマルチパートメッセージエンコード方式が書かれています。そのためデコードするために形式を取得したくとも簡単には取得できない、という問題があります。

メールプロトコルに沿わせた構成にするのが大変

メールプロトコルとは

メールを送信する上で意識しなければいけないのがメールプロトコルです。メールプロトコルとは、電子メールの送受信に関する規則や手順を定めたもので、電子メール通信をする上でメールデータが正しくやり取りされるために必要です。

RFC2822でメールプロトコルが規定されています。下記に内容の一部を紹介していきます。

  • ASCII コードで構成されること
  • 一行は 78 文字以下が推奨
  • Header フィールドは、フィールド名の後にコロン(":")、フィールド本体が続き、CRLF で終了
  • Body の前は空行にする

これらの規則や手順を守らないと、メール送信できなかったり送信できても文字化けしてしまうなどの問題に繋がります。

net/mail で解析したメールを送信可能なメールにするために

net/mail パッケージでは Message 構造体の中に HeaderBodyフィールドがあります。メール送信するためにはこれらを組み合わせ[]byte 型にしなければいけなく、具体的には以下のような処理が必要になります。

  • 複数の Header のフィールド名と値をセットで取り出し、1 行に 1 セット設定する
  • 一行が 78 文字以上にならないように適宜改行コードを入れる
  • Header と Body を組み合わせて[]byte に変換

これを自分で対応しようとすると骨の折れる作業になります。実際に行った記事としても以下のような記事がよくまとまっています

qiita.com

上記の記事のコードを手元で管理したくないという思いから、MIME のエンコードやデコードを気にせず、電子メールの生成や解析をしてくれるパッケージを探し始めました。

そこで見つけたのが以下で紹介するパッケージです。

jhillyerd/enmime パッケージ

jhillyerd/enmimeパッケージは MIME エンコードおよびデコードライブラリで、MIME エンコードされた電子メールの生成と解析に重点を置いています。

net/mail パッケージでは Message 構造体のフィールドの HeaderBody がそれぞれ分かれていたため、解析 → Header 修正 → MIME 対応 → Header をエンコード → Body と組み合わせる → メール送信可能な構造に修正 → メール送信といった流れでした。

jhillyerd/enmime パッケージでは HeaderBody も全て一緒に MIME に対応した解析と生成をするため解析 → Header 修正 → MIME 対応したエンコード → メール送信のように処理が簡易化されます。

実際に例を見てみましょう。

pkg.go.dev

メールヘッダーの設定

    // objはio.Reader型
    // メールの内容を解析
    envelope, err := enmime.ReadEnvelope(obj)

    // Fromヘッダーの上書き
    err = envelope.SetHeader("From", []string{fmt.Sprintf("%s <%s>", senderName, senderEmail)})

    // Toヘッダーの上書き
    err = envelope.SetHeader("Subject", []string{"new subject"})

    buf := &bytes.Buffer{}
    // MIMEに対応したエンコード
    err = envelope.Root.Encode(buf)

    _ := sendEmail(buf.Bytes())

以上を踏まえ、簡単にnet/mailjhillyerd/enmimeのメリット、デメリットについて以下にまとめてみました。

net/mail と jhillyerd/enmime の比較

net/mail

メリット

  • 標準パッケージのため、追加の依存関係を導入しなくて済む
  • 公式が管理しているため、安定してメンテナンスされる
  • API がシンプルで、処理が追いやすい

デメリット

  • MIME マルチパートメッセージやテキストエンコーディングの解析など、複雑なメール処理に必要な高度な機能が不足している

jhillyerd/enmime

メリット

  • MIME マルチパートメッセージの解析、添付ファイルの処理、エンコーディングの変換など、複雑なメール処理に対応している

デメリット

  • 管理しているコミュニティが小さく、メンテナンスが継続されないリスクがある

まとめ

メールヘッダーを取得・設定するだけであれば net/mail パッケージだけで十分だと思いました。MIME マルチパートメッセージの解析・エンコーディングをする必要がある場合は、複雑な処理を管理しなくて済むので jhillyerd/enmime の利用を検討してみても良いかもしれません。

最後に

Go Conference 2024 まで、あと 2 日! gocon.jp

株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! gocon.jp

ネットスーパーリプレイス〜長大なクエリと向きあう編〜

この記事は every Tech Blog Advent Calendar 2024(夏) 5 日目の記事です。

はじめに

こんにちは、TIMELINE 開発部 Service Development をしているほんだです!
初の Go Conference オフライン参戦なので浮かれてる今日この頃です。

今回はスマホ向けネットスーパーアプリの API を Python から Go へ移行する際のデータベース操作の観点での課題と実際にどのような解決策を取ったのか実装をメインに紹介します。
ネットスーパーアプリのリプレイスを行うことにした背景やシステム全体の課題、解決策に関しては前回のブログに記述しているので是非ご一読ください。tech.every.tv

技術スタック

以下は今回の記事に関係のあるリプレイス前後の技術スタックになります。

言語 DB ORM
リプレイス前 Python MySQL PyMySQL
リプレイス後 Go MySQL sqlboiler + sqlx

課題

リプレイスを行うにあたりデータベース操作の観点で以下の 4 点の課題がありました。

テストの不在

既存の実装にテストがないため、リプレイス後のコードが正しく機能するかを検証する手段が限られています。これにより、修正後のコードが期待通りの動作をするかの判断が困難です。

長大な SQL の扱い

200 行を超える長大な SQL クエリを sqlboiler で書き換えることは非常に困難です。これは、sqlboiler が主に CRUD 操作に最適化されており、複雑なクエリの扱いには向いていないためです。

名前付きプレースホルダーの問題

元のクエリでは以下の例のように名前付きプレースホルダー(%(format)s)が多用されていますが、sqlboiler はこの機能をサポートしていません。これにより、プレースホルダー(MySQL では?)で実装されたクエリでは、クエリが長くなるほど可読性と保守性が損なわれます。

WHERE item.item_name like %(search_word)s
    OR item.item_area like %(search_word)s
    OR item.item_spec like %(search_word)s
    OR event_item.event_item_name like %(search_word)s
    OR event_item.event_item_area like %(search_word)s
    OR event_item.event_item_spec like %(search_word)s

型の厳格化

既存の Python 実装ではレスポンスが dict 型で返されるため、柔軟なデータ構造を扱うことができます。しかし、sqlboiler でデータベース操作を行うとレスポンスは tag を元に構造体にバインドされるため厳格な型定義が必要となり、これがリプレイスの際の追加の課題となります。

実装

先に挙げた課題点に対処するため、以下の実装方針を採用しました。

  • 長大なクエリの移行: 長大なクエリは、可能な限りそのまま Go に移行します。これにより、既存のクエリロジックを保持し、移行に伴うリスクを最小限に抑えることができます。
  • 名前付きプレースホルダーの使用: sqlx を使用して、名前付きプレースホルダーを実装します。これにより、クエリの可読性と保守性を向上させることができます。
  • 汎用的な実行関数の作成: 生の SQL クエリを実行し、結果を Go の構造体にバインドする汎用的な関数を作成します。このアプローチにより、異なるタイプのクエリに対しても柔軟に対応することが可能になります。

クエリの移行について

「長大なクエリは可能な限りそのまま Go に移行する」という方針に基づき、sqlboiler で移行可能なクエリと生クエリを明確に区別するために、次のようなディレクトリ構成を採用しました。

repository/
├── models/
│   ├── item.go
│   ├── favorite.go
│   ├── menu.go
│   └── user.go
├── rawquery/
│   ├── util.go
│   ├── item_builder.go
│   └── menu_builder.go
├── item.go
├── favorite.go
├── menu.go
└── user.go

repository ディレクトリ直下には、sqlboiler を用いて移行されたクエリの実装があります。一方で、repository/rawquery ディレクトリには、生クエリを直接扱う実装を配置しています。これらの生クエリは、sqlboiler の Raw 関数をラップしたユーティリティ関数を介して、repository 直下のファイルから呼び出されます。repository/models ディレクトリには、クエリ実行時に結果をバインドするための構造体が定義されています。

この構成により、クエリの種類ごとに責務を分離し、コードの整理と保守性の向上を図っています。

名前付きプレースホルダーを sqlx で実装

次に、名前付きプレースホルダーの実装について説明します。既存の Python 実装では pymysql を使用し、%(format)s形式で名前付きプレースホルダーを実装していました。しかし、sqlboiler にはこの機能がないため、sqlx を採用しました。
名前付きプレースホルダーを使用することで、長大なクエリにおける多数の引数や重複する引数の取り扱いが容易になります。ここでは、名前付きプレースホルダーを含む生クエリ、引数の実装、およびそれらをバインドする関数の実装について順を追って説明します。

以下は、repository/rawquery にある名前付きプレースホルダーに渡される引数をフィールドに持つ構造体、初期化関数、名前付きプレースホルダーを含む生クエリを返すメソッド、および引数を返すメソッドの実装例です。

// repository/rawquery/item_builder.go

package rawquery

type ItemBuilder struct {
    price   int
    janCode string
    tax int
}

func NewItemBuilder(name string, price int, janCode string, tax int) *ItemBuilder {
    return &ItemBuilder{
        price:   price,
        janCode: janCode,
        tax: tax,
    }
}

func (b *ItemBuilder) BuildQueryWithArgs() (ReBindedQueryArgs, error) {
    return buildQueryWithArgsDefault(b.rawQuery(), b.args())
}

func (b *ItemBuilder) rawQuery() string {
    q := `
  SELECT
      name,item_code,price,jan_code,tax_rate
  FROM
      item
  WHERE
        price > :price
        AND
        jan_code = :jan_code`

    if b.tax != nil {
        q += " AND tax_rate = :tax_rate"
    }

    return q
}

func (b *ItemBuilder) args() map[string]interface{} {
    args := map[string]interface{}{
        "price":    b.price,
        "jan_code": b.janCode,
    }
    if b.tax != nil {
        args["tax_rate"] = *b.tax
    }

    return args
}

ItemBuilder構造体は、クエリに必要な引数を保持します。BuildQueryWithArgsメソッドを呼び出すと、sqlx を使用して名前付きプレースホルダーが含まれる生クエリのプレースホルダーを適切な形式に置き換え、引数の順序に準拠した interface{}型のスライスを返します。

以下は、BuildQueryWithArgsメソッドの実行結果の例です。

// repository/rawquery/util.go

type ReBindedQueryArgs struct {
    Query string
    Args  []interface{}
}

func buildQueryWithArgsDefault(rawQuery string, args map[string]interface{}) (ReBindedQueryArgs, error) {
    namedQuery, namedArgs, err := sqlx.Named(rawQuery, args)
    if err != nil {
        return ReBindedQueryArgs{}, err
    }

    return ReBindedQueryArgs{Query: sqlx.Rebind(sqlx.QUESTION, namedQuery), Args: namedArgs}, nil
}

sqlx.Named(rawQuery, args)は、生クエリ(rawQuery)と引数(args)を受け取り、クエリ内の名前付きプレースホルダーを引数の値で置き換えます。置き換えられたクエリ(namedQuery)と引数(namedArgs)を返します。sqlx.Rebind(sqlx.QUESTION, namedQuery)を使用して、名前付きプレースホルダーを?に再バインドします。そして、再バインドされたクエリと引数を含むReBindedQueryArgsを返します。

以下はbuildQueryWithArgsDefaultを実行した結果になります。

sql := `
  SELECT
      name,item_code,price,jan_code,tax_rate
  FROM
      item
  WHERE
        price > :price
        AND
        jan_code = :jan_code`

args := map[string]interface{}{
    "jan_code": 12345,
    "price":    200,
}

queryArgs, _ := buildQueryWithArgsDefault(sql, args)
fmt.Println(queryArgs)
# 実行結果

{
SELECT
    name,item_code,price,jan_code,tax_rate
FROM
    item
WHERE
    price > ?
    AND
    jan_code = ? [200 12345]
}

名前付きプレースホルダー:price, :jan_code?に、引数が名前付きプレースホルダに対応した順序の slice になっていることがわかります。

sqlboiler を用いたクエリの実行関数

次に生クエリを実行し Go の構造体に bind する汎用的な関数について説明します。 以下が具体的な実装になります。

// repository/rawquery/util.go

func Execute[T any](ctx context.Context, exec boil.ContextExecutor, queryArgs ReBindedQueryArgs) (*T, error) {
    var result T
    if err := queries.Raw(queryArgs.Query, queryArgs.Args...).Bind(ctx, exec, &result); err != nil {
        return nil, err
    }

    return &result, nil
}

型引数Tには response に期待する構造体を指定します。
引数に指定されたReBindedQueryArgsQueryArgsを用いてqueries.Rawでクエリを生成、Bindresultにクエリの結果をバインドます。

実行方法

最後に repository 直下のファイルの実装について説明します。
以下のように実装することで生クエリを意識することなくデータベース操作を行えるようにすること、生クエリを廃止し sqlboiler での実装に統一した時の影響が最小限になるようにしています。

// repository/item.go

type ItemRepository struct{}

func NewItemRepository() *ItemRepository {
    return &ItemRepository{}
}

func (r *ItemRepository) ListItem(ctx context.Context, exec boil.ContextExecutor, name string, price int, janCode string, tax int) (*models.Items, error) {
    queryArgs, err := rawquery.NewItemBuilder(name, price, janCode, tax).BuildQueryWithArgs()
    if err != nil {
        return nil, fmt.Errorf("failed to build item query args: %w", err)
    }
    res, err := rawquery.Execute[models.Items](ctx, exec, queryArgs)
    if err != nil {
        return nil, fmt.Errorf("failed to get items: %w", err)
    }

    return res, nil
}

まとめ

この記事では、リプレイスプロジェクトにおけるデータベース操作の課題と、それに対する実装方針について詳しく紹介しました。理想的には、リプレイス前に既存コードにテストを追加し、最低限のリファクタリングを行うことが望ましいです。しかし、今回は迅速な移行と、Go への書き換え後にリファクタリングを進めるという方針のもと、生クエリをそのまま移行することにしました。

sqlboiler と sqlx という二つの異なる ORM を併用することには無理があるように思われるかもしれませんが、結果として責務が適切に分割され、より良いコードへと近づいたと感じています。

Go Conference 2024 まで、あと 3 日! gocon.jp

株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! gocon.jp

Go 言語の並行処理: ゴルーチンとチャネルの活用法について

はじめに

この記事はevery Tech Blog Advent Calendar 2024(夏) 4 日目の記事です。

こんにちは!トモニテで開発を行っている吉田です。

今回はGo 言語の特徴的な機能である並行処理について書いていきます。並行処理を支えるゴルーチン (goroutine) とチャネル (channel) の仕組みと使い方を、サンプルコードとともに紹介します。

並行処理を実現するにあたり

まずはゴルーチンとチャネルについて理解を進めます。

ゴルーチンとは

ゴルーチンとは 他のコードに対して並行に実行している関数のことです。

前提として全ての Go のプログラムには最低 1 つのゴルーチンがあります。それがメインゴルーチンです。

下記のように関数の前に go キーワードを追加することでゴルーチンを起動することができます。

func PrintStr(str string){
    fmt.Println(str)
}

go PrintStr("start goroutine!")

// 即時関数で実装することも可能
go func() {
    fmt.Println("start goroutine!")
}

チャネルとは

チャネルは、同時実行中のゴルーチンをつなぐパイプです。あるゴルーチンからチャネルに値を送り、その値を別のゴルーチンで受け取ることができます。

チャネルはデータを順序よく受け渡すためのデータ構造(queue)になっており、バッファを持つことができます。

また Go のチャネルはブロックをします。キャパシティがいっぱいのチャネルに書き込もうとするゴルーチンはチャネルに空きが出るまで待機し、空のチャネルから読み込もうとするチャネルは少なくとも要素が 1 つ入るまで待機します。

下記のように make 関数を使ってチャネルを初期化します。

   ch := make(chan interface{}, 100) // 第2引数でバッファを指定

バッファのあるチャネルがブロックするのは、バッファが一杯になったときだけでバッファに空きが出たら値を受け取ります。

バッファ付きチャネルが空で、それに対する読み込みチャネルにも空きがある場合にはバッファはバイパスされ送信元から受信先へと直接値を渡すことができます。

その他の特徴

  • チャネル利用時は値を chan 型の変数に渡しプログラムのどこかの場所でそのチャネルから読み込む
  • チャネル同士はお互いが何をしているのかは知らずチャネルが存在しているメモリの同じ場所を参照している

ex.)

package main

import "fmt"

func main() {
    send := make(chan string) // 双方向チャネルの初期化

    // データの送信
    go func() {
        send <- "hello!" // ゴルーチンでデータを送信
    }()

    receive := <-send // メインゴルーチンでデータを受信
    fmt.Println(receive) // "hello!" を出力
}

上記のように書くことでメインゴルーチンの処理とは別に並行で異なる処理を行うことができます。

ゴルーチンとチャネルを使うことで、複数のタスクを同時に実行することができますがどのような場面でその良さが出るのでしょうか。

ここでは運用しているサービスでユーザー全員にメッセージを送信する必要があるという場面を例にゴルーチンを使用した場合とそうでない場合の差を見てみます。

※それぞれ Go のバージョンは 1.22.3 で実施しています

ゴルーチンを使わない場合

package main

import (
    "fmt"
    "sync/atomic"
    "time"
)

type (
    MessageInfo struct {
        User    string
        Message string
    }
)

var messageCount int64

// GetUsers 対象ユーザーの抽出
func GetUsers() []string {
    var names []string
    for i := range 10000 {
        names = append(names, fmt.Sprintf("Mr. %d", i))
    }
    return names
}

// Setting ユーザーごとにメッセージ作成
func Setting() ([]MessageInfo, error) {
    users := GetUsers()
    target := make([]MessageInfo, 0)

    // ユーザーごとにメッセージを作成
    for _, user := range users {
        params := MessageInfo{
            User:    user,
            Message: fmt.Sprintf("Dear. %s. We are excited to announce that our supermarket, XX, has recently opened a new branch in YY!", user),
        }
        target = append(target, params)
    }
    return target, nil
}

// SendMessage メッセージを送信する
func SendMessage(param MessageInfo) {
    time.Sleep(10 * time.Millisecond) // 送信処理に時間がかかると仮定

    // 送ったメッセージ数をカウント
    // 複数のゴルーチンが同時にmessageCountを更新することによる競合を防ぐためatomicパッケージを使用
    atomic.AddInt64(&messageCount, 1)
}

// Send 全ユーザーに対してメッセージ送信
func Send(targets []MessageInfo) error {
    for _, target := range targets {
        SendMessage(target)
    }
    return nil
}

func main() {
    start := time.Now()
    targets, err := Setting()
    if err != nil {
        fmt.Println(err)
        return
    }
    Send(targets)
    fmt.Printf("No Goroutine method took %s\n", time.Since(start))
    fmt.Printf("Messages sent: %d\n", atomic.LoadInt64(&messageCount))
}

かかった時間

$ go run main.go
No Goroutine method took 1m49.024703667s
Messages sent: 10000

ゴルーチンを使う場合

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
    "time"
)

type MessageInfo struct {
    User    string
    Message string
}

var messageCount int64

// GetUsers 対象ユーザーの抽出
func GetUsers() []string {
    var names []string
    for i := range 10000 {
        names = append(names, fmt.Sprintf("Mr. %d", i))
    }
    return names
}

// Setting ユーザーごとにメッセージ作成
func Setting() (<-chan MessageInfo, error) {
    users := GetUsers()
    targets := make(chan MessageInfo, 100) // チャネルにバッファを設定
    go func() {
        defer close(targets)
        for _, user := range users {
            targets <- MessageInfo{
                User:    user,
                Message: fmt.Sprintf("Dear. %s. We are excited to announce that our supermarket, XX, has recently opened a new branch in YY!", user),
            }
        }
    }()
    return targets, nil
}

// SendMessage メッセージを送信する
func SendMessage(user, message string) {
    time.Sleep(10 * time.Millisecond) // 送信処理に時間がかかると仮定
    atomic.AddInt64(&messageCount, 1) // 送ったメッセージ数をカウント
}

// Send 全ユーザーに対してメッセージ送信
func Send(targets <-chan MessageInfo) error {
    var wg sync.WaitGroup
    for taraget := range targets { // 各メッセージ送信は独立したgoroutineで処理
        wg.Add(1)
        go func(taraget MessageInfo) {
            defer wg.Done()
            SendMessage(taraget.User, taraget.Message)
        }(taraget)
    }
    wg.Wait()
    return nil
}

func main() {
    start := time.Now()
    targets, err := Setting()
    if err != nil {
        fmt.Println(err)
        return
    }
    Send(targets)
    fmt.Printf("Goroutine method took %s\n", time.Since(start))
    fmt.Printf("Messages sent: %d\n", atomic.LoadInt64(&messageCount))
}

かかった時間

$ go run main.go
Goroutine method took 31.348791ms
Messages sent: 10000

並行処理を使わない場合は使う場合に比べ3倍ほどの時間がかかっており、使う場合と使わない場合の差を実感することができました。

続いては並行処理に用いた実装について説明します。

まずは対象者に向けてメッセージを作成する Setting メソッド内にある defer close(targets)についてです。

// Setting ユーザーごとにメッセージ作成
func Setting() (<-chan MessageInfo, error) {
    users := GetUsers()
    targets := make(chan MessageInfo, 100)
    go func() {
        defer close(targets)
        for _, user := range users {
            targets <- MessageInfo{
                User:    user,
                Message: fmt.Sprintf("Dear. %s. We are excited to announce that our supermarket, XX, has recently opened a new branch in YY!", user),
            }
        }
    }()
    return targets, nil
}

冒頭説明したようにgoキーワードでゴルーチンが作成できます。

その直後、 defer close(targets)があります。

これはチャネルが閉じてこれ以上値が送信されることがないことを伝えるために用いられます。今回の場合だとtargetsチャネルにこれ以上値が送信されないということを伝えています。

// Send 全ユーザーに対してメッセージ送信
func Send(targets <-chan MessageInfo) error {
    var wg sync.WaitGroup
    for target := range targets { // 各メッセージ送信は独立したgoroutineで処理
        wg.Add(1)
        go func(target MessageInfo) {
            defer wg.Done()
            SendMessage(target.User, target.Message)
        }(target)
    }
    wg.Wait()
    return nil
}

なぜチャネルに値が送信されないかを伝える必要があるのかについてですが、これはtargetsチャネルを利用している Send メソッド内のfor taraget := range targetstargetsチャネルが閉じられるまで別のチャネルから値を受信し続ける(ループが永遠に終わらない)ためです。

試しにdefer closeをコメントアウトして実行するとfatal error: all goroutines are asleep - deadlock!というエラーが発生しました。これはゴルーチンが値を待ち続けて処理をブロックしてしまうためデッドロックが発生していたということです。

続いては上記 Send メソッド内のsync.WaitGroupについてです。sync パッケージは同期的な処理によく用いられますがWaitGroupはゴルーチンを終了を待つために使っています。

そもそもどうしてゴルーチンの終了を待つ必要があるのでしょうか?答えはメインスレッドはゴルーチンの終了を待ってくれないからです。

WaitGroup をコメントアウトして試してみます。

// 変更がないところは省略します。

// Send 全ユーザーに対してメッセージ送信
func Send(targets <-chan MessageInfo) error {
    // var wg sync.WaitGroup
    for taraget := range targets { // 各メッセージ送信は独立したgoroutineで処理
        // wg.Add(1)
        go func(taraget MessageInfo) {
            // defer wg.Done()
            SendMessage(taraget.User, taraget.Message)
        }(taraget)
    }
    // wg.Wait()
    return nil
}

func main() {
    start := time.Now()
    targets, err := Setting()
    if err != nil {
        fmt.Println(err)
        return
    }
    Send(targets)
    fmt.Printf("Goroutine method took %s\n", time.Since(start))
    fmt.Printf("Messages sent: %d\n", atomic.LoadInt64(&messageCount))

}
$ go run main.go
Goroutine method took 19.215833ms
Messages sent: 3356

送りたい数は 10000 ですが 3356 しか実行されておらずsync.WaitGroupの必要性を確認することができました。

コード内wgが何をしているのか簡単に説明すると以下の通りです。

  • wg.Add(1) ... 待機したいゴルーチンの数(カウンタ)を設定。カウンタが 0 になると、後述 Wait でブロックされているすべてのゴルーチンが解放される。監視対象のゴルーチンの直前に書くのが慣習
  • wg.Done() ... カウンタを 1 減らす。defer キーワードを用いてゴルーチンのクロージャーが終了する前に WaitGroup に終了することを確実に伝えるために使用
  • wg.Wait() ... WaitGroup カウンターがゼロになるまでメインゴルーチンをブロックする

最後に

以上が Go における並行処理についてです。

ゴルーチンとチャネルを使うことで、複数のタスクを同時に実行することが可能になり、プログラムの効率を大幅に向上させることができます。

今回の記事を通じて、Go の並行処理についての理解が深まっていれば幸いです!

ここまでお読みいただきありがとうございました!

Go Conference 2024 まで、あと【4】日!

gocon.jp

株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう!

gocon.jp

参考

www.oreilly.co.jp

pkg.go.dev

gobyexample.com

www.spinute.org

go 言語で cobra と slog を使った CLI ツール開発

この記事は every Tech Blog Advent Calendar 2024(夏) 3 日目の記事です。

はじめに

こんにちは、トモニテでバックエンド周りの開発を行っている rymiyamoto です。 最近は学園アイドルのプロデューサー業に追われています。

今回は、Go 言語で CLI ツールを開発する際によく使われるライブラリである cobra と go1.21 から標準パッケージで使えるようになった slog を使って、CLI ツールを開発する方法について紹介します。

選定理由

現状の課題

Go 言語だとスクリプト処理を実装する際、簡単なものであれば main.go にそのまま処理を書いていくことが多いですが、コマンドライン引数を取るような処理を書く場合、コードが複雑になりがちです。 実際トモニテ内のスクリプト処理も当時の実装メンバーに依存しており以下のような課題がありました。

  • コマンドのフォーマット
    • サブコマンド指定だったり引数だったり設計者依存
    • hoge --param=1 or hoge -param 1
  • それぞれで無駄な共通引数定義
    • dry-run
  • ログの出力
    • 標準の logger だと使いにくい
    • logrus apex/logzap と割と自由にしがち
    • 同じような pkg が多いとメンテナンスも辛い

これらの課題から、コマンドライン引数を取る処理を簡単に実装できるパッケージとして cobra を、ログは go1.21 から標準パッケージで使えるようになった構造化ログが扱える slog を採用しました。

cobra について

cobra は Go 言語で CLI ツールを開発する際に歴史があり、Kubernetes、Hugo、GitHub CLI などの多くの Go プロジェクトで使用されています。 コマンドライン引数を取る処理を簡単に実装できるだけでなく、サブコマンドを定義することで複数のコマンドを持つ CLI ツールを簡単に作成することが可能です。 また、CLI ツールで cobra-cli が提供されており、コマンドからスクリプトファイルの作成ができます。

github.com github.com

slog について

go1.21 から導入された構造化ログを扱うことができる go の標準パッケージです。 構造化ログは JSON や key=value 形式でログを出力することができ、ログの解析や可視化が容易になります。 また、標準パッケージであるため、外部パッケージを追加することなく go の標準ライブラリでログを出力することができます。

pkg.go.dev

イメージ

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("hello", "count", 3)
{"time":"2024-06-03T15:28:26.000000000-05:00","level":"INFO","msg":"hello","count":3}

環境作成

以下のようなディレクトリ構成で CLI ツールを作成していきます。

$ tree
.
├── Dockerfile
├── Makefile
├── cobra.yml
└── compose.yml

事前準備

Dockerfile

cobra-cli を使いたいので、Go のイメージに cobra-cli をインストールします。

ARG GO_VERSION=1.22.3

FROM golang:${GO_VERSION} AS dev
RUN go install github.com/spf13/cobra-cli@v1.3.0

compose.yml

name: go-cli-management
services:
  scripts:
    container_name: scripts
    build:
      context: .
      dockerfile: ./Dockerfile
      target: dev
    working_dir: /scripts
    volumes:
      - .:/scripts
    tty: true

Makefile

cobra-cli を使ったコマンドやコマンドの実行をやりやすくするために作成しています。

container = scripts

.PHONY: dev
dev:
    docker compose up -d

.PHONY: init
init: dev
    docker compose exec $(container) go mod init $(name)
    docker compose exec $(container) cobra-cli init

.PHONY: add
add: dev
    @$(eval script_file := ${name}.go)
    @$(if $(name),, $(error name is not defined))
    @$(eval script_file_exists := $(shell ls . | grep ${script_file}))
    @$(if $(script_file_exists), $(error $(name) is already exists))
    docker compose exec $(container) cobra-cli add $(name) --config ./cobra.yml

.PHONY: run
run: dev
    docker compose exec $(container) go run ./main.go $(line)

cobra.yml

cobra-cli でコマンドを追加する際の設定ファイルです。 このファイルを編集することでコマンドの中身を拡張できます。

github.com

name: author_name
useViper: true

初期設定

以下のコマンドから gomod の初期化と cobra-cli の初期化を行います。

$ make init name=go-cli

実行後は以下のようなディレクトリ構成になります。

$ tree
.
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── cmd
│   └── root.go # cobra で生成されたファイルで、このファイルをベースにしてコマンドを追加していきます
├── cobra.yml
├── compose.yml
├── go.mod
├── go.sum
└── main.go # CLI ツールのエントリーポイント

拡張

現状 cmd/root.go に処理をベタ書きしていけばそのままコマンドとして実行できますが、それだと拡張性が失われてしまうのでサブコマンドやデフォルトフラグを追加して取り回しを良くしていきます。

デフォルトフラグの追加

cmd/root.go に初期値を追加します。

今回は並列処理の管理と dry-run モードを追加します。

const (
    // concurrencyDefault デフォルトの並列数
    concurrencyDefault = 10
    // waitTimeDefault デフォルトの処理チャンク単位の待機時間
    waitTimeDefault = 1
)

// ...

func init() {
    rootCmd.PersistentFlags().Bool("dry-run", false, "Dry run mode")
    rootCmd.PersistentFlags().Uint("concurrency", concurrencyDefault, "並列更新数(1以上)")
    rootCmd.PersistentFlags().Uint("wait-time", waitTimeDefault, "処理チャンク単位の待機時間(秒)")
}

サブコマンドの追加

cobra-cli を使ってサブコマンドを追加します。

$ make add name=hello

実行すると cmd 配下に hello.go が作成されます。(以下参照)

/*
Copyright © 2024 rymiyamoto

*/
package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

// helloCmd represents the hello command
var helloCmd = &cobra.Command{
    Use:   "hello",
    Short: "A brief description of your command",
    Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("hello called")
    },
}

func init() {
    rootCmd.AddCommand(helloCmd)

    // Here you will define your flags and configuration settings.

    // Cobra supports Persistent Flags which will work for this command
    // and all subcommands, e.g.:
    // helloCmd.PersistentFlags().String("foo", "", "A help for foo")

    // Cobra supports local flags which will only run when this command
    // is called directly, e.g.:
    // helloCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

この状態で make run line=hello を実行すると hello called が出力されます。

$ make run line=hello
hello called

フラグの追加

フラグの追加は作成された cmd/hello.go に cmd/root.go のときと同様に行います。

// ...

func init() {
    rootCmd.AddCommand(helloCmd)
    helloCmd.Flags().StringP("target-at", "t", time.Now().In(time.FixedZone("Asia/Tokyo", 9*60*60)).Format(time.DateOnly), "対象日(e.g 2023-10-05)")
}

処理の整形

Runメソッドではコマンドの実行時の処理を記述しますが、エラーを返すことができないため、エラーハンドリングが限定的です。これに対し、RunEメソッドを使用すると、エラーを呼び出し元に返すことができ、より柔軟なエラー処理が可能になります。

pkg.go.dev

また、slog を使用してデフォルト引数やフラグの値をログに埋め込むことで、実行時の状況を明確に記録できます。slog の JSON ハンドラを標準出力に設定することで、レイヤードアーキテクチャにおいても、中間層を介さずに直接ログを出力することが可能です。これにより、ログの伝播に関するコードの複雑さが軽減されます。

// ...

RunE: func(cmd *cobra.Command, args []string) error {
    // デフォルトフラグ
    dryRun, _ := rootCmd.Flags().GetBool("dry-run")
    concurrency, _ := rootCmd.Flags().GetUint("concurrency")
    waitTime, _ := rootCmd.Flags().GetUint("wait-time")

    // サブコマンド固有フラグ
    targetAt, _ := cmd.Flags().GetString("target-at")

    base := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{}))
    logger := base.With("dry-run", dryRun, "concurrency", concurrency, "wait-time", waitTime, "target-at", targetAt)
    slog.SetDefault(logger)

    slog.Info("hello world!")
    return nil
},

実行すると、以下のような構造化ログが出力されます。構造化ログは、ログデータをキーと値のペアで表現することで、自動化された解析や人間による読解を容易にします。これにより、ログの監視や分析が効率的に行えるようになります。

# サブコマンド実行
$ make run line="hello"
{"time":"2024-05-29T11:20:21.059692464Z","level":"INFO","msg":"hello world!","dry-run":false,"concurrency":10,"wait-time":1,"target-at":"2024-05-29"}

# デフォルトフラグの書き換え
$ make run line="hello --dry-run"
{"time":"2024-05-29T11:20:46.268378503Z","level":"INFO","msg":"hello world!","dry-run":true,"concurrency":10,"wait-time":1,"target-at":"2024-05-29"}

# サブコマンド固有フラグの書き換え
$ make run line="hello --target-at=2024-06-02"
{"time":"2024-05-29T11:21:23.834180257Z","level":"INFO","msg":"hello world!","dry-run":false,"concurrency":10,"wait-time":1,"target-at":"2024-06-02"}

あとはサブコマンドの中身を実装や追加をしていけば、CLI ツールの開発が進められます。

まとめ

今回は Go 言語で CLI ツールを開発する際によく使われるライブラリである cobra と go1.21 から標準パッケージで使えるようになった slog を使って、CLI ツールを開発する方法について紹介しました。 cobra はコマンドライン引数を取る処理を簡単に実装できるだけでなく、サブコマンドを定義することで複数のコマンドを持つ CLI ツールを簡単に作成することが可能です。 また slog を使うことで構造化ログを出力することができ、ログの解析や可視化が容易になります。

RunE の繰り返しは面倒な作業ですが、これを改善する方法を模索していく予定です。

今後は、このベースを使って実際の処理を実装していくことで、より実用的な CLI ツールを開発していきたいと思います。

Go Conference 2024 まで、あと 5 日! gocon.jp

株式会社エブリー は、Platinum Gold スポンサーとして Go Conference 2024 に参加します。 ぜひ、ブースやセッションでお会いしましょう! gocon.jp