43
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Go+GAE+Cloud Datastoreで簡単なREST APIを構築

Last updated at Posted at 2016-11-06

今回の対象はCloud Datastoreです。
まだ手探り状態なので超初級編。
将来的にはechoフレームワークを利用する予定ですが、Goのhttp/net周りの学習をするためにしばらくはフレームワークは利用しません。

今回やりたいこと

  1. Cloud Datastoreに対して「項目名(string)、金額(int)」を持ったエンティティの登録・更新・取得・削除を行う
  2. http://localhost:8080/ でindexページが表示され、ajaxリクエストでCRUDが行える(templateを利用する)
  3. http://localhost:8080/api/{種別}/{項目名}/{金額} でアクセスするとREST APIが使える
  4. 今回の種別は「Expense(費用)」のみ
  5. 対応methodはGET(取得)、PUT(登録・更新)、DELETE(削除)の3つ
  6. PUT同時リクエスト時の「エンティティの項目の一意性担保」については考慮しない(次回以降にやる)
  7. できる限りGo自体の学習も進める

前提条件

過去の記事「WindowsのEclipseでGo+AppEngineの開発環境を構築」で構築した環境を利用しています。

事前準備

とりあえずAppEngineのライブラリを入れる。

コンソールで実行
>go get google.golang.org/appengine

前回と同じ手順で、Eclipseのプロジェクトとして「hello_datastore」を作成。

コーディング開始

param.go

まずはURLとして渡された文字列をパラメタに分解するパーサーを用意します。

3. http://localhost:8080/api/{種別}/{項目名}/{金額} でアクセスするとREST APIが使える

汎用的なものではなく、今回の目的である上記を満たすことに取ったしたパーサーを用意しました。
「特定のKeyを持つか」や「特定のValueを持つか」という用途はなかったので、KeyやValueそのものが存在するかのHas系メソッドだけ用意。

src/hello_datastore/param.go
package hello_datastore

import (
	"net/url"
	"strings"
)

// 今回のサンプルに特化した構造体。
// URLをパースし、必要な値を保持。
// ※handler系から呼ばれる事を想定
// [使い方]
// p := NewParam(r.URL)
type Param struct {
	Kind  string
	Key   string
	Value string
}

// URLのGET値をベースにParamを作成。
// 1番目はindex or api、2番目は種別、3番目はキー、4番目は値、5番目以降は破棄。
func NewParam(url *url.URL) *Param {
	path := strings.Trim(url.Path, "/")
	s := strings.Split(path, "/")
	param := new(Param)
	if len(s) >= 2 {
		param.Kind = s[1]
	}
	if len(s) >= 3 {
		param.Key = s[2]
	}
	if len(s) >= 4 {
		param.Value = s[3]
	}
	return param
}
func (p *Param) HasKey() bool {
	return len(p.Key) > 0
}
func (p *Param) HasValue() bool {
	return len(p.Value) > 0
}

###expense_datastore.go

次に、今回のメイン処理となるDatastoreへのCRUDを行う処理を用意。
構造体やメソッドの学習、logの利用も兼ねています。
詳細はソースコメントを参照。

src/hello_datastore/expense_datastore.go
package hello_datastore

import (
	"errors"
	"golang.org/x/net/context"
	"google.golang.org/appengine"
	"google.golang.org/appengine/datastore"
	"log"
	"net/http"
)

const ENTITYNAME = "Expense"

// 今回のサンプルに特化した、Datastoreに格納するエンティティ用の構造体
// jsonレスポンスとして利用するためタグ名を指定
type ExpenseEntiry struct {
	Name  string `json:"string"`
	Price int    `json:"price"`
}

// ExpenseをDatastoreにCRUDするための構造体と手続き群。
// (名前が適当すぎてDatastoreの費用を管理してそうな名前に…)
// [使い方]
// 	ed := NewExpenseDatasotre(r)
//	ed.build("項目名", 1080)
//  ed.Put()
type ExpenseDatasotre struct {
	Ctx     context.Context
	Keys    []*datastore.Key
	Expense *ExpenseEntiry
}

