Simple Lists: a tiny to-do list app written the old-school way (server-side Go, no JS)

Ben Hoyt

Simple Lists:昔ながらの方法で作った小さなToDoリストアプリ(サーバーサイドGo、JavaScriptなし)

原文は Ben Hoyt により に公開されました。 このブログを購読する

要約:本記事では、Goで作った小さなToDoリストWebアプリ「Simple Lists」をなぜ、どのように作ったのかを説明します。昔ながらの作り方で、サーバー側でHTMLをレンダリングし、HTMLフォームによる素朴なGETPOSTだけで、JavaScriptは一切使っていません。

久しぶりにちょっとしたサイドプロジェクトをやりたくなりました。純粋にものを作る楽しみのために、何かを作ってコードを書きたいと思ったのです。せっかくなら自分や家族の役に立つものを作りたいと思い、小さなToDoリストアプリを作りました。世の中には何千ものToDoアプリがあるのは承知していますが、とにかく何かを作るのが好きなのです。特にスモールウェブの理念に合うものを作るのが好きです。

Goをインストールしていれば、アプリをローカルでとても簡単に実行できます(初回はダウンロードとビルドに数秒かかります):

$ git clone https://github.com/benhoyt/simplelists.git
Cloning into 'simplelists'...
...
$ cd simplelists
$ go run .
2021/09/28 20:51:14 listening on http://localhost:8080

ソースコードは後ほど詳しく説明します。それでは見ていきましょう!

機能

Simple Listsのスクリーンショット

Simple Listsの機能はそれほど多くありません:

  • 新しいリストを作成できます。各リストはURLにランダムなIDを持つため、半ばプライベートです。URLを知っている人だけがそのリストにアクセスできます。
  • リストに項目を追加できます。
  • 項目に取り消し線を引けます(取り消しを元に戻すこともできます)。
  • リストから項目を削除できます。
  • スマートフォンでも問題なく動作します。
  • クロスサイトスクリプティング(XSS)やクロスサイトリクエストフォージェリ(CSRF)への対策がされています。

オプション機能(個人用のインスタンスで使っています):

  • サーバーを環境変数SIMPLELISTS_LISTS1に設定して実行すると、ホームページにリストの一覧が表示され、リストを削除できるようになります。
  • 環境変数SIMPLELISTS_USERNAMESIMPLELISTS_PASSHASHを設定してサーバーを実行すると、サイトにアクセスするためにユーザー名とパスワードでのサインインが必要になります(個人用なのでユーザーは1人だけです)。

シンプルに保つため、Simple Listsではあえてやっていないこともあります:

  • リスト内の項目を並べ替えることはできません。
  • 項目を編集することはできません。削除して追加し直してください。
  • 派手な色や画像、装飾はありません。

言ってみれば、Simple Listsです。でも自分にはこれで十分です!

JavaScriptなし(Node.jsのことではありません!)

当初は、もう少し気の利いた動きにしようと、クライアントサイドのJavaScriptを少しだけ使っていました。しかし、JavaScriptよりGoでプログラミングする方が好きだということもありますが、そもそもクライアントサイドのスクリプトはまったく必要ないことに気づきました。とにかく小さく速く作れば、数回ページを再読み込みするくらいは何の問題もありません。

そこで使っていたわずかなJavaScriptも取り除き、ToDoリストのデータベースの状態を変更するには、昔ながらの素朴なHTMLフォームとHTTPのPOSTメソッドだけを使うようにしました。この方法の素晴らしいところは、少しのHTMLを交えつつ、Goだけを使えばよいという点です。JavaScriptは1行もなく、BabelやWebpackのような凝ったビルドツールも必要ありません。

クライアントサイドのJavaScriptでしかインタラクションを実装したことがない方のために、これがどのように動くのかを簡単に見てみましょう。

各リスト項目(<ul>の順序なしリストの中)では、次のようなHTMLを使っています:

