Goのコーディングで気をつけていること
はじめに
フィノバレー Advent Calendar 2024 22日目の記事となります。
フィノバレーのサーバーサイドエンジニアとして働いている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/uniseg
のREADME.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.Request
のBody
はio.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))
}
上記の処理を実行したときのダンプの出力結果を添付します。
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
}
コードに修正を加えたところでダンプの出力結果を見てみます。
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のパスの区切り文字は\
であるのに対して、projects
とfile.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
}
projects
とfile.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)
}
}
出力結果は以下です。
定義順は鈴木
が最初であるのに対して、出力結果では田中
が最初になっています。
ID: 2, ユーザー名: 田中
ID: 3, ユーザー名: 佐藤
ID: 1, ユーザー名: 鈴木
解決策
maps.Keys
でmap
からキーを抽出し、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)
}
}
出力結果は以下です。
定義の順番で出力されていることが分かると思います。
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)
}
}
念の為にテストを実行してみます。
=== RUN TestGenerateReport
--- PASS: TestGenerateReport (0.00s)
PASS
ok report
無事にテストが成功しました。
さいごに
今回は個人的にGoのコーディングで気をつけていることをまとめてみました。
Goに慣れ親しんでいる方にとっては当たり前のトピックが多いとは思いますが、誰かひとりでも参考になる方がいれば幸いです。
皆さんのようなハイレベルな記事が書けるように修行します🙃
Discussion