競技プログラミング in Go #1

背景

2年半くらい前に少しやって以来あまりできていない。たまに思い立ったように問題を解いたり蟻本を読んでみたりするものの、以前の内容を憶えておらず同じような内容を初見の顔をして勉強していることが多い。

まずはこの現状を打破して継続できるようにしたい。そもそもではあるが、最強最速アルゴリズマーになってアルゴリズムを仕事にするのは力量的にも難しいため、教養と知的好奇心と趣味としての競プロを楽しんで続けられるようにしていきたい。

なんで続かなかったんだろう...

自分なりに考えてみると、理由がいくつかありそうだ。
まず、C++を使っていた。私はC++を普段仕事や趣味で使っていなかった*1。使っていた理由は、競プロの解説や解答例でC++が使われることが多いからだ。けれど、時間を置くとすぐにわからなくなり毎回調べていた。問題を解くということに集中するためにはC++は扱う技量が足りていなかった。
また、競プロの優先順位をなかなか上げられなかった。他にも知らなきゃいけないことが多かったのだ。仕事で活かせる機会が少ない*2競プロに大きく時間を割くことができなかった。

継続のために

まず言語はGoにしようかなと思っている。Goは個人的に触っており、コーディングテストでも毎回選択している*3。そのため、Goで競プロに慣れておくメリットは大きいかなと思っている。Rustを使うことも検討したけれど、業務で使う可能性を考えるとRustよりもGoの方が高いかなぁ、と思う。まだまだ求人少ないし。

また、何を考えてどう解き、解説に何が書いてあるのか、そして時間を置いてもう一度解く必要があるのか、という当たり前のことができていなかった。理解より解くことが目的化していたからこそ中途半端になっていた。ここの思考を記録して整理しておく必要性を感じた*4。管理内容は後述する。

競プロにおけるGo

微妙らしい。

これはたしかにそうで鬱陶しさや足りない機能が目立ってしまう。
例えば、未使用変数がワーニングじゃなくてエラーになるとか、タプルがないこととか、min/maxの関数がないとか。探せばたくさんある。

精選10問

なんやかんやあるけれど、とりあえず解いていこう。
思い立った時に毎回お世話になっている気がするが、drkenさんの記事を参考に解いていく。

qiita.com

今回はこれをGoで解いた。

Product Submission #19666588 - AtCoder Beginner Contest 086
Placing Marbles Submission #19666903 - AtCoder Beginner Contest 081
Shift only Submission #19667849 - AtCoder Beginner Contest 081
Coils Submission #19668136 - AtCoder Beginner Contest 087
Some Sums Submission #19668805 - AtCoder Beginner Contest 083
Card Game for Two Submission #19669348 - AtCoder Beginner Contest 088
Kagami Mochi Submission #19669660 - AtCoder Beginner Contest 085
Otoshidama Submission #19670032 - AtCoder Beginner Contest 085
白昼夢 Submission #19670532 - AtCoder Beginner Contest 049
Traveling Submission #19671230 - AtCoder Beginner Contest 086

学び

知っていると思っていたようなことも競プロをやると厳密な理解を求められるのでよりクリアになった。

  • breakで抜けるスコープは1つなので大域脱出したいならラベルを使う
  • 数値のstringをループで回してruneを操作する場合、内部では文字コードになってしまうが、'0'を引くことで数値を得られる
  • sort.Xxxに渡す関数であるfunc (i, j int) boolijはインデックスのため、比較を行う場合はfunc (i, j int) bool { v[i] > v[j] }のように書く
  • Goの配列のインデックスに負数は不可(Pythonとは違う)

管理方法

前述した思考の記録は、神ツールであるNotionを使っている。聖書や古事記などまだ印刷技術がなかった時代に書かれたもの大半はNotionで進捗管理されていたと言われている。そのくらい強力なツールだ。

f:id:TakumiKaribe:20210130194400p:plain f:id:TakumiKaribe:20210130194500p:plain

こんな感じで復習できるようにまとめつつ思考過程と解説を載せている。初めはScrapboxで関連問題が表示されるようにしていたけれど、Notionの使い勝手の良さに負けてしまった。まだまだ改善の余地がありそうなので、もっと上手く管理したい。

結び

失踪せずに#2を書きたい。次回は蟻本の全探索の部分和問題とLake Counting問題の類題を解いていく。

コンテストは出れる時に出る。寝たい。

*1:実は一瞬だけ仕事で使っていた。一瞬だけ。

*2:当然仕事による

*3:通信が絡む問題の時はすごく楽なので

*4:AtCoder Problemsもあるけど、思考過程もセットにしたかったためNotionを選択した

Go言語による並行処理を読んだ

結構前に下記の本を読んだ。メモをしていたのでここにも残しておこうと思う。

Go言語による並行処理

Go言語による並行処理

前提知識

今までgoroutineを使うことはあってもいくつか作ってsync.WaitGroupで待つ、くらいのことしかしていなかった。
なぜなら、どう動くのか/いかに動かすのかの理解がなく単純なことしかやらせていなかったからだ。 本書を読んで、こ慣れた扱いも少しはできるようになったかなと思う。よかったよかった。

