goでWebサービス No.2(webサーバ)

9 min読了の目安(約8300字TECH技術記事

今回は、Webサーバにフォーカスして説明します。Goの公式ドキュメントを追いながらGoがどのようにWebサーバを実現していくかをみていきましょう。

注意

コマンドラインを使った操作が出て来ることがあります。cd, ls, mkdir, touchといった基本的なコマンドを知っていることを前提に進めさせていただきます。
環境の違いで操作や実行結果に差異が出てくる可能性があります。私の実行環境は以下になります。

MacBook Pro (Early 2015)
macOS Catalina ver.10.15.6
go version go1.14.7 darwin/amd64
エディタはVScode

Webサーバの処理

Webサーバは以下のようにしてクライアントのリクエストを処理します。

  1. Listen Socketを作成し、指定したポートを監視します。クライアントのリクエストを待ちます。
  2. Listen Socketはクライアントのリクエストを受け付けます。Client Socketを得ると、Client Socketを通じてクライアントと通信を行います(通信の確立)。
  3. クライアントのリクエストのヘッダーから必要な処理を把握する。
  4. 対応するhandlerがリクエストを処理する。
  5. handlerがクライアントの要求するデータを準備する。
  6. Client Socketを通して準備したデータを書き出す。

Socketは、ネットワーク上で動作しているプログラムを結ぶ通信の出入り口です。ここではListen Socketがサーバの方で、Client Socketがクライアントの方になります。
handlerはリクエストを処理し要求されたデータを準備するプログラムです。

GoではどのようにWebサーバを実現しているか

上のWebサーバの処理をみてみると以下のことをGoでどのように実現しているかを理解すればGoでWebサーバを実現しているかが理解出来ます。ソースコードを追いながら仕組みを把握しいきましょう。

  • どのようにポートを監視するか?
  • クライアントのリクエストをどのように受け付けるか?
  • handlerにどのように受け渡すか?

長くなりますが前回作った簡単なWebサーバを元にソースコードを参照しながら追っていきたいと思います。前回のコードではhttp.ListenAndServe(":9090", nil)でサーバを立てていました。この関数の中を細かくみていきます。

どのようにポートを監視するか、またクライアントのリクエストをどのように受け付けるか?

code:web2-1はhttp.ListenAndServeのソースコードです。みての通り、Server型のListenAndServeメソッドを返していることがわかります。

// code:web2-1
func ListenAndServe(addr string, handler Handler) error {
	server := &Server{Addr: addr, Handler: handler}
	return server.ListenAndServe()
}

code:web2-2はserver.ListenAndServeのソースコードです。ここでは最後にln変数を引数に持つServeメソッドを返していることに注目してください。
ln変数はnet.ListenerというListen Socketの振る舞いをする型の変数です。

// code:web2-2
func (srv *Server) ListenAndServe() error {
	if srv.shuttingDown() {
		return ErrServerClosed
	}
	addr := srv.Addr
	if addr == "" {
		addr = ":http"
	}
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	return srv.Serve(ln)
}

code:web2-3がsrv.Serveのソースコードです(一部省略)。これがクライアントのリクエストを持ち受ける関数です。forの無限ループになっています。これでクライアントからのリクエストを待ち受ける状態を維持します。Acceptメソッドで接続を受信します。途中はエラー処理なので無視して最後の方のnewConnメソッドをみてみましょう。

// code:web2-3
func (srv *Server) Serve(l net.Listener) error {
   defer l.Close()
   var tempDelay time.Duration // how long to sleep on accept failure
   for {
       rw, e := l.Accept()
       if e != nil {
           if ne, ok := e.(net.Error); ok && ne.Temporary() {
               if tempDelay == 0 {
                   tempDelay = 5 * time.Millisecond
               } else {
                   tempDelay *= 2
               }
               if max := 1 * time.Second; tempDelay > max {
                   tempDelay = max
               }
               log.Printf("http: Accept error: %v; retrying in %v", e, tempDelay)
               time.Sleep(tempDelay)
               continue
           }
           return e
       }
       tempDelay = 0
       c, err := srv.newConn(rw)
       if err != nil {
           continue
       }
       go c.serve()
   }
}

code:weeb2-4はnewConn関数のソースコードですconn型の変数cを返しています。これだけだとわかりませんが、簡単に説明するとクライアントのリクエストデータへのリンクです。その後code:web2-3ではgo c.serve()を実行しているのでクライアントのリクエストをゴルーチンで並行処理しています。

// code:web2-4
func (srv *Server) newConn(rwc net.Conn) *conn {
	c := &conn{
		server: srv,
		rwc:    rwc,
	}
	if debugServerConnections {
		c.rwc = newLoggingConn("server", c.rwc)
	}
	return c
}

handlerにどのように受け渡すか?

code:web2-3のc.serveメソッドは新しいコネクションを受け取り処理を行います。コードの量が多いので全体のコードはここでは割愛します。みたい人は以下のリンクから参照してください。

c.serveのソースコード

今回は1925行目の
serverHandler{c.server}.ServeHTTP(w, w.req)
に注目します。code:web2-5はそのソースコードです。

// code:web2-5
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
	handler := sh.srv.Handler
	if handler == nil {
		handler = DefaultServeMux
	}
	if req.RequestURI == "*" && req.Method == "OPTIONS" {
		handler = globalOptionsHandler{}
	}
	handler.ServeHTTP(rw, req)
}

