Open20

詳解Go言語Webアプリケーション開発学習メモ

nabetsunabetsu

Ch1. Goのコーディングで意識しておきたいこと

Goが誕生した背景

Googleで生じた様々な問題を解決するために誕生した。

  • ビルド時間が数十分、数時間に膨れ上がった

  • 同じ内容でも表現方法がプログラマ間で異なり可読性が低い

  • 自動化ツールの作成が困難

  • マルチプロセッサ、ネットワークシステム、大規模計算クラスタ等での開発での問題

  • 動的型付け言語の持つプログラミングのしやすさと静的型付けコンパイル言語が持つ効率性と型安全性を両立

  • ネットワークプログラミングやマルチコアプログラミングを容易にする並列処理の書きやすさ

  • 大規模システムや大規模開発システムにおける効率的なプログラミング

※Cloud Native Goに書かれていたように時代の変化に伴い上記の課題が出てきて、それを解決するためにGoが誕生したと考えるとコンテキスト含めて理解しやすそう。

https://zenn.dev/panyoriokome/scraps/b03506d0cc141d

迷ったらシンプルを選ぶ

こうした背景を持つGoでは設計やコーディングに迷ったときにはシンプルであるかを判断基準として持つのが良さそう。

また、ガイドラインとして以下のようなものがあるので参考にすると良い。

nabetsunabetsu

ch2. context パッケージ

GoでWebアプリケーションを開発する際はcontextパッケージを必ず利用する。

概要

contextパッケージはGo 1.7から追加された標準パッケージ。
役割は以下の2点。

  • キャンセルやデッドラインを伝播させる
  • リクエストあるいはトランザクションスコープのメタデータを関数やゴルーチン間で伝達させる

パッケージ全体で400行未満しかない。

context.Contextパッケージがいかになる。

type Context interface {
    Deadline() (deadline time.Time, ok bool) Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

https://pkg.go.dev/context

なぜcontextパッケージを使うか

利用が必須なのは、エンドポイント内部の実装ではクライアントからの通信の切断やタイムアウトは全てcontext.Contextの値からしか知ることができないから。

もしクライアントからの通信の切断やタイムアウトを検知できないとリクエストがタイムアウトしたのに処理を続行してしまうなどの問題が発生する可能性がある。

net/httpパッケージの*http.Request型の値のContextメソッドから取得したcontext.Context型の値を利用する。
クライアントがリクエストをキャンセルした場合、*http.Request型の値から取得できるcontext.Context型の値がキャンセル状態になる。(これ以外にリクエストをキャンセルしたことを知る手段はない)
また、net/httpに限らず多くのパッケージがcontext.Context型の値を受け取る前提で設計されている(ex database/sqlなど)

このように、Webアプリを開発する際、適切な処理を行うためにcontext.Context型の値を適宜チェックする必要があるし、その他の用途でもcontext.Context型の値を利用する可能性が高いので、例え今は利用しなくてもHTTPハンドラーメソッドの関数やメソッドを設計する際には*http.Request型の値から取得したcontext.Context型の値を常に受け取るよう実装しておくべき

メタデータの透過的な伝播

また、その他の理由としてメタデータを透過的に伝播させるためというのがある。

トレースやメトリクスを計測するツールはどの関数でも呼び出す必要があるが、context.Context引数の中にcontext.WithValue関数を使ってトレースIDやリクエストIDを同梱して呼び出し先に伝播させることができる。

キャンセルを通知する

WithCancelContext型の値とCancelFunc型の値を受け取る。
ここで受け取った値を呼び出し先に渡すことで、キャンセルを通知できる。

package main

import (
	"context"
	"fmt"
)

func child(ctx context.Context) {
	if err := ctx.Err(); err != nil {
		return
	}
	fmt.Println("キャンセルされていない")
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	child(ctx) // キャンセルするのでprintされない
	cancel()
	child(ctx) // printされる
}

context.Context型の値にデータを含める

注意点

context.Context型の値をstructに保持するとスコープが曖昧になるのでアンチパターンとされている。
メソッドの中でcontext.Contextの値を使うときは引数で受け取るようにする。

以下の記事で事例付きでもう少し詳しくまとめられている。
[https://go.dev/blog/context-and-structs](Contexts and structs)

具体的にstructでcontext.Contextを定義することのデメリットとして以下が挙げられている。

  • キャンセルやdeadlineの適用範囲が個別のメソッドごとに指定できなくなってしまう。

context.Contextに含める値

公式docに以下の記載があり、

	// Use context values only for request-scoped data that transits
	// processes and API boundaries, not for passing optional parameters to
	// functions.

https://pkg.go.dev/context

  • APIのリクエストスコープの値を含めること
  • 関数への追加引数となる値を含めないこと
  • 複数のgorutineから同時に使われても安全であること

2つ目で言っているのは関数のロジックに関わる値を含めないということ?
極端なことを言えば関数が必要となる全ての値をcontextを通じて受け渡しすることもできるが、そうすると名前、引数、メソッドといった要素で判断することが難しくなり可読性、保守性が落ちてしまう。
そのバランスを考えたときに関数のロジックに関わる値は含めないという考えが出てきたものと思われる。

自分的ポイントまとめ

  • Context provides a means of transmitting deadlines, caller cancellations, and other request-scoped values across API boundaries and between processesと記載があるように、APIやプロセスなど、一定のスコープが定まった処理の中で意図を伝播させていくためのもの
  • contextにどこまで値を入れていくかというのは結構考える余地があると思った。
nabetsunabetsu

ch3. database/sqlパッケージ

https://go.dev/doc/database/

https://github.com/golang/go/wiki/SQLInterface

sql.Open関数を使うのは一度だけ

HTTPリクエストを受け取るたびに*sql.Open関数を呼ぶとコネクションが再利用されず、パフォーマンスが悪くなる。そのため、*sql.Open関数はmain関数や起動時に行う初期化処理の中で一度だけ行う。

仕組み的に以下の通りで、

  • database/sqlパッケージにはDBへのコネクションをプールする機能があり、明示的に設定をしなくても利用できる
  • *sql.DB型の値が内部構造にコネクションプールを持っており、一度だけ作成すれば利用終了時にCloseメソッドを呼べば良い。

コネクションプールの仕組みについては以下の記事が詳しい
https://please-sleep.cou929.nu/go-sql-db-connection-pool.html

XxxメソッドとXxxContextメソッドが存在する場合はXxxContextメソッドを使う。

Xxxメソッドはcontextが導入される前に定義されたメソッドで後方互換性を保つために残されている。

以下がいくつかの例。

sql.ErrNoRowsが発生するのは*sql.Row型の値が戻り値にメソッドだけ

  • database/sqlパッケージにはいくつかの定義済みerrorインタフェースを満たす変数が存在する
  • その中で一番使うのがSQLの実行結果としてレコードが得られなかったときに発生するsqlErrNoRows
  • その使い方には注意が必要で、sql.QueryRow.Scanメソッドでスキャンできるレコードがなかった時のみ発生する

そのため、sql.QueryRow.Scanメソッドの実行結果に対してチェックを行っている以下の処理では意味があるが

row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
if err := row.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
    if err == sql.ErrNoRows {
        return alb, fmt.Errorf("albumsById %d: no such album", id)
    }
    return alb, fmt.Errorf("albumsById %d: %v", id, err)
}

QueryContextの実行結果に対してチェックしている以下の処理では意味のない判定になっている。

func (r *Repository) GetUsersByAge(age int) (Users, error) { var us Users
	// QueryContextはレコードが複数行、返される可能性があるメソッド
	rows, err := r.db.QueryContext(ctx, "SELECT name FROM users WHERE age= ?", age) 
	if errors.Is(err, sql.ErrNoRows) { // この条件が満たされることはない
		// 実行される可能性がない処理
	} else if err != nil { 
		return nil, err
	}
	// 残りの処理...
}

上記の通り複数行を取得する処理でレコードが見つからない場合を考慮してsql.ErrNoRowsの場合にこの処理を実行するなどの分岐を作っても意味のない処理になってしまうので注意。

トランザクションを使うときはdefer文でRollbackメソッドを呼ぶ

  • Goでトランザクションを扱う場合は*sql.DB型の値のBegin/BeginTXメソッドから*sql.TX型の値を取得する
  • その上で、操作結果を永続化するコミットと操作結果を破棄するロールバックのどちらかを実行する必要がある
  • Goのコードではdefer文を使ってRollbackメソッドは必ず呼び出すようにしておく

以下のようにエラーハンドリング必要になる箇所で都度Rollbackメソッドを呼び出すと、呼び出し忘れてバグが混入するリスクがある。

func (r *Repository) Update(ctx context.Context) error { tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err 
	}
	_, err := tx.Exec(/* 更新処理1 */) if err != nil {
		tx.Rollback()
		return err 
	}
	_, err := tx.Exec(/* 更新処理2 */) 
	if err != nil {
		return err // Rollbackメソッドの実行を忘れている 
	}
}

