Go slog の JSON Handler で入れ子の出力をする

slog の基本

Go1.21 から公式の標準ライブラリに構造化ログを出力可能な slog が追加されました。

呼び出し方

デフォルトで時刻やログレベルが付与されます。 比較のため、fmt でも出力します

fmt.Println("Hello, World!")
slog.Info("Hello, World!")
slog.InfoContext(ctx, "Hello, World!")

出力

Hello, World!
2024/04/13 13:30:48 INFO Hello, World!
2024/04/13 13:30:48 INFO Hello, World!

コード全文

package main

import (
    "context"
    "fmt"
    "log/slog"
)

func main() {
    fmt.Println("Hello, World!")
    slog.Info("Hello, World!")
    ctx := context.TODO()
    slog.InfoContext(ctx, "Hello, World!")
}

追加の情報を付与

key: value pair をログに付与することができます

slog.Info("Hello, World!", slog.Int("line", 9), slog.String("file", "main.go"))
slog.InfoContext(ctx, "Hello, World!", slog.Int("line", 11), slog.String("file", "main.go"))

出力

2024/04/13 13:39:06 INFO Hello, World! line=9 file=main.go
2024/04/13 13:39:06 INFO Hello, World! line=11 file=main.go

コード全体

package main

import (
    "context"
    "log/slog"
)

func main() {
    slog.Info("Hello, World!", slog.Int("line", 9), slog.String("file", "main.go"))
    ctx := context.TODO()
    slog.InfoContext(ctx, "Hello, World!", slog.Int("line", 11), slog.String("file", "main.go"))
}

JSON 形式で出力

出力の形式は handller で調整可能で、デフォルトでは TextHandler が使われます。

実用上便利なことが多いので、実際の開発では JSON Handler はよく使われます

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
    logger := slog.New(jsonHandler)
    ctx := context.TODO()

    logger.Info("Hello, World!", slog.Int("line", 15), slog.String("file", "main.go"))
    logger.InfoContext(ctx, "Hello, World!", slog.Int("line", 16), slog.String("file", "main.go"))
}

出力

{"time":"2024-04-13T13:53:07.414249+09:00","level":"INFO","msg":"Hello, World!","line":15,"file":"main.go"}
{"time":"2024-04-13T13:53:07.414599+09:00","level":"INFO","msg":"Hello, World!","line":16,"file":"main.go"}

コード全体

package main

import (
    "context"
    "os"

    "log/slog"
)

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, nil)
    logger := slog.New(jsonHandler)
    ctx := context.TODO()

    logger.Info("Hello, World!", slog.Int("line", 15), slog.String("file", "main.go"))
    logger.InfoContext(ctx, "Hello, World!", slog.Int("line", 16), slog.String("file", "main.go"))
}

余談ではありますが、比較すると、人間的には(個人的には) Text の方が読みやすいと思いました

# TextHandler
2024/04/13 13:39:06 INFO Hello, World! line=11 file=main.go
# JSONHandler
{"time":"2024-04-13T13:50:21.753606+09:00","level":"INFO","msg":"Hello, World!","line":15,"file":"main.go"}

slog の AddSource Option を探索

slog.NewJSONHandler の第2引数には Option を渡せます。 AddSource Option は Stacktrace まではいきませんが、コードの場所は教えてくれます。

...
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)
...

出力

{"time":"2024-04-13T13:56:09.692728+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":15},"msg":"Hello, World!"}

コード全体

package main

import (
    "os"

    "log/slog"
)

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)

    logger.Info("Hello, World!")
}

どのファイルのどの関数で、何行目か、出力されてます。 ちなみに、 build しても source map の情報は維持されているようです

% go build -o main
% ./main 
{"time":"2024-04-13T14:04:38.670391+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":15},"msg":"Hello, World!"}

入れ子で出力する

struct の場合

構造体を出力したい場合は、json タグをつければ出力されます

package main

import (
    "os"

    "log/slog"
)

type Address struct {
    City  string `json:"city"`
    State string `json:"state"`
}

type User struct {
    Name    string   `json:"name"`
    Age     int      `json:"age"`
    Address *Address `json:"address,omitempty"`
}

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)
    u := &User{
        Name: "Alice",
        Age:  25,
        Address: &Address{
            City:  "San Francisco",
            State: "CA",
        },
    }

    logger.Info("Hello, World!", "user", u)
}

出力

{"time":"2024-04-13T14:17:13.399365+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":34},"msg":"Hello, World!","user":{"name":"Alice","age":25,"address":{"city":"San Francisco","state":"CA"}}}

