LoginSignup
3
1

More than 1 year has passed since last update.

【Go】APIサーバの構築 ~データの読み取り(Read)編~

Last updated at Posted at 2022-01-08

はじめに

Go学習の一環として、簡単なAPIサーバを作成してみました。

その他のGoの記事については以下をご覧ください。

実装

DBにmovies, genres, movies_genresテーブルを作成し、指定したIDのmovieを取得するようなAPIをつくります。

movies
スクリーンショット 2022-01-07 21.41.22.png
genres
スクリーンショット 2022-01-07 21.41.41.png
movie_genres
スクリーンショット 2022-01-07 21.41.59.png

導入

まずは以下のコマンドで外部からインポートするモジュールのパスを書いておくファイルを作成します。

go mod init backend

次に今回使う外部パッケージ(httprouterとpq)をインストールします。

go get -u github.com/julienschmidt/httprouter
go get -u github.com/lib/pq@v1.10.0

パッケージは$GOPATH/pkg/mod以下(古い環境の場合は$GOPATH/src以下)に配置されます。

main.goの作成

main.goにDBとサーバを接続してlistenする部分を記述します(詳細はインラインコメント)。

main.go
package main

import (
    "backend/models"
    "context"
    "database/sql"
    "flag"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    _ "github.com/lib/pq"
)

const version = "1.0.0"

type config struct {
    port int
    env string
    db struct {
        dsn string
    }
}

type AppStatus struct {
    Status string `json:"status"`
    Environment string `json:"environment"`
    Version string `json:"version"`
}

type application struct {
    config config
    logger *log.Logger
    models models.Models
}

func main() {
    var cfg config

    // flagでconfigのプロパティを初期化する
    // 引数は変数のポインタ(メモリのアドレス値)、フラグの名前、デフォルト値、使い方の説明
    flag.IntVar(&cfg.port, "port", 4000, "Server port to listen on")
    flag.StringVar(&cfg.env, "env", "development", "Application environment (development|production")
    flag.StringVar(&cfg.db.dsn, "dsn", "postgres://localhost/go_movies?sslmode=disable", "Postgres connection string")
    flag.Parse()

    // Loggerオブジェクトを生成して出力フォーマットを設定する
    logger := log.New(os.Stdout, "", log.Ldate|log.Ltime)

    // DBと接続する
    db, err := openDB(cfg)
    if err != nil {
        logger.Fatal(err)
    }
    // main()がreturnするときにDBとの接続を閉じる
    defer db.Close()

    // アプリケーションの設定をする(参照渡しをおこなうためにポインタを使用)
    app := &application{
        config: cfg,
        logger: logger,
        models: models.NewModels(db),
    }

    // サーバー設定をカスタマイズする
    srv := &http.Server{
        Addr: fmt.Sprintf(":%d", cfg.port),
        Handler: app.routes(),
        IdleTimeout: 10 * time.Minute,
        WriteTimeout: 30 * time.Second,
    }

    logger.Println("Starting server on port", cfg.port)

    // サーバをlistenする
    err = srv.ListenAndServe()
    if err != nil {
        log.Println(err)
    }
}

func openDB(cfg config) (*sql.DB, error) {
    // DBへアクセスする(接続はまだ確立されない)
    db, err := sql.Open("postgres", cfg.db.dsn)
    // エラー処理
    if err != nil {
        return nil, err
    }

    // 5sでタイムアウトする
    ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
    // openDB()がreturnするまで実行されない
    defer cancel()

    // DBとの接続を検証する
    err = db.PingContext(ctx)
    //エラー処理
    if err != nil {
        return nil, err
    }

    return db, nil
}

models.goの作成

models.goを作成し、main.goで定義したapplicationのプロパティであるmodelsの構造体Modelsdbを元にModelsを返すNewModels()、各テーブルの構造体Movie Genre MovieGenreを記述します。

models.go
package models

import (
    "database/sql"
    "time"
)

// Models is the wrapper for database
type Models struct {
    DB DBModel
}

