goでWebサービス No.3(静的ファイルの扱い)

15 min read読了の目安(約13900字

今回からnet/httpを使用してメッセージを返したり、html, css, JavaScriptを扱ったりいろいろなことを試していきましょう。
今回のデータはgithubにあげています。必要なファイルはクローンしてお使いください。

注意

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

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

準備

まず環境を整えます。以下の説明の通りにする必要はありませんが、特別な理由がなければ同じようにやることをオススメします。
ワーキングディレクトリ/src/に web_server_study というディレクトリを作ります。今後はこのディレクトリを作業ディレクトリとします。

DefaultServeMuxを使ったサーバ

01default_serveディレクトリを作りその中にmain.goを作りましょう。

:~/go/src/web_server_study/01default_serve
$ ls
main.go

main.goには以下のように記述します。

// code:web3-1 01defautl_serve/main.go 
package main
// ①
import (
	"fmt"
	"log"
	"net/http"
)
// ②
func handleIndex(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "welcome golang server.")
}

func handleGreet(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "hello guest!")
}
// ③
func main() {
	port := "8080"
	http.HandleFunc("/", handleIndex)
	http.HandleFunc("/greeting", handleGreet)
	log.Printf("Server listening on http://localhost:%s/", port)
	// nil -> DefaultServeMuxが適用される
	log.Print(http.ListenAndServe(":"+port, nil))
}

まずはリクエストに対して、文字列を返すだけのウェブサーバです。

  1. パッケージはレスポンスに文字を書き込むためにfmt, コンソールにメッセージを表示するためのlog, httpの機能を使用するためにnet/httpをインポートします。
  2. それぞれのリクエストに対して返信するレスポンスを生成する関数を実装します。例えばhandleIndexはFprintf関数でレスポンスに welcome golang server. を書き込んで返します。handleGreet関数も同様です。
  3. main関数では実際にサーバを立ててリクエストをリッスンします。
    1. まずポートを指定します。
    2. そのあとhandleFunc関数でリクエストのパスから呼び出す関数を指定します。呼び出すのは先ほど定義した関数です。
      handleFunc(パス, 呼び出す関数)
    3. サーバを立ち上げた際にコマンドラインに表示するメッセージを設定します。
    4. ListenAndServe関数でサーバを立ち上げます。

これが基本的な形です。今回は学習のためなので実行ファイルを作る必要はありません。以下のように実行しましょう。

:~/go/src/web_server_study/01defautl_serve
$ go run main.go
2020/10/02 23:37:18 Server listening on http://localhost:8080/

ブラウザでhttp://localhost:8080/にアクセスするとwelcome golang server.と表示されるでしょう。
http://localhost:8080/greetingの場合はhello guest!と表示されたかと思います。
終わる時はCtrl+Cです。

自分でServeMuxを作成する。

次は、自分でServeMuxを設定してみましょう。以下のようにディレクトリ・ファイルを作成してください。

:~/go/src/web_server_study/02original_serve
$ ls
main.go

main.goには以下のように記述します。

// code:web3-2 02original_serve/main.go
package main

import (
	"fmt"
	"log"
	"net/http"
)
// ①
// MyMux is Original ServeMux
type MyMux struct {
}

func (p *MyMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.URL.Path {
    case "/":
        handleIndex(w, r)
    case "/greeting":
        handleGreet(w, r)
    default:
        http.NotFound(w, r)
    }
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "welcome golang server.")
}
func handleGreet(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "hello guest!")
}

func main() {
    port := "8080"
    // ②
    mux := &MyMux{}
    log.Printf("Server listening on http://localhost:%s/", port)
    log.Print(http.ListenAndServe(":"+port, mux))
}

内容はcode:web3-1と同一です。異なる部分だけ説明します。

  1. MyMuxという空の構造体にServeHTTPというメソッドを持たせます。ServeHTTPはURLのパスから最も近いハンドラー(今回は下で定義した関数)にリクエストをディスパッチ(送信)します。
  2. muxというMyMux型の変数をListenAndServeの第二引数に設定します。

実行結果はcode:web3-1と全く一緒なので割愛します。ただ、先ほどは確認しなかったcurlコマンドでの結果を確認してみましょう。

$ curl http://localhost:8080/
welcome golang server.
$ curl http://localhost:8080/greeting
hello guest!

