🥷

現場で役立つGo言語のTipsをただまとめてみた

2024/01/03に公開
4

はじめに

こんにちは、23卒でバックエンドエンジニアをしているたかしゅんです。
私の所属しているプロダクトではサーバーサイドの開発言語としてGoを採用しております。

チームでGoの勉強会をした際にあまりにもGoの流儀や綺麗な書き方を理解していなかったので、以下の書籍を読みました。

[Go言語 100Tips ありがちなミスを把握し、実装を最適化する]
https://book.impress.co.jp/books/1122101133

この書籍から得た知見、プルリクエストのレビューで受けたアドバイス、そしてコードレビュー時に意識すべき点などを基に、知識を整理し共有したいと思います。

基礎文法は理解しているけど、実際のプロダクトで何を意識して書けば良いのかわからない方に、少しでも参考になれば幸いです。

1. コード

1.1 不用意にネストしない

可読性の悪いコードには命名、一貫性、書式など様々な原因がありますが、その中の重要な原因の一つとしてネストが関係します。

よくある例としてエラーハンドリングを例に取っております

エラーが発生した場合は、直ちに処理を終了させる(早期リターン)ことで、成功した場合のコードがネストされずに済むようになる

ネストが深いコード

func processFile(filename string) error {
    file, err := os.Open(filename)
    if err == nil {
        defer file.Close()
        contents, err := ioutil.ReadAll(file)
        if err == nil {
            fmt.Println(string(contents))
        } else {
            log.Printf("cannot read file: %v", err)
            return err
        }
    } else {
        log.Printf("cannot open file: %v", err)
        return err
    }
    return nil
}

ネストを減らしたコード

func processFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        log.Printf("cannot open file: %v", err)
        return err
    }
    defer file.Close()

    contents, err := ioutil.ReadAll(file)
    if err != nil {
        log.Printf("cannot read file: %v", err)
        return err
    }

    fmt.Println(string(contents))
    return nil
}

Goにおいてハッピーパスが左に寄っているコードほど綺麗とされています

[参考]

https://medium.com/@matryer/line-of-sight-in-code-186dd7cdea88

1.2 インタフェースの汚染しない

不必要な抽象化でコードが理解しにくくなります

よくある例は以下の三つです

過剰な責任:

インタフェースが多くのメソッドを持つ場合、そのインタフェースを実装することは困難になり、そのインターフェースは特定の責任を持つというよりも、多くの異なるタスクを実行することになってしまう

テストの困難化:

インタフェースが多くのメソッドを持つ場合、そのインタフェースに対するモックの作成が困難になる。

これにより単体テストが複雑になる

柔軟性の低下

汚染されたインターフェースは、使用する側に多くの未使用のメソッドを強いることになり、コードの柔軟性が低下する

↑これが一番よくない

(改善策として)

インタフェースの分割:

大きなインタフェースは、より小さく、特定の責任に焦点を当てた複数のインターフェースに分割する

最小限のインタフェース:

必要な機能のみを持つインタフェースを定義し、それ以外の機能は別のインタフェースに分離する。

これにより、インタフェースはその役割をより明確に果たし、より簡単に実装とテストが可能になります。

悪い例(汚染されたインターフェース):

type FileProcessor interface {
    Open(file string) error
    Read() ([]byte, error)
    Process() error
    Close() error
}

func ProcessFile(fp FileProcessor) error {
    // ...
}

良い例(分割されたインターフェース):

type Reader interface {
    Read() ([]byte, error)
}

type Processor interface {
    Process() error
}

type Closer interface {
    Close() error
}

// 個々のインターフェースを必要に応じて使用
func ProcessFile(r Reader, p Processor, c Closer) error {
    // ...
}

各インターフェースは単一の責任を持ち、コードはより明確で、テストしやすく、柔軟になる

補足:インターフェースをクライアント側に置くことで、不用意な抽象化を避けられる

良い例: クライアント側にインターフェースを定義

この例では、クライアント(使用する側)が必要とするインターフェースを定義し、それに合わせて実装を提供します。

// クライアント側で定義されたインターフェース
type DataWriter interface {
    Write(data string) error
}

// 実装
type FileWriter struct {
    // ...
}

func (fw FileWriter) Write(data string) error {
    // ファイルへの書き込みロジック
    return nil
}

// クライアント関数
func SaveData(writer DataWriter, data string) {
    writer.Write(data)
}

func main() {
    fw := FileWriter{}
    SaveData(fw, "some data")
}

このアプローチの利点は、DataWriterインターフェースがSaveData関数の要件に基づいて定義されており、実装(FileWriter)がこのインターフェースに合わせて作成されている点です。

これにより、異なる種類のDataWriter(例えばネットワークへの書き込みなど)を簡単に導入できます。

悪い例: 実装側でインターフェースを定義

この例では、実装側でインターフェースを定義し、それをクライアント側に強制します。

// 実装側で定義されたインターフェース
type Writer interface {
    Write(data string) error
    Close() error
}

// 実装
type FileWriter struct {
    // ...
}

func (fw FileWriter) Write(data string) error {
    // ファイルへの書き込みロジック
    return nil
}

func (fw FileWriter) Close() error {
    // ファイルを閉じるロジック
    return nil
}

// クライアント関数
func SaveData(writer Writer, data string) {
    writer.Write(data)
    writer.Close()
}

func main() {
    fw := FileWriter{}
    SaveData(fw, "some data")
}

このアプローチの問題は、Writerインターフェースが実装に基づいて定義されており、クライアント(SaveData関数)が必要としないCloseメソッドを含んでいる点です。

クライアントがデータの書き込みのみを行う場合、Closeメソッドは不要な抽象化であり、クライアントに余分な責務を強いることになります。

