サーバーサイド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/mailnet/rpcなどがあります。net/http配下にはServerClientだけではなく、RequestResponseなども定義されています。

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/csvencoding/xmlencoding/pemなどがあります。encoding/json配下にはEncoderDecoderの定義があります。

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兆円欲しい。