<li style="margin: 0.7em 0">
 <form style="display: inline;" action="/update-done"
       method="POST" enctype="application/x-www-form-urlencoded">
  <input type="hidden" name="csrf-token" value="{{ $.Token }}">
  <input type="hidden" name="list-id" value="{{ $.List.ID }}">
  <input type="hidden" name="item-id" value="{{ .ID }}">
  <input type="hidden" name="done" value="on">
  <button id="done-{{ .ID }}" style="width: 1.7em">&nbsp;</button>
  <label for="done-{{ .ID }}">{{ .Description }}</label>
 </form>
 <form style="display: inline;" action="/delete-item"
       method="POST" enctype="application/x-www-form-urlencoded">
  <input type="hidden" name="csrf-token" value="{{ $.Token }}">
  <input type="hidden" name="list-id" value="{{ $.List.ID }}">
  <input type="hidden" name="item-id" value="{{ .ID }}">
  <button style="padding: 0 0.5em; border: none; background: none;
                 color: #ccc" title="Delete Item">✕</button>
 </form>
</li>

一見いろいろやっているように見えますが、難しいことはありません。各項目には2つのフォームがあります。1つは項目の「完了」フラグを更新する(取り消し線を引く)ためのもの、もう1つは項目を削除するためのものです。

HTMLフォームは、テキスト入力やファイルアップロード欄を備えた重厚なUIである必要はありません。隠しデータフィールド数個とボタンだけでも十分です。delete-itemフォームで示したように、ボタンはスタイルを適用できます。この例では、きれいな✕アイコンのように見せています。

最初のフォームでは、ボタンにid属性が使われていることに注目してください。これは<label>for属性と紐づけられており、項目の説明テキストが「取り消し線を引く」ボタンのラベルとして機能します。そのため、リスト項目のラベル部分ならどこをクリック/タップしても、ブラウザが代わりにボタンをクリックして、項目に取り消し線を引いてくれます。ブラウザはJavaScriptなしでもかなり多くのことができるのです!

Goを選んだ理由

Goの標準ライブラリがどれだけよく考えられているかには、いつも感心させられます。特に、よく知っているもう一つの言語であるPythonと比べるとなおさらです。このサーバーが依存しているのは、SQLiteのデータベースドライバと、bcryptのパスワードハッシュ関数をいくつか提供する準標準ライブラリのgolang.org/x/crypto/bcryptパッケージだけです。

他のプロジェクトでは、人気のあるmattn/go-sqlite3というSQLiteドライバを使っており、こちらもよく動きます。しかし今回はmodernc.org/sqliteドライバを試してみたかったのです。これはなかなか驚くべき技術的成果で、SQLiteのCのソースコードをGoにトランスパイルすることで作られた、純粋なGoによるSQLiteの移植版(CGoバインディングなし)です。生成されたGoのコードはもちろん見た目はひどいものですが、きちんと動作し、Cコンパイラが不要になるため、Windowsやクロスコンパイルの際に便利です。

Goのコードはできるだけ慣用的な書き方を心がけました。例えば、いたるところでエラーを返すようにしています。たとえpanicしたくなるような場面でもです(データベースのコードのように、プロセス内で動くSQLiteのクエリが失敗するとしたら、クエリ自体を間違えたときくらいだろうと思う場面でも)。

ただ、Goのエラーハンドリングの冗長さは今でもあまり好きではありません。特にHTTPリクエストハンドラでは、returnが単独の行になってしまいます。1行のコードに対して、エラーハンドリングが4行です。コードが理解しにくくなるわけではありませんが、少しノイズが増えます。例えば、server.goにあるこのハンドラのコードを比べてみてください:

list, err := s.model.GetList(id)
if err != nil {
    s.internalError(w, "fetching list", err)
    return
}
if list == nil {
    http.NotFound(w, r)
    return
}

Pythonなら、データベースエラーは単に例外を送出し、Webフレームワークがそれをキャッチして(Internal Server Errorを返して)くれますし、not foundのチェックももう少し簡潔に、次のように書けます:

lst = self.model.get_list(list_id)
if lst is None:
    raise HTTPStatus(NotFound)