以下のようにdefer文を使ってRollbackメソッドを呼び出しておけば、メソッドスコープが完了するタイミングで必ず実行されるため、Rollbackを呼び出し忘れることがなくなる。

RollbackメソッドはCommitメソッドやcontext経由でのキャンセルが実行ずみのトランザクションに対して実行されてもRDBMS上でロールバックを実行しないので、正常フロー(エラーが発生しない)のケースでも問題は発生しない。

func (r *Repository) Update(ctx context.Context) error { tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err 
	}
	defer tx.Rollback()
	_, err := tx.Exec(/* 更新処理1 */) 
	if err != nil { 
		return err 
	}
	_, err := tx.Exec(/* 更新処理2 */) 
	if err != nil { 
		return err 
	}
	return tx.Commit() 
}

database/sqlパッケージの代わりによく利用されているOSSについて

以下のような理由からプロダクト開発の際にdatabase/sqlパッケージをそのまま利用することはあまりない。

  • クエリの取得結果を構造体にマッピングするのが手間

  • GoのコードからSQLを自動生成したい

  • Active RecordのようなORMを使いたい

  • sqlx

  • ent

    • スキーマやRDBMSの操作をGoのコードから自動生成してくれる
  • gorm

    • Goで一番ポピュラーなORM
  • sqlc

  • sqlboiler

nabetsunabetsu

ch4. 可視性とGo

Goでは他の言語でよくあるpublicやprivateといったアクセス修飾子の概念がなく、パッケージ外から参照できるか/できないかという区分けのみがある(これをexported, unexportedと呼ぶ)

このGoの区分けはパッケージが単位になっている点に注意が必要。
同じパッケージの中にいくつかのビジネスロジックを詰め込んだ場合、それぞれのロジックを参照、作用し合うことができるので、private/publicという概念の感覚でGoの設計、実装を行うとカプセル化に失敗する可能性がある。

type Person struct { 
	firstName string 
	lastName string
}

func (p *Person) GetFirstName() string { 
	return p.firstName 
}

type Book struct { 
	Author *Person
}
func (b *Book) AuthorName() string {
	// 同じパッケージの中なのでPerson構造体のunexpotedなフィールドを直接参照可能
	return fmt.Sprintf("%s %s", b.Author.firstName, b.Author.lastName) 
}
nabetsunabetsu

(Practice)シンプルなWebサーバを立ててみる

最低限でシンプルなwebサーバを立ててみる。

package main

import (
	"fmt"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello, World")
}

func main() {
	http.HandleFunc("/", handler)
	http.ListenAndServe(":8080", nil)
}

go run main.goなどとして実行するとHello Worldが返ってくる。

contextの章で触れられているキャンセルなどのシグナルを伝播させるという目的を考えると、GetUserなどのHandlerから呼ばれる関数にもcontextを渡しておいた方がいいよということ。

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello, World") // Hello, Worldってアクセスした人に返すよ!
	var body struct {
		ID int
	}
	u, err := GetUser(r.Context, body.ID)
}

func GetUser(ctx context.Context, id int) (*User, error) {
	// ここでcontextを利用する可能性がある
        rows, err := db.QueryContext(ctx, "Select...")
}

参考資料

nabetsunabetsu

(Practice)DBいじってみる

環境構築

以下の資料を参考にdocker-composeでmysqlを立ち上げてみる。

下の方の資料に書いてあるとおりホスト指定しないとローカルマシンのmysqlソケットを見に行ってしまって接続できなかったので、以下の通りホスト指定を行う。

mysql -h 127.0.0.1 -P 3306 -u root -p

Tutorial

https://go.dev/doc/tutorial/database-access

データの準備

テストデータをINSERTするsqlファイルを保存して、sourceコマンドで実行する。
パスはフルパスを指定すればOK。

【MySQL】 SQLをファイルから実行する方法 - Qiita

source /path/to/your-file/create-tables.sql

Driverのインストール

利用しているDBに応じて必要なDriverが変わるので以下から検索する。

SQLDrivers · golang/go Wiki

MySQLを使っているのでgo-sql-driver/mysql: Go MySQL Driver is a MySQL driver for Go's (golang) database/sql package を使うことにする。

GoでDBに接続

  • *sql.DB
    • database handle
  • sql.Open
    • 実際に接続はしない?変数とかを初期化するだけ?
package main

import (
	"database/sql"
	"fmt"
	"log"
	"os"

	"github.com/go-sql-driver/mysql"
)

// ここではわかりやすくするためにglobalで宣言している
var db *sql.DB

func main() {
	cfg := mysql.Config{
		User:   os.Getenv("DBUSER"),
		Passwd: os.Getenv("DBPASS"),
		Net:    "tcp",
		Addr:   "127.0.0.1:3306",
		DBName: "recordings",
	}

	var err error
	db, err = sql.Open("mysql", cfg.FormatDSN())
	if err != nil {
		log.Fatal(err)
	}
	// db.PingでDBに接続できることを確認
	pingErr := db.Ping()
	if pingErr != nil {
		log.Fatal(pingErr)
	}
	fmt.Println("Connected!")
}
% export DBUSER=root    
% export DBPASS=root  
% go run main.go 
Connected!

複数行のデータにアクセスする

