Edited at

Go言語のWEBフレームワークRevelを使用してセキュアなAPIを作成

More than 1 year has passed since last update.


WHY

機械学習のモデルはできたけど、サービスインするには何らかの形でweb側から動作できる形にする必要があります。

Railsだと重すぎるし、Go言語使いたい。

そのようなモチベーションから私はGo言語でAPIを作成しました。

Go言語のWEBフレームワークは多々ありますが、今回紹介する記事はrevelを用いています。

コンテナとコードを公開しているのですぐに動作を確認することが可能にしています。

Docker: https://hub.docker.com/r/masayaresearch/go_api/

github: https://github.com/SnowMasaya/go_revel_jwt_mysql

revelを採用した理由は下記です。

DI_IMG_5648_TP_V.jpg


  • testのための機能が揃っている

  • deployも簡単

  • 必要なサンプルがある


WHAT

何をすることを想定しているか

- 簡単な認証

- APIエンドポイントからパラメータ取得

- DBアクセス

- DBの結果を返却

MySQLに機械学習で予測した結果が入っていると想定してJWTによる認証からAPIを叩いてMySQLのデータを表示するAPIを作成しました。

必要なものは下記です。


  • MySQLのDocker環境

  • RevelのDocker環境

revelに必要な機能としては大きく分けると4つあります。


  • API提供部分

  • JWT認証部分

  • MySQLへの接続及びデータをモデルに保存する部分

  • MySQLの操作して結果を表示


HOW

上記に示したWHATの部分をどのように実装しているか解説していきます。


API提供部分

API提供部分の修正に必要なファイルは2つです。


  • confファイル


    • routes

    • app.conf 



  • api_jwt_mysql.go

routesファイルで必要な部分を抜粋します。どのようにエンドポイントを設定してアクセスするか設定します。

GET     /                                       App.Index

GET /get_list Api_Jwt_Mysql.List

上の場合は2つのアクセスを用意しています。

App.Indexへのアクセス

http://{ipアドレス or localhost}/

Go言語で実装したApi_Jwt_Mysql.Listのメソッドへのアクセス

http://{ipアドレス or localhost}/get_list


設定ファイル

app.confで今回に必要な部分を説明します。

下記でアプリケーション名を設定しています。

# Application

app.name=Revel_JWT_MySQL

下記でポート番号とipアドレスを設定しています。今回はipアドレスを特別に設定する必要がないので空にしています。

# Server

http.addr=
http.port=9000

ログは4種類用意してくれています。



  • trace:デバッグ用の情報


  • info:情報(取得したい情報)


  • warn:警告


  • error:エラー

出力先を全て標準エラーにしていますが標準出力と使い分ける方が良いです。通常のログとエラーのログが混ざると人はエラーのログが見づらくなるので分ける方が良いです。また各ログにつけるプレフィックスも設定可能です。

# Logging

log.trace.output = stderr
log.info.output = stderr
log.warn.output = stderr
log.error.output = stderr

log.trace.prefix = "TRACE "
log.info.prefix = "INFO "
log.warn.prefix = "WARN "
log.error.prefix = "ERROR "

prod(本番用)とdev(開発用)で設定を変えることが可能です。例えばローカルで動作するDBと本番で動作しているDBを変えるなどで有用になります。

[dev]

mode.dev=true
watch=true
module.testrunner=github.com/revel/modules/testrunner
db.uri = "172.17.0.1:3306"
db.name = "revel_jwt_mysqlapp"


API部分