へーってなったところ

syncを使う?channelを使う?

goroutineを使う時にsyncパッケージを使うのか、channelを使うのか、というのは悩みどころだと思う。事実、私はよく悩んでいた。どちらでもやりたいことはできるけど使い分ける基準があるのだろうか、とgoroutineを書くときはいつもモヤモヤしていた。

本書以前にsyncパッケージのドキュメントには下記のようにある。

syncパッケージでは排他制御といった基本的な同期のためのプリミティブを提供します。Once型とWaitGroup型以外は、低水準ライブラリ内で利用されることを想定しています。高水準の同期はチャネルや通信によって行われたほうが良いでしょう。

また、公式FAQには下記のようにある。

ミューテックスに関して言えば、syncパッケージがそれを実装していますが、Goのプログラミングスタイルでは、高水準の技術を使うことを推奨しています。特に、プログラムを書く際にはある瞬間にただ1つのゴルーチンがある特定のデータの責任を持つように心がけてください。メモリを共有することで通信してはいけません。かわりに、通信することでメモリを共有しましょう。

高水準の同期とは何だろうか。
本書ではこれらsyncパッケージとchannelのどちらを使うのか、という決定木が載っている。これが非常にわかりやすい。内容は並行処理のスコープが特に意識されていると感じた。

ちなみに公式wikiに使い分けについて言及がある。

MutexOrChannel · golang/go Wiki · GitHub

他の並行処理に関する公式資料は過去記事でまとめた。

prelude.hatenablog.jp

実装パターン

goroutine雰囲気erなので、並行処理でどう実装するのか手札をあまり持っていなかった。本書は並行処理パターンという章で色んな実装パターンの実例を読むことができる。selectの使い方やchannelを上手く使うことで孫goroutineを扱ったり。さらにpipelinefan-in/fan-outなどのロジックは単純な機能を組み立てて複雑なことまで表現することのできるGoらしさを強く感じた。

chanの仕様についても理解することができる。例えばchanがnilや空の時の挙動であったり、キューとしてどう振る舞っているのかなど。chanを深く理解していなかったが、本書を読んでからはchanを使った他人の実装も読めるようになった気がする。

「並行処理をバリバリ書かないから実装パターンを手札として増やしても仕方がない」と思うこともあったが、実装パターンを多く読むことで並行処理をGoでどう使い使われるのかの想像力がかなり豊かになる点はすごく良かったと思う。さっきも書いたけど他人の書いた並行処理が読めるとレビューもしやすくなるし、ライブラリもどんどん読める。

応用

エラー伝播やハートビート、流量制限は、若干難しかった。
しかし、実際の開発を前提に置いて話が展開されるため、イメージはしやすかった。

まだまだ読み込みの浅さを感じているので、このあたりはまたいつか読み直したい。

わからなくて調べたこと

CSP

並行処理一般の理論で追いつけないところがあった。なんとなく言いたいことはわかるけれど雲を掴むような気持ちで読んでいた。

GoはCSP(Communicating Sequential Process)という理論を土台?としている。CSPはプロセスが通信によってコミュニケーションをする、というものでGoの「共有ではなく通信によりメモリを共有する」という思想の基になっている話だ(たぶん)。公式ドキュメントにCSPを土台にした背景が少し書いてある。

golang.org

わからなかった理由として、アクターモデルとの分別がついていないことがある。アクターモデルもアクター同士がメッセージを相互に送ってコミュニケーションするのでは?といまだに腑に落ちていない。
アクターモデルの理解がそもそも浅いため、いまの自分には手に負えないものだった。
アクターモデルwikiにCSPに言及している部分もあったけど、いまいちわからなかった。

ja.wikipedia.org

ただ、本書にも記載があるがGoは並行処理の複雑さに言語仕様から立ち向かってくれたおかげで、我々は簡単にコードを書くことができている。CSPの理解を深めることでGoの並行処理がより上手に書くことができることは本書が示していると思う。

今後何か意識したいところ

  • syncchannelの使い分け。
  • パターンに依拠した実装
  • goroutineを使わない選択肢を思考すること

まずは並行処理のスコープを狭くしつつ、基本に忠実に書くこと。そしてその上でパターンを使ってgoroutineの力を引き出すように書くことが求められると思う。これが逆転するとせっかく複雑さを回避しようとしているのに、スコープの広いchanに悩まされることだろう。

また、goroutineに固執せず、使わなくて良いなら使わない。goroutineは非常に大きな力だけれども、並行処理は使わない方が単純明快だ。しっかりと検証した上で必要になったら使いたい。本書で知ったことを使いたくなってもグッと堪えるのは大事だろう。

Go言語の並行処理に関する公式ドキュメント

背景

goroutineなんもわからん。書くことができても使いこなすことができてるとは言い難いなあと日々感じている。Goは並行処理の理解の助けのため、リポジトリwikiにたくさんの資料がある。

資料を全部読んだ上で解説したいものだが、一部しか読めていない。今回はwikiにある資料を眺めてその傾向がわかったので、Goの並行処理関連資料のガイドラインとして活用していきたい。

wiki

資料がレベルで分かれているため、各レベルがどの程度の内容を扱っているのか確認しようと思う。