複数行を返す可能性のあるstatementを実行する場合にはQueryメソッドを使う。

  • DB.Query
    • rowsを返す。
  • Rows.Scan
    • pointerを受け取って対応する値を書き込む

By separating the SQL statement from parameter values (rather than concatenating them with, say, fmt.Sprintf), you enable the database/sql package to send the values separate from the SQL text, removing any SQL injection risk.

他の言語と同様にパラメータを分離することでSQLインジェクションのリスクを避けられる。

func albumsByArtist(name string) ([]Album, error) {
	// sqlの結果を受け取るためにsliceを定義
	var albums []Album

	rows, err := db.Query("SELECT * FROM album WHERE artist = ?", name)
	if err != nil {
		return nil, fmt.Errorf("albumByArtist %q: %v", name, err)
	}
         // 関数の実行後に解放するためにdeferを使う
	defer rows.Close()

	for rows.Next() {
		var alb Album
		if err := rows.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
			return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
		}
		albums = append(albums, alb)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
	}
	return albums, nil
}
% go run main.go
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]

単一行を返すクエリを実行してみる

  • DB.QueryRow
    • 単一行(sql.Row)を返す。
    • QueryRowはerrorを返さないので、後続のScanの中でエラーを特定する
    • sql.ErrNoRowsは該当するレコードが存在しなかったことを示す。
func albumByID(id int64) (Album, error) {
	var alb Album

	row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
	if err := row.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
		if err == sql.ErrNoRows {
			return alb, fmt.Errorf("albumsById %d: no such album", id)
		}
		return alb, fmt.Errorf("albumsById %d: %v", id, err)
	}
	return alb, nil
}
% go run main.go
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]
Album found: {2 Giant Steps John Coltrane 63.99}

データの更新

結果の返ってこない(データ取得ではない)処理を行うならExecを使う。

  • DB.exec
    • 結果セットの返ってこないクエリを実行する
  • Result.LastInsertID
    • 最後にInsertされたデータのIDを返す