1.3 インターフェースを返さない

関数のシグニチャを設計する際に、インタフェースか具体的な実装のどちらかを返さないといけない

インターフェースを返すことは一般的には推奨されていない

呼び出し元が関数が返す具体的な型について知ることができないため、柔軟性が低下する可能性があるためです

一方で、具体的な型を返すことにより、呼び出し元はその型の全てのメソッドを利用でき、必要に応じて型アサーションや型スイッチを使用することができます。

悪い例: 関数がインターフェースを返す

type DataProcessor interface {
    Process(data string) string
}

type jsonProcessor struct{}

func (jp jsonProcessor) Process(data string) string {
    // JSONデータを処理する
    return "Processed JSON"
}

// NewDataProcessor は DataProcessor インターフェースを返す
func NewDataProcessor() DataProcessor {
    return jsonProcessor{}
}

func main() {
    processor := NewDataProcessor()
    result := processor.Process("some data")
    fmt.Println(result)
}

このコードでは、NewDataProcessor関数がDataProcessorインターフェースを返しています。これにより、呼び出し元はjsonProcessorの具体的な実装の詳細や追加のメソッドにアクセスできません。

良い例: 関数が具体的な型を返す

type jsonProcessor struct{}

func (jp jsonProcessor) Process(data string) string {
    // JSONデータを処理する
    return "Processed JSON"
}

// NewJSONProcessor は jsonProcessor のインスタンスを返す
func NewJSONProcessor() jsonProcessor {
    return jsonProcessor{}
}

func main() {
    processor := NewJSONProcessor()
    result := processor.Process("some data")
    fmt.Println(result)
}

この改善された例では、NewJSONProcessor関数がjsonProcessorの具体的なインスタンスを返しています。これにより、呼び出し元はProcessだけでなくjsonProcessorに追加される可能性のある他のメソッドにもアクセスでき、より柔軟に処理を行うことができます。

補足:逆に関数は可能な限りインターフェースを受け取るようにするべきです

1.4 コードにコメントを残す

公開される要素の名前で始まるコメントを追加する(句点で終わる完全な文であるべき)

  • 関数またはメソッドを文書化する場合、その関数がどのように行うのかではなく、何を意図しているかを強調する

例:

// Customerは、顧客を表します
type Customer struct{}

// IDは、顧客IDを返します
func (c Customer) ID() string { return "" }

1.5 リンターとフォーマッタを使い倒す

リンター:コードを解析してエラーを検出するための自動ツールのこと

使い倒してください。

https://github.com/golangci/golangci-lint

以下を参考にさせていただきました

https://zenn.dev/sanpo_shiho/books/61bc1e1a30bf27

フォーマッタ:コードの書式を一貫したスタイルに整形するツール

gofmtなどが代表的

1.6 iota は 0 から始まるので注意

iotaとは
Go言語におけるiotaは、連続する定数を簡潔に表現するための識別子です。
iotaは常に0から始まり、それぞれの定数宣言ごとに1ずつ増加します。

※連続する定数を簡潔に表現する場合は積極的に使用すること

注意点
iotaを使用する際の主な注意点は、そのゼロスタートであることです。
Goでは、数値型のゼロ値は0であるため、iotaを用いた定数定義では、通常0に何らかの意味を割り当てます。

特に、列挙型を定義する際には、0をUnknownやNoneのような「未定義」や「不明」 を表す値として使用することが一般的です。
(またiota+1で始めることでも対応できます。)

例:

type AccessLevel int

const (
    Unknown AccessLevel = iota // 0: 未定義のアクセスレベル
    Guest                       // 1: ゲスト
    User                        // 2: ユーザー
    Admin                       // 3: 管理者
)

AccessLevel型について、iotaを用いて各アクセスレベルに連続する整数値を割り当てています。
iotaが0から始まるため、Unknownに0が自動的に割り当てられます。これにより、AccessLevelの変数が初期化されたとき、その値はデフォルトでUnknown(0)になります。

1.7 アノテーションコメント

アノテーションコメントは、プログラミングにおいてコードの特定の部分に注釈を加えるために使われる。
よく使うものでTODO:などがある

種類
TODO: 後で対応が必要なタスクを示します。例:// TODO: ログイン機能を実装する
FIXME: 修正が必要なバグや問題を示します。例:// FIXME: メモリリークを解消する
HACK: 標準的でない、または暫定的な解決策を示します。例:// HACK: この方法で一時的に問題を回避する

参考:
https://qiita.com/taka-kawa/items/673716d77795c937d422

1.8 空文字とnullは違う

空文字: 文字列の値が存在するが、その長さが0であることを意味します。例えば、var s string = ""はsが空文字であることを示します。
nil: ポインタ型、スライス、マップ、チャネル、インタフェース、関数の変数が「何も参照していない」状態を表します。

例えば、var p *string = nilはpが何も参照していないことを示します。

参考:
https://qiita.com/zr_tex8r/items/964415e71db0680bfe4e

1.9 定数を用いたコードのクリーンアップ

ハードコードされた値ではなく定数を使うことで、コードの保守性と可読性を向上させることができる。(当たり前かもしれないが。。)

悪い例:

if url == "https://example.com/data"

良い例

const DataAPIURL = "https://example.com/data"

if url == DataAPIURL

2. データ型

2.1 スライスの長さと容量を理解する

長さ(Length)

  • スライスの長さは、そのスライスが含む要素の数です。
  • len関数を使って取得できます。

容量(Capacity)

  • スライスの容量は、スライスが基になる配列の中で取り得る最大の要素数です。
  • cap関数を使って取得できます。
  • スライスの容量は、スライスが最初に作成された時点での配列のサイズによって決まる

