はじめに
オライリー・ジャパン発行書籍「Go言語によるWebプログラミング開発」(原書籍: Go programming Blueprints) を写経しました。
その内容の走り書きです。
サンプルコード もgithubに公開されています。(原書のサンプルコード)
表現が間違えていることも多々あるかと思いますがご容赦ください。
修正リクエストをいただけると幸いです。
動機
- Golangはフレームワークの作法的なものが基本的にない中、ある程度の指針が欲しかった
- Webというテーマだが、NoSQLとメッセージキューイングの連携などの個人的にあまり扱って来なかった技術の例が載っていたので単純に実装に興味があった
chapter1 - WebSocketを使ったチャットアプリケーション
chapter3までで↓のような、認証機能付きチャットアプリケーションを作成します。
この章のメイン
- net/http パッケージを利用したWebアプリの基礎を作成
- goroutine・チャネルを利用したチャットアプリの基礎を作成
WebSocketと、OAuthを利用して、認証機能付き非同期チャットアプリケーションを作成します。
// チャットルーム
type room struct {
forward chan []byte // channel for sending messages to others
join chan *client // channel for client joining chat room
leave chan *client // channel for client leaving from chat room
clients map[*client]bool
tracer trace.Tracer
}
// ...
func (r *room) ServeHTTP(w http.ResponseWriter, req *http.Request) {
socket, err := upgrader.Upgrade(w, req, nil)
if err != nil {
log.Fatal("ServeHTTP: ", err)
return
}
client := &client{
socket: socket,
send: make(chan []byte, messageBufferSize),
room: r,
}
r.join <- client
defer func() {
r.leave <- client
}()
go client.write()
client.read()
}
type client struct {
socket *websocket.Conn // websocket for client
send chan *message // channel sent messages by others
room *room // chat room client participate
userData map[string]interface{} // ユーザーに関する情報を保持
}
func (c *client) read() {
for {
var msg *message
// read from websocket and send msg to room
if err := c.socket.ReadJSON(&msg); err == nil {
c.room.forward <- msg
} else {
break
}
}
c.socket.Close()
}
func (c *client) write() {
for msg := range c.send {
err := c.socket.WriteJSON(msg)
if err != nil {
break
}
}
c.socket.Close()
}
- room構造体とclient構造体でチャットアプリケーションを表現
-
room.ServeHTTP()
は/chat
にアクセス時に実行され、- コネクションをWebSocketにUpgrade
- client構造体をnew
-
room.join
チャネルにnewしたclientを送信して、roomにclientが入室したことを通知 - websocketからのメッセージを待ち受けるため
client.write()
,client.read()
を実行
-
client.read()
- json形式でクライアントから送信されてくるメッセージ待ち受ける。
- 送信されてきたらUnMarshalして、
client.room.forward
チャネルに送信。
-
client.write()
-
client.send
チャネルの受信待ち。 - 受信したら、それをjson形式にMarshalしてWebSocketに書き込む。
- もし失敗したらコネクションをClose。
-
chapter2 - 認証機能の追加
この章のメイン
- Decoratorパターンによるhttp.Handlerに対する機能追加
- OAuthを利用して認証機能を実装
Decoratorパターン: 機能を一つひとつかぶせていくイメージ。ここではチャット機能のハンドラを認証機能を持ったハンドラでラップする形で利用している
func main() {
http.Handle("/chat", MustAuth(&templateHandler{filename: "chat.html"}))
}
// 認証機能を持ったハンドラ
type authHandler struct {
next http.Handler
}
func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ...
h.next.ServeHTTP(w, r)
}
func MustAuth(handler http.Handler) http.Handler {
return &authHandler{next: handler}
}
// テンプレートをパースして返すハンドラ
type templateHandler struct {
// ...
}
func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ...
t.templ.Execute(w, data)
}
webアプリではルーティングによってmiddlewareの適用分けをしたい場合などに利用できる。
chapter3 - プロフィール画像を追加する3つの方法
この章のメイン
- 抽象化を取り入れるべきタイミング
- データの無い型によって節約される処理時間とメモリ消費量
- 既存のインターフェースの再利用
- Gravatarの利用
- 構造体からインターフェースへと機能を抜き出すべきタイミングとその方法
リリースフローにおけるインターフェースの再利用の例
type Avatar interface {
GetAvatarURL(u ChatUser) (string, error)
}
type (
AuthAvatar struct{}
GravatarAvatar struct{}
FileServerAvatar struct{}
)
var (
UseAuthAvatar AuthAvatar
UseGravatarAvatar GravatarAvatar
UseFileServerAvatar FileServerAvatar
)
// OAuthログイン時に取得したavatarのURLを返す
func (_ AuthAvatar) GetAvatarURL(u ChatUser) (string, error) {
// ...
}
// emailを使ってgravatarから取得したavatarのURLを返す
func (_ GravatarAvatar) GetAvatarURL(u ChatUser) (string, error) {
// ...
}
// useridを使ってローカルから取得したavatarのURLを返す
func (_ FileServerAvatar) GetAvatarURL(u ChatUser) (string, error) {
// ...
}
var avatar Avatar = UseGravatarAvatar
type TryAvatars []Avatar
func (a TryAvatars) GetAvatarURL(u ChatUser) (string, error) {
for _, avatar := range a {
if url, err := avatar.GetAvatarURL(u); err == nil {
return url, nil
}
}
return "", ErrNoAvatarURL
}
var avatar Avatar = TryAvatars{
UseFileServerAvatar,
UseAuthAvatar,
UseGravatarAvatar,
}
-
GetAvatarURL()
をインターフェースを利用して抽象化 - リリース当初はOAuth経由の画像URLだけ利用可能 -> ユーザーがアップロードした画像も利用できるように機能追加 のように、実際のリリースフローを想定した場合の、抽象化の適切なタイミング・方法
chapter5 - 分散システムと柔軟なデータの処理
chapter6までで↓のような、投票&リアルタイム開票Webアプリケーションを作成します。
この章のメイン
- MongoDBとのインタラクション
- NSQを使ったSubPubの機構
- 長期間ネットワーク接続の管理
- 各責務のアプリケーションレベルでの切り分け
- twitterのstreamAPIを利用して、用意した複数の文字列に検索ヒットしたツイートを「投票」とみなして、投票数をリアルタイムで集計する
- ミドルウェアとして MongoDB, NSQ(メッセージキュー)を利用して、「投票」と「開票」のアプリケーションを作る
goroutine1
SIGINT, SIGTERM のシグナルが来た場合、signalChanに流して全体をgraceful shutdownさせるための機構
func main() {
var stoplock sync.Mutex
stop := false
stopChan := make(chan struct{}, 1) // graceful shutdownのために、このチャネルを各goroutineが共有する
signalChan := make(chan os.Signal)
go func() {
<-signalChan
stoplock.Lock()
stop = true
stoplock.Unlock()
log.Println("停止します...")
stopChan <- struct{}{}
closeConn()
}()
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
//
}
goroutine2
twitterのstreamAPIからデータを常に取得する用の関数
-
stopChan
チャネルに停止のシグナルが来ない限り、 twitter APIからデータを読み、引数で渡されているvotes
チャネルに投票された選択肢を送信し続ける - 停止時は
stoppedchan
にstruct{}{}
を送信して、main内でvote
をcloseさせる。空のstructはメモリを全く使わないので、bool型を渡すよりも少し得らしい - が、終了を通知したいだけの場合、単にcloseした方ベター
func startTwitterStream(stopChan <-chan struct{}, votes chan<- string) <-chan struct{} {
stoppedchan := make(chan struct{}, 1)
go func() {
defer func() {
stoppedchan <- struct{}{}
}()
for {
select {
case <-stopChan:
log.Println("Twitterへの問い合わせを終了します...")
return
default:
log.Println("Twitterに問い合わせます...")
readFromTwitter(votes)
log.Println(" (待機中)")
time.Sleep(10 * time.Second) // 待機してから再接続します
}
}
}()
return stoppedchan
}
goroutine3
投票された文字列(votes)を引数にして、このチャネルに送信されるたびにNSQにpublish
func publishVotes(votes <-chan string) <-chan struct{} {
stopchan := make(chan struct{}, 1)
pub, _ := nsq.NewProducer("localhost:4150", nsq.NewConfig())
go func() {
for vote := range votes {
pub.Publish("votes", []byte(vote)) // 投票内容をパブリッシュ
}
log.Println("Publisher: 停止中です")
pub.Stop()
log.Println("Publisher: 停止しました")
stopchan <- struct{}{}
}()
return stopchan
}
おまけ: チャネルについてのおすすめ資料(Gophercon 2014)
https://www.slideshare.net/cloudflare/a-channel-compendium
chapter6 - REST形式でデータや機能を公開する
この章のメイン
- httpリクエストを処理するためのハンドラのベストプラクティス
- 5章の投票の様子を、Web上でリアルタイムに確認できるアプリを公開する
- 投票アプリのインターフェースを保ったまま実現する
APIサーバー
chapter5でリアルタイム集計している様子をユーザーに公開します。
メインとなるハンドラ部分
- パスは全て統一で、httpメソッド によって
handlePolls
でdispatchします。 - シンプルに投票の 全取得・一部取得・作成・削除 を定義
- 各ハンドラでのDBコネクションの取り方などのプラクティス
type poll struct {
ID bson.ObjectId `bson:"_id" json:"id"`
Title string `json:"title"`
Options []string `json:"options"`
Results map[string]int `json:"results,omitempty"`
}
func handlePolls(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
handlePollsGet(w, r)
return
case "POST":
handlePollsPost(w, r)
return
case "DELETE":
handlePollsDelete(w, r)
return
case "OPTIONS":
w.Header().Add("Access-Control-Allow-Methods", "DELETE")
respond(w, r, http.StatusOK, nil)
return
}
// 未対応のHTTPメソッド
respondHTTPErr(w, r, http.StatusNotFound)
}
func handlePollsGet(w http.ResponseWriter, r *http.Request) {
db := GetVar(r, "db").(*mgo.Database)
c := db.C("polls")
// ...
if err := q.All(&result); err != nil {
respondErr(w, r, http.StatusInternalServerError, err)
return
}
// ...
}
func handlePollsPost(w http.ResponseWriter, r *http.Request) {
db := GetVar(r, "db").(*mgo.Database)
c := db.C("polls")
// ...
if err := c.Insert(p); err != nil {
respondErr(w, r, http.StatusInternalServerError, "調査項目の格納に失敗しました", err)
return
}
// ...
}
func handlePollsDelete(w http.ResponseWriter, r *http.Request) {
db := GetVar(r, "db").(*mgo.Database)
c := db.C("polls")
// ...
if err := c.RemoveId(bson.ObjectIdHex(p.ID)); err != nil {
respondErr(w, r, http.StatusInternalServerError, "調査項目の削除に失敗しました", err)
return
}
// ...
}
Webサーバー
ただ静的なhtmlファイルを返すだけなので、以下のみ。
リアルタイムのためのロジックはフロント側にしかないので、省略
func main() {
var addr = flag.String("addr", ":8081", "Webサイトのアドレス")
flag.Parse()
mux := http.NewServeMux()
mux.Handle("/", http.StripPrefix("/",
http.FileServer(http.Dir("public"))))
log.Println("Webサイトのアドレス:", *addr)
http.ListenAndServe(*addr, mux)
}
フロント側はビジュアライズのためにGoogle Visualizationを利用しています。
window.setTimeout()
で毎秒apiサーバーを叩くことで、リアルタイムの更新を実現しています。
7y
chapter8 - ファイルシステムのバックアップ
この章のメイン
- コマンドラインツールを含んだプロジェクト構成
- ファイルシステムとのインタラクション
- コマンドツールに関するtips
ファイルの変更を監視して、都度zipファイルにアーカイブするツールを作成します。
ファイル監視
アプローチとしては、監視対象ファイルの ファイル名・パス・サイズ・最終変更時間・ディレクトリかどうか・ファイルモードのビット を元にmd5値を計算して変更を監視するというものです。
特定のstructがinterfaceを満たしているかをチェックするテクニックです。
以下のままだと zipper struct
は Archive()
を実装していないため、コンパイラエラー、かつ実装後もメモリ消費が少ないです。
type Archiver interface {
Archive(src, dest string) error
}
type zipper struct{} // これがArchiver interface を満たしているかをチェックしたい
var _ Archiver = (*zipper)(nil)
ここは時間の関係もあり、アプローチだけさらってコーディングはしなかったです。
時間の関係上スキップ
- chapter4 - ドメイン名を検索するコマンドラインツール
- chapter7 - ランダムなおすすめを提示するWebサービス
終わりに
- TDDの開発手法や、アジャイルを想定した開発であったり、プロセスに関しての指南もあるのが面白かった。
- 監修者の注意書きでよくないコードの指摘等もあり、学び。
- Websocket, MongoDB, SubPub, goroutine・channel など、今まで自分がGoであまり触れていなかったところを広く触れた。
- 個人的にはchapter5, 6が書いていて一番学びと楽しさがあった。