// コンストラクタ的な存在
func NewExpenseDatasotre(r *http.Request) *ExpenseDatasotre {
	ed := new(ExpenseDatasotre)
	ed.Ctx = appengine.NewContext(r)
	return ed
}

// datastoreに渡す用の構造体の作成と、そのNameに一致するkeysの取得を行う
func (ed *ExpenseDatasotre) build(name string, price int) error {
	ed.Expense = &ExpenseEntiry{
		Name:  name,
		Price: price,
	}
	// Entityの中身の確認用に標準出力にログ出力。↓な感じのログが出ます。(フォーマットは%#vがお気に入り)
	// 2016/11/06 15:38:32 &hello_datastore.ExpenseEntiry{Name:"test", Price:55}
	log.Printf("%#v", ed.Expense)
	var err error
	ed.Keys, err = getKeysByName(ed)
	return err
}

// 構造体のポインタにエンティティが読み込まれる
// 今回の簡易サンプルでは問題ないが、フィールドが一致しない項目は上書きされないため注意
func (ed *ExpenseDatasotre) Get() error {
	if len(ed.Keys) == 0 {
		return errors.New("取得対象の項目が存在しません")
	}
	return datastore.Get(ed.Ctx, ed.Keys[0], ed.Expense)
}

// このput処理ではputの複数同時リクエストがNameの一意性が失われてしまうが、今回のサンプルでは考慮しない
// 次回以降にgoのvarsLockの勉強と合わせて対応してみる予定
func (ed *ExpenseDatasotre) Put() error {
	// keyがある場合は上書き、ない場合は新規作成
	if len(ed.Keys) == 0 {
		keys := make([]*datastore.Key, 1)
		// 今回のサンプルでは、新規作成時のStringIDはランダムでいいためIncompleteKeyを利用
		keys[0] = datastore.NewIncompleteKey(ed.Ctx, ENTITYNAME, nil)
		ed.Keys = keys
	}

	_, err := datastore.Put(ed.Ctx, ed.Keys[0], ed.Expense)
	return err
}

// 削除のみmultiに対応。putでnameの重複を発生させることで実験可能。
// ※トランザクションの勉強用
func (ed *ExpenseDatasotre) Delete() error {
	if len(ed.Keys) == 0 {
		return errors.New("削除対象の項目が存在しません")
	}

	// 今回のサンプルではエンティティは全てROOTエンティティなため、複数削除にはクロスグループトランザクションを指定
	option := &datastore.TransactionOptions{XG: true}
	return datastore.RunInTransaction(ed.Ctx, func(c context.Context) error {
		return datastore.DeleteMulti(c, ed.Keys)
	}, option)
}

// Nameと完全一致するEntityのKeyリストを取得
func getKeysByName(ed *ExpenseDatasotre) ([]*datastore.Key, error) {
	q := datastore.NewQuery(ENTITYNAME).Filter("Name =", ed.Expense.Name)

	var expenses []ExpenseEntiry
	return q.GetAll(ed.Ctx, &expenses)

}

###template.go

レスポンスでhtmlを返す場合のHandler。
今回は値のbind等はせずに、単純なhtmlを出力するためだけに利用。
学習用にsyncのonceを使ったりしてますが、これがあるとhtmlの修正が即時で確認できないのでhtmlコーディング時はコメントアウト推奨。

src/hello_datastore/template.go
package hello_datastore

import (
	"html/template"
	"net/http"
	"path/filepath"
	"sync"
)

// template用の構造体の定義
type templateHandler struct {
	once     sync.Once
	filename string
	tpl      *template.Template
}

func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	//templateをコンパイルするのは1回だけで良いのでonceを利用。
	t.once.Do(func() {
	t.tpl = template.Must(template.ParseFiles(filepath.Join("templates", t.filename)))
	})
	t.tpl.Execute(w, nil)
}

