5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoでWeb APIを書くときにやりがちなアンチパターン5選

5
Posted at

GoはWeb APIとの相性がかなり良いです。

  • 標準ライブラリだけでも十分書ける
  • 実行速度が高い
  • デプロイしやすい
  • Dockerとの相性もいい

その一方で、Goの文法がシンプルなぶん、なんとなく書き始めると設計の粗さがそのままコードに出やすいです。

特にWeb APIでは、最初は動いてもあとからこうなりがちです。

  • handlerに全部書いてしまって読みにくい
  • バリデーションとビジネスロジックが混ざる
  • DBアクセスがあちこちに散る
  • エラーが雑で障害調査しにくい
  • 並行処理やタイムアウトの考慮が抜ける

この記事では、GoでWeb APIを書き始めた人がやりがちなアンチパターンを5つに絞って紹介します。
どれも実務でかなり出やすいので、先に知っておくだけでレビューでも保守でもかなり有利になります。


1. handlerに全部書く

Goで最初にWeb APIを書くと、handlerに全部書きたくなります。

func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Name  string `json:"name"`
		Email string `json:"email"`
	}

	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	if req.Name == "" || req.Email == "" {
		http.Error(w, "name and email are required", http.StatusBadRequest)
		return
	}

	user := User{
		Name:  req.Name,
		Email: req.Email,
	}

	if err := db.Create(&user).Error; err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

最初はこれで十分に見えます。
でも実務ではここにどんどん処理が足されます。

  • 認証
  • 認可
  • 入力チェック
  • ビジネスロジック
  • DB保存
  • ログ出力
  • レスポンス整形

結果として、handlerが巨大化します。

なので、最低でも次の責務は分けたほうがいいです。

  • handler: HTTPの入出力を扱う
  • service: ビジネスロジックを扱う
  • repository: DBアクセスを扱う

たとえばこうです。

func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
	var req CreateUserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	if err := h.service.CreateUser(r.Context(), req.Name, req.Email); err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusCreated)
}
func (s *Service) CreateUser(ctx context.Context, name, email string) error {
	if name == "" || email == "" {
		return errors.New("name and email are required")
	}

	user := User{
		Name:  name,
		Email: email,
	}

	return s.repo.Create(ctx, user)
}

handlerはHTTPの責務だけに寄せる。
この意識だけでコードの読みやすさはかなり変わります。


2. リクエストDTOとドメインの境界が曖昧

これもかなり多いです。

json を受けるstructを、そのままDB保存用や内部ロジックにも使ってしまうパターンです。

type User struct {
	ID    int    `json:"id" db:"id"`
	Name  string `json:"name" db:"name"`
	Email string `json:"email" db:"email"`
}

一見便利ですが、だんだん破綻します。

  • API都合の項目とDB都合の項目が混ざる
  • 更新APIで必要な項目と作成APIで必要な項目が違う
  • 内部では持ちたいけど外には返したくない情報が出てくる
  • struct tag が増えて読みにくくなる

なので、少なくとも次は分けたほうが安全です。

  • リクエスト用struct
  • レスポンス用struct
  • ドメインモデル
  • 永続化モデル

たとえばリクエストだけでも分けるとかなり見通しが良くなります。

type CreateUserRequest struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

type UserResponse struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

そしてserviceの中で必要な形に組み立てます。

func (s *Service) CreateUser(ctx context.Context, name, email string) (*User, error) {
	user := &User{
		Name:  name,
		Email: email,
	}

	if err := s.repo.Create(ctx, user); err != nil {
		return nil, err
	}

	return user, nil
}

Goはシンプルなので、つい1つのstructに何でも載せたくなります。
でもWeb APIは境界が多いので、最初から役割を分けるほうが結果的にラクです。


3. errorの粒度が雑で、何が起きたか分からない

Web APIでよくあるのが、全部 internal server error に見えてしまう問題です。

if err != nil {
	http.Error(w, "internal server error", http.StatusInternalServerError)
	return
}

利用者向けのレスポンスとしてはそれでいい場面もあります。
でも、内部でのエラー管理まで雑だと、障害対応が急にきつくなります。

たとえばserviceやrepositoryでは、文脈を足して返したほうが良いです。

func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) {
	var user User
	if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
		return nil, fmt.Errorf("find user by email %s: %w", email, err)
	}
	return &user, nil
}