Goでもpanicを使えば同じようなことはできなくはありませんが、慣用的な書き方からは大きく外れてしまいます。Goのエラーハンドリングは簡潔ではないのですから、早めに割り切ってしまった方がよいでしょう。

シンプルなServeMuxによるルーティング

GoにおけるHTTPルーティングのさまざまなアプローチについて、これまでたくさん書いてきました。当初は正規表現テーブル方式か、あるいはchiルーターを使うつもりでした。

しかし今回はWebアプリをゼロから作るため、URL構造を完全にコントロールできました。そこでシンプルにすることを選びました。REST風のURL構造に慣れきっているので、これは少し奇妙に感じました。しかし現実的に考えて、DELETE /lists/{list-id}/items/{item-id}は、リストIDとアイテムIDを本文に含めるPOST /delete-itemより何が優れているのでしょうか?そしてJavaScriptを使わないのであれば、なぜJSONベースのAPIが必要なのでしょうか?

そう考えると、Goに標準で備わっているシンプルなhttp.ServeMux型でURLのルーティングができるようになりました。問題自体を設計でなくすことで、シンプルにし、依存関係も減らせたのです。

ServeMuxについて少しだけ奇妙な点があるとすれば、ルートパターンの"/"が、スラッシュで終わる他のすべてのパターンと同様に、/だけでなくその配下のすべてにマッチすることです。そのため、明示的にチェックする必要があります。アプリのルーティングコード全体は次のとおりです:

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path == "/" { // because "/" pattern matches /*
        s.home(w, r)
    } else {
        http.NotFound(w, r)
    }
})
mux.HandleFunc("/sign-in", csrf(s.signIn))
mux.HandleFunc("/sign-out", s.signedIn(csrf(s.signOut)))
mux.HandleFunc("/lists/", s.signedIn(s.showList))
mux.HandleFunc("/create-list", s.signedIn(csrf(s.createList)))
mux.HandleFunc("/delete-list", s.signedIn(csrf(s.deleteList)))
mux.HandleFunc("/add-item", s.signedIn(csrf(s.addItem)))
mux.HandleFunc("/update-done", s.signedIn(csrf(s.updateDone)))
mux.HandleFunc("/delete-item", s.signedIn(csrf(s.deleteItem)))

signedIn関数は、ユーザー名/パスワード認証を追加するミドルウェアのラッパーです。デフォルトでは(そしてデモサイトでも)ユーザー名認証はオフになっており、isSignedInは常にtrueを返します:

func (s *Server) signedIn(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if !s.isSignedIn(r) {
            location := "/?return-url=" + url.QueryEscape(r.URL.Path)
            http.Redirect(w, r, location, http.StatusFound)
            return
        }
        h(w, r)
    }
}

csrf関数は、クロスサイトリクエストフォージェリからの保護を追加するミドルウェアラッパーです。与えられたハンドラをラップし、HTTPメソッドがPOSTであること、そしてcsrf-token Cookie内のCSRFトークンがcsrf-tokenフォームフィールド内のトークンと一致することを保証します。これにより、フォームの送信がSimple Lists自身のページからのみ行われることが保証されます:

func csrf(h http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if r.Method != "POST" {
            w.Header().Set("Allow", "POST")
            http.Error(w, "405 method not allowed",
                http.StatusMethodNotAllowed)
            return
        }
        token := r.FormValue("csrf-token")
        cookie, err := r.Cookie("csrf-token")
        if err != nil || token != cookie.Value {
            http.Error(w, "invalid CSRF token or cookie",
                http.StatusBadRequest)
            return
        }
        h(w, r)
    }
}

データベースの扱い

データベースの選択は迷うまでもありませんでした。以前からSQLiteを高く評価していますし、この規模のプロジェクトにはまさに適しています。より大規模なプロジェクトで検討する唯一の代替はPostgreSQLでしょう。信頼できますし、本当に気に入っています(高速で、ドキュメントも充実しており、JSONサポートのような優れた機能も備えています)。

依存関係を最小限に抑えようとしていることと(そもそも大半のORMがあまり好きではないこともあり)、今回は標準ライブラリのdatabase/sqlパッケージだけを使うことにしました。sqlxの使用も少し検討しました。structやsliceの扱いは気に入っているのですが、このような小さなデータベースモデルではそれほどメリットがありません。