見づらいので整えると、以下のように出力が得られていることがわかります

{
  "time": "2024-04-13T14:17:13.399365+09:00",
  "level": "INFO",
  "source": {
    "function": "main.main",
    "file": "/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go",
    "line": 34
  },
  "msg": "Hello, World!",
  "user": {
    "name": "Alice",
    "age": 25,
    "address": { "city": "San Francisco", "state": "CA" }
  }
}

ちなみに json タグがない場合、struct のフィールド名が key として使われます

// タグをなくす
type Address struct {
    City  string
    State string
}


func main() {
...
    logger.Info("Hello, World!", "user", u)
}

出力

{"time":"2024-04-13T14:36:16.765509+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":34},"msg":"Hello, World!","user":{"name":"Alice","age":25,"address":{"City":"San Francisco","State":"CA"}}}

コード全体

package main

import (
    "os"

    "log/slog"
)

type Address struct {
    City  string
    State string
}

type User struct {
    Name    string   `json:"name"`
    Age     int      `json:"age"`
    Address *Address `json:"address,omitempty"`
}

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)
    u := &User{
        Name: "Alice",
        Age:  25,
        Address: &Address{
            City:  "San Francisco",
            State: "CA",
        },
    }

    logger.Info("Hello, World!", "user", u)
}

*"City", "State" と、先頭が大文字になっている(フィールド名が使われている)

group で出力する

同じ出力を slog.Group を使って再現できます。実際は struct なら group は使わず直接渡した方が楽です。

package main

import (
    "os"

    "log/slog"
)

func main() {
    jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    })
    logger := slog.New(jsonHandler)

    logger.Info(
        "Hello, World!",
        slog.Group(
            "user",
            slog.String("name", "Alice"),
            slog.Int("age", 25),
            slog.Group(
                "address",
                slog.String("city", "San Francisco"),
                slog.String("state", "CA"),
            ),
        ),
    )
}
{"time":"2024-04-13T14:26:48.645613+09:00","level":"INFO","source":{"function":"main.main","file":"/Users/atsuhiro.uchida/dev/playground/go-slog-playground/main.go","line":15},"msg":"Hello, World!","user":{"name":"Alice","age":25,"address":{"city":"San Francisco","state":"CA"}}}

余談

公式 GoDoc 曰く、

logger.LogAttrs(ctx, slog.LevelInfo, "hello", slog.Int("count", 3))

と呼ぶことが最も効率がいいようです。 LogLevel の指定は面倒なので、プロジェクトの中でこれで統一するなら、snippet を用意したいなと思いました。

OpenAI の API を叩く AI Agent を Python で作り、Go の開発を全部 AI にやってもらいたい!

こんにちは、a2 です。

Go のアプリケーションのコードをモリモリ生成してくれる AI Agent を作ることができないかなと思い、試してみてます。タイトルに書いたようなことはまだ全然できてないのですが、今回はその第一歩を書いてみます。

背景

今、開発現場における AI 活用は、GitHub Copilot を使って、エディターで開いているファイルを元に近しいコードを生成したり、関数のコメントから実装を生成してもらう、といった使い方が主流かと思います。 私も、Go の Table Driven Test のコードを書いてもらえるのがめちゃめちゃ助かってます。

しかし、これはあくまでソフトウェアエンジニアリングにおける汎用的な AI 活用方法に過ぎず、個別具体のケースでは最適化の余地が多くあると思っています。

例えば、 GitHub Copilot が生成したコードはコンパイルエラーを含んでいたり、正しく動作しないことが多々あります。 自分で修正することも可能ですが、生成されたコードとその問題点 (error message など) を ChatGPT や GitHub Copilot Chat に打ち込めば、コードをさらに修正した結果が得られます。 コンパイルエラーは、人が何かを判断する必要はありません。機械の側で認識できます。 であれば、コードを生成した後、コンパイルできるかをチェックして、エラーがあれば再度生成し直す、というところまで出来るはずです。 動作の検証だって、テストコードがあれば、コードを生成した後にテストを実行して、エラーがあったらさらに生成し直す、までできるはずです。

つまり、以下は当然のように自動で実行できるはずなのです。

  1. 要件を入力してコードを生成する
  2. コードを検証する(コンパイル、テスト実行、アプリケーション実行)
  3. 生成結果と検証結果を踏まえて再度生成する
  4. 2 と 3 をエラーがなくなるまで繰り返す

