ノート投稿型サービスをGoで作った話


概要

こんにちは。今回は1年弱ほどかけて5人の学生で開発したサービスの、サーバーサイドについてお話させていただきます。

サービスの内容

学生をターゲットにした、大学の講義ノートのシェアリングサービスeeNotesを開発しました。大学の講義のノートや、試験前に作るまとめノートを作成、シェアすることができます。ノートは大学や授業名、ノートの内容から検索することができます。気になったノートは「いいね」できる他、フレーズを保存して、自分のノートに簡単に引用することができます。

チームメンバー

チームメンバーはこんな感じです。役割分担は一応得意な分野で分かれていましたが、役割を超えてタスクを共有することが多かったです。

サーバー

フロント

インフラ

サーバーサイドの実装の話

私が主に担当したのはサーバーサイドでした。特に奇を衒ったことはしていないですが、実装の一例として紹介します。

Golang

この開発はTreasureというインターンから派生したこともあり、コードはインターンで用意していただいたベースプログラムを流用しました。そのため、特に理由があってGolangにした訳ではないのですが、パフォーマンスや並列処理の観点から、他の案件でもGolangを使うことが多いです。今回はechoなどのフレームワークは使用していません。

構成

サーバーのディレクトリ構成はこのようになっています。

├── controller
├── customerror
├── db
├── dbutil
├── firebase
├── httputil
├── middleware
├── model
├── repository
├── service
├── go.mod
├── go.sum
└── server.go
  • server.go
    サーバーの立ち上げ、ミドルウェアの設定、ルーティングを行っています。

  • middleware
    認証を行っています。今回はFirebase Authenticationを使用しており、クライアントから送られてくるヘッダーのトークンで認証を行い、contexにユーザー情報を保存し、適宜contenxから引き出すようにしています。

  • controller
    サーバーのビジネスロジックを記述しています。エンドポイントが/users/{user_id}の、あるユーザーを取得するメソッドは以下のように記述します。パスパラメーターのuser_idmuxを使って取り出しています。

controller/user.go
func (u *User) Show(w http.ResponseWriter, r *http.Request) (int, interface{}, error) {
    vars := mux.Vars(r)
    uid, ok := vars["user_id"]
    if !ok {
        return http.StatusBadRequest, nil, &httputil.HTTPError{Message: "invalid path parameter"}
    }

    user, _ := httputil.GetUserFromContext(r.Context())

    userService := service.NewUser(u.db, u.authClient)
    userDetail, err := userService.FindUserDetail(uid, user)
    if err != nil && err == sql.ErrNoRows {
        return http.StatusNotFound, nil, err
    } else if err != nil {
        return http.StatusInternalServerError, nil, err
    }

    return http.StatusOK, userDetail, nil
}
  • service

    複数のリソースを操作する時や、トランザクションを必要とするドメインロジックを記述しています。

service/user.go
func (u *User) FindUserDetail(id string, user *model.User) (*model.UserResponse, error) {
    userDetail, err := repository.FindUserByUserID(u.db, id)
    if err != nil {
        return nil, err
    }

    // 認証ユーザーが対象ユーザーをフォローしているかを確認
    isFollow := false
    if user != nil {
        follow := &model.Follow{
            FollowingID: user.ID,
            FollowedID:  userDetail.ID,
        }
        isFollow, _ = repository.ExistsFollow(u.db, follow)
    }

    userDetailResponse := &model.UserResponse{
        ID:             userDetail.ID,
        DisplayName:    userDetail.DisplayName,
        Icon:           userDetail.Icon,
        DepartmentName: userDetail.DepartmentName,
        UniversityID:   userDetail.UniversityID,
        UniversityName: userDetail.UniversityName,
        UniversitySlug: userDetail.UniversitySlug,
        Profile:        userDetail.Profile,
        IsFollow:       isFollow,
    }

    return userDetailResponse, nil
}
  • repository

    DBの操作を記述しています。sqlxを使って、DBからのレスポンスを構造体にマップしています。

repository/user.go
func FindUserByUserID(db *sqlx.DB, uid string) (*model.UserResponse, error) {
    var u model.UserResponse
    if err := db.Get(&u, `
SELECT u.account_name, u.display_name, u.icon, d.name AS department_name, uni.id AS university_id, uni.slug AS university_slug, uni.name AS university_name, u.profile
FROM users AS u
INNER JOIN departments AS d ON u.department_id = d.id
INNER JOIN universities AS uni ON d.university_id = uni.id
WHERE u.account_name = ?;
    `, uid); err != nil {
        return nil, err
    }
    return &u, nil
}
  • model

    リクエストやレスポンス、DBの構造体を定義しています。型の後ろのdbタグはsqlxが構造体にマップする時に使用する、構造体のフィールドが対応するDBのカラム名を明示的に表しています。jsonタグは構造体をクライアントにjsonとして返すときに使用するフィールド名を定義しています。