// NewModels returns models with db pool
func NewModels(db *sql.DB) Models {
    return Models{
        DB: DBModel{DB: db},
        // DBModel{db}
    }
}

type Movie struct {
    ID int `json:"id"`
    Title string `json:"title"`
    Description string `json:"description"`
    Year int `json:"year"`
    ReleaseDate time.Time `json:"release_date"`
    Runtime int `json:"runtime"`
    Rating int `json:"rating"`
    MPAARating string `json:"mpaa_rating"`
    CreatedAt time.Time `json:"-"`
    UpdatedAt time.Time `json:"-"`
    MovieGenre map[int]string `json:"genres"`
}

type Genre struct {
    ID int `json:"-"`
    GenreName string `json:"genre_name"`
    CreatedAt time.Time `json:"-"`
    UpdatedAt time.Time `json:"-"`
}

type MovieGenre struct {
    ID int `json:"-"`
    MovieID int `json:"-"`
    GenreID int `json:"-"`
    Genre Genre `json:"genre"`
    CreatedAt time.Time `json:"-"`
    UpdatedAt time.Time `json:"-"`
}

routes.goの作成

main.goのサーバ設定のカスタマイズでHandler: app.routes()と記述しましたが、この部分でルートハンドラーを設定しています。
導入部分でインストールしたhttprouterモジュールはここで使われます。

routes.go
package main

import (
    "net/http"

    "github.com/julienschmidt/httprouter"
)

// ルートハンドラーのレシーバ
func (app *application) routes() *httprouter.Router {
    router := httprouter.New()
    router.HandlerFunc(http.MethodGet, "/v1/movie/:id", app.getOneMovie)
    return router
}

router.HandlerFunc(http.MethodGet, "/v1/movie/:id", app.getOneMovie)でメソッド、パス、処理を指定しています。
処理の部分についてはまた別のファイルmovie-handlers.goに記述します。

movie-handlers.goの作成

ルートハンドラーの処理getOneMovieを記述します。
ParamsFromContextでクエリパラメータを取得するために、ここでもhttprouterを使用します。

movie-handlers.go
package main

import (
    "errors"
    "net/http"
    "strconv"

    "github.com/julienschmidt/httprouter"
)

func (app *application) getOneMovie(w http.ResponseWriter, r *http.Request) {
    // クエリパラメータを取得する
    params := httprouter.ParamsFromContext(r.Context())

    // paramsを文字列から整数に変換する
    id, err := strconv.Atoi(params.ByName("id"))
    //エラー処理
    if err != nil {
        app.logger.Print(errors.New("invalid id parameter"))
        app.errorJSON(w, err)
        return 
    }

    // 指定したidのデータを取得する
    movie, err := app.models.DB.Get(id)

    // レスポンスをJSONで返す
    err = app.writeJSON(w, http.StatusOK, movie, "movie")
    if err != nil {
        app.errorJSON(w, err)
        return 
    }
}

movies-db.goの作成

movie-handlers.gomovie, err := app.models.DB.Get(id)の部分で、指定したidのデータをDBから取得しています。
このGet()メソッドをmovies-db.goに記述します。

movies-db.go
package models

import (
    "context"
    "database/sql"
    "time"
)

type DBModel struct {
    DB *sql.DB
}

