Webアプリケーションの作成

はじめに

このチュートリアルでカバーする内容

前提知識

はじめに

現在、Goを実行するには、FreeBSD、Linux、macOS、またはWindowsマシンが必要です。コマンドプロンプトを表すために$を使用します。

Goをインストールします(インストール手順を参照)。

このチュートリアルのための新しいディレクトリをGOPATHの中に作成し、そこにcdします。

$ mkdir gowiki
$ cd gowiki

wiki.goという名前のファイルを作成し、お気に入りのエディターで開き、次の行を追加します。

package main

import (
    "fmt"
    "os"
)

Go標準ライブラリからfmtおよびosパッケージをインポートします。後で、追加機能を実装する際に、このimport宣言にパッケージを追加します。

データ構造

データ構造の定義から始めましょう。Wikiは相互接続された一連のページで構成されており、それぞれにタイトルと本文(ページコンテンツ)があります。ここでは、タイトルと本文を表す2つのフィールドを持つ構造体としてPageを定義します。

type Page struct {
    Title string
    Body  []byte
}

[]byteは「byteスライス」を意味します。(スライスの詳細については、スライス:使用法と内部構造を参照してください。)Body要素は、ioライブラリで使用される型であるため、stringではなく[]byteです。これについては後で説明します。

Page構造体は、ページデータがメモリにどのように格納されるかを記述します。しかし、永続的なストレージについてはどうでしょうか?Pagesaveメソッドを作成することで、それに対処できます。

func (p *Page) save() error {
    filename := p.Title + ".txt"
    return os.WriteFile(filename, p.Body, 0600)
}

このメソッドのシグネチャは、「これはsaveという名前のメソッドで、レシーバーとしてpを取り、Pageへのポインターです。パラメーターは受け取らず、error型の値を返します。」と読み取れます。

このメソッドは、PageBodyをテキストファイルに保存します。簡単にするために、Titleをファイル名として使用します。

saveメソッドは、WriteFile(バイトスライスをファイルに書き込む標準ライブラリ関数)の戻り値の型であるため、error値を返します。saveメソッドは、ファイルへの書き込み中に問題が発生した場合にアプリケーションが処理できるように、エラー値を返します。すべてがうまくいけば、Page.save()nil(ポインター、インターフェイス、およびその他のいくつかの型のゼロ値)を返します。

WriteFileの3番目のパラメーターとして渡される8進数リテラル0600は、ファイルが現在のユーザーのみが読み取り/書き込み権限で作成される必要があることを示します。(詳細については、Unix manページopen(2)を参照してください。)

ページの保存に加えて、ページのロードも必要になります。

func loadPage(title string) *Page {
    filename := title + ".txt"
    body, _ := os.ReadFile(filename)
    return &Page{Title: title, Body: body}
}

関数loadPageは、タイトルパラメーターからファイル名を構築し、ファイルの内容を新しい変数bodyに読み込み、適切なタイトルと本文の値で構築されたPageリテラルへのポインターを返します。

関数は複数の値を返すことができます。標準ライブラリ関数os.ReadFileは、[]byteerrorを返します。loadPageでは、エラーはまだ処理されていません。アンダースコア(_)記号で表される「空白の識別子」は、エラーの戻り値を破棄するために使用されます(本質的に、値を何にも割り当てません)。

しかし、ReadFileでエラーが発生した場合はどうなるでしょうか?たとえば、ファイルが存在しない可能性があります。そのようなエラーは無視すべきではありません。*Pageerrorを返すように関数を変更しましょう。

func loadPage(title string) (*Page, error) {
    filename := title + ".txt"
    body, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return &Page{Title: title, Body: body}, nil
}

この関数の呼び出し側は、2番目のパラメーターを確認できるようになりました。それがnilの場合は、ページのロードに成功しました。そうでない場合は、呼び出し側が処理できるerrorになります(詳細については、言語仕様を参照してください)。