func main() {
	...
	// データ更新(INSERT)
	albID, err := addAlbum(Album{
		Title:  "The Modern Sound of Betty Carter",
		Artist: "Betty Carter",
		Price:  49.99,
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("ID of added album: %v\n", albID)
}


func addAlbum(alb Album) (int64, error) {
	result, err := db.Exec("INSERT INTO album (title, artist, price) VALUES (?, ?, ?)", alb.Title, alb.Artist, alb.Price)
	if err != nil {
		return 0, fmt.Errorf("addAlbum: %v", err)
	}
	id, err := result.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("addAlbum: %v", err)
	}
	return id, nil
}

上記のコードを実行するとIDが返ってきてINSERTに成功したことがわかる。

% go run main.go
...
ID of added album: 6
nabetsunabetsu

Ch5. Go Modules

GoではGoコマンドのサブコマンドであるgo modコマンドを使ってパッケージ管理を行う。

  • Go Modules以前の情報は参考にしない
    • 2019年にリリースされたGo 1.13から正式サポートされた機能
    • glide, dep, vgoなどこれ以前に利用されていた仕組みは無視してOK

概要

go.modファイルとgo.sumファイルを用いて依存パッケージのバージョン管理を行う仕組み。

Go Modulesを利用するとGOPATH変数の指定ディレクトリ以下にパッケージのディレクトリを配置しなくてもGoの開発ができる。

セマンティックバージョニング

Goはバージョン番号の規約として?セマンティックバージョニング2.0.0を採用している。
Go Modulesで管理するモジュールもこの規約に則ってバージョニングされている必要がある(Module version numbering - The Go Programming Language )。

例えばGitHubで管理されているモジュールならセマンティックバージョニングの規約に沿ってタグやリリースでバージョンの新旧が判断される。

Minimal Version Selection(MVS)

依存先を確認する際に、極力古いモジュールのパッケージを利用するように設計されている。

research!rsc: Minimal Version Selection (Go & Versioning, Part 4)

Go Modulesを利用する

go.modファイルを作成することでGo Modulesの利用を開始できる。

$ go mod init

# モジュール名をディレクトリ構造と異なる名前にしたい場合は引数にモジュール名を指定
$ go mod init github.com/budougumi0617/example

モノレポではない場合go.modファイルはリポジトリに1ファイルで十分で、サブパッケージを作るたびにgo.modファイルを作成する必要はない。
サブディレクトリでgo getコマンドを実行した場合も自動でgo.modとgo.sumが更新される。

v2.x.x以上のバージョンのパッケージが呼び出せない

モジュール名にv2以上のバージョン名がついたモジュールをコード内で利用する場合には、バージョンのプレフィックスをつけずに利用する。

よく使うコマンド

新しいGoのパッケージを作りたい

go mod initを使う

依存パッケージを更新したい

go get -uを使う

go.mod, go.sumをきれいにしたい

go mod tidyコマンドを使う。

依存先のコードにデバッグコードを差し込みたい

go mod vendorコマンドを使う

Goはgo.modファイルがあるディレクトリにvendorディレクトリがあった場合、そのディレクトリの中のコードを使って依存パッケージを解決する。

go mod vendorコマンドはvendorディレクトリに依存するパッケージをダウンロードしてくれるので、コマンド実行後vendorディレクトリの中にあるコードを修正することでデバッグコードを差し込むことができる

go.modファイルにreplaceディレクティブを記述する

Workspaceモードを使う

Go 1.18集中連載 Workspacesモードを試してみた | フューチャー技術ブログ

Go Modulesを実現するエコシステム

go getコマンドを実行しても直接GitHubなどを参照せず、プロキシサーバでリソースの取得を試みる。

  • proxy.golang.org
  • sum.golang.org
  • index.golang.org

Go Modules Reference - The Go Programming Language

プライベートモジュールを使った開発について

デフォルトの設定のままGo Modulesを使うとGoogleが用意したサーバを介してパッケージの取得を試みるため、プライベートパッケージの取得に失敗する。
プライベートパッケージを利用するにはGOPRIVATE環境変数を設定する。

GOPRIVATE環境変数を指定しておくと、プロキシを経由せずにアクセスしに行ってくれる。

GOPRIVATE="github.com/my_org,github.com/my_account/nabetsu"

# 複数指定する場合にはカンマ区切りで指定
GOPRIVATE="github.com/my_org,github.com/my_account/nabetsu"

自作パッケージのバージョン管理

GitHub Actionsとrelease-it npmでリリース作業を自動化する - BASEプロダクトチームブログ

nabetsunabetsu

Ch6. Goとオブジェクト指向言語

本書ではオブジェクト指向に準拠したプログラム言語の条件として以下3つの要素を備えることとしている。

  • カプセル化(Encapsulation)
  • ポリモーフィズム(Polymorphism)
  • 継承(Inheritance)

Go公式のFrequently Asked Questions (FAQ) - Is_Go_an_object-oriented_language にはYesでもあり、Noでもあるとしている。

Yes and no. Although Go has types and methods and allows an object-oriented style of programming, there is no type hierarchy. The concept of “interface” in Go provides a different approach that we believe is easy to use and in some ways more general. There are also ways to embed types in other types to provide something analogous—but not identical—to subclassing. Moreover, methods in Go are more general than in C++ or Java: they can be defined for any sort of data, even built-in types such as plain, “unboxed” integers. They are not restricted to structs (classes).

Also, the lack of a type hierarchy makes “objects” in Go feel much more lightweight than in languages such as C++ or Java.

Goはサブクラス化に対応していない

クラスの階層構造(親子関係)による継承に対応していない。

埋め込みは継承か?

Goのコードで継承を表現するために埋め込み(Embedding)を使うアプローチ

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// ReadWriter is the interface that combines the Reader and Writer interfaces.
type ReadWriter interface {
    Reader
    Writer
}

A ReadWriter can do what a Reader does and what a Writer does; it is a union of the embedded interfaces. Only interfaces can be embedded within interfaces.

埋め込み元のオブジェクトを引数とするメソッドの変数に代入できない。
これは埋め込みが多態性などを満たさないため。
このように埋め込みはいわゆる継承ではなくコンポジション(他言語でいうトレイとやミックスイン)に近いもの。

こうした例以外にもリスコフの置換原則など一部のSOLID原則はそのままGoに適用できない。ただし、SOLID原則のベースとなる考えを取り入れることでシンプルで可用性の高いGoのコードを書くことはできる。

GoにおけるSOLID原則については以下の記事にまとまっている。
SOLID Go Design | Dave Cheney

また、以下でUdemyの講座で学んだ内容をまとめている。

https://zenn.dev/panyoriokome/scraps/2f66025d516945

nabetsunabetsu

Ch7. Interface

利用者側で最小のインタフェースを定義する

多くのオブジェクト指向言語ではclass File implements Readerのように実装側で明示的にインタフェースを指定して実装する必要がある。

しかし、Goのインタフェースは実装側で指定を行わない暗黙的インタフェース実装を採用しており、どのようなインタフェースを満たしているかわからず一見すると不便に感じる。

type File struct{}

func (f *File) Read(p []byte) (n int, err error){return 0, nil}

実はこの仕様を採用することにより疎結合で柔軟な設計を可能にしている。

  • 利用者側でインタフェースを定義する
  • 最小のインタフェースを定義する

利用者側でインタフェースを定義する

実装が標準パッケージやサードパーティライブラリ内にあったとしても、利用者側でインタフェースを定義して利用できる。
これにより、これらの利用するパッケージの実装に依存せず、完全な疎結合な関係性を築ける。

また、利用者側でインタフェースを定義するため、利用者側のコードで利用するメソッドだけを持つインタフェースを定義できる。

最低限のインタフェース定義を用いることで、SOLID原則のインタフェース分離の原則に則って、実装側と呼び出し側の双方に結合度が低い関係性を持たせることができる。(大きなインタフェース、複数のメソッドに依存していると他者の変更に影響を受ける可能性が高くなり、依存する対象が少なければ少ないほど、凝集度が高い疎結合な設計と言える)

erインタフェース

インタフェースの定義としてXxxメソッドを一つだけ持つインタフェースは慣習としてサフィックスのerをつけた名前にする。
以下の通りEffective Goでも推奨されている。

By convention, one-method interfaces are named by the method name plus an -er suffix or similar modification to construct an agent noun: Reader, Writer, Formatter, CloseNotifier etc.
Effective Go - The Go Programming Language

ライブラリとしてインタフェースを返す

パッケージをライブラリとして他のパッケージに提供する場合は実装の詳細を隠蔽するため、インタフェースを返す関数を作成する場合もある。

インタフェースを返すときは契約による設計を意識する

契約による設計(DBC, Design By Contract)はシステムのユーザではなく、開発者に対して事前条件、事後条件、普遍条件などを明示すること。

C++などではassert関数を使って表現する(コード コントラクト - .NET Framework | Microsoft Docs )が、Goにはassert関数に相当する機能はないためコードコメントによって表現する。

具体例としてio.Readerインタフェースには以下のコメントが記載されている。

Reader is the interface that wraps the basic Read method.

Read reads up to len(p) bytes into p. It returns the number of bytes read (0 <= n <= len(p)) and any error encountered. Even if Read returns n < len(p), it may use all of p as scratch space during the call. If some data is available but not len(p) bytes, Read conventionally returns what is available instead of waiting for more.

APIインタフェースのコメントに何を書くべきかはA Philosophy of Software Designの13.5 interface documentationが参考になる。

インタフェースの注意点

nilとインタフェース

Goのインタフェースは具象型の型情報と値の2つを要素とするデータ構造。

research!rsc: Go Data Structures: Interfaces

インタフェースを作りすぎない

Goは前述の通りあるStructがどのインタフェースを満たしているかstructの定義からは確認できず、不必要にインタフェースを定義しすぎると可読性を低下させる。

プログラミング言語Go ではインタフェースを定義するときの基準として以下のように説明されている。

インタフェースは統一的に扱わなければならないふたつ以上の具象型が存在する場合にだけ必要です。

インタフェースが単一の具象型により満足されているけれど、依存性のためにその具象型がインタフェースと同じパッケージには存在できない場合にだけ、この規則に対する例外としています。この場合、インタフェースはふたつのパッケージを分離するための優れた方法です。

nabetsunabetsu

Ch8. エラーハンドリングについて

Goのエラーの言語仕様は以下のerrorインタフェースの定義のみと非常にシンプル。

type error interface {
    Error() string
}

標準パッケージで定義されているerrorインタフェースに関する関数もerrorsパッケージの4つとfmtパッケージのErrorf関数の合計5つしかない。

// errorsパッケージ
func As(err error, target interface{}) bool func Is(err, target error) bool
func New(text string) error
func Unwrap(err error) error

// fmtパッケージ
func Errorf(format string, a ...interface{}) error

エラーはただの値

Goのエラーに関する考え方はエラーはただの値であるということ。

Errors are values - The Go Programming Language

他の言語のように例外機構(try, catch, except等)がなく、Goのエラーはintstringと同じようにreturnを使って関数やメソッドの戻り値として呼び出し元に返される。

基本的な処理の流れは以下のようになる。

  • 関数やメソッドの返り値としてerror(errorインタフェースを満たす値)を返す
  • 呼び出し元でerrorの値を元に判断する
    • エラーハンドリングをするならばログ出力などを行い、さらに上位の呼び出し元にエラーハンドリングを委譲する場合はerror型の値を返す

エラーを作成する

エラーの定義方法には以下の2つがある。

  • errors.New
  • fmt.Errorf
    • %s%dといった動的な情報を埋め込むときはこっちを使う。

func main() {
	err := errorTestErrorNew()
	if err != nil {
		fmt.Println("Error errors.New")
	}

	err = errorTestFmtErrorf()
	if err != nil {
		fmt.Println("Error fmt.Errorf")
	}

	err = errorChain()
	if err != nil {
		fmt.Printf("errorChainの呼び出しでエラー: %v", err)
	}
}

func errorTestErrorNew() error {
	return errors.New("Test: errors.New")
}

func errorTestFmtErrorf() error {
	return fmt.Errorf("Test: fmt.Errorf")
}

func errorChain() error {
	err := errorTestErrorNew()
	if err != nil {
		return fmt.Errorf("errorChain: %v", err)
	}
	return nil
}
% go run main.go
Error errors.New
Error fmt.Errorf
errorChainの呼び出しでエラー: errorChain: Test: errors.New

上記でやっているように呼び出し先で生じたエラーをさらに伝播させる場合には受け取ったerrorの値から新たなerrorを生成する(エラーのチェーン)。

※スタックとレースをとりあえず出しておくよりも、簡潔なエラー情報を埋め込んだ方が効率的という考え?

エラーをラップする

エラーのチェーンを前述のやり方でやるとエラーオブジェクトとしての情報は失われる(人間にはわかるがプログラムでわかる状態じゃない)

エラーをラップして情報を付与しつつエラー発生源のオブジェクト情報を保持しておきたいというニーズのためにGo1.13からラップ形式が追加された。

以下のようにfmt.Errorfで情報を埋め込む際に%wを利用する。

func errorChainWrap() error {
	err := errorTestErrorNew()
	if err != nil {
		// %wを使うことで元のエラーのオブジェクト情報を保持したままにする
		return fmt.Errorf("errorChain: %w", err)
	}
	return nil
}

errors.Is関数を使ってエラーを識別する

errors.Is関数を使うとエラーがラップされていてもエラーチェーンの中に特定のエラーオブジェクトが含まれているか確認できる。

	// errors.Isを使ってラップしたエラーオブジェクトの型を判別
	err = errorChainWrap()
	if err != nil {
		if errors.Is(err, ErrErrosNew) {
			fmt.Printf("ErrErrosNewのエラーを識別 %v", err)
		}
	}

エラーの定義がないと判別ができないが、エラーの定義としては以下2通りの利用方法がある。

erros.Asを使った独自情報の取得

独自エラーを宣言する

独自パッケージでもerrors.Is関数を使って特定のエラー状態を識別したい場合が発生する。
その場合にはパッケージスコープの変数としてerror型の値を定義する。
Goで独自エラーを定義する際は慣習としてErr-というプレフィックスを使えるのが一般的。

var ErrErrosNew = errors.New("errors.New")
var ErrFmtErrorf = fmt.Errorf("fmt.Errorf")

独自のエラー構造体を定義する

独自エラーを宣言するだけでなく、エラーオブジェクトのフィールドに情報を付与したりしたい場合は、エラー用に独自の構造体を用意する。

nabetsunabetsu

ch9. 無名関数・クロージャ

Goでどのように関数を扱えるのか

Goの言語仕様において関数はファーストクラスオブジェクトであり、関数を変数に代入したり、型として利用できる。関数リテラルを使って無名関数を作ることもできる。

Webアプリケーションと無名関数

無名関数を使うことが多いのはミドルウェアパターンとHTTPクライアントの実装。

無名関数を使って指定の関数型と同じシグネチャの関数を作る

状態を持つ関数を作る

goroutine利用時は無名関数から外部の変数の参照を避ける

nabetsunabetsu

ch10. 環境変数の扱い方

  • 環境変数を読み込むのはシステムコールを呼ぶ操作なので実行にコストがかかる
  • クラウドネイティブなアプリにおいて環境変数を入れ替えるときは通常デプロイを行なってインスタンスごとに入れ替える
  • 上記を踏まえて環境変数を読み込むのはアプリ起動時の処理で行うようにする

Goで環境変数をどう扱うか

利用する関数

  • os.Getenv関数
    • キーで指定された環境変数名の値を取得する
    • 指定されたキーで環境変数が設定されていない場合空文字が返される
    • 空文字が設定されている環境変数とは区別がつかない
  • os.LookupEnv関数
    • os.Getenv関数でできなかった環境変数が定義されているかを判断できる
    • 第2戻り値にboolを返すので、この値によって設定有無を判断できる
func Getenv(key string) string
func LookupEnv(key string) (string, bool)

サードパーティライブラリについて

Webアプリの開発ではDBへの接続情報やクレデンシャル情報など様々な環境変数の値が必要になる。

osパッケージのみを使って環境変数を扱おうとすると、環境変数が増える度に前述の関数を呼び出して値を設定する必要がある。また、osパッケージで取得するとstring型になるので特定の型で扱いたい場合はパース処理を書く必要もある。

caarlos0/env など環境変数を便利に扱えるサードパーティのライブラリもあるので、利用を検討してもいいかも。

環境変数にまつわるテスト

Go1.17よりt.Setenvメソッドが追加された。
このメソッドを利用すれば呼び出したテストケースが実行されている間だけ環境変数が設定された状態になる。
testing package - testing - Go Packages

注意点としてはt.Parallelメソッドと併用はできない。(他のテストケースへの副作用が避けられないため)
そのため、環境変数を操作するパッケージのテストは一つのテストケースにまとめておいた方が良い。

nabetsunabetsu

ch.11 GoとDI

上位概念の問題が下位概念の問題から独立して解決するための方法としてSOLID原則の1つに依存関係逆転の原則(Dependency Inversion priciple, DIP)がある。

上位のモジュールは下位のモジュールに依存してはならない。どちらのモジュールも「抽象」に依存すべきである。「抽象」は実装の詳細に依存してはならない。実装の詳細が「抽象」に依存すべきである。

拡張性や保守性が高いソフトウェアを実現するための鍵は構造化と適切な境界定義。
対象を型やパッケージすることで構造化し、DIPの考えを用いることで型同士、パッケージ同士を疎結合にできる。

前述の通りGoでは暗黙的インタフェース実装のみをサポートしているため、実装の詳細を利用する利用者側でインタフェースを定義する。
つまり、実装の詳細側はインタフェース定義を参照する必要がない(自分自身がどのように抽象化されているか知らない)状態になっている。

しかし、Goの中には特定のインタフェースを経由して利用されることを想定した実装(抽象に依存した実装)も存在する

database/sql/driverパッケージとDIP

database/sql/driverパッケージがDIPを利用した典型的な設計。

  • GoからRDBMSを操作する際はdatabase/sqlパッケージを介して操作する
  • database/sqlパッケージに各ベンダーやOSS個別仕様に対応する具体的な実装は含まれていない
  • 各RDBMSに対応したドライバパッケージをブランクインポートすることSQLドライバが登録され、操作が可能な状態になる
  • ドライバパッケージはdatabase/sql/driver.Driverインタフェースなどを実装することでこの仕組みを可能にしている
import (
    "database/sql"
    - "github.com/go-sql-driver/mysql"
)

これはRBDMSごとの実装の詳細が上位概念から提供されているインタフェース(database/sql/driver.Driver)に依存している状態。

その他の例としてWebアプリケーションのミドルウェアはnet/httphttp/HandlerFuncに合わせた実装になっている。

DIPに準拠した実装

GoでDIPに準拠した実装をする場合、依存性の注入(DI)パターンが利用される。

  • オブジェクト初期化時にDIする方法
  • 「setter」を用意しておいてDIする方法
  • メソッド(関数)呼び出し時にDIする方法
nabetsunabetsu

ch14. HTTPサーバを作る

Webサーバを起動する

http.ListenAndServe 関数でHTTPサーバを起動する。
第2引数にハンドラーとしてhttp.HandleFunc関数を渡して、リクエストが来たときにどのような処理するか定義する。

package main

import (
	"fmt"
	"net/http"
	"os"
)

func main() {
	err := http.ListenAndServe(
		":18080",
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Fprint(w, "Hello, %s!", r.URL.Path[1:])
		}),
	)
	if err != nil {
		fmt.Printf("failed to terminate server: %v", err)
		os.Exit(1)
	}
}