AIで置き換えたい実装フロー

とはいえ、 AI モデルの token の制限、実装の手間を考えると、現実はやってみたいと分かりません。 一般のソフトウェアエンジニアがどこまでできるのか、OpenAI の Chat API の function call を活用しながら実現可能性を探っていきます。

function call とは

ChatGPT の API には function call (今は tool call に変えようとしている?) なるものがあります。これは、関数の名前と処理内容をプロンプトと一緒に送ると、関数を呼ぶかどうか、どの関数をどんな引数で呼ぶとよいかを判断して結果を返してくれます。function call により、 Open AI の Chat API は単なるテキスト生成エンジンを超え、プログラムの実行や、より複雑なタスクを解決するためのオーケストレーションツールとして使うことができます。

autonomous agent について

OpenAI API の function call が代表的な実装方法ですが、与えられた課題に対して検索や生成などを自分で判断した上で実行し、タスクの完了を自動で目指す動きをするソフトウェアを、autonomous agent (自律的エージェント) と呼びます。

具体的なソフトウェアとしては AgentGPT が有名です。Web 上で簡単に動かせるようになっている ので、触ってみるとどんな感じかつかみやすいと思います

この定義を踏まえると今回は、自作 autonomous agent をやってみた、に近い記事になります。

やりたいこと

Agent に簡単な TODO アプリを作ってもらうことにします。

究極的には、「TODO アプリをGo で作って」と言ったらレポジトリがまるごと出来上がるような状態を作れないかな、と妄想しています。

もちろん現実には、詳細な機能要件・非機能要件を言語化して指定する必要があったりしますが、今回は自律的に開発を進めるためのおおまかな流れが実現できることをゴールに設定しました。

開発

生成したいコードは Go のコードですが、OpenAI の API に request する agent 自体は Python で書いていきます。なお、langchain は使っていませんが、使い方を学ぶのが面倒だったのと、他の言語で実装することを考えての判断です。

function の列挙

ファイルにコードを書く、 go run を実行する、など、 呼び出し可能な関数と、その説明を用意しておきます

definitions = [
  {
    "type": "function",
    "function": {
      "name": "write_to_file",
      "description": "Overwrite a whole file with given contents. Create the file if not exist. returns the whole file contents as a result of mutation",
      "parameters": {
        "type": "object",
        "properties": {
          "filename": {
            "type": "string",
            "description": "filename to write"
          },
          "content": {
            "type": "string",
            "description": "content to write"
          }
        }
      },
    }
  },
...
  {
    "type": "function",
    "function": {
      "name": "go_run",
      "description": "Execute go run {filename}",
      "parameters": {
        "type": "object",
        "properties": {
          "filename": {
            "type": "string",
            "description": "filename to write"
          },
        },
      },
    },
  },
...
]

OpenAI の Chat API は、この json の description を見て関数の内容を把握し、必要に応じて「この関数をこの引数で呼び出せ」という response を返してきます。

用意した関数をいくつか抜粋すると、

  • ファイルの新規作成+書き込み(べき等なファイル書き込み)
  • ファイルへの追記
  • ファイルの読み込み
  • go run
  • go test
  • 任意のコマンド実行

です。"任意のコマンド実行" はさすがに怖いので、実行前に確認を強制しています。

function の実装

呼び出される関数も実装しておきます。

def write_to_file(filename: str, content: str) -> str:
  with open(filename, 'w') as f:
    f.write(content)
  return f"""finished. the contents of the file:
{read_file(filename)}"""  # read_file はファイルの中身を読む helper です。


def go_run(filename):
  if not filename.endswith(".go"):
    return "file is not go file"
  command = f"go run {filename}"
  return _run(command) # _run は コマンドを実行する helper です。

write_to_file は実行結果としてファイルの中身を返していますが、token を消費するので、なくても良いかもしれません。

function の呼び出し OpenAI の Chat API の response には呼び出すべき function と その 引数が含まれるので、対応する関数を以下のように呼び出します。

      getattr(gpt_functions, function_to_call.name)(**arguments)

(普段 Go を触っているので、メタプログラミング的な関数実行にむずがゆさがありますが、とても便利です。)

最初の指示

一番最初に与えるプロンプトは適当に考えました。より良い結果を得たい時、改良の余地が一番大きそうです。

You need to create a todo app in Go.

You need to develop this app in test driven development.

after you generate code, you write this to a file and run it.

if any error or bug is found, you need to fix it.