ここでhandlerを取得していることがわかります。これはListenAndServe関数の2つ目の引数で指定したものです。前回のコードではnilを指定しています。なのでDefaultServeMuxが渡されます。この変数はルータです。これはマッチするURLを対応するhandler関数にリダイレクトするために用いられます。このマッチするURLに対応するhandler関数がhttp.HandleFunc関数になります(以下前回のコード)。

func main() {
	http.HandleFunc("/", sayhelloName)
	err := http.ListenAndServe(":9090", nil)
	fmt.Println("Listen in 9090.")
	if err != nil {
		log.Fatal("Listen and server:", err)
	}
}

HandleFuncでは第1引数にpath、第2引数にURLがそのpathを指定した時に実行する関数を指定してあります。そこで指定した関数が実行されレスポンスを作成します。

ServeMuxを詳しく見てみる

先ほどからチラチラ出ているDefaultServeMuxについてみてみましょう。これはServeMux型の構造体です。リクエストパスとハンドラーを繋ぐのに利用されています。ServeMuxのソースコードをみてみましょう。

// code:web2-6
// ServeMux is an HTTP request multiplexer.
// It matches the URL of each incoming request against a list of registered
// patterns and calls the handler for the pattern that
// most closely matches the URL.

type ServeMux struct {
   mu    sync.RWMutex
   m     map[string]muxEntry
   es    []muxEntry // slice of entries sorted from longest to shortest.
   hosts bool       // whether any patterns contain hostnames
}

type muxEntry struct {
	h       Handler
	pattern string
}

Mutexが読み込み・書き込み両方に対してロックをかけるのに対してRWMutexは読み込み・書き込みのロックを分けることが出来る型です。ゴルーチンでそれぞれのリクエストの並行処理を行うので必要ですね。muxEntryはHandlerとマッチング文字列の組みを持ちます。この文字列とリクエストのURLを比較して適当なhandlerに渡すわけですね。

ようやくするとServeMux は HTTP リクエストマルチプレクサです。
これは、登録されたパターンのリストと各受信リクエストの URL をマッチさせ、URL に最も近いパターンのハンドラを呼び出します。

Handlerを詳しく見てみる

以下にあるようにhandlerはインターフェースです。

type Handler interface {
   ServeHTTP(ResponseWriter, *Request)
}

しかしGoで簡易的なWebサーバを立てた時、ServeHTTPは実装しませんでした。ではどこで実現されていたかというとHandlerFuncです。

