#はじめに
Go学習の一環として、簡単なAPIサーバを作成してみました。
その他のGoの記事については以下をご覧ください。
#実装
DBにmovies, genres, movies_genresテーブルを作成し、指定したIDのmovieを取得するようなAPIをつくります。
##導入
まずは以下のコマンドで外部からインポートするモジュールのパスを書いておくファイルを作成します。
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する部分を記述します(詳細はインラインコメント)。
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
の構造体Models
、db
を元にModels
を返すNewModels()
、各テーブルの構造体Movie
Genre
MovieGenre
を記述します。
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
モジュールはここで使われます。
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
を使用します。
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.go
のmovie, err := app.models.DB.Get(id)
の部分で、指定したidのデータをDBから取得しています。
このGet()
メソッドを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
に記述します。
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
にアクセスすると、以下のように結果が表示されます。
##CORS(Cross-Origin Resource Sharing)
別のオリジン(ドメイン、プロトコル、ポート番号)で提供されているフロントエンドのプロジェクト(React, Vue)からGoのAPIサーバにリクエストを行うためには、CORS(オリジン間リソース共有)を行う必要があります。
CORSを行うことによって、追加のHTTPヘッダーを使用して、あるオリジンで動作しているウェブアプリケーションに、異なるオリジンにあるリソースへのアクセス権を与えるようブラウザに指示することができます。
HTTPヘッダーにAccess-Control-Allow-Origin
を付加するようなメソッドenableCORS()
をmiddleware.go
に作成して、routes.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)
})
}
func (app *application) routes() http.Handler {
router := httprouter.New()
router.HandlerFunc(http.MethodGet, "/v1/movie/:id", app.getOneMovie)
return app.enableCORS(router)
}
##コード全文
以下においてあります。
#参考資料