if you have question to me, you can ask me.

「TODO アプリを Test Driven に開発してね」ということを雑に伝えています。後述しますが、通常はここで簡単に要件を伝えるのが良さそうです。

メインロジックの実装

API 呼び出しのロジック部分は、細部を端折ってますが、以下のようになります。

import os
import json

import openai
import gpt_functions

openai.api_key = os.getenv("OPENAI_API_KEY")

client = openai.OpenAI()

first_prompt = ...

def run_app():
  message = {
    "role": "user",
    "content": first_prompt
  }

  run_conversation([message])

def run_conversation(history):
  response = client.chat.completions.create(
    messages=history,
    model="gpt-3.5-turbo-0125",
    tools=gpt_functions.definitions,
    tool_choice="auto",
  )

  assistant_message = response.choices[0].message
  history.append(assistant_message)

  if assistant_message.tool_calls and len(assistant_message.tool_calls) > 0:
    for tool_call in assistant_message.tool_calls:
      function_to_call = tool_call.function

      try:
        args = json.loads(function_to_call.arguments)
      except:
        print("ERROR: could not parse json")
        return 

      function_response = getattr(gpt_functions, function_to_call.name)(**args)

      next_message = {
        "role": "tool",
        "content": function_response,
        "tool_call_id": tool_call.id,
      }
      history.append(next_message)
    run_conversation(history)
  else: # 通常の Chat 

最初の prompt で「TODO アプリを作ってください」 と指定して、function を列挙した object (gpt_functions.definitions) と共に Chat API に request します。

response に tool_call (function call の新しい呼び名) があれば、対応する function を呼び出し、結果を history に追加しながら再帰的に Chat API に request します。 tool_call は複数帰ってくることもある(例えばファイルへの書き込みをした上で、 go run . を実行する、など)ので、loop を回すようにしています。

なお、実際に書いたコードでは、過程を追うためにログをたくさん仕込んだり、暴走を防ぐために各所で実行を継続してよいかの確認をするようにしています。

下手をすると無限にループして API 利用料がかかってしまうので注意してください

実行結果

関数実行

実際に動かすと、

  1. go mod init todo の実行
  2. go get github.com/stretchr/testify の実行
  3. todo_test.go の生成
  4. go format の実行
  5. go run の実行
  6. (chat) セットアップ終わったけど次はどうする?と聞く(←人(私)が test だけじゃなくてメインのロジックも書いて、と回答)
  7. todo_app.go の生成 (実装はコメントのみで、何もかかれていない状態)
  8. go format の実行
  9. go test の実行
  10. (chat) todo_app.go ができたよ。まだ何か必要だった教えて、と聞く(←人(私)がこのアプリは todo-item の追加と削除ができる必要があります、と要件を追加)
  11. go run の実行
  12. todo_test.go の生成
  13. todo_app.go を生成

といった操作になりました。(chat) という書いている部分は function call ではなく、chat として応答が帰って来たステップです。

どんなライブラリを使うか、どんなファイル名でファイルを作るかなどを判断して自動で動いていることがわかります。 生成されるコードの中身がコメントになったりするのは、要件を精緻に伝えれば改善されるかもしれませんが、そこまでやるなら自分で書いたほうが早いともなるので、難しいところです。

最初のプロンプトで TODO アプリの要件を一切指定していなかった(雰囲気で作ってくれると期待していた)ので、最初に生成されたアプリは中身がない空っぽのコードでした。 中身のないアプリを実装してから「できたよ」と言ってきたのは残念でしたが、要件を伝えていなかった側が悪いです。

生成されたコード 少し長いですが、以下が生成された todo_app.go です。

package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {

    // Create an empty slice to store todo items
    var todoItems []string

    for {
        // Display the menu options to the user
        fmt.Println("Choose an action:")
        fmt.Println("1. Add todo item")
        fmt.Println("2. Delete todo item")

        // Read user input for the action
        fmt.Print("Enter your choice: ")
        reader := bufio.NewReader(os.Stdin)
        choice, _ := reader.ReadString('\n')

        // Remove newline character from the choice
        choice = choice[:len(choice)-1]

        // Perform the selected action
        switch choice {
        case "1":
            // Add todo item
            fmt.Print("Enter the title of the todo item: ")
            title, _ := reader.ReadString('\n')
            title = title[:len(title)-1]
            todoItems = append(todoItems, title)
        case "2":
            // Delete todo item
            if len(todoItems) == 0 {
                fmt.Println("No todo items to delete")
            } else {
                fmt.Println("Existing todo items:", todoItems)
                fmt.Print("Enter the index of the todo item to delete: ")
                var index int
                fmt.Scanf("%d", &index)
                if index >= 0 && index < len(todoItems) {
                    todoItems = append(todoItems[:index], todoItems[index+1:]...)
                } else {
                    fmt.Println("Invalid index")
                }
            }
        default:
            fmt.Println("Invalid choice. Please try again.")
        }
    }
}

