GoのAPI開発がさらに進化!OpenAPI層「huma」の実践的な魅力(第二弾)
「humaの基本的な使い方は分かったけど、実際のプロダクトで使うにはどうなの?」
前回の記事では、GinやChiに乗せるOpenAPI層として、humaの基本的な立ち位置やgin-swaggerとの違い、そしてベンチマーク比較をご紹介しました。
前回は「既存のルーターに被せるだけで、構造体からOpenAPIドキュメントが自動生成される」という基本的な使い方を解説しました。しかし、humaの魅力はドキュメント生成だけではありません。
実際のAPI開発で必ず直面する「複雑なバリデーション」「統一されたエラーハンドリング」「ミドルウェアとの連携」、そして「テストの書きやすさ」において、humaは非常に強力な機能を提供してくれます。
今回は、humaを実戦投入する上で欠かせない、よりディープで実践的な機能たちを、「素のGin/Chiで書いた場合」との比較 を交えながらたっぷりとお届けします!
1. 複雑なバリデーションをスッキリ書ける「Resolvers」
前回、構造体タグ(maxLengthやpatternなど)を使って基本的なバリデーションを自動で行う方法を紹介しました。しかし、実際の開発では「DBに問い合わせて存在チェックをしたい」「複数のフィールドを組み合わせた相関チェックをしたい」といったケースが必ず発生します。
素のGin/Chiのアプローチ(ハンドラー内でのバリデーション)
通常、GinやChiで複雑なバリデーションを行う場合、ハンドラー(コントローラー)の中にロジックを書くことになります。
// Ginでの例
func CreateUser(c *gin.Context) {
var input CreateUserInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// ハンドラー内にバリデーションロジックが混在してしまう
if strings.HasSuffix(input.Email, "@example.com") {
c.JSON(422, gin.H{"error": "example.comドメインは使用できません"})
return
}
// 名前のフォーマット変換
input.Name = strings.Title(input.Name)
// 本来のビジネスロジック...
}
これでは、ハンドラーが肥大化し、本来のビジネスロジックが見えにくくなってしまいます。
huma のアプローチ(Resolvers)
humaでは、Resolvers(リゾルバー) という仕組みを使います。リクエストの構造体に Resolve メソッドを実装するだけで、humaが自動的にそれを呼び出し、カスタムバリデーションやデータの変換を行ってくれます。
// 入力モデル
type CreateUserInput struct {
Name string `json:"name" maxLength:"30"`
Email string `json:"email" format:"email"`
}
// Resolveメソッドを実装してカスタムバリデーションを追加
func (i *CreateUserInput) Resolve(ctx huma.Context) []error {
// 名前のフォーマット変換(例:先頭大文字)
i.Name = strings.Title(i.Name)
// カスタムバリデーション(例:特定のドメインを弾く)
if strings.HasSuffix(i.Email, "@example.com") {
return []error{&huma.ErrorDetail{
Message: "example.comドメインは使用できません",
Location: "body.email",
Value: i.Email,
}}
}
return nil
}
// インターフェース実装チェック(コンパイル時に検証)
var _ huma.Resolver = (*CreateUserInput)(nil)
// ハンドラーはビジネスロジックに集中できる!
huma.Post(api, "/users", func(ctx context.Context, input *CreateUserInput) (*UserOutput, error) {
// ここに到達した時点で、バリデーションと変換は全て完了している
createUser(input.Name, input.Email)
return &UserOutput{}, nil
})
このアプローチの素晴らしいところは、ハンドラーの中にバリデーションロジックが一切散らからない ことです。ハンドラーにリクエストが到達した時点では、すでに全てのバリデーションと変換が完了していることが保証されます。
2. RFC 9457準拠の美しいエラーハンドリング
APIを開発する際、エラーレスポンスのフォーマットをどうするかは悩みの種です。
素のGin/Chiのアプローチ(独自フォーマット)
多くの場合、プロジェクトごとに独自のエラーJSONフォーマットを定義し、それを返すヘルパー関数を作ることになります。
// Ginでの例
func GetUser(c *gin.Context) {
user, err := findUser(c.Param("id"))
if err != nil {
// 独自フォーマットでエラーを返す
c.JSON(404, gin.H{
"code": "USER_NOT_FOUND",
"message": "ユーザーが見つかりません",
})
return
}
c.JSON(200, user)
}
これでも動きますが、API全体でフォーマットを統一する手間がかかり、クライアント側もAPIごとにエラーのパース処理を書く必要があります。
huma のアプローチ(RFC 9457準拠)
humaはデフォルトで RFC 9457 (Problem Details for HTTP APIs) に準拠したエラーレスポンスを返してくれます。これはHTTP APIのエラー表現の標準仕様です。
ハンドラー内でエラーを返す場合も、humaが用意しているユーティリティ関数を使うだけで、適切なステータスコードとフォーマットでレスポンスが生成されます。
huma.Get(api, "/users/{id}", func(ctx context.Context, input *UserInput) (*UserOutput, error) {
user, err := findUser(input.ID)
if err != nil {
// 404 Not Foundエラーを返す
return nil, huma.Error404NotFound("ユーザーが見つかりません", err)
}
// ...
})
このコードが返すエラーレスポンスは、以下のような美しいJSONになります。
{
"$schema": "https://api.example.com/schemas/ErrorModel.json",
"status": 404,
"title": "Not Found",
"detail": "ユーザーが見つかりません"
}
バリデーションエラーの場合は、さらに errors 配列に詳細な情報(どのフィールドが、どういう理由でエラーになったか)が自動的に追加されます。もちろん、独自のカスタムエラーモデルを定義して、組織の標準フォーマットに合わせることも可能です。
3. ミドルウェアとグループ機能の強力な連携
前回はChiルーターにhumaを被せる例を紹介しましたが、humaは「ルーター非依存」であるため、GinやChiの既存ミドルウェアをそのまま使うことができます。それに加えて、huma独自の「ルーター非依存ミドルウェア」も定義可能です。
素のGin/Chiのアプローチ(ルーター依存のグループ化)
GinやChiでもグループ化やミドルウェアの適用は簡単です。
// Ginでの例
v1 := engine.Group("/api/v1")
v1.Use(AuthMiddleware())
{
v1.GET("/users", getUsers)
}
しかし、これだけでは「OpenAPIドキュメント上のタグ(グループ分け)」までは連動しません。ドキュメント上でもグループ化するには、gin-swagger のコメントを各ハンドラーに手書きする必要があります。
huma のアプローチ(Groups機能)
humaの Groups(グループ) 機能を使えば、特定のパスプレフィックスを持つAPI群に対して、まとめてミドルウェアを適用したり、OpenAPIのタグを設定したりできます。
// /api/v1 プレフィックスを持つグループを作成
v1 := huma.NewGroup(api, "/api/v1")
// グループ全体に認証ミドルウェアを適用
v1.UseMiddleware(func(ctx huma.Context, next func(huma.Context)) {
token := ctx.Header("Authorization")
if token == "" {
huma.WriteErr(api, ctx, http.StatusUnauthorized, "認証が必要です")
return
}
// 認証成功なら次の処理へ
next(ctx)
})
// グループ全体にOpenAPIのタグを設定(ドキュメントに反映される!)
v1.UseSimpleModifier(func(op *huma.Operation) {
op.Tags = []string{"V1 API"}
})
// グループにエンドポイントを登録
huma.Get(v1, "/users", getUsersHandler)
これにより、APIのバージョン管理や、認証が必要なエンドポイントの切り分けが非常にクリーンに実装でき、それが自動的にOpenAPIドキュメントにも反映されます。
4. 痒い所に手が届く「Auto PATCH」機能
REST APIを設計する際、リソースの部分更新(PATCH)を実装するのは意外と面倒です。
素のGin/Chiのアプローチ(手動実装)
通常、PATCHエンドポイントを実装するには、リクエストボディのJSONをパースし、指定されたフィールドだけを更新するロジックを自前で書く必要があります。
// Ginでの例(非常に面倒)
func PatchUser(c *gin.Context) {
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
// ...
}
// updatesの中身を見て、DBの更新クエリを動的に組み立てる...
}
huma のアプローチ(Auto PATCH)
humaには、なんと PATCHエンドポイントを自動生成してくれる autopatch パッケージが存在します。
GETとPUTのエンドポイントが存在する場合、サーバー起動時に自動的にPATCHエンドポイントが生成され、以下のフォーマットに対応してくれます。
- JSON Merge Patch (
application/merge-patch+json) - JSON Patch (
application/json-patch+json)
import "github.com/danielgtaylor/huma/v2/autopatch"
// GETとPUTを登録
huma.Get(api, "/users/{id}", getUser)
huma.Put(api, "/users/{id}", putUser)
// 最後にAutoPatchを呼び出すだけで、PATCHエンドポイントが自動生成される!
autopatch.AutoPatch(api)
クライアント側から柔軟な部分更新を行いたい場合に、実装コストを劇的に下げてくれる魔法のような機能です。
5. テストが書きやすい!専用の humatest パッケージ
APIのテストを書く際、HTTPリクエストをモックしてレスポンスを検証するコードは冗長になりがちです。
素のGin/Chiのアプローチ(httptestパッケージ)
標準の httptest パッケージを使うと、リクエストの組み立てやJSONのシリアライズ/デシリアライズを手動で行う必要があります。
// 標準的なテストの例
func TestGetUser(t *testing.T) {
router := setupRouter()
req, _ := http.NewRequest("GET", "/users/123", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
// レスポンスボディの検証も面倒...
}
huma のアプローチ(humatestパッケージ)
humaはテスト用のユーティリティパッケージ humatest を提供しており、直感的にテストを記述できます。
import (
"testing"
"github.com/danielgtaylor/huma/v2/humatest"
"github.com/stretchr/testify/assert"
)
func TestGetUser(t *testing.T) {
// テスト用のAPIインスタンスを作成
_, api := humatest.New(t)
// ルーティングを登録
huma.Get(api, "/users/{id}", getUserHandler)
// GETリクエストを送信
resp := api.Get("/users/123")
// レスポンスの検証
assert.Equal(t, 200, resp.Code)
assert.Contains(t, resp.Body.String(), "John Doe")
}
api.Get() や api.Post() といったメソッドが用意されており、リクエストボディもGoの map や構造体を渡すだけで自動的にJSONにシリアライズしてくれます。テストコードの可読性が格段に上がりますね。
6. 気になるパフォーマンス(ベンチマーク実測)
「humaが便利なのは分かったけど、間に層を挟む分、パフォーマンスが落ちるのでは?」と心配になる方もいるでしょう。
そこで、実際にUbuntu環境(Intel Xeon 2.50GHz)でベンチマークを計測してみました。
計測条件
- Go 1.25.0
-
benchtime=5s,count=3 - 単純なGETリクエスト(パスパラメータのパースとJSONレスポンス)
| フレームワーク | 処理時間 (ns/op) | メモリ割当 (B/op) | アロケーション回数 |
|---|---|---|---|
| Gin(素) | 6,314 ns | 6,632 B | 27 回 |
| huma + Gin | 8,097 ns | 6,421 B | 28 回 |
| Chi(素) | 5,689 ns | 6,855 B | 23 回 |
| huma + Chi | 8,941 ns | 7,134 B | 32 回 |
※数値は3回の計測の平均値
結果の考察
- オーバーヘッドは数マイクロ秒レベル: 素のGin/Chiと比較して、humaを挟むことで約28%〜57%(約1.7〜3.2マイクロ秒)のオーバーヘッドが発生します。
- 実用上は誤差: このオーバーヘッドは、実際のAPI開発においてDBへのクエリや外部APIの呼び出し(数ミリ秒〜数十ミリ秒)が発生することを考えると、完全に誤差の範囲です。
- メモリ効率は優秀: メモリ割り当て量(B/op)に関しては、humaを挟んでもほとんど増加せず、むしろGinの場合はわずかに減少する結果となりました。
結論として、「パフォーマンスを犠牲にすることなく、型安全と自動ドキュメント生成の恩恵をフルに受けられる」 と言って良いでしょう。
まとめ
前回の「OpenAPI層としての基本機能」に加えて、今回はhumaの実践的な機能をご紹介しました。
| 機能 | 素のGin/Chiでの課題 | humaでの解決策 |
|---|---|---|
| バリデーション | ハンドラー内にロジックが混在し肥大化する | Resolvers で構造体にカプセル化。ハンドラーを汚さない |
| エラーハンドリング | 独自フォーマットの統一が面倒 | RFC 9457準拠 の美しいエラーJSONを自動生成 |
| グループ化 | ドキュメントのタグ付けと連動しない | Groups でミドルウェアとOpenAPIタグをまとめて管理 |
| 部分更新 | PATCHの実装が非常に面倒 | Auto PATCH でGETとPUTから自動生成 |
| テスト |
httptest の組み立てが冗長 |
humatest で直感的で短いコードで結合テストが書ける |
humaは単なる「Swagger生成ツール」ではなく、GoでのAPI開発体験全体をモダンに引き上げてくれる強力なフレームワーク です。
GinやChiといった使い慣れたルーターの良さを活かしつつ、FastAPIのような宣言的で型安全な開発体験を手に入れたい方は、ぜひ次のプロジェクトでhumaを試してみてください!
Discussion