LearnConcurrency · golang/go Wiki · GitHub

Beginner

ざっと眺めたところ公式ドキュメント詰め合わせパックって感じだった。普段Goを書いている人でもこれらの資料を全部理解している人は少なそう。きちんと読めば他のGopherと比べて頭一つ抜けそうな気がした。

言語仕様からGoの並行性へのコンセプト、あとはgoroutineが何であるか、という話が多い。Beginnerレベルの資料を読み込めばgroutineの輪郭が掴めるだろうと思う。

Do not communicate by sharing memory; instead, share memory by communicating.

これを言えるとカッコいい。ここぞというタイミングで詠唱しよう。

Intermediate

goroutineでの実装パターンが多い印象。下記に記載されていたポストをいくつか載せておく。

これらの実装パターンを読めば並行処理の引き出しが増えそう。また、他人の書いた並行処理に関して良い悪いの分別もつけられるようになりそう。

さらに、並行処理を使った際に必ず付き合うことになる競合のサポートツールであるrace-detectorの資料もあった。

Introducing the Go Race Detector - The Go Blog

かなり実践的。このレベルを押さえると普段の業務で並行処理の実装で困ることはほとんどなさそう。

Advanced

メモリモデルやスケジューラ、チャンネルについて、それぞれどのようなメカニズムなのか解説している。

ここまでいくとメモリ上でGoの並行性がどのように動いているか理解できる。ただ資料を読めていないので何も言うことがない。内容は難しそうだけど面白そうなので時間を作って読みたい。

Additional Go Programming Wikis

wikiに「mutexかchannelか」というページがある。この話題はよく行われるし実装時にも意識すべきことなのでしっかりと理解したい。

下記に引用するが、楽しいからってchannelやgoroutineを多用するな、という点は本質的だと思う。

A common Go newbie mistake is to over-use channels and goroutines just because it's possible, and/or because it's fun.

そして、どちらを使うのかの判断基準はsimpleさである、という点も常に頭に入れておきたい。

Use whichever is most expressive and/or most simple.

MutexOrChannel · golang/go Wiki · GitHub

まとめ

wikiにある各レベルを整理するとこんな感じだろうか。

  • Beginner:Goの並行性は何なのか
  • Intermediate:Goの並行性をどのように使うか
  • Advanced:Goの並行性はどのように動くか

Goの並行処理について何か知りたいことが生まれた場合、上記に照らし合わせてwikiに貼ってあるリンクを読んでいくと良いのかなと思った。

雑談

タイミング的に2021年からになってしまったけれど、これからは目に見えるアウトプットをしっかりと残していきたいなと思ってブログの投稿を再開した。
ただ、普段から書いていたわけじゃないので生産体制が整わない中、Notionを使って投稿タイトルと簡単な骨子、公開スケジュールなどを組んでいくと自然と逆算して資料読んだり検証できるようになった。Notionは偉大。

サーバーサイドGo入門 #4 ~test~

前回はこちら prelude.hatenablog.jp

今回作るもの

gingormを使ったTODOアプリが完成しました。今回はこれらのテストを行います。

基本的には公式のtestingパッケージを使いますが、DBのモック用にgo-sqlmockを使用します。
また、net/httpのテスト用のUtilであるnet/test/httptestも使用します。

testing

標準パッケージであるtestingは非常に便利ですが、アサーションやutilityがあまり充実していません。これは未開発なのではなく、意図的に開発していない機能になります。

理由には、アサーションやUtility、フレームワークなどに頼ると、正しいエラーハンドリングや正しいエラー報告について考慮せずにテストを書いてしまうということです。さらに、テストフレームワークは独自の発展によりミニ言語のようなものが生まれてしまう傾向にある、という厳しい言及もあります。

以上のことは下記のリンクにて説明されています。

アサーションがないことによって記述が冗長になると懸念されるかもしれません。しかしGoではテスト対象の入力値と期待値をデータ構造として定義し、そのリストを作成してループで回してテストを行うテーブルドリブンテストというものが紹介されています。

TableDrivenTests · golang/go Wiki · GitHub

HTTPサーバーのテスト

net/http/httptest

net/http/httptestには下記のような定義があります。

func NewRequest(method, target string, body io.Reader) *http.Request
type ResponseRecorder struct
type Server struct { ... }

1つ目のNewRequestと3つ目のServerはなんとなくわかります。では、ResponseRecorderとは何でしょうか?

定義を見てみると下記のようになっています。

func NewRecorder() *ResponseRecorder
func (rw *ResponseRecorder) Flush()
func (rw *ResponseRecorder) Header() http.Header
func (rw *ResponseRecorder) Result() *http.Response
func (rw *ResponseRecorder) Write(buf []byte) (int, error)
func (rw *ResponseRecorder) WriteHeader(code int)
func (rw *ResponseRecorder) WriteString(str string) (int, error)

これらの関数によりhttp.ResponseWriterinterfaceを満たしています。テストで使う際にはこのResponseRecorderを通じて結果を書き込んでもらい、それを検証すれば良いだろうということがわかります。

