競技プログラミング in Go #1
背景
2年半くらい前に少しやって以来あまりできていない。たまに思い立ったように問題を解いたり蟻本を読んでみたりするものの、以前の内容を憶えておらず同じような内容を初見の顔をして勉強していることが多い。
まずはこの現状を打破して継続できるようにしたい。そもそもではあるが、最強最速アルゴリズマーになってアルゴリズムを仕事にするのは力量的にも難しいため、教養と知的好奇心と趣味としての競プロを楽しんで続けられるようにしていきたい。
なんで続かなかったんだろう...
自分なりに考えてみると、理由がいくつかありそうだ。
まず、C++を使っていた。私はC++を普段仕事や趣味で使っていなかった*1。使っていた理由は、競プロの解説や解答例でC++が使われることが多いからだ。けれど、時間を置くとすぐにわからなくなり毎回調べていた。問題を解くということに集中するためにはC++は扱う技量が足りていなかった。
また、競プロの優先順位をなかなか上げられなかった。他にも知らなきゃいけないことが多かったのだ。仕事で活かせる機会が少ない*2競プロに大きく時間を割くことができなかった。
継続のために
まず言語はGoにしようかなと思っている。Goは個人的に触っており、コーディングテストでも毎回選択している*3。そのため、Goで競プロに慣れておくメリットは大きいかなと思っている。Rustを使うことも検討したけれど、業務で使う可能性を考えるとRustよりもGoの方が高いかなぁ、と思う。まだまだ求人少ないし。
また、何を考えてどう解き、解説に何が書いてあるのか、そして時間を置いてもう一度解く必要があるのか、という当たり前のことができていなかった。理解より解くことが目的化していたからこそ中途半端になっていた。ここの思考を記録して整理しておく必要性を感じた*4。管理内容は後述する。
競プロにおけるGo
微妙らしい。
これ理由はけっこう簡単で、いくつか問題解いてみると分かるんだけど、強い型付けなのがかなり辛くって、キャストとかを超明示的に書かないといけない部分が多すぎるんだよね。実装速度がとにかく出ない。
— chokudai(高橋 直大)🍆 (@chokudai) March 7, 2017
go言語競プロだと厳しいって言ったけどtouristならtargetキープできる程度には使えると思うし、コンテストで競い合う向きじゃないけど、それがメインウェポンだと楽しめないってほどではない、くらいの印象。
— chokudai(高橋 直大)🍆 (@chokudai) March 7, 2017
これはたしかにそうで鬱陶しさや足りない機能が目立ってしまう。
例えば、未使用変数がワーニングじゃなくてエラーになるとか、タプルがないこととか、min/maxの関数がないとか。探せばたくさんある。
精選10問
なんやかんやあるけれど、とりあえず解いていこう。
思い立った時に毎回お世話になっている気がするが、drkenさんの記事を参考に解いていく。
今回はこれをGoで解いた。
Placing Marbles
Submission #19666903 - AtCoder Beginner Contest 081
Card Game for Two
Submission #19669348 - AtCoder Beginner Contest 088
Kagami Mochi
Submission #19669660 - AtCoder Beginner Contest 085
学び
知っていると思っていたようなことも競プロをやると厳密な理解を求められるのでよりクリアになった。
- breakで抜けるスコープは1つなので大域脱出したいならラベルを使う
- 数値のstringをループで回してruneを操作する場合、内部では文字コードになってしまうが、
'0'
を引くことで数値を得られる sort.Xxx
に渡す関数であるfunc (i, j int) bool
のi
とj
はインデックスのため、比較を行う場合はfunc (i, j int) bool { v[i] > v[j] }
のように書く- Goの配列のインデックスに負数は不可(Pythonとは違う)
管理方法
前述した思考の記録は、神ツールであるNotionを使っている。聖書や古事記などまだ印刷技術がなかった時代に書かれたもの大半はNotionで進捗管理されていたと言われている。そのくらい強力なツールだ。
こんな感じで復習できるようにまとめつつ思考過程と解説を載せている。初めはScrapboxで関連問題が表示されるようにしていたけれど、Notionの使い勝手の良さに負けてしまった。まだまだ改善の余地がありそうなので、もっと上手く管理したい。
結び
失踪せずに#2を書きたい。次回は蟻本の全探索の部分和問題とLake Counting問題の類題を解いていく。
コンテストは出れる時に出る。寝たい。
Go言語による並行処理を読んだ
結構前に下記の本を読んだ。メモをしていたのでここにも残しておこうと思う。
- 作者:Katherine Cox-Buday
- 発売日: 2018/10/26
- メディア: 単行本(ソフトカバー)
前提知識
今まで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
他の並行処理に関する公式資料は過去記事でまとめた。
実装パターン
goroutine雰囲気erなので、並行処理でどう実装するのか手札をあまり持っていなかった。本書は並行処理パターンという章で色んな実装パターンの実例を読むことができる。select
の使い方やchannel
を上手く使うことで孫goroutineを扱ったり。さらにpipeline
やfan-in
/fan-out
などのロジックは単純な機能を組み立てて複雑なことまで表現することのできるGoらしさを強く感じた。
chan
の仕様についても理解することができる。例えばchanがnilや空の時の挙動であったり、キューとしてどう振る舞っているのかなど。chanを深く理解していなかったが、本書を読んでからはchanを使った他人の実装も読めるようになった気がする。
「並行処理をバリバリ書かないから実装パターンを手札として増やしても仕方がない」と思うこともあったが、実装パターンを多く読むことで並行処理をGoでどう使い使われるのかの想像力がかなり豊かになる点はすごく良かったと思う。さっきも書いたけど他人の書いた並行処理が読めるとレビューもしやすくなるし、ライブラリもどんどん読める。
応用
エラー伝播やハートビート、流量制限は、若干難しかった。
しかし、実際の開発を前提に置いて話が展開されるため、イメージはしやすかった。
まだまだ読み込みの浅さを感じているので、このあたりはまたいつか読み直したい。
わからなくて調べたこと
CSP
並行処理一般の理論で追いつけないところがあった。なんとなく言いたいことはわかるけれど雲を掴むような気持ちで読んでいた。
GoはCSP(Communicating Sequential Process)という理論を土台?としている。CSPはプロセスが通信によってコミュニケーションをする、というものでGoの「共有ではなく通信によりメモリを共有する」という思想の基になっている話だ(たぶん)。公式ドキュメントにCSPを土台にした背景が少し書いてある。
わからなかった理由として、アクターモデルとの分別がついていないことがある。アクターモデルもアクター同士がメッセージを相互に送ってコミュニケーションするのでは?といまだに腑に落ちていない。
アクターモデルの理解がそもそも浅いため、いまの自分には手に負えないものだった。
アクターモデルのwikiにCSPに言及している部分もあったけど、いまいちわからなかった。
ただ、本書にも記載があるがGoは並行処理の複雑さに言語仕様から立ち向かってくれたおかげで、我々は簡単にコードを書くことができている。CSPの理解を深めることでGoの並行処理がより上手に書くことができることは本書が示していると思う。
今後何か意識したいところ
sync
とchannel
の使い分け。- パターンに依拠した実装
- 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での実装パターンが多い印象。下記に記載されていたポストをいくつか載せておく。
- Go Concurrency Patterns: Timing out, moving on - The Go Blog
- Go Concurrency Patterns: Pipelines and cancellation - The Go Blog
これらの実装パターンを読めば並行処理の引き出しが増えそう。また、他人の書いた並行処理に関して良い悪いの分別もつけられるようになりそう。
さらに、並行処理を使った際に必ず付き合うことになる競合のサポートツールであるrace-detectorの資料もあった。
Introducing the Go Race Detector - The Go Blog
かなり実践的。このレベルを押さえると普段の業務で並行処理の実装で困ることはほとんどなさそう。
Advanced
メモリモデルやスケジューラ、チャンネルについて、それぞれどのようなメカニズムなのか解説している。
- The Go Memory Model - The Go Programming Language
- The Scheduler Saga - Speaker Deck
- Understanding Channels - Speaker Deck
ここまでいくとメモリ上で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
さである、という点も常に頭に入れておきたい。
MutexOrChannel · golang/go Wiki · GitHub
まとめ
wikiにある各レベルを整理するとこんな感じだろうか。
- Beginner:Goの並行性は何なのか
- Intermediate:Goの並行性をどのように使うか
- Advanced:Goの並行性はどのように動くか
Goの並行処理について何か知りたいことが生まれた場合、上記に照らし合わせてwikiに貼ってあるリンクを読んでいくと良いのかなと思った。
雑談
タイミング的に2021年からになってしまったけれど、これからは目に見えるアウトプットをしっかりと残していきたいなと思ってブログの投稿を再開した。
ただ、普段から書いていたわけじゃないので生産体制が整わない中、Notionを使って投稿タイトルと簡単な骨子、公開スケジュールなどを組んでいくと自然と逆算して資料読んだり検証できるようになった。Notionは偉大。
サーバーサイドGo入門 #4 ~test~
前回はこちら prelude.hatenablog.jp
今回作るもの
gin
とgorm
を使ったTODOアプリが完成しました。今回はこれらのテストを行います。
基本的には公式のtesting
パッケージを使いますが、DBのモック用にgo-sqlmock
を使用します。
また、net/http
のテスト用のUtilであるnet/test/httptest
も使用します。
testing
標準パッケージであるtesting
は非常に便利ですが、アサーションやutilityがあまり充実していません。これは未開発なのではなく、意図的に開発していない機能になります。
理由には、アサーションやUtility、フレームワークなどに頼ると、正しいエラーハンドリングや正しいエラー報告について考慮せずにテストを書いてしまうということです。さらに、テストフレームワークは独自の発展によりミニ言語のようなものが生まれてしまう傾向にある、という厳しい言及もあります。
以上のことは下記のリンクにて説明されています。
- Frequently Asked Questions (FAQ) - The Go Programming Language
- Frequently Asked Questions (FAQ) - The Go Programming Language
アサーションがないことによって記述が冗長になると懸念されるかもしれません。しかし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.ResponseWriter
interfaceを満たしています。テストで使う際にはこのResponseRecorder
を通じて結果を書き込んでもらい、それを検証すれば良いだろうということがわかります。
改めてnet/http/httptest
の全体観としては、Request
とResponse
とServer
、というすごくシンプルなものであるとわかります。
httptest - The Go Programming Language
DBのテスト
DATA-DOG/go-sqlmock
これはsql-driverのモックを提供してくれます。
モックの初期化の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関連のライブラリ
- julienschmidt/httprouter(★12.2k)
- gorilla/mux(★13.5k)
- go-chi/chi(★8.7k)
- labstack/echo(★18.9k)
- gin-gonic/gin(★44.7k)
- gofiber/fiber(★11k)
ベンチマーク
Webフレームワークのベンチマークを計測しているリポジトリもあります。 github.com
database/sql関連のライブラリ
database/sql
の軽量ラッパーやORMなどなど。
ライブラリを使って書き換え
今回はスター数の多いgin
とgorm
を使おうと思います。どちらもREADME以外に公式サイトが存在します。
- Documentation | Gin Web Framework
- GORM - The fantastic ORM library for Golang, aims to be developer friendly.
ginの使用
まずはnet/http
をgin
を使ったものに書き替えます。
これまでhttp.HandleFunc
に関数を渡して処理を行っていました。ここをgin.Default
を呼び出して返ってくる*gin.Engine
に関数を渡すように変更します。
それに伴い、渡す関数のシグネチャがfunc (c *gin.Context)
になります。list
やadd
はその通りに変更します。
また、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
型の変数を宣言するように変更しています。それに伴いOpen
やSQLの書き方が若干変わります。
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
によってID
とCreatedAt
だけでなくUpdatedAt
とDeletedAt
も追加されました。
まとめ
今回は有名なライブラリをいくつか紹介して実際に使ってみました。標準パッケージを置き換えるようなライブラリを使いましたが、どれも基礎となるのはnet/http
やdatabase/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/sql
のRegister
を呼び出し、引数にMySQLDriver
を渡しています。Register
の第2引数の型はdatabase/sql/driver
のDriver
interfaceです。このinterfaceは何ができるのか見てみましょう。
type Driver interface { Open(name string) (Conn, error) }
go/driver.go at 59bfc18e3441d9cd0b1b2f302935403bbf52ac8b · golang/go · GitHub
ここまで読むとdatabase/sql
でOpen
が呼ばれると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 import
もinit
も使い方によっては非常に便利ですが、濫用するとコードから副作用を追うことが難しくなるため注意が必要です。
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/sqlはSQLを渡すだけです。
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/sql
はSQLをそのまま渡せるため、そこまでわからないところは多くなかったのではないでしょうか?単にライブラリのどの関数を呼べばよいのかの紹介程度になっているかと思います。
次回はこれら標準ライブラリではなくプロダクションで使われることの多いライブラリに書き換えていこうと思います。
雑談
こないだ初めて検便をした。
サーバーサイド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兆円欲しい。