基底配列とスライスの動作

スライスは基底配列(underlying array)上に構築されます。

スライスに新たな要素を追加すると、以下のような挙動が発生します:

  1. 容量内での追加: 現在のスライスの容量内であれば、要素は基底配列に追加され、スライスの長さが増加します。

  2. 容量を超える追加: スライスにさらに要素を追加すると、その容量を超える場合、より大きな新しい基底配列が作成され、元の要素が新しい配列にコピーされます。

    その後、新しい要素が追加され、スライスは新しい基底配列を参照するようになります。

具体例

package main

import "fmt"

func main() {
    // 3要素のスライスを作成する
    slice := make([]int, 3, 5) // 長さ3、容量5のスライス
    fmt.Println(slice, len(slice), cap(slice)) // 出力: [0 0 0] 3 5

    // スライスに要素を追加する
    slice = append(slice, 4, 5) // 容量内に追加
    fmt.Println(slice, len(slice), cap(slice)) // 出力: [0 0 0 4 5] 5 5

    // 容量を超えて要素を追加する
    slice = append(slice, 6) // 容量を超えるため、新しい基底配列が作成される
    fmt.Println(slice, len(slice), cap(slice)) // 出力: [0 0 0 4 5 6] 6 10
}

2.2 効率的なスライスの初期化

makeを使ってスライスの初期化する際に、適切な長さと容量を指定する

make(配列型, length, capacity)

基底配列を作成し、コピーする動作は、メモリにかなりの負荷をかける原因になる
(GCが基底配列を解放するための追加処理が発生)

あるスライス型を別のスライス型に変換することは、Go開発者が頻繁に行う

  1. 容量を指定して、appendを使用する(読みやすい)
func convert(foos []Foo) []Bar {
	n := len(foos)
	bars := make([]Bar, 0, n) // <-長さ0と容量を指定して初期化

	for _, foo := range foos {
		// 新たな要素を追加するためにbarsを更新する
		bars = append(bars, fooToBar(foo))
	}
	return bars
}
  1. 長さを指定して、格納する(僅かに早い)
func convert(foos []Foo) []Bar {
	n := len(foos)
	bars := make([]Bar, n) // <-長さを指定して初期化

	for i, foo := range foos {
 		bars[i] = fooToBar(foo) // <-スライスのi番目要素に設定する
	}
	return bars
}

2.3 nilスライスと空スライスの使い分け

nilスライス

  • nilと等しいなら、スライスはnil
  • nilスライスにデータを追加するには、appendを使用しますが、最初のappend呼び出しで新しい基底配列が割り当てられます。
var s []int // sはnilスライス
fmt.Println(s == nil) // 出力: true

空スライス

  • 長さが0なら、スライスは空
  • 空スライスには既に基底配列が割り当てられている
s := make([]int, 0) // sは空スライス
fmt.Println(s == nil) // 出力: false

初期化されていない状態の表現:nilスライスは、スライスがまだ初期化されていないか、データが存在しない状態を表すのに適しています。

空のコレクション:空スライスは、データが存在しないが、スライスが初期化されている状態(つまり「空のコレクション」)を表すのに適しています。

2.4 スライスが空か否かを適切に検査する

if len(スライス) != 0

以上

2.5 スライスのコピーを正しく行う

悪い例: 参照をコピーする

package main

import "fmt"

func main() {
    src := []int{1, 2, 3}
    dst := src // 参照をコピー

    // 元のスライスを変更すると、コピーも影響を受ける
    src[0] = 100
    fmt.Println(src) // 出力: [100 2 3]
    fmt.Println(dst) // 出力: [100 2 3]
}

良い例: copy関数を使用する

package main

import "fmt"

func main() {
    src := []int{1, 2, 3}
    dst := make([]int, len(src))
    copy(dst, src)

    // 元のスライスを変更してもコピーには影響しない
    src[0] = 100
    fmt.Println(src) // 出力: [100 2 3]
    fmt.Println(dst) // 出力: [1 2 3]
}

スライスのコピーを行う際には、copy関数を使用する

2.6 スライスとメモリリーク

容量リーク

スライスは基底配列への参照を持っているため、スライスの一部分だけを参照している場合でも、基底配列全体はメモリに残り続る

これが大きな配列である場合、必要のないメモリが長時間占有され続けることになります

例:

func main() {
    largeSlice := make([]int, 1000000)

    smallSlice := make([]int, 100)
    copy(smallSlice, largeSlice[:100])

    // smallSliceを使用する処理
    // ...

    // smallSliceはlargeSliceの基底配列とは独立しているため、
    // largeSliceの基底配列は不要になった時点で解放される
}

largeSliceの必要な部分だけを新しいスライスsmallSliceにコピーしてるため、largeSliceの基底配列は不要になるとメモリから解放され、容量リークを防ぐことができます。

スライスとポインタ

スライスを関数に渡すとき、スライス自体は値としてコピーされますが、スライスが参照する基底配列はコピーされません。そのため、関数内でスライスの要素を変更すると、元のスライスに影響を与える

悪い例:

func modifySlice(s []int) {
    s[0] = 999
}

func main() {
    mySlice := []int{1, 2, 3}
    modifySlice(mySlice)
    fmt.Println(mySlice) // 出力: [999 2 3]
}

良い例:copyしたスライスを渡す

package main

import (
    "fmt"
)

func modifySlice(s []int) {
    if len(s) > 0 {
        s[0] = 999 // コピーされたスライスの変更
    }
}