todo item の List を要件で伝え損ねたので、add/delete のみしかできませんが、最低限の例外ケース含めて、うまくできています。

実行してみると、以下のように、動いています。

$ go run todo_app.go 
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: clean the room
Invalid choice. Please try again.
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 1
Enter the title of the todo item: clean the room
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 1  
Enter the title of the todo item: buy food
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 2
Existing todo items: [clean the room buy food]
Enter the index of the todo item to delete: 2
Invalid index
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 2
Existing todo items: [clean the room buy food]
Enter the index of the todo item to delete: 1
Choose an action:
1. Add todo item
2. Delete todo item
Enter your choice: 2
Existing todo items: [clean the room]
Enter the index of the todo item to delete: 

後から add/delete を実装するという要件を伝えた後、 todo_test.go を生成してくれましたが、中身は空っぽで放置されていました。

Test Driven の説明をする際に、"一度テストを失敗させる" ステップを伝えていなかったので、test が成功 = OK と判断した可能性は高いです。

package todo

import (
    "testing"
)

func TestAddTask(t *testing.T) {
    // implement the test
}

func TestCompleteTask(t *testing.T) {
    // implement the test
}

その後

上記のように、最初にダーッと作ったものでもそれなりに動くものが作れたので、「改善すればもっとよくなるのでは??」と期待が高まりました。 しかし、ls cat コマンド相当の function を用意したり、go のコマンドを追加したり、選択肢を増やし、最初の prompt にアプリの要件を追加したりと色々追加しても、最初より良い結果が得られませんでした。

具体的には、 go test を実行してエラーを見つけるところまではいいのですが、その修正として生成されたコードが修正前と差分がなく、無限ループしてしまったり、実装をコメントで // implement here で済ませてしまったり。モデルが扱えるコンテキストの大きさの限界であれば、最初に全て伝えるのではなく、順次実装を進めてもらう形が良いかもしれません。

他にもまだまだ工夫の余地はたくさんあり、

  • より大きいモデルを使ってみる
  • 読み込ませるファイルとコード量を制限・選択する
  • プロンプトを改善する
  • 全体の設計を調整する agent とコードを実装する agent など、複数 agent を用意する

あたりは試してみたいなと思います。

他に気になったこととして、そもそも変更の差分を示す git の commit log が gpt モデルの学習に使われているのか気になりました。もし学習されていれば、コードの状態の遷移を生成する期待ができますが、コードを snapshot として学習していた場合、完成形の状態を生成するのには向いていても、既にあるコードを改修していく方向には活かしにくいかも、、などと考えていました。

先行研究もいくつかあるので、次は論文も漁ってみたいと思います、詳しい方がいらっしゃったら要チェックの論文を教えてください。

Postmanを使用したブラウザCookieの同期と効率化

はじめに

Web 開発において、ブラウザCookieの効率的な使用は、APIテストの精度を向上させる上で重要です。

Postman では Cookie を手動で入力することも可能ですが、Developer tools から cookie をみてコピー&ペーストする作業を繰り返し行うのは無駄が大きいです。

この記事では、Postmanを使用してブラウザCookieを同期し、APIテストと開発プロセスを効率化させる方法に焦点を当てます。

手順

手順は全て公式サイトに記載がありますので、ここでは概要を記します。

Chrome拡張機能のインストール

必要な拡張機能をインストールしてください

Postman Interceptor - Chrome ウェブストア

Cookieの同期

Chrome拡張機能とPostman間でCookieを同期します。 インストールした拡張機能を有効化し、アイコンをクリックし、"Sync Cookies" のタブに切り替えます。

Postman 拡張機能の window

取得したい Cookie の domain を入力して、 "Sync Cookies" を押します。

すぐに "Stop Cookies" を押しても、Cookie の同期自体は完了しています。

Postman の画面の右上

Postman の画面の右上に "cookies" とあるので、開きます

cookies を開いた時の画面

同期したい cookie の key をクリックすると、"save" すれば Postman の request にも反映されます。

はまったところ