この時点で、単純なデータ構造と、ファイルへの保存およびファイルからのロード機能ができました。作成したものをテストするために、main関数を作成しましょう。

func main() {
    p1 := &Page{Title: "TestPage", Body: []byte("This is a sample Page.")}
    p1.save()
    p2, _ := loadPage("TestPage")
    fmt.Println(string(p2.Body))
}

このコードをコンパイルして実行すると、p1の内容を含むTestPage.txtという名前のファイルが作成されます。その後、ファイルは構造体p2に読み込まれ、そのBody要素が画面に出力されます。

プログラムは次のようにコンパイルして実行できます。

$ go build wiki.go
$ ./wiki
This is a sample Page.

(Windowsを使用している場合は、プログラムを実行するために「./」なしで「wiki」と入力する必要があります。)

ここをクリックして、これまでに作成したコードを表示してください。

net/httpパッケージの紹介(休憩)

次に、単純なWebサーバーの完全な動作例を示します。

//go:build ignore

package main

import (
    "fmt"
    "log"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

main関数は、http.HandleFuncの呼び出しから始まり、Webルート("/")へのすべてのリクエストをhandlerで処理するようにhttpパッケージに指示します。

次に、任意のアドレス(":8080")でポート8080をリッスンする必要があることを指定して、http.ListenAndServeを呼び出します。(今のところ、2番目のパラメーターであるnilについては心配しないでください。)この関数は、プログラムが終了するまでブロックされます。

ListenAndServeは、予期しないエラーが発生した場合にのみ戻るため、常にエラーを返します。そのエラーをログに記録するために、関数呼び出しをlog.Fatalでラップします。

関数handlerhttp.HandlerFunc型です。http.ResponseWriterhttp.Requestを引数として取ります。

http.ResponseWriterの値は、HTTPサーバーの応答を組み立てます。そこに書き込むことで、データをHTTPクライアントに送信します。

http.Requestは、クライアントのHTTPリクエストを表すデータ構造です。r.URL.Pathは、リクエストURLのパスコンポーネントです。末尾の[1:]は「Pathの1文字目から最後までサブスライスを作成する」ことを意味します。これにより、パス名の先頭の「/」が削除されます。

このプログラムを実行して、URLにアクセスすると

http://localhost:8080/monkeys

プログラムは次を含むページを表示します。

Hi there, I love monkeys!

net/httpを使用してWikiページを提供する

net/httpパッケージを使用するには、インポートする必要があります。

import (
    "fmt"
    "os"
    "log"
    "net/http"
)

Wikiページをユーザーが表示できるようにするハンドラーviewHandlerを作成しましょう。これは、"/view/"で始まるURLを処理します。

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
}

ここでも、loadPageからのerror戻り値を無視するために_を使用していることに注意してください。これは簡略化のためにここで行われており、一般的に悪い習慣と考えられています。後でこれに対処します。

まず、この関数はリクエストURLのパスコンポーネントであるr.URL.Pathからページタイトルを抽出します。Pathは、リクエストパスの先頭の"/view/"コンポーネントを削除するために、[len("/view/"):]で再度スライスされます。これは、パスが必ず"/view/"で始まるため、ページタイトルの一部ではないためです。

次に、関数はページデータをロードし、シンプルなHTML文字列でページをフォーマットして、http.ResponseWriterであるwに書き込みます。

このハンドラーを使用するには、/view/パス下のすべてのリクエストを処理するためにviewHandlerを使用してhttpを初期化するようにmain関数を書き換えます。