func (c Api_Jwt_Mysql) List() revel.Result {

var res models.Api_Jwt_Mysql
  :
c.Params.Bind(&res.Id, "Id")
c.Params.Bind(&res.Date, "Date")
c.Params.Bind(&res.Title, "Title")
c.Params.Bind(&res.Picture, "Picture")
c.Params.Bind(&res.Apitoken, "Apitoken")

var res models.Api_Jwt_Mysqlの部分はapiから取得した値を設定しておくmodelを用意しておきます。

type Api_Jwt_Mysql struct {

Id int
Date int
Title string
Picture string
Apitoken string
All string
}

パラメータの取得です。c.Params.Bind&で渡しているパラメータ解析してGoのmodelに渡しています。

c.Params.Bind(&res.Id, "Id")の場合は下記でアクセスすると

http://{ipアドレス or localhost}/api_jwt_mysql&Id=1

&res.Idに1の値が代入されます。他の部分も同様のことをしています。

APIにパラメータをgo言語に渡す方法になります。


JWT認証部分

JWTトークンを使用している理由は下記です。


  • 認証用のトークンの期限を決められる。またRSA方式を利用すれば秘密鍵と公開鍵を用いた認証が可能

  • HTTPヘッダーに載せられるため、非常に軽く、必要な情報を全て載せることが可能

  • 認証のためにData Baseを必要としない。

apiトークンをエンドポイントのパラメータから取得して評価しています。

            if val, ok := c.Params.Query["Apitoken"]; ok {

check_token = CheckJWTHandler(c.Params.Query["Apitoken"][0])
if check_token {
fmt.Printf("Success")
}else {
fmt.Printf("Failed")
}
}else{
fmt.Printf("Apitoken no setting. The Title is %#v", val)
}

評価する関数はトークンを引数にしてboolean型で値を返す関数です。

func CheckJWTHandler(api_token string) (bool){

関数内でトークンが存在するかチェック

    tokenString := api_token

if tokenString == "" {
return false
}

ここがややこしいので丁寧に解説します。

    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {

if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return LookupPublicKey()
})

下記のように”#_#”に置き換えます

jwt.Parse(tokenString, #_#)

そうすると取得したトークンと"#_#"の部分をParseして比較していることが分ります。

"#_#"を展開していきます。

jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {#_#})

ある関数から値を取得していることが分ります。

メソッドレシーバーとしてtoken *jwt.Tokenを使用しています。

戻り値としてinterfaceerrorを取得することが目的です。

関数の中身を見てみます。

        if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {

return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
}
return LookupPublicKey()

メソッドレシーバーを使用してtoken.Method.(*jwt.SigningMethodRSA)の部分でトークン認証用のメソッドとして正しいかをチェックしています。

正しければreturn LookupPublicKey()公開鍵を読み込んで、interface{}, errorに返しています。

    if err != nil || !token.Valid {

fmt.Println("Failed")
return false
}
return true

errnilでなくかつtokenValid(有効)ならばtrueを返して、それ以外ならfalseを返しています。


func LookupPublicKey() (*rsa.PublicKey, error) {
prev, err := filepath.Abs(".")
if err != nil {
defer fmt.Println("Error")
}
key, _ := ioutil.ReadFile(prev + "/demo.rsa.pub")
parsedKey, err := jwt.ParseRSAPublicKeyFromPEM(key)
return parsedKey, err
}

下記の手順で公開鍵の認証情報を取得しています。


  • パスの取得

  • 鍵の読み込み

  • jwwt認証用に情報を取得


MySQLへの接続及びデータをモデルに保存する部分

機械学習のプログラムはPythonまたはC++などを使って実装している場合が多いので直接的に使用する場合はシステムコールを使って使用する必要がありますが、それはセキュリティ上問題があり、あまり好まれる使い方ではありません。

この記事では機械学習で予測した結果が何らかのDBに保存されている場合を想定しています。

下記の順で説明していきます。


  • DBの設定と接続

  • Contraller からDBを叩く部分


DBの設定と接続

gorp.goでDBの初期設定と処理をしています。

func InitDB() {

db.Init()
// conf ファイルのに記載されているDB名を読み込み処理を行なう
// `revel revel_jwt_mysql run`と`revel revel_jwt_mysql run prod`で読み込むDBが異なる`prod`が本番用
uri := read_conf("db.uri")

DBの初期設定と設定が記述されているapp.confから値を読み込んでuriに設定しています。

        // mysqlへの接続処理

db_access_uri := "ユーザー名:パスワード@tcp(" + uri +")/データベース名"
db, err := sql.Open("mysql", db_access_uri)
if err != nil {
panic(err)
}
Dbm = &gorp.DbMap{Db: db, Dialect: gorp.MySQLDialect{"InnoDB", "UTF8"}}

setColumnSizes := func(t *gorp.TableMap, colSizes map[string]int) {
for col, size := range colSizes {
t.ColMap(col).MaxSize = size
}
}

db_access_uriで予め起動しておいたMySQLのユーザー名、パスワード、DB名を設定します。

DBからのORMにはgorpを使用しています。

gorpは使用したいSQLを変更したい場合は下記のSQLドライバーから選択し、下記の部分をそのドライバー用に書き換えれば柔軟にDBを変えることができます。

    _ "github.com/go-sql-driver/mysql"

Dbm = &gorp.DbMap{Db: db, Dialect: gorp.MySQLDialect{"InnoDB", "UTF8"}}

SQLドライバー一覧

        // Tableに設定する値の長さを設定している

t := Dbm.AddTable(models.Revel_JWT_MySQL{}).SetKeys(true, "Revel_JWT_MySQL_Id")
setColumnSizes(t, map[string]int{
"Date": 10,
"Title": 40,
"Picture": 140,
"Picture_Link": 140,
"Caption": 140,
})

主キーを設定している場合にその値が必ず設定されていることが確認しているのがSetKeys(true, "Revel_JWT_MySQL_Id")です。

setColumnSizesでサイズがこれ以上の値になることを禁止しています。

        Dbm.TraceOn("[gorp]", r.INFO)

Dbm.CreateTables()

DBのテーブルを作成しています。


prev, err := filepath.Abs(".")
if err != nil {
defer fmt.Println("Error")
}

// 最初に登録するcsv形式のデータ
file, err := os.Open(prev + "/csv_diary_data/revel_jwt_mysql.csv")
failOnError(err)
defer file.Close()

csvから読み込んだ値をDBに初期の値として保存するためcsvの情報を取得します。


f_count := 0
reader := csv.NewReader(file)
var revel_jwt_mysqls []*models.Revel_JWT_MySQL

// DBへの登録
for {
recoad, err := reader.Read()
if err == io.EOF {
break
} else {
failOnError(err)
}
img_data := img_path + recoad[3]
revel_jwt_mysqls = append(revel_jwt_mysqls, &models.Revel_JWT_MySQL{f_count, t_value, recoad[0], img_data,
recoad[1], recoad[2]})
f_count++
}

reader := csv.NewReader(file)でcsv読込用のreaderを作成します。

recoad, err := reader.Read()でcsvからデータを読み込みます。

下記のような形式のcsvの場合

テスト1,テスト2,テスト3

下記のように値が保持されます。

recoad[0]="テスト1"

recoad[1]="テスト2"

recoad[2]="テスト3"

revel_jwt_mysqls=append(revel_jwt_mysqls,models.Revel_JWT_MySQL{f_count,t_value,recoad[0],img_data,recoad[2],recoad[3]})で読み込んだ値を配列に保持しています。

        for _, revel_jwt_mysql := range revel_jwt_mysqls {

if err := Dbm.Insert(revel_jwt_mysql); err != nil {
panic(err)
}
}

読み込んだ値をDbm.InsertでDBに挿入していきます。


MySQLの操作して結果を表示

MySQLを操作してデータを取得、登録、更新、削除する部分の説明をします。SQLのべた書きなのでもっとイケてる方法があると思います。

JWT認証後に各処理に振り分けています。

            switch choose_parameter{

case "GET":
for _, k := range keys {
if _, ok := c.Params.Query[k]; ok {
revel.TRACE.Printf("Param OK")
no_key = false
return c.returnParameter(k, res.Revel_JWT_MySQL_Id)
}
}
if no_key {
revel.ERROR.Printf("No settings %#v", val)
return c.Forbidden("No setting.")
}
case "GET_ALL":
length, err := strconv.Atoi(c.Params.Query["Length"][0])
if err != nil {
revel.ERROR.Printf("No settings %#v", length)
return c.Forbidden("No setting length.")
}else{
return c.GetRobohonAllMethod(length)
}
case "GET_JWT":
limit, err := strconv.Atoi(c.Params.Query["Limit"][0])
if err != nil {
revel.ERROR.Printf("No settings %#v", limit)
return c.Forbidden("No setting length.")
}else{
return c.GetJWTMethod(limit)
}
case "POST":
return c.RegistRobohonMethod(res)
case "DELETE":
return c.DeleteRobohonMethod(res.Revel_JWT_MySQL_Id)
case "UPDATE" :
for _, k := range keys {
if _, ok := c.Params.Query[k]; ok {
revel.TRACE.Printf("Param OK")
no_key = false
return c.UpdateRobohonMethod(k, c.Params.Query[k][0], res.Revel_JWT_MySQL_Id, res.Date)
}
}
if no_key {
revel.ERROR.Printf("No settings %#v", val)
return c.Forbidden("No setting.")
}


データの取得

func (c Revel_JWT_MySQL) returnParameter (key string, revel_jwt_mysql_id int) revel.Result {

データの取得処理をしている部分です。パラメータをkeyで取得し、MySQLで取得するデータのidをrevel_jwt_mysql_idで取得しています。結果をrevel.Resultで返しています。


var result *models.Revel_JWT_MySQL
revel_jwt_mysqls, err := c.Txn.Select(models.Revel_JWT_MySQL{},
`select * from Revel_JWT_MySQL where Revel_JWT_MySQL_Id = ?`,
revel_jwt_mysql_id)
if err != nil {
panic(err)
return c.Forbidden("SQL Select Error")
}
if len(revel_jwt_mysql) == 0 {
return c.NotFound("Could not find")
}
result = revel_jwt_mysqls[0].(*models.Revel_JWT_MySQL)

MySQL内をselectで検索して結果を返しています。返却された値をModelで扱えるように変換して渡しています。


switch key {
case "Title":
return c.RenderJson(result.Title)
case "Picture":
return c.RenderJson(result.Picture)
case "Picture_Link":
return c.RenderJson(result.Picture_Link)
case "All":
return c.RenderJson(result)
default:
return c.Forbidden("Failed no setting Parameter.")
}
}

取得された項目からどの値を取得するか選択しています。

選択された結果をJson形式で返しています。


データの登録

エンドポイントから取得したデータを登録する処理になります。


res.Validate(c.Validation)

// Handle errors
if c.Validation.HasErrors() {
c.Validation.Keep()
c.FlashParams()
return c.Forbidden("URL parameter Validate error")
}

t_value := time.Now().Format("2006-01-02")

// Revel_JWT_MySQL_Idは主キーに指定しているため、自動で設定されるがモデルの構造上設定が必要
robohon := models.Revel_JWT_MySQL{res.Revel_JWT_MySQL_Id, t_value, res.Title, res.Picture,
res.Picture_Link}
// DB にデータを登録
if err := c.Txn.Insert(&robohon); err != nil {
panic(err)
return c.Forbidden("SQL Regist Error")
}

r := Response{robohon}
return c.RenderJson(r)

下記の処理ををしています。


  • 取得したデータのチェック

  • モデルに取得したパラメータを設定

  • モデルをDBに登録

登録された処理をJson形式で結果として返しています。


データの更新

データの更新処理です。MySQLに登録されているデータを更新します。

    switch key {

case "Title":
_, err := c.Txn.Exec("update Revel_JWT_MySQL set Title = ? where Revel_JWT_MySQL_Id = ? ",
value, robohonId)
if err != nil {
panic(err)
}
case "Picture":
_, err := c.Txn.Exec("update Revel_JWT_MySQL set Picture = ? where Revel_JWT_MySQL_Id = ?",
value, robohonId)
if err != nil {
panic(err)
}
case "Picture_Link":
_, err := c.Txn.Exec("update Revel_JWT_MySQL set Picture_Link = ? where Revel_JWT_MySQL_Id = ?",
value, robohonId)
if err != nil {
panic(err)
}
default:
return c.Forbidden("Failed no setting Parameter.")
}

show_char := "UpdateRobohon " + value
r := Response{show_char}
  return c.RenderJson(r)

主キーであるIdで更新したい情報を選びその情報を更新しています。

更新された結果をJson形式で返しています。


データの削除

データの削除処理です。

    _, err := c.Txn.Delete(&models.Revel_JWT_MySQL{Revel_JWT_MySQL_Id: robohonId})

if err != nil {
panic(err)
return c.Forbidden("SQL Deelete Error")
}

show_char := "Delete " + string(robohonId)
r := Response{show_char}
return c.RenderJson(r)

主キーのIDを使用して削除処理を行ないます。

削除された結果をJson形式で返しています。


実際に動作させる

動作環境は下記の図のようなイメージです。

Vagrantを立ち上げる前にVagrantfileにipアドレスを設定しておきます。このipアドレスは後で使用するのでメモしておきます。

Vagrantを立ち上げます。

vagrant up

そのVagrantにansibleで動作環境を設定します。

hostsに先ほど設定したVagrantのipアドレスを設定します。

ansible-playbook -i hosts site.yml

これでDocker環境が設定されたVagrantが完成します。

vagrantに入って今回のRevelの環境が設定されているコンテナをビルドして作成します。



cd /home/vagrant/go_lang/docker
make test-api

ビルド後に自動でdockerの環境に入りますがexitで一旦出ます。

exit

Docker composeでMySQL用のコンテナを立ち上げてと先ほどビルドしたイメージに入ります。

vagrant ssh

sh docker_compose.sh

テストが全て通るか試します。

revel test revel_jwt_mysql

下記で実行します。

revel run revel_jwt_mysql

そうすると下記のアドレスでアクセスできて動作を確認することができます。

http://{vagrantで設定したアドレス(デフォルトは"192.168.33.41")}/revel_jwt_mysql?Id=1&Date=2016-04-02&Title&JWTtoken=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.nK6gM51kP4klRVQoJ0w6dQLQcbQ2Jrf0TPYp9hZS_8mNZWQaHD9zq5-mIFam1xwJt_dorgBhZrfQzu7tKKbeyHmfj6TcWsSJ7T2W6mG0uFSHyAMHJRpjhFrwGB6K5dzUOvcYgw1B1L-AD6-37zWt6tXP_9Y8HVy4xL-oCR_979Y

結果

Screen Shot 2016-11-28 at 9.18.02 AM.png

もし上手くいかなければ下記のdocker hubから下記のコマンドでイメージを取得してください。

docker pull masayaresearch/go_api


まとめ

この記事を読んで機械学習のモデルを作れてサービスインできる形で提供できるエンジニアが増えると幸いです。

(機械学習について一切触れていませんが・・・)