ドメイン指定で、 localhost:5173 を指定したため、cookie が sync されなくてしばらく困りました。 正しくは localhost を使用する必要があります。ドメインは port 番号を含みません。

セキュリティに関する考慮事項

今回見たように、拡張機能cookie へのアクセスが可能です。

不要な時は拡張機能をオフにしておくと良いでしょう。

また、日常的に、信頼できる拡張機能の選択が重要です。開発者の信頼性、レビュー、アクセス許可を確認してください。

ogen の convenient error の用途と使い方をメモ

ogen を使うとログに出力される INFO がある

(ogen とは、OpenAPI の Go サーバーコード生成ツールの一つ。ogen については他の記事を参照のこと。)

ogen を使い始めるとすぐ、コード生成コマンドで以下の info がログに出ることに気づく

INFO convenient Convenient errors are not available {"reason": "operation has no \"default\" response", "at": "file:///h/o/g/e/hoge.yaml:5:5"}

Convenient Error が使えなかった、とのこと。

Convenient Error が何かは知らないが、 Convenient な Error なのだから、API Schema 全体で共通して定義できるエラーなど、なんらかコードを書く量を減らせる類の機能だと予想される。

実際どうか、調べてみた。

公式 document をみてみる

公式 document

ogen は document が充実しているとはいえない(2023/12/17 時点)のが難点だが、独自の概念であるからか Convenient Errors については記載がある

以下の 2 条件を満たしたときに使える機能らしい

  • すべての operation で同じデフォルトのレスポンスが定義されている
  • このレスポンスはapplication/jsonメディアのみを定義している

まぁまぁ厳しい条件な気もするが、そもそも endpoint によらないエラーを定義する機能なんだと考えれば納得はいく

どんな目的で使うか

ogen では interface を生成するが、その実装時、事前定義されているエラーについては一つ目の返り値で返すことが想定されている。 API Schema に 404 が定義されていれば、 一つ目の返り値で 404 に相当する構造体を返す。(これはこれで面白いが、割愛)

二つ目の返り値は error interface を満たす値だが、これはアプリケーションの想定外の場合に返すイメージ。

Convenient Error を使わない通常の生成の場合、二つ目の返り値があると、500 が返され、err.Error の中身が message として返る。

Convenient Error は、二つ目の返り値があるときの挙動に共通してロジックを適用できる機能だ。 具体的な用途でパッと思い作るのは、

  • エラーの種類によって通知を分ける実装を挟みたい
  • エラーメッセージの中身をクライアントに見せたくない
  • その他エラーに関する独自ロジックを挟みたい

といったところだろうか。

これらは interceptor のような middleware で実装するイメージだが、ogen では middleware はexperimental だし、ogen が生成するコードが内部で response を書き込んでいるので、ogen の外側の middleware ではresponse の中身を調整する類の実装は難しそうだ。

実装

公式 doc にあるように、API スキーマに記述すべきことは以下だ。

  • components/schema に エラー型を定義
  • 全ての response に default response を追記し、同じ記述をする

コード生成後は Handler で error interface を満たすように実装すればよい。

例えばアプリケーション内部で発生するエラーを、サーバー起因のエラーとユーザー起因のエラーのどちらかに分類し、それをエラーメッセージに含めているとすれば、公式サイトの例について、以下のように実装できる。

type TrivialHandler struct{}

func (TrivialHandler) MeGet(ctx context.Context) (r *api.User, _ error) {
    me, err := doSomething()
    if err != nil {
        return nil, err
    }

    return &api.User{ID: me.ID}, nil
}

func (TrivialHandler) UsersPost(ctx context.Context, req *api.User) error {
    // implement
    return ht.ErrNotImplemented
}

func (TrivialHandler) NewError(ctx context.Context, err error) (r *api.ErrorStatusCode) {
    r = new(api.ErrorStatusCode)
    if strings.Contains(err.Error(), "[UserError]") {
        r = new(api.ErrorStatusCode)
        r.StatusCode = 400
        r.Response = api.Error{
            Code:    113355,
            Message: err.Error(),
        }
        return r
    }
    if strings.Contains(err.Error(), "[ServerError]") {
        fmt.Println("sending error to sentry...")
        // TODO: send error to sentry

        r = new(api.ErrorStatusCode)
        r.StatusCode = 500
        r.Response = api.Error{
            Code:    555000,
            Message: "please try again later",
        }
        return r
    }
    r.StatusCode = 500
    r.Response = api.Error{
        Code:    555000,
        Message: "unknown error",
    }
    return r
}