func main() {
    http.HandleFunc("/view/", viewHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

ここをクリックして、これまでに作成したコードを表示してください。

ページデータ(test.txtとして)を作成し、コードをコンパイルして、Wikiページを提供してみましょう。

エディターでtest.txtファイルを開き、文字列「Hello world」(引用符なし)を保存します。

$ go build wiki.go
$ ./wiki

(Windowsを使用している場合は、プログラムを実行するために「./」なしで「wiki」と入力する必要があります。)

このWebサーバーが実行されている場合、http://localhost:8080/view/testにアクセスすると、「Hello world」という単語を含む「test」というタイトルのページが表示されるはずです。

ページの編集

ページを編集する機能がなければ、WikiはWikiではありません。2つの新しいハンドラーを作成しましょう。1つは「編集ページ」フォームを表示するためのeditHandlerという名前で、もう1つはフォームから入力されたデータを保存するためのsaveHandlerという名前です。

まず、それらをmain()に追加します。

func main() {
    http.HandleFunc("/view/", viewHandler)
    http.HandleFunc("/edit/", editHandler)
    http.HandleFunc("/save/", saveHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

関数editHandlerはページをロードし(存在しない場合は、空のPage構造体を作成します)、HTMLフォームを表示します。

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はすべて醜いです。もちろん、より良い方法があります。

html/templateパッケージ

html/templateパッケージは、Go標準ライブラリの一部です。html/templateを使用すると、HTMLを別のファイルに保持できるため、基盤となるGoコードを変更することなく、編集ページのレイアウトを変更できます。

まず、html/template をインポートリストに追加する必要があります。また、fmt はもう使用しないため、削除する必要があります。

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

HTMLフォームを含むテンプレートファイルを作成しましょう。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>

ハードコードされたHTMLの代わりに、テンプレートを使用するように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}
    }
    t, _ := template.ParseFiles("edit.html")
    t.Execute(w, p)
}

関数template.ParseFilesは、edit.htmlの内容を読み取り、*template.Templateを返します。

メソッドt.Executeはテンプレートを実行し、生成されたHTMLをhttp.ResponseWriterに書き込みます。ドット付き識別子.Title.Bodyは、p.Titlep.Bodyを参照します。

テンプレートディレクティブは二重中括弧で囲まれています。printf "%s" .Body命令は、.Bodyをバイトストリームではなく文字列として出力する関数呼び出しであり、fmt.Printfの呼び出しと同じです。html/templateパッケージは、テンプレートアクションによって安全で正しい外観のHTMLのみが生成されることを保証するのに役立ちます。たとえば、大なり記号(>)を自動的にエスケープし、&gt;に置き換えて、ユーザーデータがフォームHTMLを破損させないようにします。

テンプレートを扱うようになったので、viewHandler用のテンプレートをview.htmlという名前で作成しましょう。

<h1>{{.Title}}</h1>

<p>[<a href="/edit/{{.Title}}">edit</a>]</p>

<div>{{printf "%s" .Body}}</div>

それに応じてviewHandlerを修正します。

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    t, _ := template.ParseFiles("view.html")
    t.Execute(w, p)
}

両方のハンドラーでほぼまったく同じテンプレートコードを使用していることに注意してください。テンプレートコードを独自の関数に移動して、この重複を削除しましょう。

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    t, _ := template.ParseFiles(tmpl + ".html")
    t.Execute(w, p)
}

そして、その関数を使用するようにハンドラーを修正します。

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    renderTemplate(w, "view", p)
}
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}
    }
    renderTemplate(w, "edit", p)
}

mainで実装されていないsaveハンドラーの登録をコメントアウトすると、再びプログラムをビルドしてテストすることができます。ここをクリックして、これまでに作成したコードを表示してください。

存在しないページの処理

/view/APageThatDoesntExist にアクセスしたらどうなるでしょうか?HTMLを含むページが表示されます。これは、loadPageからのエラー戻り値を無視し、データなしでテンプレートを埋めようとし続けるためです。代わりに、リクエストされたページが存在しない場合は、コンテンツを作成できるように、クライアントを編集ページにリダイレクトする必要があります。

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, err := loadPage(title)
    if err != nil {
        http.Redirect(w, r, "/edit/"+title, http.StatusFound)
        return
    }
    renderTemplate(w, "view", p)
}