func main() {
    mySlice := []int{1, 2, 3}
    sliceCopy := make([]int, len(mySlice))
    copy(sliceCopy, mySlice) // 元のスライスのコピーを作成

    modifySlice(sliceCopy)   // コピーに対して変更を行う
    fmt.Println(mySlice)     // 元のスライスは変更されない: [1 2 3]
    fmt.Println(sliceCopy)   // 変更されたコピー: [999 2 3]
}

2.7 効率的なマップの初期化を行う

mapについての説明は以下を参照してください

https://zenn.dev/jboy_blog/articles/a3b57e42642d7d

Go言語のマップはハッシュテーブルを使用している

再ハッシュとは

  • ハッシュテーブルはキーと値のペアを効率的に格納し、検索するためのデータ構造
  • 再ハッシュは、ハッシュテーブルの容量が不足すると発生するプロセスで、既存の要素を新しい、より大きなテーブルに移動します。

パフォーマンス: すべての要素を新しいテーブルにコピーし直す必要があるため、時間がかかる

メモリ使用量: 一時的には、旧テーブルと新テーブルの両方がメモリ上に存在するため、メモリ使用量が増加する

→ スライスと同様に要素数を指定して初期化する必要がある

例:再ハッシュが行われず、メモリリークが起きない

func main() {
    // 予想される要素数に基づいてマップを初期化
    myMap := make(map[string]int, 10000)

    // 要素をマップに追加
    for i := 0; i < 10000; i++ {
        myMap[fmt.Sprintf("key%d", i)] = i
    }
}

2.8 マップとメモリリーク

マップが消費するメモリ量を減らす方法

  1. マップのコピーを定期的に再作成すること
package main

import "fmt"

func main() {
    // 初期のマップを作成
    originalMap := map[int]string{
        1: "Apple",
        2: "Banana",
        3: "Cherry",
    }

    // マップを再作成する
    newMap := make(map[int]string)
    for k, v := range originalMap {
        newMap[k] = v
    }

    // 古いマップを破棄
    originalMap = newMap

    // 新しいマップを表示
    fmt.Println("New Map:", newMap)
}

マップの要素を新しいマップにコピーし、古いマップを破棄し、不要になったエントリがメモリに残らず、メモリの断片化を減らすことができる。

  1. map型が配列へのポインタを保持するようにする
package main

import "fmt"

func main() {
    // データを保持する配列
    data := []string{"Apple", "Banana", "Cherry"}

    // マップを作成(キーとデータ配列のインデックスを格納)
    mapWithPointers := make(map[int]int)
    mapWithPointers[1] = 0 // Apple
    mapWithPointers[2] = 1 // Banana
    mapWithPointers[3] = 2 // Cherry

    // マップを使用してデータにアクセス
    for k, v := range mapWithPointers {
        fmt.Printf("Key: %d, Value: %s\n", k, data[v])
    }
}

マップはキーと配列へのポインタのみを格納し、実際のデータは別の配列に保存する。

2.9 正しい型の選択とメモリ効率

型の選択の重要性
ここに来て当たり前のことにはなるのですが、プログラミングにおいて、データの特性に合った適切な型を選択することは、メモリ効率とパフォーマンスに大きく影響します

特に、限定された範囲の値を持つデータに対しては、その範囲に合った最小のデータ型を使用することが望ましいです。

具体例

uint8の使用例として、簡単なカテゴリ型のデータを表すための列挙型を考る
以下のコードは、異なる種類のメッセージタイプを表す列挙型を定義しています。これらのメッセージタイプが256種類未満である場合、uint8は適切な選択です。

package main

import "fmt"

type MessageType uint8

const (
    TextMessage MessageType = iota
    ImageMessage
    VideoMessage
    // 他のメッセージタイプをここに追加...
)

func main() {
    // メッセージタイプの使用例
    messageType := TextMessage
    fmt.Println("Message Type:", messageType)
}

MessageTypeはuint8を基にしており、TextMessage, ImageMessage, VideoMessageなどの異なるメッセージタイプを効率的に表しています。

拡張性の考慮
もしメッセージタイプが256種類を超える可能性がある場合、より大きな範囲の整数型(例えばuint16)を使用することが考えられます。

必要に応じてより大きな範囲のデータ型を使用することが重要になる。
脳死でintなどを使っていた...

3. 制御構造

3.1 rangeループで要素がコピーされることを無視しない

例:

type BigStruct struct {
    Data [1024]int
}

func main() {
    slice := []BigStruct{{}, {}, {}}

    // 要素がコピーされる
    for _, v := range slice {
        // ここでvはsliceの各要素のコピー
        // vを変更しても元のsliceには影響しない
    }
}

要素が大きいため、このコピーはパフォーマンスに影響を及ぼす可能性がある

改善例:

type BigStruct struct {
    Data [1024]int
}

func main() {
    slice := []BigStruct{{}, {}, {}}

    // 要素のポインタを使う
    for i := range slice {
        v := &slice[i]
        // vはBigStructへのポインタ
        // vを通じて変更を加えると、元のsliceの要素に影響する
    }
}

vはスライスの各要素へのポインタです。

これにより、大きな構造体のコピーを避けて、元のスライスの要素を直接操作することができます。

3.2 rangeループでポインタ要素を使う影響を無視しない

例:

type myStruct struct {
    Value int
}

func main() {
    items := make([]*myStruct, 3)
    for i, v := range items {
        v = &myStruct{Value: i} // これは間違い
        // この時点でvは新しいmyStructのインスタンスを指しますが、
        // items[i]には影響を与えません。
    }
    for _, v := range items {
        if v != nil {
            fmt.Println(v.Value) // すべてnil
        } else {
            fmt.Println("nil")
        }
    }
}

vsliceの各要素へのポインタです。

