今回の対象はCloud Datastoreです。
まだ手探り状態なので超初級編。
将来的にはechoフレームワークを利用する予定ですが、Goのhttp/net周りの学習をするためにしばらくはフレームワークは利用しません。
今回やりたいこと
- Cloud Datastoreに対して「項目名(string)、金額(int)」を持ったエンティティの登録・更新・取得・削除を行う
- http://localhost:8080/ でindexページが表示され、ajaxリクエストでCRUDが行える(templateを利用する)
- http://localhost:8080/api/{種別}/{項目名}/{金額} でアクセスするとREST APIが使える
- 今回の種別は「Expense(費用)」のみ
- 対応methodはGET(取得)、PUT(登録・更新)、DELETE(削除)の3つ
- PUT同時リクエスト時の「エンティティの項目の一意性担保」については考慮しない(次回以降にやる)
- できる限り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系メソッドだけ用意。
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の利用も兼ねています。
詳細はソースコメントを参照。
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コーディング時はコメントアウト推奨。
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レスポンスを返す勉強くらいにはなったかも。
(リクエストパラメタによってフォーマットを変えたりする場合は色々頑張りがいがありそう)
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
を呼んでいます。
こちらも詳しくはソースコメントを参照。
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でリクエストしているだけです。
<!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サーバー起動する。
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。
項目の追加(PUT)
PUTの項目に、項目名「サンプル」金額「500」を入力しEnter。
「サンプル」の登録を行いました。
という表示がされればOK。
http://localhost:8000 の「Datastore Viewer」を選択すると追加したレコードが確認できる。
項目の取得(GET)
GETの項目に、項目名「サンプル」を入力しEnterを押下すると、先ほど登録したエンティティが取得可能。
「サンプル」の金額は500円です。
と表示されればOK。
項目の削除(DELETE)
DELETEの項目に、項目名「サンプル」を入力しEnterを押下すると、URLで渡したNameと一致するエンティティの一括削除が可能。
「サンプル」の削除を行いました。
と表示されればOK。
感想
前回までは環境構築がメインだったので、やっと「GO言語に触れてる」感が出てきました。
構造体、メソッド、インターフェイス、Goのポインタ、この辺の理解を徐々に深めている状況。
とりあえず悩みながら学ぶしかないので、まだしばらくは簡易的なサンプルを作りながら学習する予定です。
次回はTaskQueueと組み合わせてみる予定。
その前にゴルーチン/チャネルだったり、そもそもの記述別パフォーマンス調査を行うべきかもしれない。
参考文献
-
以下の書籍の第六章はかなり参考にしました。
Go言語によるWebアプリケーション開発 -
Go言語の基礎的な部分としては以下を参考にしています。
スターティングGo言語