サーバを起動して動作確認する。

% go run .
% curl localhost:18080/from_cli
Hello, %s!from_cli

リファクタリング

現在の課題点を解消していく

テスト容易性が低い

main関数自体はテストコードから実行可能だが、中断操作ができず関数の戻り値もないため出力を検証することが困難。異常状態になったときはos.Exit関数を実行するため直ちに終了してしまう。

  • テスト完了後に終了する術がない
  • 出力を検証しにくい
  • 異常時にos.Exit関数がよばれてしまう
  • ポート番号が固定されてしまう

run関数へ処理を分離する

上記の問題点を解消するため、まずmain関数から処理をrun関数へと分離する。
run関数はGoでメインプロセスを実装する際に使われる実装パターンで、Webサーバの実装に限らず、コマンドラインの実装でも利用される。)

具体的に以下のようにcontext.Context型の値を引数に取り、異常時にos.Exit関数を呼ぶのではなく、error型の値を戻す。

func main() {
	if err := run(context.Background()); err != nil {
		log.Printf("failed to terminate server: %v", err)
	}

関数の外部からサーバのプロセスを中断可能にする

net/httpパッケージにはhttp.Server型があり、今使っているhttp.ListenAndServe関数ではなく、*http.Server型のListenAndServeメソッドを利用することでもHTTPサーバを起動できる。

http.Server型にはShutdownメソッドが実装されており、これを呼び出すことでHTTPサーバを終了できる。
こうしたメリットのほか、*http.Server型を使うとサーバのタイムアウト時間などを柔軟に設定することも可能なため、HTTPサーバを起動する際は*http.Server型を経由してHTTPサーバを起動するのが定番になる。
(タイムアウトの設定についてはThe complete guide to Go net/http timeouts が参考になる)

まず、以下のように*http.Server型を使ってHTTPサーバを起動する。

func run(ctx context.Context) error {
	s := &http.Server{
		Addr: ":18080",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Fprint(w, "Hello, %s!", r.URL.Path[1:])
		}),
	}
	s.ListenAndServe()
}