静的ファイルを扱う

HTMLファイル

これまでは文字列を返すだけでしたが今度はHTMLファイル返してみましょう。以下のような構成でディレクトリを用意してください。画像に関しては任意のものをお使いいただくか、冒頭で紹介したGithubのリポジトリから取得してください。

:~/go/src/web_server_study/03static_file
$ tree
.
├── assets
│   ├── img
│   │   └── biplane.png
│   ├── js
│   │   └── script.js
│   └── style
│       └── style.css
├── main.go
└── root
    └── index.html

rootディレクトリがHTMLファイルを置く領域とします。assetsはCSS, JS, 画像等のファイルを置く領域です。まずは、rootディレクトリをを使ってHTMLファイルをブラウザに出力してみましょう。
index.htmlを以下のように作成してください。

<!-- code:web3-3 index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Golang!!</title>
</head>
<body>
    Welcome Golang Server.
</body>
</html>
// code:web3-4 main.go
package main

import (
	"log"
	"net/http"
)

func main() {
	port := "8080"

	http.Handle("/", http.FileServer(http.Dir("root/"))) // ①
	log.Printf("Server listening on http://localhost:%s/", port)
	log.Print(http.ListenAndServe(":"+port, nil))
}

①のファイルサーバが今回の主役です。

  • http.Dirは型の一種です。http.Dir(string)で指定された文字列をFileSystemを持った型に変換します。これにより文字列で指定されたディレクトリツリーをたどることができます。
  • http.Dirをhttp.FileServerで公開します。それをhttp.Handleでパスに対してハンドリングしてあげることでHTMLファイルへアクセスします。

実行して http://localhost:8080/ にアクセスするとhtmlファイルの内容が表示されたかと思います。

一度サーバを停止して、次にrootディレクトリにgreeting.htmlを作成して以下のように記述します。

<!-- code:web3-5 greeting.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Greeting</title>
</head>
<body>
    <h1>Hello Guest!</h1>
    <p>どの言語が好きですか?</p>
    <ul>
        <li>JavaScript</li>
        <li>Python</li>
        <li>PHP</li>
        <li>もちろんGolang</li>
    </ul>
</body>
</html>

もう一度実行し http://localhost:8080/greeting.html にアクセスするとちゃんと表示されることが解ります。

http.Dirは指定したディレクトリ直下の全てのディレクトリ・ファイルを公開する可能性があります。またシンボリックリンクから全く別のディレクトリにアクセスできるかもしれません。.gitや.pswardなどの機密ファイルを置いていた場合、そのファイルにアクセスされる可能性もあります。

ただし、今回のようにhttp.FileServerと組み合わせて使えば指定したディレクトリの外へはアクセスできなくなります。ただし指定したディレクトリ内に置いている機密ファイルにはアクセスできる可能性があるので気を付けましょう。

アセットファイル

画像やCSSなどのファイルも扱ってみましょう。先ほど作っていたファイルに記述してください。

/* code:web3-6 style.css */
html body {
    margin: 0;
    padding: 0;
    font-size: 16px;
    font-weight: bold;
    background: #2193b0;  /* fallback for old browsers */
    background: -webkit-linear-gradient(to top, #6dd5ed, #2193b0);  /* Chrome 10-25, Safari 5.1-6 */
    background: linear-gradient(to top, #6dd5ed, #2193b0); /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */
    color: aliceblue;
    width: 100vw;
    height: 100vh;
    overflow: hidden;
    position: relative;
    text-align: center;
}

#title {
    width: 100%;
    color: aliceblue;
    position: fixed;
}

#fly {
    position: relative;
    cursor: pointer;
}

.flyBefore {
    left: 100%;
}

.flyAfter {
    left: 0;
    transition: left 2s ease-in;
}

.onClickFly {
    left: -100%;
    transition: left 2s ease-out;
}

.contents {
    position: relative;
    top: 100px;
}

.footer {
    position: fixed;
    width: 100%;
    text-align: center;
    bottom: 0;
}
// code:web3-7 script.js
(function() {
    setTimeout(function() {
        const title = document.getElementById("fly");
        title.classList.remove("flyBefore");
        title.classList.add("flyAfter");
    }, 100);

    const img = document.getElementById("fly");
    img.addEventListener("click", function() {
        console.log("fly");
        img.classList.remove("flyAfter");
        img.classList.add("onClickFly");
        setTimeout(function(){
            img.classList.remove("onClickFly");
            img.classList.add("flyBefore");
            setTimeout(function() {
                img.classList.remove("flyBefore");
                img.classList.add("flyAfter");
            }, 10);
        }, 1000);
    });
})();

