サーバーサイドGo入門 #1 ~net/httpでサーバーを立てる~
背景・モチベーション
前職の同僚が、Goでサーバーサイド開発をすることになったけれど文法の後に何をやるか困っていそうだったので簡単なものを作りここに置いておこうと思います。
前提として他の言語でサーバーサイド開発を行った経験がありGoの文法は学習済みの人を対象としています。また、示すコードの他にもっと良い書き方があるとは思うので公式ドキュメントなどを参照して何かわかったらぜひコメント欄にて教えてほしいです。
作るもの
todoアプリを作ります。サーバーを立てtodoをDBに永続化します。
まずは標準ライブラリを用いて開発し、その後に標準ライブラリ以外のライブラリを用いてリプレイスする予定です。
その後、テストとgoroutineによる非同期処理を書いて終わろうと思います。
今回はインメモリの配列にTODOを追加することをゴールとします。
プロジェクトを作る
Goではモジュールという単位でプロジェクト的なものを作ります。下記のコマンドを叩いてtodoというモジュールを作成します。
main.go
も作成しておきます。
$ go mod init todo
$ touch main.go
$ tree .
.
├- go.mod
└- main.go
サーバーを立てる
サーバーを立てる事自体は非常に簡単です。
// main.go package main import "net/http" func main() { _ = http.ListenAndServe(":8080", nil) }
これだけです。
ではサーバーを立ち上げてみましょう。
$ go run main.go
立ち上げたサーバーに対してcurlを叩きます。
$ curl http://localhost:8080
404 page not found
何も処理を入れていないため404になってしまっていますがサーバーからレスポンスが返却されています。net/http
パッケージはGoの標準ライブラリにあるネットワーク関連のパッケージです。net/http
と並んでnet/mail
やnet/rpc
などがあります。net/http
配下にはServer
やClient
だけではなく、Request
やResponse
なども定義されています。
go/src/net/http at master · golang/go · GitHub
処理を加える
404ではつまらないのでアクセスが来たら何か処理をさせてみましょう。http.HandleFunc
を用いて特定のパスに対して処理を仕込むことができます。ここでは標準出力に"Hello World"を出力しています。
// main.go package main import ( "fmt" "net/http" ) func handler(response http.ResponseWriter, request *http.Request) { fmt.Println("Hello World") } func main() { http.HandleFunc("/", handler) _ = http.ListenAndServe(":8080", nil) }
$ curl http://localhost:8080 Hello World
JSONを返却する
標準出力だけでは業務で使えないと思うので、JSONをクライアントに返してみましょう。ここで返却するものはなんでも良いのでリクエストに送られてきたヘッダをそのまま返してみます。
// main.go package main import ( "encoding/json" "net/http" ) func handler(response http.ResponseWriter, request *http.Request) { _ = json.NewEncoder(response).Encode(request.Header) } func main() { http.HandleFunc("/", handler) _ = http.ListenAndServe(":8080", nil) }
$ curl http://localhost:8080 | jq { "Accept": [ "*/*" ], "User-Agent": [ "curl/7.64.1" ] }
ヘッダをそのまま返しただけですが、これでクライアントにJSONを返却することができました。後は構造を定めてドメインに応じて処理を挟んであげれば良いですね。
モデルを定義する
ではTODOのモデルを定義しましょう。
// main.go type TODO struct { Author string `json:"author"` Name string `json:"name"` Description string `json:"description"` DueDate time.Time `json:"dueDate"` }
jsonタグ(json:"xxx"
)に応じてstructがEncoding/Decodingされます。タグはフィールド名に対応している必要はなく自由に設定できるため、例えばクライアントがスネークケースだった場合にはjsonタグにスネークケースをつけ、Go側の定義ではキャメルケースにするということができます。 タグを省略した場合はフィールド名がキーになります。また、タグにjson:"-"
と設定するとそのフィールドは無視されます。
json
に関する実装はencoding/json
に定義されています。encoding/json
と並んでencoding/csv
やencoding/xml
、encoding/pem
などがあります。encoding/json
配下にはEncoder
やDecoder
の定義があります。
go/src/encoding/json at master · golang/go · GitHub
レスポンスを返却する
では、定義したモデルを返却してみましょう。ここではクライアントから登録はせずに予めデータを作ってそれを返却します。
// main.go package main import ( "encoding/json" "net/http" "time" ) type TODO struct { Author string `json:"author"` Name string `json:"name"` Description string `json:"description"` DueDate time.Time `json:"dueDate"` } func list(response http.ResponseWriter, request *http.Request) { encoder := json.NewEncoder(response) due, err := time.Parse("2006/01/02", "2022/12/31") if err != nil { _ = encoder.Encode(err.Error()) return } todo := TODO{ Author: "Rob", Name: "Develop Generics", Description: "Add feature of generic type system", DueDate: due, } _ = encoder.Encode(todo) } func main() { http.HandleFunc("/todo/list", list) _ = http.ListenAndServe(":8080", nil) }
$ curl http://localhost:8080/todo/list | jq { "author": "Rob", "name": "Develop Generics", "description": "Add feature of generic type system", "dueDate": "2022-12-31T00:00:00Z" }
http.HandleFunc
に渡している処理の中身が変わりました。今回は新たにtime
パッケージをimportしています。time
パッケージは名前の通り時間関連のパッケージです。指定するフォーマットにひと癖あるので気をつけてください。time.Parse
の引数として"2006-01-02"
を渡していますが、これを"2041-05-22"
などにするとtime.Parse
はエラーを返します。Goではtimeのフォーマットは定数で決められているためです。詳細はリンク先を見てみてください。
go/format.go at master · golang/go · GitHub
RFC3339
などの規格は定数としてきちんと定義されているので必要であれば提供されている定数を使いましょう。
TODOを登録する
最後に登録処理をします。今回はDBへの登録ではなくメモリ上の配列に追加することにします。
// main.go package main import ( "encoding/json" "net/http" "time" ) var todos []TODOObject = []TODOObject{} type MetaData struct { ID int `json:"id"` CreatedAt time.Time `json:"createdAt"` } type TODO struct { Author string `json:"author"` Name string `json:"name"` Description string `json:"description"` DueDate time.Time `json:"dueDate"` } type TODOObject struct { MetaData TODO } func list(response http.ResponseWriter, request *http.Request) { _ = json.NewEncoder(response).Encode(todos) } func add(response http.ResponseWriter, request *http.Request) { encoder := json.NewEncoder(response) if request.Body == http.NoBody { _ = encoder.Encode("request body is nil") return } var todo TODO if err := json.NewDecoder(request.Body).Decode(&todo); err != nil { _ = encoder.Encode(err.Error()) return } metadata := MetaData{ ID: len(todos) + 1, CreatedAt: time.Now(), } object := TODOObject{ TODO: todo, MetaData: metadata, } todos = append(todos, object) _ = encoder.Encode(object) } func main() { http.HandleFunc("/todo/list", list) http.HandleFunc("/todo/add", add) _ = http.ListenAndServe(":8080", nil) }
$ curl -X POST http://localhost:8080/todo/add -d \ '{ "author": "Rob", "name": "Develop Generics", "description": "Add feature of generic type system", "dueDate": "2022-12-31T00:00:00Z" }' | jq { "id": 1, "createdAt": "2021-01-03T23:21:00.101796+09:00", "author": "Rob", "name": "Develop Generics", "description": "Add feature of generic type system", "dueDate": "2022-12-31T00:00:00Z" } $ curl http://localhost:8080/todo/list | jq [ { "id": 1, "createdAt": "2021-01-03T23:21:00.101796+09:00", "author": "Rob", "name": "Develop Generics", "description": "Add feature of generic type system", "dueDate": "2022-12-31T00:00:00Z" } ]
TODOにメタ情報としてIDと作成時間を付与したTODOObject
を作成しました。embedすることで既存のモデルに手を入れることなく構造を定義することができます。また、embedでの定義にjsonタグをつけないことによりフラットな構造を作ることができます。(逆にjsonタグをつけると構造化できます)
ついでにlistの処理でtodosを返却するようにしてあります。
まとめ
文法を把握した人にとってはライブラリの使い方以外にもエラー処理やembed、jsonタグなど知識の整理もできたんじゃないかなと思います。importしている標準ライブラリのパッケージのリンクを張ってきましたが、今回使った関数だけではなく並列して存在しているパッケージや同パッケージ内の他のファイルや似たような関数なども流し読みすると新たな発見があると思います。しれっと便利な定数が定義されていたりするので、探してみてください。(私はhttp.NoBody
を今回初めて知りました)
ちなみに、embedする必要がないように思った方もいるかもしれません。実は、ORMのライブラリで今回のようにembedしてメタ情報を付与する実装もあるので今回はそのようにしてみました。また、このembedによる実装はリポジトリ層で抽象化させるなど設計においても多く考慮することのできる言語仕様なので、実際の開発をイメージしてもらえればと思います。
次回はTODOをメモリではなくDBに登録するところを書こうと思います。
雑談
今年からiDecoやつみたてNISAを始めようと思っているので投資関連の情報ばかり見ていた。何も知識がないところから投資信託とETFの区別ができたりアセットアロケーションについて考えられる程度にはなったので知識って大事だなぁと改めて思った。ただ、まだ身銭を切っていないので投資をしてからどう感じるようになるかわからない。毎秒5,000兆円欲しい。