改めてnet/http/httptestの全体観としては、RequestResponseServer、というすごくシンプルなものであるとわかります。

httptest - The Go Programming Language

DBのテスト

DATA-DOG/go-sqlmock

これはsql-driverのモックを提供してくれます。

github.com

モックの初期化のI/Fは下記のようになっています。

func New(options ...func(*sqlmock) error) (*sql.DB, Sqlmock, error)
func NewWithDSN(dsn string, options ...func(*sqlmock) error) (*sql.DB, Sqlmock, error)

戻り値のモックを利用して期待するSQLを記述しておき、最後にExpectationsWereMetで検証します。
また、下記のサンプルの通り、トランザクションの開始・終了も記述可能です。

// Open new mock database
db, mock, err := New()
if err != nil {
    fmt.Println("error creating mock database")
    return
}

// columns to be used for result
columns := []string{"id", "status"}

// expect transaction begin
mock.ExpectBegin()

// expect query to fetch order, match it with regexp
mock.ExpectQuery("SELECT (.+) FROM orders (.+) FOR UPDATE").
    WithArgs(1).
    WillReturnRows(NewRows(columns).AddRow(1, 1))

// expect transaction rollback, since order status is "cancelled"
mock.ExpectRollback()

// run the cancel order function
someOrderID := 1

// call a function which executes expected database operations
err = cancelOrder(db, someOrderID)
if err != nil {
    fmt.Printf("unexpected error: %s", err)
    return
}

// ensure all expectations have been met
if err = mock.ExpectationsWereMet(); err != nil {
    fmt.Printf("unmet expectation error: %s", err)
}

テスト

では前述のパッケージを用いて、さっそく書いてみましょう。ちなみに今回は異常系を省略してます。

// main_test.go

package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"

    "github.com/DATA-DOG/go-sqlmock"
)

func setupDB() (*gorm.DB, sqlmock.Sqlmock, error) {
    sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
    if err != nil {
        return nil, nil, err
    }

    gormdb, err := gorm.Open(mysql.Dialector{
        Config: &mysql.Config{
            DriverName:                "mysql",
            Conn:                      sqlDB,
            SkipInitializeWithVersion: true,
        },
    }, &gorm.Config{})
    if err != nil {
        return nil, nil, err
    }

    return gormdb, mock, nil
}

func TestList(t *testing.T) {
    _gormDB, mock, err := setupDB()
    if err != nil {
        t.Fatal(err.Error())
    }
    gormDB = _gormDB

    expect := TODO{
        Author:      "author",
        Name:        "name",
        Description: "description",
        DueDate:     time.Now(),
        Model:       gorm.Model{ID: 1},
    }
    mock.ExpectQuery("SELECT * FROM `todos` WHERE `todos`.`deleted_at` IS NULL").
        WillReturnRows(sqlmock.NewRows([]string{"id", "author", "name", "description", "due_date"}).
            AddRow(expect.ID, expect.Author, expect.Name, expect.Description, expect.DueDate))

    request := httptest.NewRequest(http.MethodGet, "http://localhost:8080/todos", nil)
    recorder := httptest.NewRecorder()
    setupRouter().ServeHTTP(recorder, request)

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Fatal(err.Error())
    }

    response := recorder.Result()
    defer response.Body.Close()

    var responseTODOs []TODO
    if err := json.NewDecoder(response.Body).Decode(&responseTODOs); err != nil {
        t.Fatal(err.Error())
    }

    if len(responseTODOs) != 1 {
        t.Errorf("got response count = %d. want = %d", len(responseTODOs), 1)
    }
    if responseTODOs[0].ID != expect.ID {
        t.Errorf("got ID = %d. want = %d", responseTODOs[0].ID, expect.ID)
    }
    if responseTODOs[0].Author != expect.Author {
        t.Errorf("got Author = %s. want = %s", responseTODOs[0].Author, expect.Author)
    }
    if responseTODOs[0].Name != expect.Name {
        t.Errorf("got Name = %s. want = %s", responseTODOs[0].Name, expect.Name)
    }
    if responseTODOs[0].Description != expect.Description {
        t.Errorf("got Description = %s. want = %s", responseTODOs[0].Description, expect.Description)
    }
    if responseTODOs[0].DueDate.Unix() != expect.DueDate.Unix() {
        t.Errorf("got Description = %d. want = %d", responseTODOs[0].DueDate.Unix(), expect.DueDate.Unix())
    }
}