内容はこの通りである必要はありません。自分の好きなように記述してOKです。記述内容の説明も本筋とはズレるので今回は割愛します。
index.htmlも書き換えましょう。

<!-- code:web3-8 index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hello Golang</title>
    <link rel="stylesheet" type="text/css" href="/assets/style/style.css" />
</head>
<body>
    <h1 id="title" class="titleBefore">Hello Golang</h1>
    <div class="contents">
        <p>こんにちは</p>
        <img id="fly" class="flyBefore" src="/assets/img/biplane.png" width="300px"/>        
    </div>
    <div class="footer">Copyright &copy; bkc</div> 
    <script src="/assets/js/script.js"></script>
</body>
</html>

最後にmain.goも書き換えましょう。

// code:web3-9 main.go
func main() {
	port := "8080"

	http.Handle("/", http.FileServer(http.Dir("root/")))
	http.Handle("/assets/", http.FileServer(http.Dir("assets")))
	log.Printf("Server listening on http://localhost:%s/", port)
	log.Print(http.ListenAndServe(":"+port, nil))
}

これで実行してみてください。いかがでしょう?assetsファイルが読み込まれていないのが確認できると思います。デベロッパーツールなどを確認するとファイルが見つからない等のメッセージが出ていると思います。
この原因は、/assets/の指定にあります。例えばCSSファイルを読み込む時/assets/style/style.cssとパスを指定しています。Handle関数で/assets/というパスはassetsディレクトリを参照するようになっています。ではこの時assetsディレクトリのどこを参照しているのでしょうか?実はassetsディレクトリ内の/assets/style/style.cssを参照するようになっているのです。つまり
assets/assets/style/style.css
となっています。つまりHandle関数は/assets/というパスでルーティングしますが、そのあとのファイルパスもそのままURLのパスが使用されるということです。
これを見越してディレクトリ構成をしても良いのですが、ファイルの配置がめちゃくちゃになりそうでよくありません。そこでStripPrefix関数を使い以下のようにFileServer関数をラップします。

http.StripPrefix("/assets/", http.FileServer(http.Dir("assets")))

StripPrefixはその名の通り引数にとった接頭辞(前についている語)を除外します。上の場合だと/assets/style/style.css/assets/を取り除いたstyle/style.cssというパスをFileServerに渡します。これでこちらの意図したパスになります。main.go全体はこのようになります。

// code:web3-10 main.go
func main() {
	port := "8080"

	http.Handle("/", http.FileServer(http.Dir("root/")))
	http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets"))))
	log.Printf("Server listening on http://localhost:%s/", port)
	log.Print(http.ListenAndServe(":"+port, nil))
}

これで実行するとそれぞれのアセットファイルが適用された形で表示されると思います。
実行結果

テンプレート

テンプレートを使う

Goではhtml/templateというhtmlに情報を埋め込む機能を持ったパッケージがあります。phpでもテンプレートといいますね。pythonだとjinjaというテンプレートエンジンが有名なんですかね。JavaScriptにもテンプレートエンジンがあるようですがreactやvueなどのライブラリ・フレームワークが人気なところからするとDOM操作の方が一般的な気がします。
いずれにせよ上の言語と同様にGoでもテンプレートが扱えます。

Goでのテンプレートの扱い方を上のAssetsファイルで作成したものと同じものを作成して確認してみましょう。ディレクトリ構成は以下のようになります。

:~/go/src/web_server_study/04template
$ tree
.
├── assets
│   ├── img
│   │   └── biplane.png
│   ├── js
│   │   └── script.js
│   └── style
│       └── style.css
├── main.go
└── root
    ├── index.html
    └── template
        ├── footer.html
        └── header.html

変わったところはgreeting.htmlを削除してtemplateフォルダとheader.html, footer.htmlを作成したところです。
まず、header.htmlとfooter.htmlを書きましょう。

<!-- code:3-11 header.html -->
{{ define "header" }}

<h1 id="title" class="titleBefore">{{ .Title }}</h1>
{{ end }}

<!-- footer.html -->
{{ define "footer" }}

<div class="footer">Copyright &copy; bkc</div>
{{ end }}

headerとfooterは埋め込む側です。埋め込む側は、{{ define "template名" }}で始まり要素を書いていきます。必ず{{ end }}で終わるようにしましょう。
index.htmlは内容を修正します。

<!-- code:web3-12 index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{.Title}}</title>
    <link rel="stylesheet" type="text/css" href="/styles/style.css" />
</head>
<body>
    {{ template "header" . }}
    <div class="contents">
        <p>{{.Message}}</p>
        <p>{{.Time.Format "2006/1/2 15:04:05"}}</p>
        <img id="fly" class="flyBefore" src="/assets/biplane.png" width="300px"/>        
    </div>
    {{ template "footer" }}
    <script src="/js/script.js"></script>
</body>
</html>

index.htmlでは先ほど作ったテンプレートの他にサーバから埋め込むデータもあります。
{{ .Title }}はサーバから受け取ったTitle変数の値が埋め込まれてクライアントに送られます。
{{ template "footer" }}で先ほど作ったテンプレートを埋め込むことができます。
headerでも{{ .Title }}を見かけたと思います。つまりheaderにはデータを受けわたす必要があります。受けわたす際は{{ template "header" . }}と最後にドット.を付けてください。

main.goも書き換えます。

// code:web3-13
package main

import (
	"html/template"
	"log"
	"net/http"
	"time"
)

// ①Embed htmlファイルに埋め込むデータ構造体
type Emb struct {
	Title   string
	Message string
	Time    time.Time
}

func main() {
	port := "8080"

	http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets"))))
    // ②
	http.HandleFunc("/", handleIndex)
	log.Printf("Server listening on http://localhost:%s/", port)
	log.Print(http.ListenAndServe(":"+port, nil))
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
    // ③
	t, err := template.ParseFiles(
		"root/index.html",
		"root/template/header.html",
		"root/template/footer.html",
	)
	if err != nil {
		log.Fatalf("template error: %v", err)
    }
    // ④
	temp := Emb{"Hello Golang!", "こんにちは!", time.Now()}
	if err := t.Execute(w, temp); err != nil {
		log.Printf("failed to execute template: %v", err)
	}
}

html/templateとtimeパッケージをインポートします。
埋め込み用の変数をまとめてEmbという構造体にします。
埋め込みの処理があるのでhandleIndex関数を作成しそこで処理します。そのため、②のようにhtmlファイルのハンドリングはhandleFunc関数に戻します。
handleIndexでは③でまず引数に与えられたテンプレートを解析します。④で解析したテンプレートにExecuteメソッドで変数tempを埋め込んでw(ResponseWriter)に書き込みます。

これで実行すると先ほどと同じ結果をブラウザで確認できると思います。

少しだけ処理を効率化

おまけです。ここでは簡単な説明に留めます。上のcode:web3-13の例だとクライアントからリクエストが来る度に解析を行います。毎回同じテンプレートを解析するのですからこれは無駄が多いですね。以下のようにサーバ立ち上げ時のみテンプレートの解析をするように変更すると良いでしょう。

package main

import (
	"html/template"
	"log"
	"net/http"
	"time"
)

// Embed htmlファイルに埋め込むデータ構造体
type Embed struct {
	Title   string
	Message string
	Time    time.Time
}

var templates = make(map[string]*template.Template) // *

func main() {
	port := "8080"

	templates["index"] = loadTemplate("index") // *
	http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets"))))

	http.HandleFunc("/", handleIndex)
	log.Printf("Server listening on http://localhost:%s/", port)
	log.Print(http.ListenAndServe(":"+port, nil))
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
	temp := Embed{"Hello Golang!", "こんにちは!", time.Now()}
	if err := templates["index"].Execute(w, temp); err != nil { // *
		log.Printf("failed to execute template: %v", err)
	}
}

func loadTemplate(name string) *template.Template { // *
	t, err := template.ParseFiles(
		"root/"+name+".html",
		"root/template/header.html",
		"root/template/footer.html",
	)
	if err != nil {
		log.Fatalf("template error: %v", err)
	}
	return t
}

変更した部分は*がついているところです。説明はしませんので自分で確認してみてください。