vを通じて加えられた変更は、元のsliceの要素に直接影響します。

改善例:

type myStruct struct {
    Value int
}

func main() {
    items := make([]*myStruct, 3)
    for i := range items {
        items[i] = &myStruct{Value: i} // 正しい方法
    }
    for _, v := range items {
        fmt.Println(v.Value) // 0, 1, 2
    }
}

vは各反復でスライスの異なる要素へのポインタです。vを通じて加えられた変更は、各要素に適切に反映されます。

補足:

rangeループでは、値要素はコピーが使われます。

構造体を更新するには、そのインデックスを介して、あるいは従来のforループを介してアクセスする。

(更新したい要素やフィールドがポインタである場合を除く)

4. 関数とメソッド

4.1 値レシーバ or ポインタレシーバ

値レシーバ

  • メソッドがそのインスタンスの状態を変更する必要がない場合
  • メソッド呼び出しがオリジナルのインスタンスに影響を与えてはいけない場合

ポインタレシーバ

  • メソッドがインスタンスの状態を変更する場合
  • 大きなデータ構造を扱い、パフォーマンスの観点からコピーを避けたい場合

4.2 ファクトリ関数での型変換

ファクトリ関数の引数で受け取る値は、可能な限り既に型変換されている状態にしておくことが望ましいです。
よくある例としては独自型を定義している場合、独自型を引数に受け取るべきということです。

もしファクトリ関数内で直接的に型変換を行う場合、特に入力値が不正確または不適切な場合に、予期せぬ挙動やエラーが発生する可能性があります

例:
ユーザーのアクセス権限レベルを文字列から特定の型に変換する例

type AccessLevel int

const (
    Admin AccessLevel = iota
    User
    Guest
)

func CreateAccessLevel(accessLevelParam string) AccessLevel {
    switch accessLevelParam {
    case "admin":
        return Admin
    case "user":
        return User
    case "guest":
        return Guest
    default:
        return Guest // ここでデフォルト値を設定
    }
}

accessLevelParam が無効な文字列(例:"")の場合、デフォルトとして Guest が返されます。これは、無効な入力に対して無意識のうちに有効な値を割り当ててしまうという問題がある

改善例:
事前に文字列から AccessLevel へのマッピングを定義し、そのマッピングを用いて変換する

MapAccessLevel = map[string]AccessLevel{
    "admin": Admin,
    "user": User,
    "guest": Guest,
}

accessLevel, ok := MapAccessLevel[accessLevelParam]
if !ok {
    // エラー処理をする
}

無効な文字列(例えば "")が入力された場合、ok は false となり、適切なエラー処理を行うことができます
また、ファクトリ関数の主たる目的に集中できるため、コードの可読性と整合性が向上します。

4.3 関数の責任範囲の明確化

悪い例

type UserData struct {
    UserID string
    Data   string
}

func ProcessData(userID string, data string) string {
    if userID == "A123" {
        // 特定のユーザーのデータ処理ロジック
    }
    // 一般的なデータ処理ロジック
}

// 呼び出し
ud := UserData{UserID: "A123", Data: "sample data"}
result := ProcessData(ud.UserID, ud.Data)

この例では、ProcessData関数がユーザーIDに基づく条件分岐を直接行っており、拡張性や保守性に欠けています

良い例

type UserData struct {
    UserID string
    Data   string
}

func (ud UserData) Process() string {
    if ud.UserID == "A123" {
        // 特定のユーザーのデータ処理ロジック
    }
    // 一般的なデータ処理ロジック
}

// 呼び出し
ud := UserData{UserID: "A123", Data: "sample data"}
result := ud.Process()

UserData型のレシーバを用いて、データ処理のロジックを構造体内にカプセル化しています。これにより、Processメソッドは可読性が向上し、責任範囲が明確になります

4.4 fmt.Sprintfの効率的な利用

独自型にstring()やint()を定義することについて
Go言語では、独自の型に対して.String()や.Int()のようなメソッドを定義することで、その型の値を文字列や整数として扱いやすくすることが一般的です

type MyType int

func (m MyType) String() string {
    return strconv.Itoa(int(m))
}

またfmt.Sprintfを使用する場合、独自の.String()メソッドは省略可能が可能です!

before := fmt.Sprintf("%s", myTypeInstance.String()) // 独自の.String()を使用
after := fmt.Sprintf("%s", myTypeInstance)           // fmt.Sprintfが自動的に変換

これにより、コードの可読性が向上し、変換処理が一箇所に集約されます。

5. 並列処理

ここではGoにおける並列処理や並行処理の書き方については解説しません。

5.1 並行処理と並列処理を混同しない

以下を参照してください…

https://zenn.dev/hsaki/books/golang-concurrency/viewer/term

  • 並行処理は、タスクが同時に進行することを意味し、必ずしも同時に実行されるわけではありません。
  • 並列処理は、タスクが物理的に同時に実行されることを意味し、マルチコアのシステムでその利点が最大化されます。

5.2 Goの並行処理について

スレッド: OSが実行できる最小の処理単位
プロセスが複数の処理を同時に実行したい場合、複数のスレッドを生成する。

CPUコア: 多くのCPUはマルチコアを持ち、それぞれのコアが独立して処理を行うことができます。これにより、複数のスレッドを同時に実行することが可能となり、マルチタスキングと並列処理の効率が向上する

コンテキストスイッチ: CPUが現在のタスクから別のタスクへと切り替える際に行われるプロセス。これによりマルチタスキングが可能になる。

goroutine: Go言語における軽量なスレッド。goroutineはOSレベルのスレッドよりもはるかに軽量で、Goランタイムによって管理している。

チャネル (Channels)

用途: データの送受信に使用され、主にgoroutine間の通信や同期に利用されます。

特徴:
データの受け渡しを通じて、goroutine間での同期を実現します。
チャネルを介して送られるデータは、一度に一つのgoroutineによってのみ処理されるため、データ競合を防ぎます。
バッファリングされていないチャネルは、送信と受信が同時に行われるまでブロックされるため、明示的な同期メカニズムとして機能します。

具体例:
複数のgoroutine間でのデータの受け渡し。
タスクの分散実行や結果の集約。
パイプラインパターンやファンイン・ファンアウトパターン。

ミューテックス (Mutexes)

用途: データ構造への排他的アクセスを保証し、競合状態を防ぎます。

特徴:
複数のgoroutineが同時に共有リソースにアクセスする際の同期を提供します。
ミューテックスによりロックされたセクション(クリティカルセクション)は、一度に一つのgoroutineによってのみアクセス可能です。
正しくロックとアンロックを行うことで、データ競合を避けることができます。

使用場面:
共有リソースへのアクセス制御。
複数のgoroutineが同じデータ構造にアクセスする際の同期。

※私たちのプロダクトでは、可読性を下げるという観点で、なるべくcannelを使用しないようにしております

5.3 並行処理と作業負荷

  • CPUバウンド: CPUの処理能力によって性能が制限されるタスク
    マルチスレッドやマルチプロセッシングを使用して、複数のCPUコアでタスクを分散実行することが効果的

  • I/Oバウンド: 入出力操作によって性能が制限されるタスク
    I/O操作は非同期処理やイベント駆動のアプローチを使用して、CPUがI/Oの待ち時間中に他のタスクを実行できるようにすることが効果的

5.4 並行処理の実装:errgroupを使用した異なるデータタイプの処理

1. errgroupの概要
errgroup パッケージは、複数のゴルーチンの実行を同期し、エラーを効率的に処理するための方法を提供します。errgroup は、並行処理のエラーハンドリングを簡素化し、コードの構造を明確にするために役立ちます。

2. クロージャの活用:
errgroup でゴルーチンを作成する際には、クロージャ(無名関数)を使用します。クロージャは、各ゴルーチンに必要なデータや処理をカプセル化し、独立した作業を実行するための環境を提供します。

3. データ処理の並列化:
errgroup を使用すると、異なるデータタイプや処理を並列に実行できます。これにより、複数の処理を効率的に並行して行い、結果を統合することが可能になります。

具体例

type Processor struct {
    // ここに必要なフィールドや依存関係を定義
}

func (p *Processor) ProcessTypeA(ctx context.Context, data []DataTypeA) error {
    // ここにタイプAのデータ処理ロジックを実装
    return nil
}

func (p *Processor) ProcessTypeB(ctx context.Context, data []DataTypeB) error {
    // ここにタイプBのデータ処理ロジックを実装
    return nil
}

func (p *Processor) Execute(ctx context.Context, dataA []DataTypeA, dataB []DataTypeB) error {
    eg, ctx := errgroup.WithContext(ctx)

    // タイプAのデータ処理
    eg.Go(func() error {
        return p.ProcessTypeA(ctx, dataA)
    })

    // タイプBのデータ処理
    eg.Go(func() error {
        return p.ProcessTypeB(ctx, dataB)
    })

    // すべてのゴルーチンが完了するまで待つ
    if err := eg.Wait(); err != nil {
        return err
    }

    return nil
}

この例では、Processor 型に ProcessTypeA と ProcessTypeB という2つのデータ処理メソッドがあり、Execute メソッドでこれらを並行して実行します。

異なるタイプのデータ処理を効率的に管理し、エラーが発生した場合には適切に処理することができます

6. SQL

6.1 コネクションプール

sql.Openは*sql.DBを返すが、1つのコネクションを表すのではなく、コネクションプールを表している

設定パラメータ

  • 最大接続数の設定: SetMaxOpenConnsメソッドを使用して、プール内の最大接続数を設定できます。

    db.SetMaxOpenConns(10) // デフォルトでは無制限
    
  • アイドル接続の設定: SetMaxIdleConnsメソッドを使用して、プール内の最大アイドル(未使用)接続数を設定できます。

    db.SetMaxIdleConns(5) // デフォルトでは2
    
  • 接続のタイムアウト設定: SetConnMaxLifetimeメソッドを使用して、接続がプール内に存在できる最大時間を設定できます。

	db.SetConnMaxLifetime(time.Minute * 5) // デフォルトでは無制限

6.2 プリペアドステートメント

SQL文をコンパイルし、繰り返し実行するために使用されます。これにより、SQLインジェクション攻撃を防ぎ、パフィーマンスが向上します

またダイナミッククエリを毎回作成する場合、データベースはその都度クエリを解析し、実行プランを作成する必要があります。プリペアドステートメントを使用すると、この解析のオーバーヘッドが一回だけになります。

具体例

package main

import (
    "database/sql"
    "log"
)

func main() {
    db, err := sql.Open("mysql", "user:password@/dbname")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // プリペアドステートメントの作成
    stmt, err := db.Prepare("SELECT name FROM users WHERE age = ?")
    if err != nil {
        log.Fatal(err)
    }
    defer stmt.Close()

    // 異なる年齢のユーザーを繰り返し検索
    ages := []int{20, 25, 30, 35} // 検索する年齢のリスト
    for _, age := range ages {
        // ステートメントの実行
        rows, err := stmt.Query(age)
        if err != nil {
            log.Fatal(err)
        }

        // 結果の処理
        for rows.Next() {
            var name string
            if err := rows.Scan(&name); err != nil {
                log.Fatal(err)
            }
            log.Printf("Found user: %s with age: %d\n", name, age)
        }

        // エラーチェック
        if err := rows.Err(); err != nil {
            log.Fatal(err)
        }

        rows.Close()
    }
}

