🤗

Goのコーディングで気をつけていること

2024/12/22に公開

はじめに

フィノバレー Advent Calendar 2024 22日目の記事となります。

https://adventar.org/calendars/10707

フィノバレーのサーバーサイドエンジニアとして働いているwasuです!
業務では主にGoとVue.jsで開発しています。

本記事では過去にハマった経験などから、Goのコーディングで気をつけているポイントをピックアップしてまとめています。
最近よくトレンドに上がっているGoの文法系の記事に影響を受けました。

Goの初心者から中級者の方まで、ぜひご活用いただけると幸いです!

マルチバイト文字が含まれる文字列を切り出すときは[]runeに変換する

絵文字以外のマルチバイト文字が含まれる文字列を切り出すときは、string[]runeに変換しましょう。
Goの文字列はUTF-8エンコーディングで扱われており、マルチバイト文字は言葉の通り複数のバイトで表現されます。

func main() {
  s1 := "a"
  s2 := "あ"
  // シングルバイト文字
  fmt.Println("出力:", len(s1)) // 出力: 1
  // マルチバイト文字
  fmt.Println("出力:", len(s2)) // 出力: 3
}

スライス構文はバイト単位で範囲を指定するため、stringからマルチバイト文字を含む文字列を切り出すのは困難です。

問題の具体例

以下のコードでは文字列から世界を切り出そうとしていますが、期待通りの値が取得できません。
日本語文字は3バイトであるため、6から8を指定すると「に」の先頭から2バイトを切り出すことになり、出力時に文字化けしてしまいます。