service側でもそのまま握りつぶさず、必要な分岐は分けます。

func (s *Service) CreateUser(ctx context.Context, name, email string) error {
	_, err := s.repo.FindByEmail(ctx, email)
	if err == nil {
		return ErrEmailAlreadyExists
	}
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		return fmt.Errorf("check existing user: %w", err)
	}

	user := User{
		Name:  name,
		Email: email,
	}

	if err := s.repo.Create(ctx, &user); err != nil {
		return fmt.Errorf("create user: %w", err)
	}

	return nil
}

handler側では業務エラーと想定外エラーを分けます。

if err := h.service.CreateUser(r.Context(), req.Name, req.Email); err != nil {
	if errors.Is(err, ErrEmailAlreadyExists) {
		http.Error(w, "email already exists", http.StatusConflict)
		return
	}

	http.Error(w, "internal server error", http.StatusInternalServerError)
	return
}

Goのerror処理は冗長に見えがちですが、Web APIではこの丁寧さがそのまま運用性につながります。


4. contextを受け取るだけで使っていない

GoでWeb APIを書くなら、context.Context はかなり重要です。
でも実際には、引数に置いただけで終わっているコードがよくあります。

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
	return s.repo.FindByID(id)
}

これだと、HTTPリクエストがキャンセルされても下の処理が残り続ける可能性があります。

ちゃんと下の層まで流しましょう。

func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
	return s.repo.FindByID(ctx, id)
}
func (r *Repository) FindByID(ctx context.Context, id int) (*User, error) {
	row := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = ?", id)

	var u User
	if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
		return nil, fmt.Errorf("find user by id %d: %w", id, err)
	}
	return &u, nil
}

さらに、handler側でtimeoutを設定するとより実務的です。

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	user, err := h.service.GetUser(ctx, 1)
	if err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	_ = json.NewEncoder(w).Encode(user)
}

GoのWeb APIでは、速く処理することだけでなく、不要になった処理をきちんと止めることも大事です。
context を最後まで流すだけで、かなり事故を減らせます。


5. JSONレスポンスを毎回その場で雑に組み立てる

小さいAPIだと、レスポンスをその場で書いてしまいがちです。

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
	"id":    user.ID,
	"name":  user.Name,
	"email": user.Email,
})

もちろんこれでも返せます。
ただ、APIが増えると徐々につらくなります。

  • レスポンス形式が揺れる
  • エラー形式が統一されない
  • フィールド名のブレが起きる
  • フロントとの約束が曖昧になる

なので、レスポンス用structをちゃんと作るのがおすすめです。

type UserResponse struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}
func writeJSON(w http.ResponseWriter, status int, v any) error {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	return json.NewEncoder(w).Encode(v)
}
resp := UserResponse{
	ID:    user.ID,
	Name:  user.Name,
	Email: user.Email,
}

if err := writeJSON(w, http.StatusOK, resp); err != nil {
	http.Error(w, "internal server error", http.StatusInternalServerError)
	return
}

エラーレスポンスも統一しておくとかなり扱いやすいです。

type ErrorResponse struct {
	Message string `json:"message"`
}
_ = writeJSON(w, http.StatusBadRequest, ErrorResponse{
	Message: "invalid request",
})

派手ではないですが、レスポンス形式を揃えるだけでAPI全体の品質が上がります。


まとめ

GoでWeb APIを書くときにやりがちなアンチパターンを5つ紹介しました。

もう一度まとめると、こんな感じです。

  • handlerに全部書かない
  • リクエストDTOと内部モデルを分ける
  • errorを雑に扱わない
  • contextを最後まで流す
  • JSONレスポンスの形式を揃える

Goはシンプルなぶん、設計の良し悪しがかなりそのままコードに出ます。
逆にいうと、今回の5つを意識するだけでも、かなり実務っぽいコードになります。

これからGoでWeb APIを書きたい人は、まず 速く書く ことよりも、責務を分けて読みやすくする ことを意識するとかなり伸びやすいです。

もし反応が良ければ次は、

  • Goのディレクトリ構成は結局どう切るべきか
  • Goで並行処理を書くときの事故パターン集
  • Goのinterface設計でやりがちなミス

あたりも書けそうです。


以下はGoのUdemyの講座の半額クーポン自分の講座
https://www.udemy.com/course/go-31100/?couponCode=818EB20AF096FC6CC4FD

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?