🦮

カウシェの『バックエンドコーディング規約』を大公開!

2024/11/22に公開

はじめに

大規模なバックエンドプロジェクトを複数人で開発する際、コードの品質と一貫性を保つことは重要な課題です。
カウシェでは開発の早い段階からバックエンドのコーディング規約を定め、継続的なアップデートを行っています。
本記事では、実際に運用しているコーディング規約の主要な部分を紹介します。

前提

  • あまり細かいことは書いていません。細かい部分はlinterで担保します。
  • カウシェにおけるE2Eテストの立ち位置はこちらの本を参照してください。

レイヤー

  • 以下の3層で構成します
    • handler(infra)
    • usecase
    • domain
  • [SHOULD] 下位レイヤは上位レイヤを参照しないこと
    • 上位レイヤは下位レイヤを参照してOK
      • 例)handlerはdomainを参照してOK

サードパーティライブラリ

  • [SHOULD] 依存をなるべく増やさない
    • コピペで済ませられる場合はコピペで済ませ、依存を増やさない
    • サードパーティライブラリは標準パッケージだけで構成されていることが望ましい
    • 以下は導入しない判断をしています
      • pkg/errors
        • アーカイブされているのと、公式でerrors.Is, Asが使えるようになったため不要
      • testify
        • assertionの書き方が独特
        • 導入の必要がないと判断
      • go-funk
        • 「書くのが面倒」という理由では導入しないと判断
        • 今となっては標準ライブラリでカバーできる

命名

  • [SHOULD] DBからデータを取得するメソッドは以下の命名規則で実装します

    Find: 一件を確実に取得(NotFound, Duplicated発生しうる)
    Get: それ以外(NotFound, Duplicated発生しない)
    

エラーハンドリング

  • [SHOULD] http status code / grpc status code

    • Expected but Unacceptedな場合
      • 通常利用で想定される異常系のこと
      • 例)入力フォームではひらがなも入力できるが、半角英数字しか許容しない
      • 2XXで返す(かつ、エラー情報を返す)
    • Unexpectedな場合
      • 通常利用で起こり得ない異常系のこと
      • 例)入力フォームで20文字に制限しているのに、21文字のリクエストが来た
      • 4XX系で返す(場合によっては5XX系)
    • 背景
      • 4XX系、5XX系を異常系の監視対象としている
      • そのためアプリケーションの通常利用で想定されるリクエストに対するレスポンスでは、2XX系で返す方針としています
  • [SHOULD] エラーのwrap方法

    • 基本方針
      • エラーメッセージを読む時に、右から左にエラーの発生を辿れるような形でwrapする
      • Go1.20のWrapping multiple errorsを用いる
      • 独自エラー型定義は本当に必要なときのみ用いる
    • 形式例:ListProducts内でのエラーハンドリング
    // usecase, 外部ハンドリングが必要で、呼び出し元の情報等を付与するのみでOKな時
    return nil, fmt.Errorf(
      "%w: failed to find user by user id: %w",
      ErrListProducts, err,
    )
    
    // usecase, 外部ハンドリングは不要で、呼び出し元の情報等を付与するのみでOKな時
    return nil, fmt.Errorf("failed to find user by user id: %w", err)
    
    // handler
    u, err := usecase.ListProducts(..)
    return nil, grpcerrors.Code(codes.XXX, fmt.Errorf("failed to list product ratings usecase: %w", err))
    
  • [SHOULD] エラーがnilの場合

    • 必ず何かしらの正常な値を返すようにします
      • 例)戻り値が(何か, error)の場合、return nil, nilのような返し方はしない
    • 背景
      • 正常系ではエラーがnilである場合、返す値はnilでないことが前提でコードが書かれていることが多いため

Panic

  • [MUST] いかなる理由でもPanicを使用しない
    • 初期化時に終了させたい、テストでの使用のみ、などもNG
  • 例外:言語内部や低レイヤの本当に予期しない、基本的には起こり得ない場合のハンドリングのみ
    • メモリ、IO周りのエラーなど