どんな感じになるのか雰囲気を掴んでもらうために、db.goから2つの関数を紹介します。GetListは1つのリストを取得し、ヘルパーのgetListItemsはそのリストのすべての項目を取得します:

// GetList fetches one list and returns it, or nil if not found.
func (m *SQLModel) GetList(id string) (*List, error) {
    row := m.db.QueryRow(`
        SELECT id, name
        FROM lists
        WHERE id = ? AND time_deleted IS NULL
        `, id)
    var list List
    err := row.Scan(&list.ID, &list.Name)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    list.Items, err = m.getListItems(id)
    return &list, err
}

func (m *SQLModel) getListItems(listID string) ([]*Item, error) {
    rows, err := m.db.Query(`
        SELECT id, description, done
        FROM items
        WHERE list_id = ? AND time_deleted IS NULL
        ORDER BY id
        `, listID)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var items []*Item
    for rows.Next() {
        var item Item
        err = rows.Scan(&item.ID, &item.Description, &item.Done)
        if err != nil {
            return nil, err
        }
        items = append(items, &item)
    }
    return items, rows.Err()
}

ご覧のとおり、QueryRowはテーブルから1行を取得するのに使われ、使い方はシンプルです。Queryは複数行を取得しますが、こちらは少し手間がかかります。行を手動でイテレートし、各行をScanし、スキャンエラーをチェックして、スキャンした項目をスライスに追加しなければなりません。あ、rowsオブジェクトをCloseすることも忘れずに。簡略化する方法を探してみましたが、素のdatabase/sqlでは、リフレクションを使わずにできるのはこのあたりが限界です(もっと良い方法があれば教えてください)。

依存関係を最小限にするのは良いことですが、確かにトレードオフもあります。クエリをたくさん書く必要があるなら、前述のsqlxライブラリを使うことをお勧めします。sqlxを使えば、getListItemsヘルパーからイテレーションやScanのボイラープレートがすべてなくなります:

func (m *SQLModel) getListItems(listID string) ([]*Item, error) {
    var items []*Item
    err := m.db.Select(&items, `
        SELECT id, description, done
        FROM items
        WHERE list_id = ? AND time_deleted IS NULL
        ORDER BY id
        `, listID)
    return items, err
}

一方で、変更を伴うクエリの実行は、行をスキャンする必要がないためdatabase/sqlでもシンプルです。以下はリストから項目を削除する方法です(私は通常、後で復元できるようにソフトデリートを好みます):

// DeleteItem (soft) deletes the given item in a list.
func (m *SQLModel) DeleteItem(listID, itemID string) error {
    _, err := m.db.Exec(`
            UPDATE items
            SET time_deleted = CURRENT_TIMESTAMP
            WHERE list_id = ? AND id = ?
        `, listID, itemID)
    return err
}

手動での依存性注入

実際のテストの実装をお見せする前に、Goのインターフェースと依存性注入について少し触れておきたいと思います。「依存性を注入する」こと自体は良いことですが、依存性注入のフレームワークやライブラリは厄介です。たいてい実行時のリフレクションを使い、型チェックを迂回して、IDE(あるいは自分の目で)コードを追うのを困難にするからです。幸い、手動で依存関係を結線するのは安全かつ簡単です。

Goでは、インターフェースを実装を提供するパッケージではなく、それを利用するパッケージ側で定義する傾向があります(しかもインターフェースは、実装が持つメソッドのサブセットだけでよい場合もあります)。

Simple Listsのコードは非常にフラットで、すべてがmainという1つのパッケージに収まっています。しかし大規模なプロジェクトではどうするのかを示すために、使用箇所の近くにあるserver.goに、データベースのModelインターフェースを定義しました。Serverが必要とするインターフェースと、サーバーインスタンスを生成するためのNewServer関数のシグネチャは次のとおりです:

// Model is the database model interface used by the server.
type Model interface {
    GetLists() ([]*List, error)
    CreateList(name string) (string, error)
    DeleteList(id string) error
    GetList(id string) (*List, error)

    AddItem(listID, description string) (string, error)
    UpdateDone(listID, itemID string, done bool) error
    DeleteItem(listID, itemID string) error

    CreateSignIn() (string, error)
    IsSignInValid(id string) (bool, error)
    DeleteSignIn(id string) error
}

// Logger is the logger interface used by the server.
type Logger interface {
    Printf(format string, v ...interface{})
}

// NewServer creates a new server with the specified dependencies.
func NewServer(
    model Model,
    logger Logger,
    timezone string,
    username string,
    passwordHash string,
    showLists bool,
) (*Server, error) {
    ...
}

そしてmain.goでは、すべてを次のように結線します:

func main() {
    ...
    db, err := sql.Open("sqlite", *dbPath)
    exitOnError(err)
    model, err := NewSQLModel(db)
    exitOnError(err)
    server, err := NewServer(model, log.Default(), *timezone,
                             *username, passwordHash, *showLists)
    exitOnError(err)
    ...
}

私の意見では(両方のアプローチを使ったことがありますが)、mainですべてを明示的に結線する方がはるかに優れています。普通のGoのコードであり、型チェックが効き、IDEフレンドリーだからです。

データベースモデルにインターフェースを使うことで、テストで使うモックやフェイクのデータベース実装を簡単に作ることができます。これは本番コードがPostgreSQLやMongoDBのような重い外部データベースを使っている場合に便利です。ただ今回はテストでも(インメモリではありますが)実際のSQLiteデータベースを使っているので、フェイクを書く必要すらありません。テストでサーバーを結線する方法は次のとおりです:

db, err := sql.Open("sqlite", ":memory:")
if err != nil {
    t.Fatalf("opening database: %v", err)
}
model, err := NewSQLModel(db)
if err != nil {
    t.Fatalf("creating model: %v", err)
}
server, err := NewServer(model, nullLogger{}, "Pacific/Auckland",
                         "", "", true)
if err != nil {
    t.Fatalf("creating server: %v", err)
}

テスト

可能な限り、テストでは実際のデータベースに対して実行するのが好きです。コンテナ内でPostgreSQLデータベースを安価に立ち上げられるなら、あるいはインメモリデータベースを使えるなら、本物に対してテストしていることになります。

今回はインメモリのSQLiteデータベース(:memory:)を使っているので、すべてがプロセス内で完結し、ディスクにすら触れません。フェイクを書いたりモックを使ったりする必要はなく、SQLiteのおかげでテストは非常に高速に実行されます。本番でPostgreSQLを使っていたとしても、テストを高速に保つためにやはりSQLiteを使い、両者で異なる必要があるクエリだけを上書きすると思います。

テストはエンドツーエンドにかなり近い形になっており、実装ではなく機能をテストしています。各テストはServer.ServeHTTPハンドラを呼び出し、net/http/httptestパッケージを使ってHTTPレスポンスを記録します。

テストのスニペットは次のような感じです:

func TestServer(t *testing.T) {
    ...
    jar, err := cookiejar.New(nil)
    if err != nil {
        t.Fatalf("creating cookie jar: %v", err)
    }

    // Fetch homepage
    var csrfToken string // CSRF token stays same for entire session
    {
        recorder := serve(t, server, jar, "GET", "/", nil)

        ensureCode(t, recorder, http.StatusOK)
        forms := parseForms(t, recorder.Body.String())
        ensureInt(t, len(forms), 1)
        ensureString(t, forms[0].Action, "/create-list")
        csrfToken = forms[0].Inputs["csrf-token"]
        if csrfToken == "" {
            t.Fatal("csrf-token input not found")
        }
    }

    // Create list
    var listID string
    var listIDs []string
    {
        form := url.Values{}
        form.Set("csrf-token", csrfToken)
        form.Set("name", "Shopping List")
        recorder := serve(t, server, jar, "POST", "/create-list", form)

        ensureCode(t, recorder, http.StatusFound)
        location := recorder.Result().Header.Get("Location")
        ensureRegex(t, location, "/lists/[a-z]{10}")
        listID = location[7:]
        listIDs = append(listIDs, listID)
    }
    ...

ここでは、おそらくスケールしないであろう単純化のための設計上の選択をしています。すべてのテストを1つの連続したTestServer関数の中に書き、テストの後半部分が前半に依存するようにしているのです。テストの実行が非常に高速で(go testでは10ミリ秒と表示されます)、各サブテストの開始時にリストを作成するような共通のセットアップ手順を省けるため、このやり方でも問題ありません。

より大規模なコードベースで同じことをするなら、セットアップ手順をヘルパー関数に入れ、t.Runを使って各テスト部分をサブテストとして実行するでしょう。

if got != want { t.Fatalf("got %v, want %v", got, want) }というブロックを何度も繰り返すのは少しうんざりするので、それらをヘルパー関数に切り出しました。数あるアサーションライブラリの1つを使うこともできます(私はAPIが小さいgocheckが好みです)が、依存関係を増やさずに済むよう、小さなensure*ヘルパー関数を2、3個書くのは簡単でした。

これはJSON APIではないため、golang.org/x/net/htmlパッケージを使ってレスポンスボディ内のHTMLフォームをパースするparseFormsヘルパーを書きました。これにより、CSRFトークンのようなさまざまなフィールドを抜き出して後で使うことができます。

リクエストを実行して記録するserveヘルパー関数は次のとおりです:

// serve records a single HTTP request and returns the response recorder.
func serve(t *testing.T, server *Server, jar http.CookieJar,
           method, path string, form url.Values,
) *httptest.ResponseRecorder {
    t.Helper()
    var body io.Reader
    if form != nil {
        body = strings.NewReader(form.Encode())
    }
    r, err := http.NewRequest(method, "http://localhost"+path, body)
    if err != nil {
        t.Fatalf("creating request: %v", err)
    }
    if form != nil {
        r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
    }
    for _, c := range jar.Cookies(r.URL) {
        r.Header.Add("Cookie", c.Name+"="+c.Value)
    }
    recorder := httptest.NewRecorder()
    server.ServeHTTP(recorder, r)
    jar.SetCookies(r.URL, recorder.Result().Cookies())
    return recorder
}

ここではnet/http/cookiejarを使って、前のリクエストでセットされたCookieを後続のリクエストに確実に渡している点に注目してください。CSRF Cookieもこの方法で自動的に処理されます。

これらのGoのテストは、基本的なサーバーサイドの機能のほとんどをカバーしています。ただしレイアウトやUIはテストしていないので、変更を加えた際にはブラウザでローカルに簡単な手動テストも行っています。Simple Listsの機能は少ないので、すべてを確認するのに1分もかかりません。

最小限のHTMLとCSS

HTMLテンプレートにはGoのhtml/templateパッケージを使いました。少し癖はありますが(例えば、式の構文がGoの式のサブセットであればよかったのにと思いますが)、ドキュメントを読んでしまえば悪くありません。テンプレートに関するドキュメントの大部分は、実際にはtext/templateのドキュメントにあることに注意してください。

HTMLは非常にシンプルで、ページは2つだけです(ホームページとリストページ)。html/templateパッケージにはテンプレートの再利用を可能にする「blocks」のサポートがありますが、2つのテンプレート間で少し重複があっても、そのままにしておく方がシンプルです。

テンプレートのソース全体をご覧ください。metaのviewportタグに注目してください。これによりモバイル端末でもレイアウトがきれいに表示されます。Simple Listsは「完全レスポンシブ」なのです!

<meta name="viewport" content="width=device-width, initial-scale=1">

HTMLでは、リスト要素やボタンのスタイルに少量のCSSを使っています。この規模のアプリでは、例えばdelete-itemボタンの枠線を消して色を設定するのに、インラインCSSを使うのが一番簡単だと感じました。ボタンのラベルにUnicodeのを使っている点にも注目してください。アイコンなんて誰が必要でしょうか!

<button style="padding: 0 0.5em; border: none; background: none;
               color: #ccc" title="Delete Item">✕</button>

より大きなアプリを作るのであれば、こうしたスタイルを再利用しやすくし、一箇所で定義できるように、CSSクラスやその他の仕組みを使いたいところです。

セキュリティ

このアプリはセキュリティレビューを受けていないので、ここでは何も保証できませんが、できる限り注意して作りました。注意点をいくつか挙げます:

  • 前述のとおり、csrf-token Cookieとcsrf-tokenフォームフィールドが一致することを保証することで、CSRF保護を実装しています。
  • Goのテンプレートライブラリを使用しており、クロスサイトスクリプティング(XSS)攻撃から自動的に保護されます。
  • Goのdatabase/sqlライブラリをパラメータ化クエリとともに使用しているため、SQLインジェクション攻撃に対して安全です。
  • SQLite自体が徹底的にテストされており、私たちが使っているmodernc.org/sqliteパッケージも同じ大規模なテストスイートでテストされています。
  • GoのHTTPサーバーは安全で、本番環境で鍛え上げられていることで知られています。

ユーザー名/パスワード認証はデモサイトではオフになっていますが、家族で使っている個人用のインスタンスでは有効にしています。ユーザー名とパスワードは1組だけですが、自分で使うにはそれで十分です。

このモードでは、ユーザー名はコマンドライン引数としてサーバーに渡し、bcryptでハッシュ化されたパスワードは環境変数で渡します。もちろん複数ユーザーをサポートしたい場合は、ユーザー名とハッシュ化されたパスワードをデータベースに保存することになるでしょう。セッション、つまり「サインイン」はシンプルなsign_insというデータベーステーブルに保存され、90日で期限切れになります。

参考までに、signInリクエストハンドラのコードを以下に示します。フォームフィールドからユーザー名とパスワードを取得し、bcryptCompareHashAndPassword関数でパスワードをチェックし、サインインが有効であればsign_ins行を作成してsign-in Cookieをセットします。

func (s *Server) signIn(w http.ResponseWriter, r *http.Request) {
    username := strings.TrimSpace(r.FormValue("username"))
    password := r.FormValue("password")
    returnURL := r.FormValue("return-url")
    if returnURL == "" {
        returnURL = "/"
    }
    if username != s.username || bcrypt.CompareHashAndPassword(
            []byte(s.passwordHash), []byte(password)) != nil {
        location := "/?error=sign-in&return-url=" +
            url.QueryEscape(returnURL)
        http.Redirect(w, r, location, http.StatusFound)
        return
    }
    id, err := s.model.CreateSignIn()
    if err != nil {
        s.internalError(w, "creating sign in", err)
        return
    }
    cookie := &http.Cookie{
        Name:     "sign-in",
        Value:    id,
        MaxAge:   90 * 24 * 60 * 60,
        Path:     "/",
        Secure:   r.URL.Scheme == "https",
        HttpOnly: true,
        SameSite: http.SameSiteStrictMode,
    }
    http.SetCookie(w, cookie)
    http.Redirect(w, r, returnURL, http.StatusFound)
}

おわりに

Simple Listsを作るのは本当に楽しく、すでに家族で共有するリスト——誕生日やクリスマスのリスト、観たい映画のリストなど——に役立っています。私は物事を小さく、速く、軽く保つのが好きで、ここではそれを達成できたと思っています。

Goを使うのはとても楽しいものです。静的型チェック、高速なコンパイル、素晴らしい標準ライブラリ、そしてフォーマットやテスト実行のための優れたツール。クロスコンパイルやデプロイも極めて簡単です。GOOS=linux GOARCH=amd64 go buildと実行するだけで、数秒後には(たとえmacOSやWindowsで開発していても)本番サーバーにコピーできるLinux実行ファイルが手に入ります。

JavaScriptなしでも十分使いやすいものができた点も気に入っています。最近のHTMLはそれだけでもかなり多くのことができるので、Reactを持ち出す前に一度よく考えてみてください。

お楽しみいただけたか、何か学びがあれば幸いです。ご意見があればぜひお聞かせください!

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント