詳解Go言語Webアプリケーション開発学習メモ
Ch1. Goのコーディングで意識しておきたいこと
Goが誕生した背景
Googleで生じた様々な問題を解決するために誕生した。
-
ビルド時間が数十分、数時間に膨れ上がった
-
同じ内容でも表現方法がプログラマ間で異なり可読性が低い
-
自動化ツールの作成が困難
-
マルチプロセッサ、ネットワークシステム、大規模計算クラスタ等での開発での問題
-
動的型付け言語の持つプログラミングのしやすさと静的型付けコンパイル言語が持つ効率性と型安全性を両立
-
ネットワークプログラミングやマルチコアプログラミングを容易にする並列処理の書きやすさ
-
大規模システムや大規模開発システムにおける効率的なプログラミング
※Cloud Native Goに書かれていたように時代の変化に伴い上記の課題が出てきて、それを解決するためにGoが誕生したと考えるとコンテキスト含めて理解しやすそう。
迷ったらシンプルを選ぶ
こうした背景を持つGoでは設計やコーディングに迷ったときにはシンプルであるかを判断基準として持つのが良さそう。
また、ガイドラインとして以下のようなものがあるので参考にすると良い。
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{}
}
なぜ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を同梱して呼び出し先に伝播させることができる。
キャンセルを通知する
WithCancel
でContext
型の値と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.
- 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にどこまで値を入れていくかというのは結構考える余地があると思った。
ch3. database/sqlパッケージ
sql.Open関数を使うのは一度だけ
HTTPリクエストを受け取るたびに*sql.Open関数
を呼ぶとコネクションが再利用されず、パフォーマンスが悪くなる。そのため、*sql.Open関数
はmain関数や起動時に行う初期化処理の中で一度だけ行う。
仕組み的に以下の通りで、
-
database/sql
パッケージにはDBへのコネクションをプールする機能があり、明示的に設定をしなくても利用できる -
*sql.DB
型の値が内部構造にコネクションプールを持っており、一度だけ作成すれば利用終了時にClose
メソッドを呼べば良い。
コネクションプールの仕組みについては以下の記事が詳しい
XxxメソッドとXxxContextメソッドが存在する場合はXxxContextメソッドを使う。
Xxxメソッドはcontextが導入される前に定義されたメソッドで後方互換性を保つために残されている。
以下がいくつかの例。
- https://pkg.go.dev/database/sql#DB.Exec
- https://pkg.go.dev/database/sql#DB.ExecContext
- https://pkg.go.dev/database/sql#DB.Query
- https://pkg.go.dev/database/sql#DB.QueryContext
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
パッケージをそのまま利用することはあまりない。
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)
}
(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...")
}
参考資料
(Practice)DBいじってみる
環境構築
以下の資料を参考にdocker-composeでmysqlを立ち上げてみる。
下の方の資料に書いてあるとおりホスト指定しないとローカルマシンのmysqlソケットを見に行ってしまって接続できなかったので、以下の通りホスト指定を行う。
mysql -h 127.0.0.1 -P 3306 -u root -p
Tutorial
データの準備
テストデータをINSERTするsqlファイルを保存して、sourceコマンドで実行する。
パスはフルパスを指定すればOK。
【MySQL】 SQLをファイルから実行する方法 - Qiita
source /path/to/your-file/create-tables.sql
Driverのインストール
利用しているDBに応じて必要なDriverが変わるので以下から検索する。
MySQLを使っているのでgo-sql-driver/mysql: Go MySQL Driver is a MySQL driver for Go's (golang) database/sql package を使うことにする。
GoでDBに接続
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
メソッドを使う。
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
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プロダクトチームブログ
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の講座で学んだ内容をまとめている。
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 ではインタフェースを定義するときの基準として以下のように説明されている。
インタフェースは統一的に扱わなければならないふたつ以上の具象型が存在する場合にだけ必要です。
インタフェースが単一の具象型により満足されているけれど、依存性のためにその具象型がインタフェースと同じパッケージには存在できない場合にだけ、この規則に対する例外としています。この場合、インタフェースはふたつのパッケージを分離するための優れた方法です。
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のエラーはint
やstring
と同じように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通りの利用方法がある。
-
定義済みのエラーを使う
- 前述の通りGoにはほとんどエラーに関する定義がないが、例えば
database/sql
には行が見つからなかったことを示すErrNoRows
や、トランザクションがコミットもしくはロールバック済みであることを示すErrExDone
が存在する
- 前述の通りGoにはほとんどエラーに関する定義がないが、例えば
-
自分で新しいエラーを定義する
-
Go の自作エラーを errors.Is と errors.As で wrap 元のエラーと識別するときには、Unwrap も実装しよう
erros.Asを使った独自情報の取得
独自エラーを宣言する
独自パッケージでもerrors.Is関数を使って特定のエラー状態を識別したい場合が発生する。
その場合にはパッケージスコープの変数としてerror型の値を定義する。
Goで独自エラーを定義する際は慣習としてErr-
というプレフィックスを使えるのが一般的。
var ErrErrosNew = errors.New("errors.New")
var ErrFmtErrorf = fmt.Errorf("fmt.Errorf")
独自のエラー構造体を定義する
独自エラーを宣言するだけでなく、エラーオブジェクトのフィールドに情報を付与したりしたい場合は、エラー用に独自の構造体を用意する。
ch9. 無名関数・クロージャ
Goでどのように関数を扱えるのか
Goの言語仕様において関数はファーストクラスオブジェクトであり、関数を変数に代入したり、型として利用できる。関数リテラルを使って無名関数を作ることもできる。
Webアプリケーションと無名関数
無名関数を使うことが多いのはミドルウェアパターンとHTTPクライアントの実装。
無名関数を使って指定の関数型と同じシグネチャの関数を作る
状態を持つ関数を作る
goroutine利用時は無名関数から外部の変数の参照を避ける
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
メソッドと併用はできない。(他のテストケースへの副作用が避けられないため)
そのため、環境変数を操作するパッケージのテストは一つのテストケースにまとめておいた方が良い。
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/http
のhttp/HandlerFunc
に合わせた実装になっている。
DIPに準拠した実装
GoでDIPに準拠した実装をする場合、依存性の注入(DI)パターンが利用される。
- オブジェクト初期化時にDIする方法
- 「setter」を用意しておいてDIする方法
- メソッド(関数)呼び出し時にDIする方法
ch12. ミドルウェアパターン
ch13. ハンズオンの内容
Beyond the Twelve-Factor Appに準拠。
「The Twelve-Factor App」を15項目に見直した「Beyond the Twelve-Factor App」を読んだ - kakakakakku blog
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については以下のスクラップでもまとめている。
以下の通り動的にポート番号が選択されていることが確認できる。
% 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
ch15. 開発環境を構築する
開発環境や継続的インテグレーションを整備する。
Dockerを利用した実行環境
Goはビルドすればシングルバイナリでデプロイできるので、コンテナを作成するときもビルドしてできたバイナリファイルだけをコンテナに含めればOK。
ビルド前のソースコードなどは不要なため中間ビルドステージでビルドするマルチステージビルドを実施する。
まず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へのコメントのされ方とかスクショがあるので参考になるかも)
golangci-lintはyamlファイルで静的解析の内容を定義できるのでファイルを作成する。
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 にまとめられている。
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のサンプルとほとんど同じ。
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
}
テストファイルは以下の通りで、デフォルト値の設定と明示的に設定した場合の確認になっている。
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関数はコードを大幅に削減して、シンプルにサーバを起動するだけになった。
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つがあるっぽい。
import (
...
// "todo_app/config"
"github.com/panyoriokome/golang-web-application/config")
テストファイルの一時的なスキップ
上記の設定変更によりテストファイルにコンパイルエラーが発生するようになっているので修正する。
具体的にはt.Skip
メソッドを使う。
func TestRun(t *testing.T) {
t.Skip("リファクタリング中")
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インタフェースにすることで内部実装に依存しない関数シグネチャにしていること。
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()
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