6
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

「O'Reilly Japan - Go言語によるWebアプリケーション開発」を写経した

Last updated at Posted at 2020-03-24

はじめに

オライリー・ジャパン発行書籍「Go言語によるWebプログラミング開発」(原書籍: Go programming Blueprints) を写経しました。
その内容の走り書きです。

サンプルコード もgithubに公開されています。(原書のサンプルコード

表現が間違えていることも多々あるかと思いますがご容赦ください。
修正リクエストをいただけると幸いです。

動機

  • Golangはフレームワークの作法的なものが基本的にない中、ある程度の指針が欲しかった
  • Webというテーマだが、NoSQLとメッセージキューイングの連携などの個人的にあまり扱って来なかった技術の例が載っていたので単純に実装に興味があった

chapter1 - WebSocketを使ったチャットアプリケーション

chapter3までで↓のような、認証機能付きチャットアプリケーションを作成します。
websocket-01.gif

この章のメイン

  • 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の機構
  • 長期間ネットワーク接続の管理
  • 各責務のアプリケーションレベルでの切り分け
  1. twitterのstreamAPIを利用して、用意した複数の文字列に検索ヒットしたツイートを「投票」とみなして、投票数をリアルタイムで集計する
  2. ミドルウェアとして 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 チャネルに投票された選択肢を送信し続ける
  • 停止時は stoppedchanstruct{}{} を送信して、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 structArchive() を実装していないため、コンパイラエラー、かつ実装後もメモリ消費が少ないです。


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が書いていて一番学びと楽しさがあった。
6
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?