これでHTTPサーバを起動するところまでは実装できたが、context.Contextから伝播される終了通知を待機する実装をする必要がある。(中断ができるように)

go文を使って平行処理を行うと実相が複雑になるので、準標準パッケージであるsync module - golang.org/x/sync - Go Packages を利用する。

まずインストールする。

% go get -u golang.org/x/sync

このライブラリの使い方について、errgroupサブパッケージが含まれており、errgroup.Group型を使うと戻り値にエラーが含まれるgoroutine間の平行処理の実装をシンプルにできる(Goの標準パッケージにあるsync.WaitGroup型でもgoroutineの扱いをシンプルにできる型があるが、別goroutine上で実行する関数から戻り値でエラーを取得できないので、今回の用途には適さない)

最終的な実装としては以下のようになる。

func run(ctx context.Context) error {
	s := &http.Server{
		Addr: ":18080",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Fprint(w, "Hello, %s!", r.URL.Path[1:])
		}),
	}
	eg, ctx := errgroup.WithContext(ctx)
	// http.ErrServerClosedはhttp.Server.Shutdown()が正常に終了したことを示すので異常ではない
	eg.Go(func() error {
		if err := s.ListenAndServe(); err != nil &&
			err != http.ErrServerClosed {
			log.Printf("failed to close: %+v", err)
			return err
		}
		return nil
	})

	// チャネルからの通知を待機する
	<-ctx.Done()
	if err := s.Shutdown(context.Background()); err != nil {
		log.Printf("failed to shutdown: %+v", err)
	}
	// Goメソッドで起動した別goroutineの終了を待つ
	return eg.Wait()
}

run関数をテストする

  • 期待通りにHTTPサーバが起動しているか
  • テストコードから意図通りに終了するか
func TestRun(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	eg, ctx := errgroup.WithContext(ctx)
	// 別goroutineでrun関数を実行してHTTPサーバを起動する
	eg.Go(func() error {
		return run(ctx)
	})
	in := "message"

	// エンドポイントにリクエストを送信
	rsp, err := http.Get("http://localhost:18080/" + in)
	if err != nil {
		t.Errorf("failed to get: %+v", err)
	}
	defer rsp.Body.Close()
	got, err := io.ReadAll(rsp.Body)
	if err != nil {
		t.Fatalf("failed to read body: %v", err)
	}

	// HTTPサーバからの戻り値を検証する
	expected := fmt.Sprintf("Hello, %s!", in)
	if string(got) != expected {
		t.Errorf("expected %q, but got %q", expected, got)
	}

	// run関数に終了通知を送信する
	cancel()
	// run関数の戻り値を検証する
	if err := eg.Wait(); err != nil {
		t.Fatal(err)
	}
}

テストを実行する

% go test -v ./...
=== RUN   TestRun
--- PASS: TestRun (0.01s)
PASS
ok      todo_app        0.466s

ポート番号を変更できるようにする

netパッケージやnet/httpパッケージではポート番号に0を指定すると利用可能なポートを同的に選択してくれる。ただし、run関数の中でポート番号を自動選択するとテストコードからどのポート番号へリクエストを飛ばせばいいのかわからなくなる。
そのため、run関数外部から動的に選択したポート番号のリッスンを開始したnet.Listenerインタフェースを満たす型の値を渡すようにする。

func main() {
	if len(os.Args) != 2 {
		log.Printf("need port number\n")
		os.Exit(1)
	}
	p := os.Args[1]
	l, err := net.Listen("tcp", ":"+p)
	if err != nil {
		log.Fatalf("failed to listen port %s: %v", p, err)
	}
	if err := run(context.Background(), l); err != nil {
		log.Printf("failed to terminate server: %v", err)
	}
}

