Golangの"Writing Web Applications"を読む
Goを触ってみたくなったので、Goの公式ドキュメントにあるWriting Web Applicationsを読む
一通り読み終わってアプリが出来たらクローズする
昨日Using net/http to serve wiki pagesまで読んだ
ここまでで http://localhost:8080/view/{{ title }} で{{ title }}.txt
を読み込み、それを内容とするシンプルなwikiページが表示できるようになった。
ただし、エラーハンドリングはしていないので、存在しないファイルを指定すると404になる。
またアプリ側から直接記事を追加したり編集する機能はまだ無い。
今日は続きのEditing Pagesから読む
ちなみに404エラーになったとき、ターミナルには以下のようなエラーが表示されている
エラーハンドリングをしていないので当然だろうが、不正なメモリアクセス・ぬるぽがあったことしか分からない。実行にはgoroutineというものが使われていそうなこともなんとなく読み取れる、goroutineで並列処理を行っているみたいな話は聞いたことがあるけど詳しくは知らない。
2021/02/13 17:33:14 http: panic serving [::1]:57290: runtime error: invalid memory address or nil pointer dereference
goroutine 9 [running]:
net/http.(*conn).serve.func1(0xc000122be0)
/usr/local/go/src/net/http/server.go:1801 +0x147
panic(0x663300, 0x855ed0)
/usr/local/go/src/runtime/panic.go:975 +0x47a
main.viewHandler(0x6fb220, 0xc0002141c0, 0xc000176300)
/home/nanae/gowiki/wiki.go:32 +0x7c
net/http.HandlerFunc.ServeHTTP(0x6bbca8, 0x6fb220, 0xc0002141c0, 0xc000176300)
/usr/local/go/src/net/http/server.go:2042 +0x44
net/http.(*ServeMux).ServeHTTP(0x8625e0, 0x6fb220, 0xc0002141c0, 0xc000176300)
/usr/local/go/src/net/http/server.go:2417 +0x1ad
net/http.serverHandler.ServeHTTP(0xc000154000, 0x6fb220, 0xc0002141c0, 0xc000176300)
/usr/local/go/src/net/http/server.go:2843 +0xa3
net/http.(*conn).serve(0xc000122be0, 0x6fb660, 0xc0000784c0)
/usr/local/go/src/net/http/server.go:1925 +0x8ad
created by net/http.(*Server).Serve
/usr/local/go/src/net/http/server.go:2969 +0x36c
ここではeditHandler
という関数を作り、localhost:8080/edit/{{ title }}
にアクセスしたらeditHandler
を実行して編集ページを返すということをやっているっぽい。
func editHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/edit/"):]
p, err := loadPage(title)
if err != nil {
p = &Page{Title: title}
}
fmt.Fprintf(w, "<h1>Editing %s</h1>"+
"<form action=\"/save/%s\" method=\"POST\">"+
"<textarea name=\"body\">%s</textarea><br>"+
"<input type=\"submit\" value=\"Save\">"+
"</form>",
p.Title, p.Title, p.Body)
}
HTMLべた書きで今時こんなレガシーアプリみたいな…と思ったら
This function will work fine, but all that hard-coded HTML is ugly. Of course, there is a better way.
と書いてあった、まんまと引っかかりましたね。というわけで次のThe html/template packageへ。
html/template
はGoの標準ライブラリにあるものらしい。標準であるものなんですね。
webアプリとかほとんど作ったこと無いけど、N予備校でnode.jsでwebアプリを作るチュートリアルをやったときはPugというテンプレートエンジンを使った記憶がある。
ともかく、html/template
をimportしてedit.html
に
<h1>Editing {{.Title}}</h1>
<form action="/save/{{.Title}}" method="POST">
<div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div>
<div><input type="submit" value="Save"></div>
</form>
と書いておけばいいらしい。この{{}}
で囲まれた部分に渡された変数が入るという感じで、.
で渡した変数の値が展開されるけど、今回は構造体を渡しているので.Title
とかで値を取得している。
ということは変数は1つしか渡せなくて、複数渡したいときはこういった形で構造体にしなきゃいけないということなんだろうか…?
あと急に出てきたprintf "%s" .Body
って何?って感じだけど、これはhtml/template
がtext/template
と共通のinterfaceを持っていて、そちらに
printf
An alias for fmt.Sprintf
と書いてありました。
そういえばhtmll/templateはサニタイジングも自動的にやってくれるみたい、<
を>
にするとか。今時そういうのは当たり前なのかもしれないけど。
saveHandler
も実装して一通り最低限機能するwikiページになった
その後、renderTemplateやsaveHandlerにもちゃんとエラーハンドリングを実装した。
しかしページが見つからない404と違ってこのあたりは意図的にエラーを起こしづらそう
新規ページのsaveに失敗するようにしてみよう、~/gowiki
の書き込みを不可にしてみる。
nanae@silent:~$ chmod 555 gowiki/
nanae@silent:~$ ll | grep gowiki/
dr-xr-xr-x 2 nanae nanae 4096 Feb 13 19:25 gowiki/
その後、適当なページを新規作成しようとするとディレクトリに書き込み権限が無いのでp.save()
でエラーが起こるはず…すると
open def.txt: permission denied
という簡素なエラーメッセージがブラウザ上に表示され500エラーも返ってくることが確認できた。
As you may have observed, this program has a serious security flaw: a user can supply an arbitrary path to be read/written on the server. To mitigate this, we can write a function to validate the title with a regular expression.
気づかなかったが、確かにURLに突っ込まれた文字列をそのままファイルの名前として開こうとしているので../
みたいなのが使われるとディレクトリトラバーサルされそうである。(自分で試してもうまくできなかったが)
こうならないようにあらかじめ許容するURLを/(view|edit|save)/[a-zA-Z0-9]+$
で表せるもののみに限定しようということで正規表現を使ってvalidationを行う
var validPath = regexp.MustCompile("^/(?:edit|save|view)/([a-zA-Z0-9]+)$")
本編と少し変えて(edit|save|view)
の部分は後で使わないので(?:)
を使って囲むようにしてみた
そしてこれを使ってタイトルを取得する用の関数を作って、その際にvalidationに失敗したら404に飛ばしてエラーを返すようにする
func getTitle(w http.ResponseWriter, r *http.Request) (string, error) {
m := validPath.FindStringSubmatch(r.URL.Path)
if m == nil {
http.NotFound(w, r)
return "", errors.New("Invalid Page Title")
}
fmt.Print(m)
return m[1], nil
}
機能としては完成だがxxxHandler
の最初に
title, err := getTitle(w, r)
if err != nil {
return
}
という同じ記述が連続しているのがなんかアレじゃない?とのこと。確かに。
というわけでFunction Literals(要するに無名関数?)とクロージャを使ってhandlerを作る関数を作ったらいい感じになるよ~っていう話。
こんな感じで書くらしい
func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
m := validPath.FindStringSubmatch(r.URL.Path)
if m == nil {
http.NotFound(w, r)
return
}
fn(w, r, m[1])
}
}
この辺は「ほ~そうやるもんですかあ」っていう感じで、あんまりよくわかっていない。が、まぁそういうもんなのだと受け取っておく。
これで一通りWriting Web Applicationsのチュートリアルは終わり、お疲れさまでした!
触ってみて気になることは
- TopPageが欲しい、今作成されている記事へのリンクぐらいあればよさそう
-
/save/{{ title }}
にGETでリクエストすると自動的に空の内容で上書きされてしまう、よくない - 見た目が簡素すぎる
そしてチュートリアルの追加タスク
- Store templates in tmpl/ and page data in data/.
- Add a handler to make the web root redirect to /view/FrontPage.
- Spruce up the page templates by making them valid HTML and adding some CSS rules.
- Implement inter-page linking by converting instances of [PageName] to
<a href="/view/PageName">PageName</a>. (hint: you could use regexp.ReplaceAllFunc to do this)
今回はWebアプリを本格的に作るために始めたわけではないのでそんなに時間割くつもりは無いができそうなところは明日時間使ってやってみてもいいかな。
せっかくなので一旦ここまでの内容をGithubにpushしておいた
あと主題とは関係無いけどgit log
したら色変化のための制御文字?がESC[33m
みたいに表示されてしまっていて焦ったけどどうやらlessの環境変数に$LESS
っていうのがあってそこで-R
がついてないとこのように表示されてしまうということを知った
~/.bashrc
を見ると
export LESS=-q
としか書いておらず、なんでこんなことになっているのかと思い起こすとそういえばwindows terminalでlessで見るときになんかポッみたいな音がうるさいなと思って-q
だけ指定してそのままにしていたからか
チュートリアルの追加タスクであった
- Store templates in tmpl/ and page data in data/.
- Add a handler to make the web root redirect to /view/FrontPage.
- Spruce up the page templates by making them valid HTML and adding some CSS rules.
をやった、ただし最後にCSSが当たらずコンソールに
Resource interpreted as Stylesheet but transferred with MIME type text/html: "http://localhost:8080/style/style.css".
と表示されおり、cssがcssとして読み込めていない?様子だった。この手のバグは別の場所でも聞いたことがある。
調べたところResponse-headerにContent-Type:text/css
をつけてやればいいらしいがそれがちょっとめんどそうなので一旦issueにだけして置いておく
/saveにPOST以外のメソッドでリクエストされた場合、saveしないようにした
実装は以下のようなシンプルなものだが、もう少しいい書き方があるだろうか
func saveHandler(w http.ResponseWriter, r *http.Request, title string) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/view/"+title, http.StatusSeeOther)
return
}
本当はエラーページに飛ばすほうがよいのでは、と思い405 Method Not Allowed
を返してエラーページに飛ばそうと思っていたのだがMDNに
サーバーがリクエストメソッドを理解しているものの、無効にされており使用することができません。例えば、 API がリソースを DELETE することを禁止できます。 GET および HEAD の二つは必須で、無効にすることができず、このエラーコードを返してはいけません。
と書いていたのでGETに対して405を返すのはよくないってことなのか…?と思い、とりあえずリダイレクトにしておいた。こういうのってどうするのが正しいのだろうか。
追加タスクである
Implement inter-page linking by converting instances of [PageName] to
<a href="/view/PageName">PageName</a>. (hint: you could use regexp.ReplaceAllFunc to do this)
をやろうとしているのだが、これも難しい、renderTemplateの中で正規表現による置き換えをやればよさそうなので
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
regInnerLink := regexp.MustCompile("\\[([a-zA-Z0-9]+)\\]")
page := Page{
Title: p.Title,
Body: regInnerLink.ReplaceAll(p.Body, []byte("<a href=\"/view/$1\">$1</a>")),
}
err := templates.ExecuteTemplate(w, tmpl+".html", page)
と書いたが、html/templateの親切機能であるHTMLの自動エスケープが効いてしまうので<a href=\"/view/$1\">$1</a>
の部分がリンクにならない
template.HTMLを使えば自動エスケープを避けられるようだが、Page.Bodyは[]byteだしうまいやり方が思いつかない
PageにHTMLを返却する用のメソッドを作り、それをhtml側で呼び出せばよさそう?
func (p Page) getBodyHTML() template.HTML {
return template.HTML(regInnerLink.ReplaceAll(p.Body, []byte("<a href=\"/view/$1\">$1</a>")))
}
とmethod作って呼び出そうとしても
http: superfluous response.WriteHeader call from main.renderTemplate (wiki.go:63)
って出てくるのでうまくいかず、よくわからない。流石にそろそろ雰囲気でやっているツケが回ってきた気がする。
またそれとは無関係に適当に/view/test
などにアクセスしたときも/view/FrontPage
へのリクエストが複数飛んでるという現象が起きているというのがあった。なんとなく不味いんじゃないかなとは思っていたけどやっぱり
http.HandleFunc("/", rootHandler)
http.HandleFunc("/view/", makeHandler(viewHandler))
って書いててそれがなんか競合起こしてたりする感じだろうか。
よく見ると競合してるとかそういうのじゃなくて/favicon.ico
とか/style/style.css
が全部rootHandlerで処理されてFrontPageが返っているっていう現象が起きているということか、CSSが当たらないっていうのもそれが原因っぽい、たぶん
ちょこっとやってfaviconを付けた
これに対するモチベーションが落ちてきたのでCSSの問題だけ解決したら一旦クローズしたいのだが
CSSの問題も解決した
やり残した課題はあるが区切りをつけたいのでクローズ