実際はカスタムエラーなどを定義して、型判定等をするのが良いと思う。

まとめ

ogen 独自の拡張として Convenient Error というものがあり、想定外の error をどう処理するか、API サーバー全体を横断して定義できる。

おまけの一言

ogen の開発が続いていくか心配です

クリーンアーキテクチャ で New して Execute をする理由の具体例を考えた

クリーンアーキテクチャ再学習

仕事で一からプロダクトを実装することになったので、この機会にクリーンアーキテクチャについてもう一度深く学んでみることにしました。

クリーンアーキテクチャは具体的な実装を読むと、「本当にこの複雑さは必要なのか?」と疑問を抱くことがしばしばあります。

そこで、具体的なコードを例に、クリーンアーキテクチャを使わない場合に生じる問題点を探ってみることにしました。

この記事は、クリーンアーキテクチャについてある程度知っているけれど、実際には手を動かして実装したことがない方々に向けて書きます。

クリーンアーキテクチャの基礎には触れず、実装における具体的な疑問点とその解決策に焦点を当てています。

クリーンアーキテクチャと Easy な実装の比較

まず、よくある Usecase/Interactor の実装を見てみます。ユーザー作成ロジックを例に挙げます。

type CreateUserUsecaseInput struct {
    name string
    email string
}

type CreateUserUsecaseOutput struct {}

type CreateUserUsecase interface {
    Execute(ctx context.Context, input *CreateUserUsecaseInput) (*CreateUserUsecaseOutput, error)
}

type CreateUserInteractor struct {}

func NewCreateUserInteractor() *CreateUserInteractor {
    return &CreateUserInteractor{}
}

func (interactor *CreateUserInteractor) Execute(ctx context.Context, input *CreateUserUsecaseInput) (*CreateUserUsecaseOutput, error) {
    return nil, nil
}

DBアクセスの struct や実際のロジックは書いていないですが、既にコード量が多いです。

これを見ると、「本当にこの複雑さは必要なのか?」と思ってしまいます。("複雑" はコード量から受ける印象にすぎないので、細かいことは無視してください)

これを、以下のように実装すると何がいけないのでしょうか

func CreateUser(ctx context.Context, name string, email string) error {
    return nil, nil
}

クリーンアーキテクチャに言わせれば、テストの難しさ、コードの拡張性の欠如、保守の複雑化などがあるそうです。

問題点の深掘り

正直、現状の単純な例だと、そんなに問題はないように見えます。

入力のformatなどを validate して、DBに保存して、unique制約にかかればエラーを返す、くらいならこれで十分です。

では、例えば email validation のロジックが非常に細かくなったとします。すると、

func CreateTmpUser(ctx context.Context, name string, email string) error {
    // メールフォーマットのチェック
    if !isValidEmailFormat(email) {
        return errors.New("無効なメールフォーマット")
    }

    // 特定のドメインのみを許可
    if !isAllowedDomain(email) {
        return errors.New("許可されていないドメイン")
    }

    // メールアドレスの再利用チェック
    if isRecentlyUsedEmail(ctx, email) {
        return errors.New("メールアドレスは最近使用されています")
    }

    // ユーザー作成のロジック
    // ...
    
    return nil
}

これは CreateTmpUser の責務を超えるので、email validation logic を切り出したくなります

func validateEmail(email string) error {
    if !isValidEmailFormat(email) {
        return errors.New("無効なメールフォーマット")
    }
    if !isAllowedDomain(email) {
        return errors.New("許可されていないドメイン")
    }
    return nil
}

func CreateTmpUser(ctx context.Context, name string, email string) error {
    if err := validateEmail(email); err != nil {
        return err
    }

    // ユーザー作成のロジック
    // ...
    
    return nil
}

しかし、このアプローチでもまだ問題が残ります。

単一責任の原則に反すると言って仕舞えば簡単ですが、この記事は具体的な話をします。

例えばこれまで許可されていなかったドメインを許可するようになったとします。 validateEmail のロジックに変更を加えます。validateEmail のテストも修正する必要があります。

ここで、もし影響があるデータをCreateTmpUser のテストデータとして使っていると、CreateTmpUser のテストデータも修正する必要が生まれます。

これを避けたいので、email validation は別の service として実装して、DI したくなります

type EmailValidator interface {
    Validate(email string) error
}

type EmailValidationService struct {}