http.Redirect関数は、HTTPステータスコードhttp.StatusFound(302)とLocationヘッダーをHTTP応答に追加します。

ページの保存

関数saveHandlerは、編集ページにあるフォームの送信を処理します。mainの関連行のコメントを解除した後、ハンドラーを実装しましょう。

func saveHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/save/"):]
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    p.save()
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

ページタイトル(URLで提供)とフォームの唯一のフィールドであるBodyは、新しいPageに保存されます。次に、save()メソッドが呼び出され、データをファイルに書き込み、クライアントは/view/ページにリダイレクトされます。

FormValueによって返される値はstring型です。その値をPage構造体に適合させる前に、[]byteに変換する必要があります。[]byte(body)を使用して変換を実行します。

エラー処理

プログラムには、エラーが無視されている箇所がいくつかあります。これは悪い習慣であり、特にエラーが発生した場合、プログラムは意図しない動作をします。より良い解決策は、エラーを処理してエラーメッセージをユーザーに返すことです。そうすることで、何か問題が発生した場合でも、サーバーは私たちが望むとおりに機能し、ユーザーに通知することができます。

まず、renderTemplateのエラーを処理しましょう。

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    t, err := template.ParseFiles(tmpl + ".html")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    err = t.Execute(w, p)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

http.Error関数は、指定されたHTTP応答コード(この場合は「Internal Server Error」)とエラーメッセージを送信します。これを別の関数に入れるという決定は、すでに効果を上げています。

次に、saveHandlerを修正しましょう。

func saveHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/save/"):]
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    err := p.save()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

p.save()中に発生したエラーは、すべてユーザーに報告されます。

テンプレートのキャッシュ

このコードには非効率な点があります。renderTemplateは、ページがレンダリングされるたびにParseFilesを呼び出します。より良い方法は、プログラムの初期化時にParseFilesを1回呼び出し、すべてのテンプレートを単一の*Templateに解析することです。その後、ExecuteTemplateメソッドを使用して、特定のテンプレートをレンダリングできます。

まず、templatesという名前のグローバル変数を作成し、ParseFilesで初期化します。

var templates = template.Must(template.ParseFiles("edit.html", "view.html"))

関数template.Mustは、nilでないerror値が渡された場合にパニックになり、それ以外の場合は変更されていない*Templateを返す便利なラッパーです。ここではパニックが適切です。テンプレートをロードできない場合、実行できる唯一の賢明なことはプログラムを終了することです。

ParseFiles関数は、テンプレートファイルを識別する任意の数の文字列引数を受け取り、それらのファイルを基本ファイル名にちなんで名付けられたテンプレートに解析します。プログラムにテンプレートを追加する場合は、ParseFiles呼び出しの引数に名前を追加します。

次に、renderTemplate関数を修正して、適切なテンプレートの名前でtemplates.ExecuteTemplateメソッドを呼び出すようにします。

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    err := templates.ExecuteTemplate(w, tmpl+".html", p)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

テンプレート名はテンプレートファイル名であるため、tmpl引数に".html"を追加する必要があることに注意してください。

検証

既にご覧のとおり、このプログラムには重大なセキュリティ上の欠陥があります。ユーザーは、サーバー上で読み書きされる任意のパスを指定できます。これを軽減するために、正規表現でタイトルを検証する関数を作成できます。

まず、importリストに"regexp"を追加します。次に、検証式を格納するグローバル変数を作成できます。

var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")

関数regexp.MustCompileは正規表現を解析およびコンパイルし、regexp.Regexpを返します。MustCompileは、式のコンパイルが失敗した場合にパニックになる点でCompileとは異なり、Compileは2番目のパラメータとしてerrorを返します。

次に、validPath式を使用してパスを検証し、ページタイトルを抽出する関数を作成しましょう。

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")
    }
    return m[2], nil // The title is the second subexpression.
}