model/user.go
type UserResponse struct {
    ID             string  `db:"account_name" json:"id"`
    DisplayName    string  `db:"display_name" json:"display_name"`
    Icon           string  `db:"icon" json:"icon"`
    DepartmentName string  `db:"department_name" json:"department_name"`
    UniversityID   int64   `db:"university_id" json:"university_id"`
    UniversityName string  `db:"university_name" json:"university_name"`
    UniversitySlug string  `db:"university_slug" json:"university_slug"`
    Profile        *string `db:"profile" json:"profile"`
    IsFollow       bool    `json:"is_follow"`
}

工夫した点

サービスを開発する中で工夫した点や、使ってよかったものなどは以下の通りです。

エラーハンドリング

Goのエラーハンドリングに関しては、個人的にベストプラクティスが確立されていないと思っています。今回はGo標準のerrorsを使い、予めエラーの型を定義しておき、それを上位層に返し、上位層でハンドリングを行いました。例えば、講義ノートを投稿したり、更新する際に、講義が行われた大学の名前がDBで見つからなかった時に、Bad Requestを返すことにします。まず、universityというリソースが見つからなかった時のエラー型を定義します。

customerror/db.go
package customerror

import (
    "errors"
)

var NotFoundUniversity = errors.New("not found university")

使い方としては、domain層で、想定するエラー(ここではuniversityが見つからない)が起きた時、定義したエラーを返します。

service/note.go
univ, err := repository.FindUniversityByName(n.db, reqParam.UniversityName)
if err != nil || univ == nil {
    return customerror.NotFoundUniversity
}

controllerのようなserviceやrepositoryを呼び出している上位層では、エラーにカスタムエラーが含まれているかを確認し、含まれていたらそれにあったレスポンスをクライアントに返します。

controller/note.go
if errors.Is(err, customerror.NotFoundUniversity) {
    return http.StatusBadRequest, err, nil
}

ここで、errors.Isを使っているのは、他のerrがwrapされていても判定することができるからです。具体的には以下のようなコードになります。(PlayGroundで試せます。)

package main

import (
    "fmt"
    "github.com/pkg/errors"
)

func main() {
    NotFoundUniversity := errors.New("not found university")

    err := NotFoundUniversity
    wrapErr := errors.Wrap(err, "other error")



    fmt.Println("err: ", errors.Is(err, NotFoundUniversity))  // err:  true
    fmt.Println("wrapErr: ", errors.Is(wrapErr, NotFoundUniversity))  // wrapErr:  true
}

errorsはerrをWrapすることができます。繰り返しWrapされていても、errors.Isは、第一引数のエラーに第二引数のエラーがWrapされているか、またはそれ自体だったらtrueを返します。そのため、repositoryやserviceなどのdomain層で、エラーをWrapし、handler層では関心のあるエラー型のみハンドリングするということが可能になります。

このような方針を取っているのは、domain層はクライアントにどのようなエラー(NotFound, BadRequest, ...)を返すかを知るべきではないからです。

上の例では、domain層は「大学というリソースが見つからない」という意味を持つNotFoundUniversityというエラーを返しています。例えば大学の情報を取得するような、大学に関するエンドポイントであれば、handlerは404 NotFoundを返します。しかし、講義ノートを投稿するような、大学がメタデータになっているエンドポイントであれば、handlerは400 BadRequestを返すべきです。

Elasticsearch

サービスに検索機能を作るにあたり、Elasticsearchを導入しました。検索が強い以外の、Elasticsearchを導入してよかった点とイマイチだった点は以下の通りです。

よかった点

  • スキーマレスである

    開発している中で、データの構造が変わることや、検索に使うフィールドが増えることが多くありました。そういう時に、RDBであればmigrarionファイルを作るなど、スキーマを変更する必要がありますが、Elasticsearchは基本的にはスキーマがないので、雑に格納するデータ構造を変えられて、スムーズに開発ができました。

  • プライマリーDBから切り離して考えられる

    厳密に言えばElasticsearchの利点ではないのですが、Elasticsearchを検索用として割り切って使うことで、プライマリーDB(今回はMySQL)の管理が楽になりました。
    例えば、今回のノートを投稿してもらうサービスの中で、ノートの内容をバージョンごとに保存しておく機能があり、更新するごとにノートの内容が保存されたレコードがDBに1つ増える仕様になっています。プライマリーDBで検索をしようとすると、検索対象は一番新しいバージョンのレコードに対してのみになり、これを実現するためには最新バージョンであることを示すフラグを追加するなどの対応が必要になります。ノートを更新した際には、更新する前まで最新だったバージョンのレコードを探し、最新フラグをfalseにする作業が増え、最新バージョンのレコードを探すためのロジックも必要になり、考えなければいけないことが増大します。

