GAE/Goでカスタム404ページを返す(多分間違った)方法

# デフォルトの404ページを返す
Google App Engine/Go で404ページを表示する場合、例えば以下のように設定していたとすると、

```go:root.go
package app

import "net/http"

func init() {
  http.Handle("/", http.FileServer(http.Dir("static")))
  http.HandleFunc("/app/", HandleApp)
}
```

```go:app.go
package app

import "net/http"

func HandleApp(w http.ResponseWriter, r *http.Request) {
  // 何らかの操作
  if (isNotFound()) {
    http.NotFound(w, r)
  } else {
    // 任意のページの表示処理
  }
}
```

static/ ディレクトリ以下に存在しないファイルへのリクエスト、
もしくは何らかの操作を行い表示するべきページが無い場合には
「404 page not found」とのみ表示されたtext/plainが表示される。
この文字列は
`http.NotFound(w, r)`
の代わりに
`http.Error(w, "ページが見つかりません", http.StatusNotFound)`
とすることで変更することができるが、さて任意のhtmlやテンプレートを表示させるには
どうすればいいのだろう。
色々調べてかなり強引なやり方で実現してみた。

# カスタムの404ページを返す

```go:static_files.go
package app

import (
  "appengine"
  "io/ioutil"
  "net/http"
  "strings"
)

const dir = "static/"

func HandleStatic(w http.ResponseWriter, r *http.Request) {
  path := r.URL.Path
  if (strings.HasSuffix(path, "/")) {
    path = path + "index.html"
  }
  body, err := ioutil.ReadFile(dir + path)
  if err != nil {
    c := appengine.NewContext(r)
    c.Errorf(path + " is not found.")
    Handle404(w)
    return
  }

  w.Write(body)
}
```

```go:root.go
package app

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

func init() {
  http.Handle("/", HandleStatic)
  http.HandleFunc("/app/", HandleApp)
}

func Handle404(w http.ResponseWriter) {
  t, err := template.ParseFiles(
    "templates/error/404.html",
    )
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
  }
  w.WriteHeader(http.StatusNotFound)
  t.ExecuteTemplate(w, "404", nil)
}
```

```go:app.go
package app

import "net/http"

func HandleApp(w http.ResponseWriter, r *http.Request) {
  // 何らかの操作
  if (isNotFound()) {
    Handle404(w)
  } else {
    // 任意のページの表示処理
  }
}
```

こうすることで、templates/error/404.html に配置したファイルを
404ページとして返すことができる。
(404.htmlでは `{{define "404"}}` を宣言)

静的ファイルを返したい場合、 `http.FileServer(http.Dir("static"))` は使用せず、
ioutil.ReadFile してファイルの読み込みに失敗した場合、404として扱うようにした。

ところが、`http.FileServer(http.Dir("static"))` を使用しないことにしたことにより、
2つ問題が発生した。

1. 適切に Content-Type が付与されない
2. etagを見て304を返さず、常に200が返される

それぞれ対処していく。

## 適切な Content-Type の付与
`http.DetectContentType` というのが使えそうなので
`ioutil.ReadFile` で得られたバイト配列を引数に実行してみたところ、
.html, .jpg, .xml などは期待通りの Content-Type が返されたが、
.css, .js などは「text/plain」が返されてしまう。
仕方が無いので、ワークアラウンド的にまず特定の拡張子のみ個別の Content-Type を返し、
それ以外は `http.DetectContentType` で処理することにした。

```go:static_files.go
package app

import (
  "appengine"
  "io/ioutil"
  "net/http"
  "strings"
)

const dir = "static/"

func HandleStatic(w http.ResponseWriter, r *http.Request) {
  path := r.URL.Path
  if (strings.HasSuffix(path, "/")) {
    path = path + "index.html"
  }
  body, err := ioutil.ReadFile(dir + path)
  if err != nil {
    c := appengine.NewContext(r)
    c.Errorf(path + " is not found.")
    Handle404(w)
    return
  }

  w.Header().Add("Content-Type", getContentType(path, body))
  w.Write(body)
}

func getContentType(path string, body []byte) (string) {
  if (strings.HasSuffix(path, ".css")) {
    return "text/css"
  } else if (strings.HasSuffix(path, ".js")) {
    return "application/javascript"
  }
  return http.DetectContentType(body)
}
```

とりあえず例として .css と .js のみ指定しているが、
例えば僕の個人サイトでは Maven 野良リポジトリとしても使っているので

```go
  } else if (strings.HasSuffix(path, ".jar")) {
    return "application/java-archive"
```

こういう分岐も追加していたりしている。

## etag の付与、リクエストヘッダの etag との比較及び 304 の返却
etag としてどのような文字列を使用するかだが、
`appengine.VersionID` が返す値が 「<GAE のバージョンID>.<デプロイごとに振られる数値>」なので、
ドット以降のデプロイごとに振られる数値を使用することにする。
ファイル更新日時などから特定の値を返すようにしないといけないか、などと考えてみたが、
どうも GAE デフォルトでも、デプロイごとに6文字の固定文字列を全ファイルの etag としているようなので
これに倣うことにした。

```go:static_files.go
package app

import (
  "appengine"
  "io/ioutil"
  "net/http"
  "strings"
)

const dir = "static/"

func HandleStatic(w http.ResponseWriter, r *http.Request) {
  path := r.URL.Path
  if (strings.HasSuffix(path, "/")) {
    path = path + "index.html"
  }
  body, err := ioutil.ReadFile(dir + path)
  if err != nil {
    c := appengine.NewContext(r)
    c.Errorf(path + " is not found.")
    Handle404(w)
    return
  }

  w.Header().Add("Content-Type", getContentType(path, body))

  etag := getEtag(r)

  if (etag == r.Header.Get("If-None-Match")) {
    w.WriteHeader(http.StatusNotModified)
    return
  }

  w.Header().Add("ETag", etag)
  w.Write(body)
}

func getEtag(r *http.Request) string {
  version := appengine.VersionID(appengine.NewContext(r))
  return strings.Split(version, ".")[1]
}

func getContentType(path string, body []byte) (string) {
  if (strings.HasSuffix(path, ".css")) {
    return "text/css"
  } else if (strings.HasSuffix(path, ".js")) {
    return "application/javascript"
  }
  return http.DetectContentType(body)
}
```

`w.Header().Add("Content-Type", getContentType(path, body))` が凄く微妙な位置にあって
何とかまとめられないかと考えてみたが、
`w.WriteHeader(http.StatusNotModified)` で処理を終える場合にも、
それより前に Content-Type を付与しておかないと一律「text/html」になるようなので
仕方なくここに…。

以上で一応やりたいことは実現できたが、
独自に Content-Type を付与したり 304 の面倒を見たりするのが
正規のやり方とは思い難い…。
そもそも `http.FileServer(http.Dir("static"))` を放棄している時点で悪手だと思う…。
知らないだけでもっと楽なやり方があるのでは…あってほしい…。
(イメージ的には app.yaml で何らかの設定をするか、
 404 をどこかで一括で catch して任意のベージを表示するような処理を入れるかと想定していたが)