タイトルが有効な場合、nilエラー値とともに返されます。タイトルが無効な場合、関数はHTTP接続に「404 Not Found」エラーを書き込み、ハンドラーにエラーを返します。新しいエラーを作成するには、errorsパッケージをインポートする必要があります。

それぞれのハンドラーにgetTitleの呼び出しを配置しましょう。

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title, err := getTitle(w, r)
    if err != nil {
        return
    }
    p, err := loadPage(title)
    if err != nil {
        http.Redirect(w, r, "/edit/"+title, http.StatusFound)
        return
    }
    renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request) {
    title, err := getTitle(w, r)
    if err != nil {
        return
    }
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    renderTemplate(w, "edit", p)
}
func saveHandler(w http.ResponseWriter, r *http.Request) {
    title, err := getTitle(w, r)
    if err != nil {
        return
    }
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    err = p.save()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

関数リテラルとクロージャの導入

各ハンドラーでエラー状態をキャッチすると、多くの繰り返しコードが発生します。この検証とエラーチェックを行う関数で、各ハンドラーをラップできたらどうでしょうか。Goの関数リテラルは、ここで役立つ機能を抽象化する強力な手段を提供します。

まず、各ハンドラーの関数定義を書き換えて、タイトル文字列を受け入れるようにします。

func viewHandler(w http.ResponseWriter, r *http.Request, title string)
func editHandler(w http.ResponseWriter, r *http.Request, title string)
func saveHandler(w http.ResponseWriter, r *http.Request, title string)

次に、上記の型の関数を受け取りhttp.HandlerFunc型(関数http.HandleFuncに渡すのに適しています)の関数を返すラッパー関数を定義しましょう。

func makeHandler(fn func (http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Here we will extract the page title from the Request,
        // and call the provided handler 'fn'
    }
}

返される関数は、外部で定義された値を囲むため、クロージャと呼ばれます。この場合、変数fnmakeHandlerへの単一の引数)はクロージャによって囲まれています。変数fnは、save、edit、またはviewハンドラーのいずれかになります。

次に、getTitleからコードを取得して、ここで使用します(いくつかの小さな変更を加えます)。

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[2])
    }
}

makeHandlerによって返されるクロージャは、http.ResponseWriterhttp.Requestを受け取る関数(つまり、http.HandlerFunc)です。クロージャは、リクエストパスからtitleを抽出し、validPath正規表現で検証します。titleが無効な場合、http.NotFound関数を使用して、エラーがResponseWriterに書き込まれます。titleが有効な場合、囲まれたハンドラー関数fnResponseWriterRequest、およびtitleを引数として呼び出されます。

次に、httpパッケージに登録される前に、mainmakeHandlerを使用してハンドラー関数をラップできます。

func main() {
    http.HandleFunc("/view/", makeHandler(viewHandler))
    http.HandleFunc("/edit/", makeHandler(editHandler))
    http.HandleFunc("/save/", makeHandler(saveHandler))

    log.Fatal(http.ListenAndServe(":8080", nil))
}

最後に、ハンドラー関数からgetTitleの呼び出しを削除して、ハンドラー関数を大幅に簡略化します。

func viewHandler(w http.ResponseWriter, r *http.Request, title string) {
    p, err := loadPage(title)
    if err != nil {
        http.Redirect(w, r, "/edit/"+title, http.StatusFound)
        return
    }
    renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request, title string) {
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    renderTemplate(w, "edit", p)
}
func saveHandler(w http.ResponseWriter, r *http.Request, title string) {
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    err := p.save()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

試してみてください!

ここをクリックして、最終的なコードリストを表示してください。

コードを再コンパイルして、アプリを実行します。

$ go build wiki.go
$ ./wiki

http://localhost:8080/view/ANewPage にアクセスすると、ページ編集フォームが表示されます。次に、テキストを入力し、「保存」をクリックして、新しく作成されたページにリダイレクトできるはずです。

その他のタスク

ここでは、自分自身で取り組みたいと思うかもしれない簡単なタスクをいくつか示します。