type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
	f(w, r)
}

これによりcode:web2-7が実行されます。リクエストを受け取った後、*であれば接続を切断し、そうでなければmux.handler(r).ServeHTTP(w, r)をコールして対応する設定された処理Handlerを返し、h.ServeHTTP(w, r)を実行します。

// code:web2-7
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
	if r.RequestURI == "*" {
		if r.ProtoAtLeast(1, 1) {
			w.Header().Set("Connection", "close")
		}
		w.WriteHeader(StatusBadRequest)
		return
	}
	h, _ := mux.Handler(r)
	h.ServeHTTP(w, r)
}

mux.Handler(r)をもう少し詳しくみるとcode:web2-8の2つの関数で実際にリクエストのパスからマッチするHandlerを探して返しています。

// code:web2-8
func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) {

	if r.Method == "CONNECT" {
		if u, ok := mux.redirectToPathSlash(r.URL.Host, r.URL.Path, r.URL); ok {
			return RedirectHandler(u.String(), StatusMovedPermanently), u.Path
		}
		return mux.handler(r.Host, r.URL.Path)
	}

	host := stripHostPort(r.Host)
	path := cleanPath(r.URL.Path)

	if u, ok := mux.redirectToPathSlash(host, path, r.URL); ok {
		return RedirectHandler(u.String(), StatusMovedPermanently), u.Path
	}
	if path != r.URL.Path {
		_, pattern = mux.handler(host, path)
		url := *r.URL
		url.Path = path
		return RedirectHandler(url.String(), StatusMovedPermanently), pattern
	}
	return mux.handler(host, r.URL.Path)
}

func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
	mux.mu.RLock()
	defer mux.mu.RUnlock()

	if mux.hosts {
		h, pattern = mux.match(host + path)
	}
	if h == nil {
		h, pattern = mux.match(path)
	}
	if h == nil {
		h, pattern = NotFoundHandler(), ""
	}
	return
}

これがルーティングのプロセスになります。少し思い出して欲しいのですが、大元のListenAndServe(":9090", nil)ではnilを渡しているのでDefaultServeMuxつまり上で見てきたようなことが実行されます。ここを自分で定義したServeMuxに置き換えることでルータを実装することも出来ます。

まとめ

これまでに説明したことをまとめましょう。

  1. まずHttp.HandleFuncをコールします。
    順序にしたがっていくつかの事を行います:

    1. DefaultServeMuxのHandlerFuncをコールする。
    2. DefaultServeMuxのHandleをコールする。
    3. DefaultServeMuxのmap[string]muxEntryで目的のhandlerとルーティングルールを追加する。
  2. 次にhttp.ListenAndServe(":9090", nil)をコールする。

    順序にしたがっていくつかの事を行う:

    1. Serverのエンティティ化

    2. ServerのListenAndServe()をコールする

    3. net.Listen("tcp", addr)をコールし、ポートを監視する

    4. forループを起動し、ループの中でリクエストをAcceptする

    5. 各リクエストに対してConnを一つエンティティ化し、このリクエストに対しgoroutineを一つ開いてgo c.serve()のサービスを行う。

    6. 各リクエストの内容を読み込むw, err := c.readRequest()

    7. handlerが空でないか判断する。もしhandlerが設定されていなければ(この例ではhandlerは設定していません)、handlerはDefaultServeMuxに設定されます。

    8. handlerのServeHttpをコールする

    9. この例の中では、この後DefaultServeMux.ServeHttpの中に入ります

    10. requestに従ってhandlerを選択し、このhandlerのServeHTTPに入りますmux.handler(r).ServeHTTP(w, r)

    11. handlerを選択します:

      • ルータがこのrequestを満足したか判断します(ループによってServerMuxのmuxEntryを走査します。)
      • もしルーティングされれば、このルーティングhandlerのServeHttpをコールします。
      • ルーティングされなければ、NotFoundHandlerのServeHttpをコールします。