func run(ctx context.Context, l net.Listener) error {
	// 引数で受け取ったnet.Listenerの値を利用するためAddrは指定しない
	s := &http.Server{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
		}),
	}
	eg, ctx := errgroup.WithContext(ctx)
	// http.ErrServerClosedはhttp.Server.Shutdown()が正常に終了したことを示すので異常ではない
	eg.Go(func() error {
		// ListenAndServeではなくServeにし、net.Listener型の値を渡す
		if err := s.Serve(l); err != nil &&
			...

なお、実装にあたりos.Args変数を使ってコマンドライン変数を取得しているが、flagsパッケージを使ってフラグとして引数を受け取る方法もある。

flagsについては以下のスクラップでもまとめている。
https://zenn.dev/link/comments/978da7670dd17e

以下の通り動的にポート番号が選択されていることが確認できる。

% go test -v ./...
=== RUN   TestRun
    main_test.go:27: try request to "http://127.0.0.1:59848/message"
--- PASS: TestRun (0.01s)
PASS
ok      todo_app        0.456s
nabetsunabetsu

ch15. 開発環境を構築する

開発環境や継続的インテグレーションを整備する。

Dockerを利用した実行環境

Goはビルドすればシングルバイナリでデプロイできるので、コンテナを作成するときもビルドしてできたバイナリファイルだけをコンテナに含めればOK。
ビルド前のソースコードなどは不要なため中間ビルドステージでビルドするマルチステージビルドを実施する。

まずdockerignoreファイルを作成する。

.dockerignore
.git
.DS_Store

次にDockerfileを作成する。以下3種類のステージに分ける。

ステージ名 役割
deploy-builder リリース用のビルドを行う
deploy マネージドサービス上で動かすことを想定したリリース用のイメージ
dev ローカル開発で利用するイメージ

ホットリロード環境

cosmtrek/air: ☁️ Live reload for Go apps を使ってホットリロードを実現する。
airコマンドを実行するとファイルの更新を検知するたびにgo buildコマンドを再実行して実行中のGoプログラムを再起動してくれる。

airコマンド用に.air.tomlという設定ファイルを用意する。
ここには監視から除外するディレクトリや実行時の引数を指定できる。

Docker Composeの設定

開発するアプリではMySQLやRedisを利用する想定のため、これらを後々簡単にローカルで立ち上げるためDocker Composeを利用する。

version: "3.9"
services:
    app:
        image: gotodo
        build:
            args:
                - target-dev
        volumes:
            - .:/app
        ports:
            - "18000:80" # 80でポートが起動するのでローカルマシンの指定したポートにバインド

以下のコマンドでイメージのビルドとコンテナの起動を行う。

$ docker compose build --no-cache
$ docker compose up

curlでサーバが動作していることが確認できればOK。

% curl localhost:18000/hello
Hello, hello!

Makefileの作成

GoのプロジェクトではタスクランナーとしてMakefileを使うケースが多い。
今後利用するコマンドについてMakefileに定義をしておく。

.PHONY: help build build-local up down logs ps test
.DEFAULT_GOAL := help

DOCKER_TAG := latest
build: ## Build docker image to deploy
	docker build -t budougumi0617/gotodo:${DOCKER_TAG} \
		--target deploy ./

build-local: ## Build docker image to local development
	docker compose build --no-cache 
	
up: ## Do docker compose up with hot reload 
	docker compose up -d

down: ## Do docker compose down
	docker compose down

上記のようにコマンド: ##コマンドの説明という書き方をしてhelpコマンドを用意しておくと以下のようなヘルプ表示ができるので特にチーム開発で便利。(より詳しくはMakefileを自己文書化する | POSTD 参照)

% make help
build                Build docker image to deploy
build-local          Build docker image to local development
up                   Do docker compose up with hot reload 
down                 Do docker compose down
logs                 Tail docker compose logs 
ps                   Check container status 
test                 Execute tests
help                 Show options

GitHub Actionsを利用したCI環境の作成

テストとコードカバレッジ取得の自動化

コードカバレッジをPRにコメントするアクションとしてoctocov を利用する

カバレッジの取得自体はgo test ./... -coverprofile=coverage.outのコマンドを使う。

静的解析の追加

Goでは多くの静的解析ツールが公開されており個別に管理すると大変なのでgolangci-lint を使って複数の静的解析ツールを呼ぶのが定石。

GitHub Actions上で実行するならreviewdog/action-golangci-lint を使うのが良い。
これを使えば静的解析でエラーが報告されたときPR上に該当行をコメントしてくれる。

以前以下の記事にもまとめていた。(PRへのコメントのされ方とかスクショがあるので参考になるかも)

https://zenn.dev/link/comments/d5abe3cdb7beb0

golangci-lintはyamlファイルで静的解析の内容を定義できるのでファイルを作成する。

golangci.yml
linters-settings: 
  govet:
    check-shadowing: false 
  gocyclo:
    min-complexity: 30 
  misspell:
    locale: US

linters: 
  disable-all: true

enable:
  - goimports
  - deadcode
  - errcheck
  - gocognit
  - gocritic
  - gocyclo
  - gofmt
  - govet
  - misspell
  - staticcheck 
  - whitespace

使用できるlinterの一覧はLinters | golangci-lint にまとめられている。

nabetsunabetsu

ch16 HTTPサーバを疎結合な構成にする

環境変数から設定をロードする

Beyond the Twelve-Factor Appに従い、設定値を環境変数から読み込めるようにする。
「5. Configuration, credentials, and code」に設定値に関する記載があり、Twelve-Factor Appでは以下の通り環境依存の値は環境変数に入れておくという旨が書いてあった。

例えば「接続する API のエンドポイント情報」だったり「データベースの接続情報」だったり,環境依存な設定を環境変数に入れておくというプラクティス

Beyond the Twelve-Factor Appでは、外部化(可能なら外部サービスに入れること)がポイントとして付け加えられている。

Beyond the Twelve-Factor App では,環境変数を外部化 (Externalizing Configuration) する必要性と,可能なら外部サービスに入れると書いてあった。

Configパッケージの作成

caarlos0/env: A simple and zero-dependencies library to parse environment variables into structs. を取得して、新しいパッケージを作成するためディレクトリを作る。

% go get -u "github.com/caarlos0/env/v6"
go: downloading github.com/caarlos0/env v3.5.0+incompatible
go: downloading github.com/caarlos0/env/v6 v6.9.3
go get: added github.com/caarlos0/env/v6 v6.9.3
% mkdir config

新しいパッケージを作成する理由はt.Setenv メソッドを使って環境変数の設定を操作するテストが他のテストに影響を及ぼさないため。
configの定義は以下の通りで、基本的にgithubのサンプルとほとんど同じ。

config/config.go
type Config struct {
	Env  string `env:"TODO_ENV" envDefault:"dev"`
	Port int    `env:"PORT" envDefault:"80"`
}

func New() (*Config, error) {
	cfg := &Config{}
	if err := env.Parse(cfg); err != nil {
		return nil, err
	}
	return cfg, nil
}

テストファイルは以下の通りで、デフォルト値の設定と明示的に設定した場合の確認になっている。

config/config_test.go
func TestNew(t *testing.T) {
	// 設定した環境変数が適用されているか
	wantPort := 3333
	t.Setenv("PORT", fmt.Sprint(wantPort))

	got, err := New()
	if err != nil {
		t.Fatalf("cannnot create config: %v", err)
	}
	if got.Port != wantPort {
		t.Errorf("want %d, but got %d", wantPort, got.Port)
	}

	// デフォルト値が設定されているか
	wantEnv := "dev"
	if got.Env != wantEnv {
		t.Errorf("want %s, but got %s", wantEnv, got.Env)
	}
}
% make test
go test -race -shuffle=on ./...
ok      todo_app        0.500s
ok      todo_app/config 0.468s

環境変数を使って起動する

先程作成したConfigパッケージをimportしてポートの値をconfigを通じて取得するようにする。
それに伴いmain関数はコードを大幅に削減して、シンプルにサーバを起動するだけになった。

main.go
func run(ctx context.Context) error {
        // 環境変数を使うように修正した部分
	cfg, err := config.New()
	if err != nil {
		return err
	}
	l, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Port))
	if err != nil {
		log.Fatalf("failed to listen port %d: %v", cfg.Port, err)
	}
	url := fmt.Sprintf("http://%s", l.Addr().String())
	log.Printf("start with: %v", url)

	// 引数で受け取ったnet.Listenerの値を利用するためAddrは指定しない
	s := &http.Server{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
		}),
	}