イマイチだった点

  • index内に違うスキーマのドキュメントがあるとエラーになる

    (スキーマレスであることを良い点で挙げておきながら、何言ってるのというツッコミが飛んできそうですが、)
    スキーマレスなので、雑にスキーマを変えたドキュメントをインデックスに突っ込もうとすると、たまに怒られて入りません。これは、フィールドを追加したor削除した時などには起きないのですが、フィールドの型を変えた時に起こります。検索の時に、違う型のフィールドを持つドキュメントは走査できないからだと考えられます。例えば以下のようなドキュメントは同じインデックスには入れられません。

スキーマ変更前

{
    "name": "Sato",
    "type": 1,
}

スキーマ変更後

{
    "name": "Sato",
    "type": "admin",
}
  • トランザクションが貼れないので、RDBとの不整合が起きる場面がある

    これもElasticsearchの欠点というより、NoSQLの問題なのですが、トランザクションを貼ることができないので、途中で処理が失敗しても簡単にロールバックするということができません。なので、複数リソースを編集&削除するような、トランザクションを貼りたい場面でElasticsearchも操作する場合、トランザクションの最後や、トランザクションが終わった後に実行する必要があります。

ロールバックがうまくいっているように見える例

- Transaction start
  - RDB処理A
  - RDB処理B
  - Elasticsearch処理A
- Transaction end

3つの処理がTransactionの中で行われるとします。もし仮に最後のElasticsearch処理Aで失敗しても、この3つの処理はトランザクションの中で行われているので、ロールバックすることができます。(厳密にはRDB処理ARDB処理Bしかロールバックしていません。)

ロールバックがうまくいかない例

- Transaction start
  - RDB処理A
  - RDB処理B
  - Elasticsearch処理A
  - Elasticsearch処理B
- Transaction end

しかし、複数のElasticsearchの処理をトランザクションの中で行い、Elasticsearch処理Bで失敗した場合、トランザクションが正しく貼れているRDBの処理であるRDB処理ARDB処理Bはロールバックできますが、トランザクションをサポートしていないElasticsearch処理Aはロールバックすることができません。上のロールバックがうまくいっているように見える例も、Elasticsearch処理Aの中で破壊的な処理が既に行われていれば、それはロールバックすることはできません。

このように、Elasticsearchはトランザクションがサポートされていないため、プライマリーDBとの不整合が起こる可能性があり、工夫が必要だと感じました。

Stoplight(OpenAPI)でAPI仕様を残す

フロントとサーバーで役割分担をしていたので、フロントの開発メンバーにサーバーの仕様を予め伝えることで、開発の手が止まらないように工夫しました。最初にOpenAPIで仕様を全部書き、その後にコードを書き始めるという開発プロセスだったので、仕様書の自動生成などは行いませんでした。

当初はswaggerをローカルで立ち上げて、SwaggerUIで仕様書を確認していたのですが、立ち上げるのが面倒だったので、stoplightを導入しました。

Stoplight StdioはSwagger UIのように仕様書を確認 & リクエスト実行だけでなく、GUIでAPI仕様書を編集、任意のブランチにコミットができます。もちろん、表示する仕様書のブランチも変更することができます。Stoplight導入で、個人的にはサーバーの開発スピードをあげることができたと感じています。導入前と導入後の仕様書を編集する手順は以下の通りです。

  • 導入前

    1. ローカルでswaggerを立ち上げる
    2. 任意のエディターでyamlを編集する
    3. ブラウザで仕様書を確認
    4. (良い感じになるまで2,3を繰り返す)
    5. 任意のツールでgit add & git commit & git push
  • 導入後

    1. Stoplight Stdioにアクセスする
    2. 仕様書をGUIで編集
    3. ブラウザからコミット&プッシュ

もちろん手順も少なくなっているのですが、一番大きいのはyamlを直接編集する必要がなくなった事です。yamlを直接編集していたときは、インデントのずれやtypo一つでエラーになり、無駄なデバックの時間が生まれていました。

まとめ

今回はGoを使って開発を行った時のお話でした。他のチームメンバーが色んな視点から記事を書いているので、是非そちらもご覧になってください!