func TestAdd(t *testing.T) {
    _gormDB, mock, err := setupDB()
    if err != nil {
        t.Fatal(err.Error())
    }
    gormDB = _gormDB

    expect := TODO{
        Author:      "author",
        Name:        "name",
        Description: "description",
        DueDate:     time.Now(),
        Model:       gorm.Model{ID: 1},
    }

    mock.ExpectBegin()
    mock.ExpectExec("INSERT INTO `todos` (`created_at`,`updated_at`,`deleted_at`,`author`,`name`,`description`,`due_date`,`id`) VALUES (?,?,?,?,?,?,?,?)").WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), expect.Author, expect.Name, expect.Description, sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectCommit()

    b, err := json.Marshal(expect)
    if err != nil {
        t.Fatal(err.Error())
    }

    request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/todo", bytes.NewBuffer(b))
    recorder := httptest.NewRecorder()
    setupRouter().ServeHTTP(recorder, request)

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Fatal(err.Error())
    }

    response := recorder.Result()
    defer response.Body.Close()

    var responseTODO TODO
    if err := json.NewDecoder(response.Body).Decode(&responseTODO); err != nil {
        t.Fatal(err.Error())
    }

    if responseTODO.ID != expect.ID {
        t.Errorf("got ID = %d. want = %d", responseTODO.ID, expect.ID)
    }
    if responseTODO.Author != expect.Author {
        t.Errorf("got Author = %s. want = %s", responseTODO.Author, expect.Author)
    }
    if responseTODO.Name != expect.Name {
        t.Errorf("got Name = %s. want = %s", responseTODO.Name, expect.Name)
    }
    if responseTODO.Description != expect.Description {
        t.Errorf("got Description = %s. want = %s", responseTODO.Description, expect.Description)
    }
    if responseTODO.DueDate.Unix() != expect.DueDate.Unix() {
        t.Errorf("got Description = %d. want = %d", responseTODO.DueDate.Unix(), expect.DueDate.Unix())
    }
}

補足

setupDB内のモックの初期化にてsqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)を渡しています。これはSQLアサーションのために利用されます。

type QueryMatcher interface {
    // Match expected SQL query string without whitespace to
    // actual SQL.
    Match(expectedSQL, actualSQL string) error
}

Match(expectedSQL, actualSQL string) errorを満たしていれば自作のMatcherも作ることが可能です。今回はgo-sqlmockで提供されているQueryMatcherEqualを使用しました。

var QueryMatcherEqual QueryMatcher = QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
    expect := stripQuery(expectedSQL)
    actual := stripQuery(actualSQL)
    if actual != expect {
        return fmt.Errorf(`actual sql: "%s" does not equal to expected "%s"`, actual, expect)
    }
    return nil
})

まとめ

今回はロジックが非常に簡単なため、テストコードも非常に簡単なものでした。しかし、testingによるテストは公式で言及されているように考えることがとても多いパッケージです。使用する際は公式のドキュメントやテストコードを見て肌感を掴むと良い気がしています。

サーバーサイドGo入門 #3 ~gin, gormを使う~

前回はこちら prelude.hatenablog.jp

今回作るもの

前回まででnet/httpでリクエストを受け取りdatabase/sqlでDBを扱うところまで書きました。これでアプリケーションとして成立しますが、今回はアプリケーションの振る舞いは変えずに有名なライブラリで書き替えていこうと思います。

使うかどうかに関わらずライブラリの列挙をします。詳細はREADMEを読んで頂ければと思います。スター数は2021/01/10現在の数字です。

net/http関連のライブラリ

軽量なルーターライブラリからフレームワークまで存在します。

ベンチマーク

Webフレームワークのベンチマークを計測しているリポジトリもあります。 github.com

database/sql関連のライブラリ

database/sqlの軽量ラッパーやORMなどなど。

ライブラリを使って書き換え

今回はスター数の多いgingormを使おうと思います。どちらもREADME以外に公式サイトが存在します。

ginの使用

まずはnet/httpginを使ったものに書き替えます。

これまでhttp.HandleFuncに関数を渡して処理を行っていました。ここをgin.Defaultを呼び出して返ってくる*gin.Engineに関数を渡すように変更します。
それに伴い、渡す関数のシグネチャfunc (c *gin.Context)になります。listaddはその通りに変更します。
また、APIのレスポンスは引数のresponse http.ResponseWriterを使っていましたが、ginではc *gin.Contextになるためc.JSONとしてレスポンスを返します。書き替えはこれだけです。

ちなみに、今回は使いませんがパスパラメータやクエリパラメータを受け取る場合は下記のように書きます。

func main() {
    router := gin.Default()

    // This handler will match /user/john but will not match /user/ or /user
    router.GET("/user/:name", func(c *gin.Context) {
        name := c.Param("name")
        c.String(http.StatusOK, "Hello %s", name)
    })

    // However, this one will match /user/john/ and also /user/john/send
    // If no other routers match /user/john, it will redirect to /user/john/
    router.GET("/user/:name/*action", func(c *gin.Context) {
        name := c.Param("name")
        action := c.Param("action")
        message := name + " is " + action
        c.String(http.StatusOK, message)
    })

    // For each matched request Context will hold the route definition
    router.POST("/user/:name/*action", func(c *gin.Context) {
        c.FullPath() == "/user/:name/*action" // true
    })

    router.Run(":8080")
}
func main() {
    router := gin.Default()

    // Query string parameters are parsed using the existing underlying request object.
    // The request responds to a url matching:  /welcome?firstname=Jane&lastname=Doe
    router.GET("/welcome", func(c *gin.Context) {
        firstname := c.DefaultQuery("firstname", "Guest")
        lastname := c.Query("lastname") // shortcut for c.Request.URL.Query().Get("lastname")

        c.String(http.StatusOK, "Hello %s %s", firstname, lastname)
    })
    router.Run(":8080")
}

また、パスのグルーピングもnet/httpと異なり簡便です。

func main() {
    router := gin.Default()

    // Simple group: v1
    v1 := router.Group("/v1")
    {
        v1.POST("/login", loginEndpoint)
        v1.POST("/submit", submitEndpoint)
        v1.POST("/read", readEndpoint)
    }

    // Simple group: v2
    v2 := router.Group("/v2")
    {
        v2.POST("/login", loginEndpoint)
        v2.POST("/submit", submitEndpoint)
        v2.POST("/read", readEndpoint)
    }

    router.Run(":8080")
}

他にもバリデーションやリダイレクトなど多くの機能が提供されています。READMEにサンプルコードが書いてありますので詳細はそちらを参照してください。

今回はかなり簡単なAPIでかつ細かく設定していないため、簡単に書き替えることができます。

// main.go

package main

import (
    "database/sql"
    "encoding/json"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    _ "github.com/go-sql-driver/mysql"
)

var DB *sql.DB

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(c *gin.Context) {
    rows, err := DB.Query("SELECT * FROM todos")
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    defer rows.Close()

    todos := make([]TODOObject, 0)
    for rows.Next() {
        var todo TODOObject
        if err := rows.Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
            return
        }
        todos = append(todos, todo)
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    object := TODOObject{
        TODO: todo,
        MetaData: MetaData{
            CreatedAt: time.Now(),
        },
    }
    result, err := DB.Exec("INSERT INTO todos (author, name, description, due_date, created_at) VALUES (?, ?, ?, ?, ?)", object.Author, object.Name, object.Description, object.DueDate.Format("2006-01-02 15:03:04"), object.CreatedAt.Format("2006-01-02 15:03:04"))
    if err != nil {
        panic(err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
        panic(err)
    }
    var responseData TODOObject
    if err := DB.QueryRow("SELECT * FROM todos WHERE id=?", lastInsertID).Scan(&responseData.ID, &responseData.Author, &responseData.Name, &responseData.Description, &responseData.DueDate, &responseData.CreatedAt); err != nil {
        panic(err)
    }

    c.JSON(http.StatusOK, responseData)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos", list)
    router.POST("/todo", add)
    return router
}

func main() {
    var err error
    DB, err = sql.Open("mysql", "root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true")
    if err != nil {
        panic(err)
    }
    defer DB.Close()
    if err := DB.Ping(); err != nil {
        panic(err)
    }

    if _, err := DB.Exec("CREATE TABLE IF NOT EXISTS todos (id INTEGER AUTO_INCREMENT, author VARCHAR(32), name VARCHAR(32), description VARCHAR(64), due_date DATETIME, created_at DATETIME, PRIMARY KEY (id))"); err != nil {
        panic(err)
    }

    log.Println(setupRouter().Run(":8080"))
}

ライブラリがnet/httpを意識したものなのか、パスと関数を渡すようになっているため直感的に移行できました。

gormの使用

続いてgormを使用してdatabase/sqlを書き替えます。

DBとして*sql.DB型のグローバル変数を保持していましたが、gormで定義されている*gorm.DB型の変数を宣言するように変更しています。それに伴いOpenSQLの書き方が若干変わります。
IDや作成時間を保持していたTODOObjectも廃し、gorm.Modelを利用するように変更しています。 gorm.Modelは下記のような定義になっています。これをTODOに埋め込んで定義します。

type Model struct {
  ID        uint           `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
}

gormのタグを含めたモデル周りの扱いは下記のドキュメントに記載されています。

Declaring Models | GORM - The fantastic ORM library for Golang, aims to be developer friendly.

// main.go

package main

import (
    "encoding/json"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var gormDB *gorm.DB

type TODO struct {
    gorm.Model
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

func list(c *gin.Context) {
    todos := make([]TODO, 0)
    if err := gormDB.WithContext(c).Find(&todos).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
        return
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    if err := gormDB.WithContext(c).Create(&todo).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todo)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos", list)
    router.POST("/todo", add)
    return router
}

func main() {
    _gormDB, err := gorm.Open(mysql.Open("root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true"), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    db, err := _gormDB.DB()
    if err != nil {
        panic(err)
    }
    defer db.Close()

    if !_gormDB.Migrator().HasTable(&TODO{}) {
        if err := _gormDB.Migrator().CreateTable(&TODO{}); err != nil {
            panic(err)
        }
    }

    gormDB = _gormDB

    log.Println(setupRouter().Run(":8080"))
}

database/sqlと異なりRowsをループしてScanする必要がなくなり、記述量がかなり減ったことがわかります。また、gormはdatabase/sqlを内部的に利用しているため、CloseするためにDBという関数でdatabase/sql*sql.DBを取得しています。

curlで確認

$ curl -X POST http://localhost:8080/todo -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-11T12:40:00.365172+09:00",
  "UpdatedAt": "2021-01-11T12:40:00.365172+09:00",
  "DeletedAt": null,
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system",
  "dueDate": "2022-12-31T00:00:00Z"
}

$ curl http://localhost:8080/todos | jq
[
  {
    "ID": 1,
    "CreatedAt": "2021-01-11T03:40:00.365Z",
    "UpdatedAt": "2021-01-11T03:40:00.365Z",
    "DeletedAt": null,
    "author": "Rob",
    "name": "Develop Generics",
    "description": "Add feature of generic type system",
    "dueDate": "2022-12-31T00:00:00Z"
  }
]

gorm.ModelによってIDCreatedAtだけでなくUpdatedAtDeletedAtも追加されました。

まとめ

今回は有名なライブラリをいくつか紹介して実際に使ってみました。標準パッケージを置き換えるようなライブラリを使いましたが、どれも基礎となるのはnet/httpdatabase/sqlの知識です。ライブラリ選定時には何が土台となっているのかも確認できると良いですね。

雑談

体重が増えてしまったので散歩をするようにしたら毎日8,000歩程度歩くようになった。けどこのくらいってリモート前なら割と歩いていたような気もするからいかに日常的に歩かないようになってしまったのかがわかる。筋トレもして基礎代謝上げつつもっと歩かないと...。
散歩のお供は主にPodcast。英語の勉強時間を兼ねられると効率的なので歩きながらできるやり方を考えないと。 rebuild.fm misreading.chat

サーバーサイドGo入門 #2 ~database/sqlでDBを扱う~

前回はこちら prelude.hatenablog.jp

今回作るもの

今回は、インメモリに作成したデータをDBに永続化します。 MySQLをDockerで動かしGoから接続、前回作成したTODOを格納してWebサーバのレスポンスとして扱うところまで書きます。

Dockerで動かす

コンテナを立てる

Dockerイメージを取得してください。Dockerの慣れている方はお好みの手順でセットアップしてもらえればと思います。

$ docker pull mysql

MySQLのイメージを実行します。

$ docker run --rm --name mysql -e MYSQL_ROOT_PASSWORD=mysql -e MYSQL_DATABASE=todo -p 3306:3306 mysql

MySQLの中に入る

実行中のコンテナの一覧を取得します。

$ docker container ls

コンテナのIDを用いてコンテナ内のシェルを立ち上げます。

$ docker exec -it ${↑で確認したmysqlのコンテナID} bash
root@container_id:/# mysql -p
Enter password: ここに先ほどMYSQL_ROOT_PASSWORDに渡した値を入力(今回はmysql)
mysql> SHOW DATABASES;

最後にデータベースの一覧を表示して、MYSQL_DATABASEに設定したtodoが存在することを確認してください。

GoからDBを扱う

さっそく接続してみましょう。

// main.go

package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "net/http"
    "time"

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

// 前回書いたコード
// ...
// ...

func main() {
    db, err := sql.Open("mysql", "root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true")
    if err != nil {
        panic(err)
    }
    defer db.Close()
    if err := db.Ping(); err != nil {
        panic(err)
    }
    fmt.Printf("%+v\n", db)

    // 前回書いたコード
    // ...
    // ...
}

実行し標準出力にDBの情報が出力されれば疎通に成功しています。

ではコードについて補足をします。
まず、importにてgithub.com/go-sql-driver/mysql_という記述があります。import時にはpackage名の前に別名をつけることができます。その別名に_を用いるとpackage名を破棄します。そのため、packageにアクセスすることはできなくなります(厳密には識別子がないためアクセスする方法がなくなります)。 _blank identifierと呼びます。

Effective Go - The Go Programming Language

次に、initについても説明する必要があります。initはimportされた際に実行される特別な関数です。

Effective Go - The Go Programming Language

つまり、blank identifierを用いたimportはinitのみを行うということです。

では、go-sql-driver/mysqlがinitで何をしているのか読んでみましょう。

func init() {
    sql.Register("mysql", &MySQLDriver{})
}

mysql/driver.go at master · go-sql-driver/mysql · GitHub

database/sqlRegisterを呼び出し、引数にMySQLDriverを渡しています。Registerの第2引数の型はdatabase/sql/driverDriver interfaceです。このinterfaceは何ができるのか見てみましょう。

type Driver interface {
    Open(name string) (Conn, error)
}

go/driver.go at 59bfc18e3441d9cd0b1b2f302935403bbf52ac8b · golang/go · GitHub

ここまで読むとdatabase/sqlOpenが呼ばれるとMySQLDriverを使ったMySQLの接続情報をConn型として返却していそうなことがわかります。では最後にmain.goで読んでいるsql.Openを読んでみましょう。

func Open(driverName, dataSourceName string) (*DB, error) {
    driversMu.RLock()
    driveri, ok := drivers[driverName]
    driversMu.RUnlock()
    if !ok {
        return nil, fmt.Errorf("sql: unknown driver %q (forgotten import?)", driverName)
    }

    if driverCtx, ok := driveri.(driver.DriverContext); ok {
        connector, err := driverCtx.OpenConnector(dataSourceName)
        if err != nil {
            return nil, err
        }
        return OpenDB(connector), nil
    }

    return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil
}

go/sql.go at master · golang/go · GitHub

このOpenDBを読んでいくと設定したDriverを使ってコネクションを作成していることがわかると思います。

見てきたようにblank importは設定を隠蔽する時に使っているものをよく見かけます。blank importinitも使い方によっては非常に便利ですが、濫用するとコードから副作用を追うことが難しくなるため注意が必要です。

SQLを渡す

ではTODOObject用のテーブルを作成します。TODOObjectをそのままカラムにしているだけです。

if _, err := db.Exec("CREATE TABLE IF NOT EXISTS todos (id INTEGER AUTO_INCREMENT, author VARCHAR(32), name VARCHAR(32), description VARCHAR(64), due_date DATETIME, created_at DATETIME, PRIMARY KEY (id))"); err != nil {
    panic(err)
}
object := TODOObject{
    MetaData: MetaData{
        ID:        1,
        CreatedAt: time.Now(),
    },
    TODO: TODO{
        Author:      "author",
        Name:        "name",
        Description: "description",
        DueDate:     time.Now(),
    },
}
if _, err := db.Exec("INSERT INTO todos (author, name, description, due_date, created_at) VALUES (?, ?, ?, ?, ?)", object.Author, object.Name, object.Description, object.DueDate.Format("2006-01-02 15:03:04"), object.CreatedAt.Format("2006-01-02 15:03:04")); err != nil {
    panic(err)
}

MySQLにて実際にテーブルが作成されたことを確認しましょう。

mysql> SHOW CREATE TABLE todos;
mysql> SELECT * FROM todos;

見てわかる通り、database/sqlSQLを渡すだけです。

DBを読み込む

カラムに対応したフィールドのポインタを渡すとそこに書き込んでくれます。

var todo TODOObject
if err := db.QueryRow("SELECT * FROM todos WHERE id=?", 1).Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
    panic(err)
}

Queryから始まる関数はRowもしくはRowsを返します。いま使っているQueryRowはクエリにマッチした1行を返却するためRowを返します。
登録した全てのTODOを取得したい場合には下記のように書きます。

rows, err := db.Query("SELECT * FROM todos")
if err != nil {
    panic(err)
}
defer rows.Close()

todos := make([]TODOObject, 0)
for rows.Next() {
    var todo TODOObject
    if err := db.QueryRow("SELECT * FROM todos WHERE id=?", 1).Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
        panic(err)
    }
    todos = append(todos, todo)
}
fmt.Printf("%+v\n", todos)

他の関数についてはドキュメントを参照してください。

sql - The Go Programming Language

TODOをインメモリからDBへ

さて、DBの読み書きがわかったので、http経由でTODOをDBを扱ってみましょう。

// main.go

package main

import (
    "database/sql"
    "encoding/json"
    "net/http"
    "time"

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

var DB *sql.DB

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) {
    rows, err := DB.Query("SELECT * FROM todos")
    if err != nil {
        panic(err)
    }
    defer rows.Close()

    todos := make([]TODOObject, 0)
    for rows.Next() {
        var todo TODOObject
        if err := rows.Scan(&todo.ID, &todo.Author, &todo.Name, &todo.Description, &todo.DueDate, &todo.CreatedAt); err != nil {
            panic(err)
        }
        todos = append(todos, todo)
    }

    _ = 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
    }

    object := TODOObject{
        TODO: todo,
        MetaData: MetaData{
            CreatedAt: time.Now(),
        },
    }
    result, err := DB.Exec("INSERT INTO todos (author, name, description, due_date, created_at) VALUES (?, ?, ?, ?, ?)", object.Author, object.Name, object.Description, object.DueDate.Format("2006-01-02 15:03:04"), object.CreatedAt.Format("2006-01-02 15:03:04"))
    if err != nil {
        panic(err)
    }

    lastInsertID, err := result.LastInsertId()
    if err != nil {
        panic(err)
    }
    var responseData TODOObject
    if err := DB.QueryRow("SELECT * FROM todos WHERE id=?", lastInsertID).Scan(&responseData.ID, &responseData.Author, &responseData.Name, &responseData.Description, &responseData.DueDate, &responseData.CreatedAt); err != nil {
        panic(err)
    }

    _ = encoder.Encode(responseData)
}

func main() {
    var err error
    DB, err = sql.Open("mysql", "root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true")
    if err != nil {
        panic(err)
    }
    defer DB.Close()
    if err := DB.Ping(); err != nil {
        panic(err)
    }

    if _, err := DB.Exec("CREATE TABLE IF NOT EXISTS todos (id INTEGER AUTO_INCREMENT, author VARCHAR(32), name VARCHAR(32), description VARCHAR(64), due_date DATETIME, created_at DATETIME, PRIMARY KEY (id))"); err != nil {
        panic(err)
    }

    http.HandleFunc("/todo/list", list)
    http.HandleFunc("/todo/add", add)
    _ = http.ListenAndServe(":8080", nil)
}

curlのコマンドとレスポンスのJSONも全く同じなため、省略します。(前回を参照してください。)

まとめ

database/sqlSQLをそのまま渡せるため、そこまでわからないところは多くなかったのではないでしょうか?単にライブラリのどの関数を呼べばよいのかの紹介程度になっているかと思います。
次回はこれら標準ライブラリではなくプロダクションで使われることの多いライブラリに書き換えていこうと思います。

雑談

こないだ初めて検便をした。

サーバーサイド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兆円欲しい。