// 指定idのmovieかerrorを返すメソッド(DBModelのポインタレシーバ)
func (m *DBModel) Get(id int) (*Movie, error) {
    // 3sでタイムアウトする
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // 指定したIDのmoviesを取得するクエリ
    query := `select id, title, description, year, release_date, runtime, rating, mpaa_rating,
                            created_at, updated_at from movies where id = $1
    `

    // 指定したidのmoviesを取得する(1行)
    row := m.DB.QueryRowContext(ctx, query, id)

    var movie Movie

    // クエリの結果をmovieに割り当てる
    err := row.Scan(
        &movie.ID,
        &movie.Title,
        &movie.Description,
        &movie.Year,
        &movie.ReleaseDate,
        &movie.Runtime,
        &movie.Rating,
        &movie.MPAARating,
        &movie.CreatedAt,
        &movie.UpdatedAt,
    )
    if err != nil {
        return nil, err
    }

    // 指定したmovie_idのgenresを取得するクエリ
    query = `select
                        mg.id, mg.movie_id, mg.genre_id, g.genre_name
                    from
                        movies_genres mg
                        left join genres g on (g.id = mg.genre_id)
                    where
                        mg.movie_id = $1
    `

    // 指定したmovie_idのgenresを取得する(複数行)
    rows, _ := m.DB.QueryContext(ctx, query, id)
    defer rows.Close()

    genres := make(map[int]string)
    for rows.Next() {
        var mg MovieGenre
        err := rows.Scan(
            &mg.ID,
            &mg.MovieID,
            &mg.GenreID,
            &mg.Genre.GenreName,
        )
        if err != nil {
            return nil, err
        }
        genres[mg.ID] = mg.Genre.GenreName
    }

    movie.MovieGenre = genres

    return &movie, nil
}

utilities.goの作成

movie-handlers.goでは、DBから取得したデータやエラーを取得したあとに、レスポンスをJSONで返します。
writeJSON()errorJSONといったメソッドをutilities.goに記述します。

utilities.go
package main

import (
    "encoding/json"
    "net/http"
)

func (app *application) writeJSON(w http.ResponseWriter, status int, data interface{}, wrap string) error {
    // メモリを割り当てる
    wrapper := make(map[string]interface{})

    // wrapキーの構造体にdataを代入する
    wrapper[wrap] = data

    // 構造体をJSONに変換する
    js, err := json.Marshal(wrapper)
    if err != nil {
        return err
    }

    // ヘッダーにContent-Typeを付加する
    w.Header().Set("Content-Type", "application/json")
    // ステータスコードとともにHTTP応答ヘッダーを返す
    w.WriteHeader(status)
    // ボディを返す
    w.Write(js)

    return nil
}

func (app *application) errorJSON(w http.ResponseWriter, err error) {
    type jsonError struct {
        Message string `json:"message"`
    }

    theError := jsonError {
        Message: err.Error(),
    }

    app.writeJSON(w, http.StatusBadRequest, theError, "error")
}

json.Marshal()を行う際、構造体タグを利用することで以下のように結果を制御することができます。

type Genre struct {
    ID int `json:"-"` // Marshal時に省略される
    GenreName string `json:"genre_name"` // Marshal時にjsonのキーが'genre_name'になる
    CreatedAt time.Time `json:"-"`
    UpdatedAt time.Time `json:"-"`
}

動作確認

go run cmd/api/*.goもしくはgo run ./cmd/apiを実行します。
http://localhost:4000/v1/movie/1にアクセスすると、以下のように結果が表示されます。
スクリーンショット 2022-01-08 10.19.11.png

CORS(Cross-Origin Resource Sharing)

別のオリジン(ドメイン、プロトコル、ポート番号)で提供されているフロントエンドのプロジェクト(React, Vue)からGoのAPIサーバにリクエストを行うためには、CORS(オリジン間リソース共有)を行う必要があります。
CORSを行うことによって、追加のHTTPヘッダーを使用して、あるオリジンで動作しているウェブアプリケーションに、異なるオリジンにあるリソースへのアクセス権を与えるようブラウザに指示することができます。

HTTPヘッダーにAccess-Control-Allow-Originを付加するようなメソッドenableCORS()middleware.goに作成して、routes.goに追記します。

middleware.go
package main

import (
    "net/http"
)

func (app *application) enableCORS(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")

        next.ServeHTTP(w, r)
    })
}
routes.go
func (app *application) routes() http.Handler {
    router := httprouter.New()

    router.HandlerFunc(http.MethodGet, "/v1/movie/:id", app.getOneMovie)

    return app.enableCORS(router)
}

コード全文

以下においてあります。

参考資料

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1