###respond.go

REST APIのレスポンス用の処理。JSONで返すことのみを考慮した作りになっている。
特に難しいことはしておらず、josnレスポンスを返す勉強くらいにはなったかも。
(リクエストパラメタによってフォーマットを変えたりする場合は色々頑張りがいがありそう)

src/hello_datastore/respond.go
package hello_datastore

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

// 今回はすべてjsonで返却
func respond(w http.ResponseWriter, r *http.Request, status int, data interface{}) {
	w.WriteHeader(status)
	if data != nil {
		json.NewEncoder(w).Encode(data)
	}
}

func respondErr(w http.ResponseWriter, r *http.Request, status int, args ...interface{}) {
	respond(w, r, status, map[string]interface{}{
		"error": map[string]interface{}{
			"message": fmt.Sprint(args...),
		},
	})
}

main.go

今回のメインとなるファイル。とはいえ真のメインはexpense_datastore.goであって、このファイルは各処理を呼び出しているだけのもの。
init()の通りですが、/api/*でのリクエスト時にはrestHandler、それ以外の場合にはindex.htmlを出力するためのtemplateHandlerを呼んでいます。
こちらも詳しくはソースコメントを参照。

src/hello_datastore/main.go
package hello_datastore

import (
	"net/http"
	"strconv"
)

// 初期化
func init() {
	http.Handle("/", &templateHandler{filename: "index.html"})
	http.HandleFunc("/api/", restHandler)
}

// REST用のハンドラ
func restHandler(w http.ResponseWriter, r *http.Request) {

	p := NewParam(r.URL)
	if p.Kind != "expense" {
		respondErr(w, r, http.StatusBadRequest, "expense以外の種別には対応してません")
	}

	// PUT以外の場合はprice用にダミーの0を入れる
	if r.Method != "PUT" && !p.HasValue() {
		p.Value = "0"
	}

	// kindがexpenseの場合はvalueをintと定義したのでキャスト
	price, err := strconv.Atoi(p.Value)
	if err != nil {
		respondErr(w, r, http.StatusBadRequest, err.Error())
		return
	}

	ed := NewExpenseDatasotre(r)
	if err := ed.build(p.Key, price); err != nil {
		respondErr(w, r, http.StatusInternalServerError, err.Error())
	}

	switch r.Method {
	case "GET":
		handleGet(ed, w, r)
		return
	case "PUT":
		handlePut(ed, w, r)
		return
	case "DELETE":
		handleDelete(ed, w, r)
		return
	default:
		respondErr(w, r, http.StatusNotFound, "未対応のHTTPメソッドです")
	}
}

type SuccessResponse struct {
	Expense ExpenseEntiry `json:"entity"`
	Message string        `json:"message"`
}

// GET用のhandler。エンティティの取得を行う
func handleGet(ed *ExpenseDatasotre, w http.ResponseWriter, r *http.Request) {
	if err := ed.Get(); err != nil {
		respondErr(w, r, http.StatusBadRequest, err.Error())
		return
	}
	message := "「" + ed.Expense.Name + "」の金額は" + strconv.Itoa(ed.Expense.Price) + "円です。"
	respond(w, r, http.StatusOK, SuccessResponse{*ed.Expense, message})
}

// PUT用のhandler。エンティティの作成・更新を行う
func handlePut(ed *ExpenseDatasotre, w http.ResponseWriter, r *http.Request) {
	if err := ed.Put(); err != nil {
		respondErr(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	message := "「" + ed.Expense.Name + "」の登録を行いました。"
	respond(w, r, http.StatusOK, SuccessResponse{*ed.Expense, message})
}

// DELETE用のhandler。エンティティの削除を行う
func handleDelete(ed *ExpenseDatasotre, w http.ResponseWriter, r *http.Request) {
	if err := ed.Delete(); err != nil {
		respondErr(w, r, http.StatusInternalServerError, err.Error())
		return
	}
	message := "「" + ed.Expense.Name + "」の削除を行いました。"
	respond(w, r, http.StatusOK, SuccessResponse{*ed.Expense, message})
}

###index.html

上記の処理が通れば、あとは http://localhost:8080/ でアクセスした際に表示するHTMLを記述。
かなり簡単な処理ですが、formで記述されたGET,PUT,DELETEの各処理のsubmit時にフックして非同期化&GET値の/化を行い、ajaxでリクエストしているだけです。

src/templates/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>sample</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
<script type="text/javascript">
jQuery(function($) {
	$("form").submit(function(event){
		event.preventDefault();
				var $form = $(this);
		var $url = $form.attr('action');
		$($form.serializeArray()).each(function(){
			if(this.value){
				$url += '/' + this.value;
			}
		});
		$.ajax({
            url: $url,
            type: $form.attr('method'),
		}).done(function(data) {
		    alert($.parseJSON(data).message);
		}).fail(function(data) {
		    alert($.parseJSON(data.responseText).error.message);
		});
	});
});
</script>
</head>
<body>
<section>
<h2>GET</h2>
<form action="/api/expense" method="get">
<label>項目名 : <input type="text" value="" name="key"></label>
<input type="submit" value="金額を取得">
</form>
</section>
<section>
<h2>PUT</h2>
<form id="put-expense" action="/api/expense" method="put">
<label>項目名 : <input type="text" value="" name="key"></label>
<label>金額 : <input type="text" value="" name="value"></label>
<input type="submit" value="項目を登録・更新">
</form>
</section>
<section>
<h2>DELETE</h2>
<form action="/api/expense" method="delete">
<label>項目名 : <input type="text" value="" name="key"></label>
<input type="submit" value="項目を削除">
</form>
</section>
</body>
</html>

実行

ローカル環境でのGAEエミュレータを起動

上記の準備がそろったらAppEngine SDKのgaeサーバー起動する。

コンソールかEclipseから実行
goapp serve {eclipse_project}\src

{eclipse_project}は筆者の場合はC:\works\eclipse\workspace\gae-sample

実行時ログ
>goapp serve src
INFO     2016-11-06 22:54:52,542 devappserver2.py:769] Skipping SDK update check.
INFO     2016-11-06 22:54:52,760 api_server.py:205] Starting API server at: http://localhost:57742
INFO     2016-11-06 22:54:52,769 dispatcher.py:197] Starting module "default" running at: http://localhost:8080
INFO     2016-11-06 22:54:52,773 admin_server.py:116] Starting admin server at: http://localhost:8000

表示確認

http://localhost:8080 へアクセス。
以下のようなページが表示されればOK。

datastore_01.PNG

項目の追加(PUT)

PUTの項目に、項目名「サンプル」金額「500」を入力しEnter。

「サンプル」の登録を行いました。

という表示がされればOK。
http://localhost:8000 の「Datastore Viewer」を選択すると追加したレコードが確認できる。
datastore_02.PNG

項目の取得(GET)

GETの項目に、項目名「サンプル」を入力しEnterを押下すると、先ほど登録したエンティティが取得可能。

「サンプル」の金額は500円です。

と表示されればOK。

項目の削除(DELETE)

DELETEの項目に、項目名「サンプル」を入力しEnterを押下すると、URLで渡したNameと一致するエンティティの一括削除が可能。

「サンプル」の削除を行いました。

と表示されればOK。

感想

前回までは環境構築がメインだったので、やっと「GO言語に触れてる」感が出てきました。
構造体、メソッド、インターフェイス、Goのポインタ、この辺の理解を徐々に深めている状況。
とりあえず悩みながら学ぶしかないので、まだしばらくは簡易的なサンプルを作りながら学習する予定です。

次回はTaskQueueと組み合わせてみる予定。
その前にゴルーチン/チャネルだったり、そもそもの記述別パフォーマンス調査を行うべきかもしれない。

参考文献

43
36
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
43
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?