汎用処理

  • [SHOULD] map, unique, chunkなどのメソッド
    • slicesで賄えるものはslicesを使用
    • loは基本的に使用しない

ロギング

  • [SHOULD] ログレベルはinfoかerrorの二つのみを使用

  • [SHOULD] 出力タイミング

    • 無闇に出さず、ログが必要なタイミングでのみ、必要な項目のみを出力
    • 個人情報漏洩のリスクを考慮し、必要と判断される場合のみ出力
  • [SHOULD] 先頭小文字のキャメルケースとイコールを使用

    // good
    fmt.Sprintf("... userID=%s", order.UserID)
    fmt.Sprintf("... userID=%s", user.ID)
    
    // bad
    fmt.Sprintf("... UserID=%s", order.UserID)
    fmt.Sprintf("... UserID: %s", order.UserID)
    fmt.Sprintf("... user_id=%s", yaml.UserID)
    

テスト

  • [SHOULD] コードテスト方針
    • 原則としてE2Eテストを書きます。その他のユニットテストについては後述します
    • protoに記載されている仕様を満たせているかという観点でテストコードを作成します
    • 実装とテストをセットでPRに含める必要は必ずしもありません
      • ただし、原則として一緒に提出します(レビュアーがレビューしやすいため)
      • 実装のPR先に作成し、後からテストのPRを作成することも可能です
  • [SHOULD] 基本方針
    • TDDで開発を行い、GoTestsなどを用いて雛形を使用
    • テストコードの書き方を統一し、容易に書ける状態を目指します
  • [SHOULD] e2eのテストケース
    • 対象のServiceのステータスコードごとにテストケースを分けます
      • TestHoge_Unauthenticated
      • TestHoge_OK
      • TestHoge_OK_xxx(OKの中で特に確認したい点がある場合)
  • [SHOULD] e2etestsの配置
    • /app/services/XXXX/e2etests 配下に配置
  • [SHOULD] e2eデータ用意と結果確認
    • endpointがある場合は使用し、ない場合はDBを使用
    • 基本的にはテストリソース作成もAPI経由で行います
  • [SHOULD] e2e以外のユニットテストの扱い
    • modelのユニットテストなど、詳細なテーブルテストでの検証が必要な場合は、各々の判断で作成可能

Handler層

  • [SHOULD] 外部からの入力および外部への出力を扱う場合は、request, responseパッケージを使用
    • ID一つの取得や返却などの簡易な場合を除き、request, responseパッケージに整形処理を集約

Usecase層

  • [SHOULD] 実装方針

    • Usecaseにstructを定義し、structに紐づくメソッドを定義
    • Handler層でRepositoryをサーバ起動時にDIしてUsecaseを生成
    • ❌ 個別の関数を定義して、ランタイムでrepositoryを引数として渡すことは避ける
  • [MUST NOT] transaction内での制約事項

    • 外部APIの呼び出し
    • push通知の実行
      • retryにより複数回のpush通知が発生する可能性があるため

Spanner

  • [MUST] jobで大量データを扱う際の注意点

    • Read-Write Transaction内でデータを取得する場合、Shared Lockを意識する
    • SpannerのRead-Write Transactionでは、読み込みを行うレコードに対してはShared Lockを、書き込みを行うレコードに対してはExclusive Lockを取得するため、別トランザクションの書き込みに対してデッドロックが発生する可能性があります
  • [MUST] select * の使用制限

    • マイグレーション観点から使用しない
    • クエリのカラム部分をフォーマット文字列に変更し、自動生成されたカラム名を使用
    • 理由:カラム追加後、全てのクエリのselect句にカラムを追加する必要があり、それが結果としてインシデントになることが多いため

その他

  • [SHOULD] クライアントの役割

    • 業務ロジックを持たせず、UIに関する関心事に専念させる
  • [SHOULD] マルチテナント対応

    • 事業者間の管理画面などマルチテナントなサービスでは、IDでの検索時に想定された事業者のデータであるかチェックする
  • [SHOULD] リリース管理

    • 影響の大きいリリースには、安全性を確保するためにFeature Flagsを使用
カウシェ Tech Blog

Discussion