func (s *EmailValidationService) Validate(email string) error {
    // メール検証ロジック
    // ...
    return nil
}

func CreateUser(ctx context.Context, name string, email string, emailValidator EmailValidator) error {
    if err := emailValidator.Validate(email); err != nil {
        return err
    }
    return nil, nil
}

これで emailValidator が true/false はtest の中で宣言的にコントロールできるようになりました。

emailValidator のロジックの変更がCreateUser のテストに影響を及ぼす可能性は低くなりました。(もちろん、emailValidator で弾かれるべきデータが弾かれずに DB でエラーになるケースなどもあり得るので、可能性はゼロではないです。)

さて、CreateUser 関数には既に怪しさがあります。nameValidator が必要になったら、それも引数に追加することになります。そもそも validator が引数にあるのはおかしいです

CreateUser の引数に渡す前に validate しておけば良いのでは、と思いますか?私は思いました。 しかし、それだと結局 CreateUser の呼び出し側で validator と CreateUser を繋げた時の挙動のテストが必要になります。 下手をすると layer が増えてしまいます。

ということで、結局 New して Execute するような感じになっていきます

type UserCreator struct {
    emailValidator EmailValidator
}

func NewUserCreator(validator EmailValidator) *UserCreator {
    return &UserCreator{
        emailValidator: validator,
    }
}

func (uc *UserCreator) CreateTmpUser(ctx context.Context, name string, email string) error {
    if err := uc.emailValidator.Validate(email); err != nil {
        return err
    }

    // ユーザー作成のロジック
    // ...
    
    return nil
}

また、引数に値を追加したい時も同様の思考をすると、Input struct を作りたくなります(例示は十分ですかね)

締め

具体例を書くと長くなってしまうので、書くのも読むのも大変ですね

なかなかこれだというリソースが見つからないのはその辺でSEO に勝てないからなのかもしれません。

Go の project layout のやつ

背景

Go で project を作る時に directory 構造をどうするかは議論がある。

公式と非公式の間みたいな資料 (project layout )がずっと参照されていたが、ついに公式からある程度の指針が示された。

Organizing a Go module - The Go Programming Language

短い内容なので、私なりのメモを残す。

内容

package

go で package を作るなら - github.com/someuser/modname という名前にする。 - modname.gopackage modname で書き始める。

すると利用側は

import "github.com/someuser/modname"

と書ける。

Command Line Tool

CLI ツールなどを作る場合は普通に main.go を作り、同様にmodule名を設定する。

利用側は

go install github.com/someuser/modname@latest

と書ける。

大きな Package や依存 package を作る場合

internal/ directory を作って package を区切る。

modname.go で露出していない内部 API は refactor や変更をしやすくなる。

複数 package あっても同様に複数のdirectory を切ればいい

Package と Command が両方入る場合

project-root-directory/
  go.mod
  modname.go
  modname_test.go
  auth/
    auth.go
    auth_test.go
  internal/
    ... internal packages
  cmd/
    prog1/
      main.go
    prog2/
      main.go

auth, internal は import して使われつつ、 progX は go install できる。

Server がある場合

project-root-directory/
  go.mod
  internal/
    auth/
      ...
    metrics/
      ...
    model/
      ...
  cmd/
    api-server/
      main.go
    metrics-analyzer/
      main.go
    ...
  ... the project's other directories with non-Go code

サーバーとして使う場合、export することはあまりないので、internal に logic を全て実装する。

Go 以外のファイルが必要になることも多々あるので、internal 以外のgo ファイルも cmd/ に全て入れる。

感想

ちょうど CobraCLI ツールを作ろうとしていたが、 main.go と同じ階層に cmd/ が自動で作られる点で、この推奨パターンとは少し異なっているかもしれない。

Server を開発する場合に go ファイルをできるだけroot に置かないようにする、というのは次回から取り入れたい。 たしかにroot はよく散らかってしまう。

短かったのでサクッと読めてよかった。

vscode で Go を開発していて、ファイル保存すると import されるパッケージが想定と違う時

import される package をカスタマイズする方法はわかりませんが、正しい package を go get してあるのなら、古いキャッシュが影響している可能性があるので、

go clean --modcache

とすると、上手く行くかもしれません。

私の場合は、

pgx.New....

と書いていて、ファイルを保存すると import に

import (
    "github.com/jackc/pgx/v4"
)

が補完されていたのですが、実際使っているのはv5 なので、毎回書き換えなくてはならず困っていました。

上記コマンドを実行したら無事に v5 が import されるようになりました。