...
func main() {
	// if len(os.Args) != 2 {
	// 	log.Printf("need port number\n")
	// 	os.Exit(1)
	// }
	// p := os.Args[1]
	// l, err := net.Listen("tcp", ":"+p)
	// if err != nil {
	// 	log.Fatalf("failed to listen port %s: %v", p, err)
	// }
	if err := run(context.Background()); err != nil {
		log.Printf("failed to terminate server: %v", err)
	}
}

なお自作したパッケージのimport方法は以下2つがあるっぽい。

main.go
import (
	...
	// "todo_app/config"
	"github.com/panyoriokome/golang-web-application/config")

テストファイルの一時的なスキップ

上記の設定変更によりテストファイルにコンパイルエラーが発生するようになっているので修正する。
具体的にはt.Skipメソッドを使う。

main_test.go
func TestRun(t *testing.T) {
	t.Skip("リファクタリング中")
docker-compose.yml
version: "3.9"
services:
  app:
    image: gotodo
    build:
      args:
        - target-dev
    # 環境変数を設定
    environment:
      TODO_ENV: dev
      PORT: 8080
    volumes:
      - .:/app
    ports:
      # 8080でenvironmentに指定したポートにつながるようにする
      - "18000:8080" # 80でポートが起動するのでローカルマシンの指定したポートにバインド

シグナルをハンドリングする

Webアプリケーションのサーバとして必要な機能の一つがグレースフルシャットダウン。
サーバやコンテナが終了することになった場合、プロセスは終了シグナルを受け取る。
処理の実行中に終了シグナルを受け取った場合、処理を正しく終了するまでプロセスを終了しないようにしたい。

実装

Linuxのシグナルは多数あるが、ハンドリングするのは割り込みシグナル(SIGINT)と終了シグナル(SIGTERM)。

よくサーバを止めるときにコマンドラインで入力するCTRL+Cを押したときには割り込みシグナルがアプリケーションに送信される。(コンテナ環境(Kubernetes, Amazon ECSなど)なら外部からコンテナに終了シグナルが送信される)

実装としては以下の2行だけでOK。
Go1.15以前はチャネルを使ったやりとりを実装する必要があったが、1.16以降はos/signalパッケージに追加されたsignal.NotifyContext関数を利用してシグナルの受信を検知できるようになった。(context.Context型の値を経由して)

func run(ctx context.Context) error {
	ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
	defer stop()

Server構造体を定義する

Run関数の内部で行う処理が多くなってきた。今後はさらにエンドポイントごとにハンドラーやルーティングの定義も増えてくるので、Server型を作り、HTTPサーバに関わる部分の処理を分割する。

現在のRun関数の定義は以下の通り。

func run(ctx context.Context) error {
	ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
	defer stop()
	cfg, err := config.New()
	if err != nil {
		return err
	}
	l, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Port))
	if err != nil {
		log.Fatalf("failed to listen port %d: %v", cfg.Port, err)
	}
	url := fmt.Sprintf("http://%s", l.Addr().String())
	log.Printf("start with: %v", url)

	// 引数で受け取ったnet.Listenerの値を利用するためAddrは指定しない
	s := &http.Server{
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
		}),
	}
	eg, ctx := errgroup.WithContext(ctx)
	// http.ErrServerClosedはhttp.Server.Shutdown()が正常に終了したことを示すので異常ではない
	eg.Go(func() error {
		// ListenAndServeではなくServeにし、net.Listener型の値を渡す
		if err := s.Serve(l); err != nil &&
			err != http.ErrServerClosed {
			log.Printf("failed to close: %+v", err)
			return err
		}
		return nil
	})

	// チャネルからの通知を待機する
	<-ctx.Done()
	if err := s.Shutdown(context.Background()); err != nil {
		log.Printf("failed to shutdown: %+v", err)
	}
	// Goメソッドで起動した別goroutineの終了を待つ
	return eg.Wait()
}

上記の実装が含まれた状態だとサーバはレスポンスを返してから終了する。

# グレースフルシャットダウンを実装した場合
$ time curl -i localhost:28000/hello
HTTP/1.1 200 OK
Date: Sat, 27 Aug 2022 01:47:43 GMT
Content-Length: 13
Content-Type: text/plain; charset=utf-8
Connection: close

Hello, hello!
real    0m5.019s
user    0m0.003s
sys     0m0.008s

# 実装していない場合
$ time curl -i localhost:28000/hello
curl: (52) Empty reply from server

real    0m3.317s
user    0m0.004s
sys     0m0.010s

ルーティング定義の分離

次にHTTPハンドラーの定義を分離する。
実装上のポイントとしては戻り値を*http.ServeMux型の値ではなくhttp.Handlerインタフェースにすることで内部実装に依存しない関数シグネチャにしていること。

mux.go
package main

import "net/http"

func NewMux() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_, _ = w.Write([]byte(`{"status: "ok"}`))
	})
	return mux
}

テスト時にはhttptestパッケージを利用することでHTTPサーバを起動しなくても簡単にHTTPハンドラーに対するテストコードを作成できる。

具体的には以下のようにhttptest.NewRecorder関数とhttptest.NewRequest関数を利用する。

	// httptestを使ってServeHTTPに渡すためのモックを生成
	// NewRecorder関数を使うとResponseWriterインタフェースを満たす
	// *ResponseRecorder型の値を取得できる
	w := httptest.NewRecorder()
	// NewRequest関数を呼んで*http.Request型の値を生成する
	r := httptest.NewRequest(http.MethodGet, "/health", nil)
	sut := NewMux()
	sut.ServeHTTP(w, r)
	// Resultメソッドを呼ぶことでクライアントが受け取るレスポンス内容が含まれる
	// http.Response型の値を取得できる
	resp := w.Result()

nabetsunabetsu

Ch17. エンドポイントを追加する

型の定義

IDフィールドとStatusフィールドにDefined Typeを使って独自の型を定義している。
これにより誤った代入を防ぐことができる。(A型のIDを使ってB型を検索してしまうなど)

package entity

import "time"

type TaskID int64
type TaskStatus string

const (
	TaskStatusTodo  TaskStatus = "todo"
	TaskStatusDoing TaskStatus = "doing"
	TaskStatusDone  TaskStatus = "done"
)

type Task struct {
	ID      TaskID     `json:"id"`
	Title   string     `json:"title"`
	Status  TaskStatus `json:"status"`
	Created time.Time  `json:"created"`
}

type Tasks []*Task