func main() {
  s := "こんにちは、世界!"
  // 単純な文字数計算で6文字目から8文字目を指定
  fmt.Println("結果:", s[6:8]) // 出力: �

解決策

絵文字以外のマルチバイト文字を正確に切り出すためには、文字列を[]runeに変換しましょう。
ルーンを使うと、Unicodeコードポイントの単位で範囲を指定することができるため、バイト数を意識せずに文字列を切り出すことができます。

func main() {
  s := "こんにちは、世界!"
  r := []rune(s)
  fmt.Println("結果:", string(r[6:8])) // 出力: 世界
}

【注意】絵文字が文字列に含まれる場合

絵文字が文字列に含まれる場合は、rivo/unisegなどの外部パッケージを使うことをオススメします。
複数のUnicodeコードポイントが使われている絵文字が存在する以上、[]runeで絵文字を切り出すことは困難です。

func main() {
  s := "👨🏻‍🦱😌"
  r := []rune(s)
  fmt.Println("出力:", string(r[:1])) // 出力: 👨 ← 誰?
}

パッケージの使い方はrivo/unisegREADME.mdを参照してください。

ファクトリー関数に初期化の処理を集約する

構造体のフィールドに不正な値を混入させないために、ファクトリー関数に初期化の処理を集約しましょう。
構造体リテラルを使ってロジックの中で直接初期化すると、構造体のフィールドに不正な値を混入させてしまう可能性が高いです。
これは予期せぬバグに繋がる可能性があります。

問題の具体例

要件でユーザー名が1文字以上6文字以下の制約があるケースで考えてみましょう。
以下のように構造体リテラルで直接初期化した後のバリデーションを忘れてしまうと、不正なユーザー名がロジックの中に残り続けてしまいます。

type User struct {
  Name string
}

func (u User) validate() error {
  if u.Name == "" {
    return errors.New("ユーザー名が空です")
  }
  if utf8.RuneCountInString(u.Name) > 6 {
    return errors.New("ユーザー名が6文字を超えています")
  }
  return nil
}

func main() {
  // 制限がないため、ユーザー名が10文字でも構造体を初期化できてしまう
  u := User{Name: "super user"}
  // 不注意によりvalidateUserの実行を忘れてしまった
  // u.validate()

  // 結果、不正なユーザー名がロジック中に残り続ける
}

解決策

ファクトリー関数に初期化の処理を集約することにより、不正な値の混入を防ぐことができます。
仮にNewUserを使わずに初期化してしまったとしても、コードレビューの段階で気付ける可能性が高いです。

type User struct {
  Name string
}

func (u User) validate() error {
  if u.Name == "" {
    return errors.New("ユーザー名が空です")
  }
  if utf8.RuneCountInString(u.Name) > 6 {
    return errors.New("ユーザー名が6文字を超えています")
  }
  return nil
}

func main() {
  // ファクトリー関数により、正常なユーザー名の場合にのみユーザーが初期化される
  u, err := NewUser("suzuki")
  if err != nil {
    panic(err)
  }
  fmt.Println("ユーザー名:", u.Name) // ユーザー名: suzuki

  // 不正なユーザー名を設定するとエラーが返却される
  _, err = NewUser("suzuki tarou")
  fmt.Println("エラー:", err) // エラー: ユーザー名が6文字を超えています
}

// NewUser ファクトリー関数
func NewUser(name string) (*User, error) {
  u := &User{
    Name: name,
  }
  // 初期化時にバリデーションする
  if err := u.validate(); err != nil {
    return nil, err
  }
  return u, nil
}

さらに強力な制限をかけたい場合は、構造体のフィールドをプライベートに変更し、ゲッターとセッターを作成する実装も考えられます。

type User struct {
  name string
}

func (u User) GetName() string {
  return u.name
}

func (u *User) SetName(name string) error {
  validateUser := User{name: name}
  if err := validateUser.validateName(); err != nil {
    return err
  }
  u.name = name
  return nil
}

func (u User) validate() error {
  return u.validateName()
}

func (u User) validateName() error {
  if u.name == "" {
    return errors.New("ユーザー名が空です")
  }
  if utf8.RuneCountInString(u.name) > 6 {
    return errors.New("ユーザー名が6文字を超えています")
  }
  return nil
}

// NewUser ファクトリー関数
func NewUser(name string) (*User, error) {
  u := &User{
    name: name,
  }
  // 初期化時にバリデーションする
  if err := u.validate(); err != nil {
    return nil, err
  }
  return u, nil
}

ただし、構造体のフィールドがjson.Unmarshalなどに認識されなくなるため、私はパブリックに定義しています。

io.Readerを再利用する時はコピーする

io.Readerを再利用する時はコピーしましょう。
io.Readerはストリームを読み進めるようなインターフェースであり、Readを実行すると読み取り位置が進むため、2回以上読み取ることはできません。

問題の具体例

HTTPリクエストをリトライする時に問題になる印象です。
http.RequestBodyio.Readerであるため、失敗時にリクエストをリトライする処理を書いた場合、2回目以降のリクエストのボディが空になってしまいます。

今回は例として、レスポンスから成功を受け取るまで3回までリクエストを投げてみます。
指数バックオフは使いません。

type RequestBody struct {
  Amount int64
}

func main() {
  req, err := newRequest()
  if err != nil {
    panic(err)
  }
  c := &http.Client{}

  // リクエストを送信する
  for range 3 {
    dump, _ := httputil.DumpRequest(req, true)
    fmt.Println("Dump: ", string(dump))

    res, err := c.Do(req)
    if err != nil {
      panic(err)
    }
    defer res.Body.Close()

    // レスポンスのステータスコードが200ではない場合はリトライする
    if res.StatusCode != http.StatusOK {
      continue
    }
  }
}

func newRequest() (*http.Request, error) {
  body := RequestBody{
    Amount: 1000,
  }
  b, err := json.Marshal(body)
  if err != nil {
    return nil, err
  }
  return http.NewRequest(http.MethodPost, "https://exmaple.com", bytes.NewReader(b))
}

上記の処理を実行したときのダンプの出力結果を添付します。

log
POST / HTTP/1.1
Host: exmaple.com

{"Amount":1000}

POST / HTTP/1.1
Host: exmaple.com



POST / HTTP/1.1
Host: exmaple.com



前述した通り、2回目以降のリクエストのボディが空になっています。
これではリトライ時に正しいリクエストを送信することができません。

解決策

リクエストを送信する前の段階でリクエストボディの中身を全て読み込み、メモリ上に確保しておきます。
これによりリクエストボディの再利用が可能です。

type RequestBody struct {
  Amount int64
}

func main() {
  req, err := newRequest()
  if err != nil {
    panic(err)
  }

  // リクエストボディをコピーするために記録しておく
  getBody, err := rewindBody(req)
  if err != nil {
    panic(err)
  }

  c := &http.Client{}
  // 成功するまで3回リクエストを投げる
  for range 3 {
    // リクエストボディを巻き戻してあげる
    req.Body, err = getBody()
    if err != nil {
      panic(err)
    }

    dump, _ := httputil.DumpRequest(req, true)
    fmt.Println("Dump: ", string(dump))

    res, err := c.Do(req)
    if err != nil {
      panic(err)
    }
    defer res.Body.Close()

    // HTTPステータスコードが200ではない場合はリトライする
    if res.StatusCode != http.StatusOK {
      continue
    }
  }
}

func newRequest() (*http.Request, error) {
  body := RequestBody{
    Amount: 1000,
  }
  b, err := json.Marshal(body)
  if err != nil {
    return nil, err
  }
  return http.NewRequest(http.MethodPost, "https://exmaple.com", bytes.NewReader(b))
}

func rewindBody(req *http.Request) (func() (io.ReadCloser, error), error) {
  // リクエストボディが存在しない場合は処理を終了
  if req.Body == nil || req.Body == http.NoBody {
    return func() (io.ReadCloser, error) {
      return req.Body, nil
    }, nil
  }

  defer req.Body.Close()

  // request.GetBody(リクエストボディのコピーを返すメソッド)が存在する場合はそれを返す
  if req.GetBody != nil {
    return req.GetBody, nil
  }

  // request.GetBodyが存在しない場合は作成する
  buf, err := io.ReadAll(req.Body)
  if err != nil {
    return nil, err
  }
  return func() (io.ReadCloser, error) {
    return io.NopCloser(bytes.NewReader(buf)), nil
  }, nil
}

コードに修正を加えたところでダンプの出力結果を見てみます。

log
POST / HTTP/1.1
Host: exmaple.com

{"Amount":1000}

POST / HTTP/1.1
Host: exmaple.com

{"Amount":1000}

POST / HTTP/1.1
Host: exmaple.com

{"Amount":1000}

2回目以降のリクエストボディが設定されています。
正しくリクエストを送信できていることが確認できました。

物理ファイルのパスを操作するときはpath/filepathを使う

物理ファイルのパスを操作するときは、pathではなくpath/filepathを使いましょう。
pathはURLなどのパスを操作するためのパッケージであり、物理ファイル用ではありません。
WindowsとUNIX系OSではパスの区切り文字などに違いがあるため、pathではWindowsの物理ファイルの操作で問題が発生してしまいます。

問題の具体例

まずはWindowsでpath.Joinを使用してパスを作成してみます。

func main() {
  // Windowsでは通常、パスの区切り文字にバックスラッシュが使われる
  dir := "C:\\Users\\Wasu\\Documents"
  // path.Joinを使用してバスをジョイン
  path := path.Join(dir, "projects", "file.txt")
  fmt.Println("出力:", path) // 出力: C:\Users\Wasu\Documents/projects/file.txt
}

出力を確認すると、Windowsのパスの区切り文字は\であるのに対して、projectsfile.txt/で結合しています。
これではファイルのパスとして機能しません。

解決策

path.Joinではなく、filepath.Joinを使ってパスを結合しましょう。

func main() {
  // Windowsでは通常、パスの区切り文字にバックスラッシュが使われる
  dir := "C:\\Users\\Wasu\\Documents"
  // filepath.Joinを使用してバスをジョイン
  path := filepath.Join(dir, "projects", "file.txt")
  fmt.Println("出力:", path) // 出力: C:\Users\Wasu\Documents\projects\file.txt
}

projectsfile.txt\で結合されています。
正しい形式のパスであることが確認できました。

引数が多いときは値を渡すための構造体を定義する

引数が多いときは値を渡すための構造体を定義しましょう。
関数に渡す引数が増えすぎると、引数の順番を間違えるなどのケアレスミスが発生しやすくなります。

問題の具体例

以下はユーザーから加盟店に支払いを行い、その支払い金額に応じてポイントを付与する関数の例です。
支払い金額が1,000円で、ポイントは10ポイント(支払い金額の1%)の付与が正しい処理だとします。

func main() {
  // ユーザーID
  var userID int64 = 100
  // 加盟店ID
  var merchantID int64 = 10
  // 金融機関ID
  var financialID int64 = 1
  // 支払い金額
  var paymentAmount int64 = 1000
  // ポイント(支払い金額の1%)
  var point int64 = 10
  // 手数料
  var fee int64 = 50

  // Paymentへ引数をそのまま渡しているが、引数の数が多く可読性が悪い
  Payment(
    userID,
    merchantID,
    financialID,
    // 支払い金額 → ポイントの順なのに、誤って順番を逆にしてしまった
    point,
    paymentAmount,
    fee,
  )
}

func Payment(userID, merchantID, financialID, paymentAmount, point, fee int64) {
  // ...
}

上記のコードでは、支払い金額とポイントの順番を誤って渡してしまいました。
結果、本来は「1,000円の支払いで10ポイントを付与」という処理が、「10円の支払いで1,000ポイントの付与」という不正なロジックになってしまいます。

解決策

上記のような問題を避けるために、Paymentに渡す引数をひとつの構造体にまとめましょう。
構造体のフィールド名により、引数が何を指しているか明確になります

func main() {
  // ユーザーID
  var userID int64 = 100
  // 加盟店ID
  var merchantID int64 = 10
  // 金融機関ID
  var financialID int64 = 1
  // 支払い金額
  var paymentAmount int64 = 1000
  // ポイント(支払い金額の1%)
  var point int64 = 10
  // 手数料
  var fee int64 = 50

  // 専用の構造体に引数をまとめて指定した
  // どの値がどのフィールドに対応しているか明確になり、間違いにくい
  args := PaymentArgs{
    UserID:        userID,
    MerchantID:    merchantID,
    FinancialID:   financialID,
    PaymentAmount: paymentAmount,
    Point:         point,
    Fee:           fee,
  }
  Payment(args)
}

type PaymentArgs struct {
  UserID        int64
  MerchantID    int64
  FinancialID   int64
  PaymentAmount int64
  Point         int64
  Fee           int64
}

func Payment(args PaymentArgs) {
  // ...
}

仮に間違えて実装したとしても、コードレビューの段階でミスを発見しやすくなるためオススメです。

mapで順番を固定したいときはキーをソートする

mapで順番を固定したいときは、キーをソートしてあげる必要があります。
Goのmapは挿入順や定義順に依存しないため、ループ時に要素の順序が保証されません。

問題の具体例

以下はmapが保持するユーザーを順に出力するプログラムです。
mapを単純にfor rangeで反復しているため、ランダムな順番でユーザーが出力されてしまいます。

type User struct {
  Name string
}

func main() {
  m := map[int64]User{
    1: {Name: "鈴木"},
    2: {Name: "田中"},
    3: {Name: "佐藤"},
  }
  for k, v := range m {
    fmt.Printf("ID: %d, ユーザー名: %s\n", k, v.Name)
  }
}

出力結果は以下です。
定義順は鈴木が最初であるのに対して、出力結果では田中が最初になっています。

log
ID: 2, ユーザー名: 田中
ID: 3, ユーザー名: 佐藤
ID: 1, ユーザー名: 鈴木

解決策

maps.Keysmapからキーを抽出し、slices.Sortedでキーをソートする実装が簡単です。

type User struct {
  Name string
}

func main() {
  m := map[int64]User{
    1: {Name: "鈴木"},
    2: {Name: "田中"},
    3: {Name: "佐藤"},
  }
  // キーの昇順で出力される。
  for _, k := range slices.Sorted(maps.Keys(m)) {
    fmt.Println("ID:", k, "ユーザー名:", m[k].Name)
  }
}

出力結果は以下です。
定義の順番で出力されていることが分かると思います。

log
ID: 1 ユーザー名: 鈴木
ID: 2 ユーザー名: 田中
ID: 3 ユーザー名: 佐藤

time.Nowなどは関数の引数で渡す

time.Nowなどの実行するタイミングで値が変動する関数は引数で渡しましょう。
仮に関数内でtime.Nowを呼び出してしまうと、その関数は現在時刻に依存するため、テストコードで期待される出力を確認することが困難になります。

問題の具体例

以下の関数をテストする際、time.Nowが返却する値が実行するタイミングによって変動するため、期待される出力をテストすることが困難です。

func GenerateReport() string {
  // 関数内でtime.Now()を呼び出しているため、関数が実行するタイミングに依存してしまう
  now := time.Now()
  return fmt.Sprintf("Report generated at: %s", now.Format(time.RFC3339))
}

解決策

time.Nowなどの実行するタイミングで値が変動する関数は外で初期化し、引数で渡すようにします。
これによりテスト側で固定の時刻を指定できるため、処理の結果を安定的に比較することが可能です。

func GenerateReport(now time.Time) string {
  return fmt.Sprintf("Report generated at: %s", now.Format(time.RFC3339))
}

以下は簡易的なテストコードです。

テストコード
func TestGenerateReport(t *testing.T) {
  fixedTime := time.Date(2024, time.December, 1, 00, 0, 0, 0, time.UTC)
  want := "Report generated at: 2024-12-01T00:00:00Z"
  got := report.GenerateReport(fixedTime)
  if got != want {
    t.Errorf("期待値: %q\n実際の出力: %q", want, got)
  }
}

念の為にテストを実行してみます。

log
=== RUN   TestGenerateReport
--- PASS: TestGenerateReport (0.00s)
PASS
ok      report

無事にテストが成功しました。

さいごに

今回は個人的にGoのコーディングで気をつけていることをまとめてみました。
Goに慣れ親しんでいる方にとっては当たり前のトピックが多いとは思いますが、誰かひとりでも参考になる方がいれば幸いです。

皆さんのようなハイレベルな記事が書けるように修行します🙃

Discussion