ages スライス内の各年齢に対してプリペアドステートメントを使用しています。
これにより、ステートメントの解析と実行プランの作成は最初の1回だけで済み、以降のクエリ実行ではこのオーバーヘッドを回避できます。また、SQLインジェクションのリスクも最小限に抑えられます。

6.3 null値

SQLクエリの結果としてnull値が返される可能性がある場合、これを適切に扱う必要があります。Goでは、sql.NullStringsql.NullInt64などの特別な型を使用してnull値を扱います。

(基本型がデフォルトでnull値をサポートしていないため)

具体例

package main

import (
    "database/sql"
    "fmt"
    _ "github.com/go-sql-driver/mysql" // SQLドライバー
    "log"
)

func main() {
    // データベースへの接続を設定
    db, err := sql.Open("mysql", "user:password@/dbname")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // クエリを実行
    var (
        id int
        name sql.NullString  // nameがnull値を取り得る場合
    )
    rows, err := db.Query("SELECT id, name FROM users WHERE id = ?", 1)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    for rows.Next() {
        err := rows.Scan(&id, &name)
        if err != nil {
            log.Fatal(err)
        }

        // null値のチェック
        if name.Valid {
            // name はnullではない
            fmt.Println("ID:", id, "Name:", name.String)
        } else {
            // name はnull
            fmt.Println("ID:", id, "Name: null")
        }
    }

    err = rows.Err()
    if err != nil {
        log.Fatal(err)
    }
}

7. デザインパターン

7.1 Builder パターン

複雑なオブジェクトの構築を段階的に行い、そのプロセスをより管理しやすくするデザインパターンです。
特にオブジェクトが多数のパラメータを持つ場合や、オブジェクトの構築が複数のステップを必要とする場合に有用。

ビルダーパターンの利点

  • 分離と抽象化: オブジェクトの構築ロジックをクライアントコードから分離し、異なる表現を持つオブジェクトの構築を容易にします。
  • 可読性: オブジェクトの構築プロセスが段階的かつ明確になるため、コードの可読性が向上します。
  • 柔軟性: 異なるオブジェクトを構築するために、異なるビルダーを用いることができます。

具体例

package main

import "fmt"

// Car はビルダーによって構築されるオブジェクトです。
// この例では、Car インスタンスのホイール数と色を設定します。
type Car struct {
    Wheels int
    Color  string
}

// CarBuilder インターフェースはCarオブジェクトの構築ステップを定義します。
// SetWheels と SetColor はそれぞれ車のホイール数と色を設定します。Build は最終的なCarオブジェクトを返します。
type CarBuilder interface {
    SetWheels(wheels int) CarBuilder
    SetColor(color string) CarBuilder
    Build() Car
}

// carBuilder はCarBuilderインターフェースを実装する具体的なビルダーです。
// Car のインスタンスを内部に保持し、設定を逐次適用します。
type carBuilder struct {
    car Car
}

// NewCarBuilder は新しい carBuilder インスタンスを生成し、CarBuilder インターフェースを返します。
func NewCarBuilder() CarBuilder {
    return &carBuilder{}
}

// SetWheels はCarのホイール数を設定します。
func (b *carBuilder) SetWheels(wheels int) CarBuilder {
    b.car.Wheels = wheels
    return b
}

// SetColor はCarの色を設定します。
func (b *carBuilder) SetColor(color string) CarBuilder {
    b.car.Color = color
    return b
}

// Build は構築されたCarオブジェクトを返します。
func (b *carBuilder) Build() Car {
    return b.car
}

// main関数ではビルダーパターンを使用してCarオブジェクトを構築し、その内容を表示します。
func main() {
    builder := NewCarBuilder()
    car := builder.SetWheels(4).SetColor("Red").Build()
    fmt.Printf("Car: %+v\n", car)
}

この例では、Carオブジェクトの構築にCarBuilderインターフェースを使用しています。

carBuilderはこのインターフェースを実装し、Carオブジェクトの構築過程で複数の設定(ここではホイールの数と色)を提供します。

ビルダーはメソッドチェーンを使用して、コードの可読性と使いやすさを向上させています。

7.2 Functional Optionsパターン

Functional Optionsパターンは、オブジェクトの構築時にオプションの設定を柔軟に行うためのパターンです。特に、関数の引数が多く、いくつかの引数がオプションである場合に有用です。

特徴

  • 柔軟性: 必要なオプションのみを設定し、他はデフォルト値を使用することができます。
  • 拡張性: 新しいオプションを追加しても、既存の関数シグネチャを変更する必要がなく、互換性を保ちやすくなります。

具体例

package main

import "fmt"

// Server - サーバー設定を保持する構造体
type Server struct {
    Host   string
    Port   int
    Secure bool
}

// ServerOption - サーバー設定のオプションを定義する関数型
type ServerOption func(*Server)

// NewServer - 新しいServerオブジェクトを生成し、オプションを適用する
func NewServer(opts ...ServerOption) *Server {
    srv := &Server{
        Host:   "localhost", // デフォルト値
        Port:   8080,        // デフォルト値
        Secure: false,       // デフォルト値
    }

    // 各オプションを適用
    for _, opt := range opts {
        opt(srv)
    }

    return srv
}

// WithHost - ホスト名を設定するオプション
func WithHost(host string) ServerOption {
    return func(srv *Server) {
        srv.Host = host
    }
}

// WithPort - ポート番号を設定するオプション
func WithPort(port int) ServerOption {
    return func(srv *Server) {
        srv.Port = port
    }
}

// WithSecure - セキュアフラグを設定するオプション
func WithSecure(secure bool) ServerOption {
    return func(srv *Server) {
        srv.Secure = secure
    }
}

// 使用例
func main() {
    // カスタムオプションでサーバーを作成
    srv := NewServer(
        WithHost("example.com"),
        WithPort(9090),
        WithSecure(true),
    )

    fmt.Printf("Server: %+v\n", srv)
}

このコード例では、Server 構造体を構築する際に、ホスト名、ポート番号、セキュアフラグといったオプションを柔軟に指定できます。NewServer 関数は可変長のオプション引数を受け取り、これらのオプションを適用してサーバーの設定を行います。

https://zenn.dev/ikechan0829/articles/foggo_generate_fop_cli

7.3 First Class Collection

First Class Collectionパターンは、コレクション操作をカプセル化し、その操作を一箇所に集約するデザインパターンです。これにより、コレクションに対する操作をより再利用可能にし、読みやすく保守しやすいコードを作成することができます。

特徴

  • 単一の責任: コレクションに対する操作を一つのクラスまたは構造体に集約します。
  • 再利用性とクリーンなコード: コレクション操作を抽象化することで、コードの再利用性が高まり、コードベースが整理されます。

具体例

type Users []User

func (u Users) Filter(predicate func(User) bool) Users {
    var filtered Users
    for _, user := range u {
        if predicate(user) {
            filtered = append(filtered, user)
        }
    }
    return filtered
}

// 使用例
func main() {
    users := Users{
        {Name: "Alice", Age: 30},
        {Name: "Bob", Age: 20},
    }

    // 年齢が25以上のユーザーをフィルタリング
    adultUsers := users.Filter(func(u User) bool {
        return u.Age >= 25
    })
    // ...
}

この例では、Users型に対してFilterメソッドを定義しています。

このメソッドは、特定の条件に一致するユーザーをフィルタリングします。

https://zenn.dev/yuyu_hf/articles/93ba0a734208b3

8. エラーについて

8.1 deferによるエラーハンドリング

deferはエラーハンドリングにおいても有効で,複数のエラーが発生する可能性がある関数内で、最後に発生したエラーを適切にキャプチャして返す必要がある場合に役立ちます。

具体例

以下は、ファイル操作を行う関数内でdeferを使用してエラーハンドリングを行う例です。この例では、ファイルを開き、何らかの操作を行った後、ファイルを閉じる際に発生するエラーをキャプチャして返します。

package main

import (
    "fmt"
    "os"
)

func performFileOperation(filename string) (err error) {
    file, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer func() {
        // ファイルクローズ時のエラーをキャプチャ
        if closeErr := file.Close(); closeErr != nil {
            if err == nil {
                err = closeErr
            } else {
                err = fmt.Errorf("%v; additionally, file close error: %v", err, closeErr)
            }
        }
    }()

    // ファイルに対する何らかの操作
    // ...

    return nil
}

func main() {
    err := performFileOperation("example.txt")
    if err != nil {
        fmt.Println("Error:", err)
    }
}

ファイル操作中にエラーが発生した場合、それをerrにセットし、関数の終わりにファイルを閉じる際のエラーもチェックしています。もしファイルクローズ時にエラーが発生した場合、それをerrに追加している。

使用シナリオ
deferの使用: 複数のリソースを管理し、それらのクリーンアップを一貫して行う必要がある場合や、複数のエラーを一か所で処理する必要がある場合に適しています。
例えば、ファイル操作やデータベース接続のような場合です。

逐次的なエラーハンドリング: エラーが発生した場合に即時に対応する必要がある場合や、エラーハンドリングのロジックを単純に保ちたい場合に適しています。
例えば、ユーザー入力の検証や簡単なAPI呼び出しなどです。

9. アーキテクチャ

9.1 ドメイン固有型の活用とそのメリット

ドメインについて
ドメイン固有型(Domain-Specific Type)を利用することで、プログラムの各部分がどのようなデータを扱うかが明確になります。
これは、特定のコンテキストにおける値の意味や用途を明確にするための手法です。

型の明示的な定義により、コードの意図がより明確になり、エラーのリスクを減少させることができます。

具体例

package payment

type (
	TransactionID string
	Amount        int
)

func (t TransactionID) String() string { return string(t) }
func (a Amount) Int() int { return int(a) }

const (
	DefaultTransactionID TransactionID = "default_tx_id"
	MinimumAmount        Amount        = 100
)

// 使用例
func ProcessPayment(txID TransactionID, amount Amount) {
    // トランザクションIDと金額に関する処理
}

支払い処理に関連するデータをドメイン固有型で定義しています
これにより、関数の引数が何を表しているかが一目でわかり、コードの可読性と安全性が向上します。

最後に

普段はAWSやDevOpsの促進をしております。しかし、アプリケーションのパフォーマンスを改善することが、コストを大幅に削減できるということを実体験を通して学びました。

アプリケーションとインフラの両方で改善の余地があり、それぞれがボトルネックになる可能性があります。これらを正確に特定し、適切に対処する能力を身につけたいです。

また、Goのランタイムやスケジューラについても深く理解したいと思っています。
最後までお読みいただき、ありがとうございました。

Discussion

mattnmattn

誤記があるようです。

意味hhします

ましろましろ

素晴しい記事をありがとうございます!
特にrangeの要素のポインタの利用などはこれまで出来ていなかったので非常に勉強になりました。
一点、Builderパターンの具体例でコメントで説明されている名前と実装が異なる部分がいくつかあるようなのでご確認頂けると助かります。

moko-poimoko-poi

ご指摘ありがとうございます!
修正させていただきました🙏