• home
  • Go言語のWAF Beegoの紹介

Go言語のWAF Beegoの紹介

この記事はWanoアドベントカレンダーの24日目の記事です。

Go の Web Application Framework に Beego というものがあります。
以前、プロジェクトで使ったことがありますので、軽く紹介しようと思います(ver 1.9.1です。最新は、1.10.0なので、若干変わってるかもしれません)。

BeegoのWebPageを見る感じ、中国の企業に多く使われているようです。日本語の記事もちらほらあるという感じですね。
この記事を読むよりも、Qitaでbeegoのタグとか見てみると良いかもしれません。

さて、僕らのプロジェクトでBeegoを選んだ理由として(ちなみに、別のチームではecho使ってました)、

  • フルスタック
  • 実績ありそう
  • i18nに対応している
  • ドキュメントがしっかりしてる

と言ったところです。

開発メンバーが、まだGoに不慣れであるというのと、開発期間の問題もありまして、全部入りのもので実績のあるものが良いという点がありました。
また、最初から国際化が必要なサービスでしたので、i18nも必須だなというところで、全部入ってそうな Beegoを利用した感じです。

簡単なアプリケーションの作り方は、割と出てくるので、もう少し、細かい話を以下に適当にピックアップしてみます。

設定ファイル

各ファーマット選べますが、iniでやりました。

[dev]
...
[prod]
...
[test]
...

のように書くことで、RunMode(BEEGO_RUNMODEの値)によって読み先が変わります。

ただ、環境毎に必要なファイルを読み込むようなことはしないので、セクション毎にきれいに分ける…てのは向いておらず、必然、設定名が長くなりがちでファイルが見通しが悪いような気はします。
設定のキーは、内部的には、lower case で持っています。※2019/02/04追記: 1.10では、BEEGO_RUNMODE=prod であれば、prod.app.conf を読みにいくようになっています。1.9 では、たぶん誤って BEEGO_MODE という環境変数で読み分けが実装されていたのですが、1.10で fix されています。1.9 だと、BEEGO_RUNMODEでは、ファイルは同じファイルを読むけど、セクションを読み分けるという謎な感じだったということですね。
※なお、BEEGO_RUNMODEのことをBEE_ENVと誤って書いてしまっていました。すみません。

セッション

設定ファイルに以下のように書きます。

SessionGcMaxLifeTime     = 2592000
SessionName              = "session"
SessionOn                = true
SessionProvider          = "mysql"

他にも、以下のような設定があるようです。

  • SessionCookieLifeTime … session cookieでも有効期限を作ることが出来るようです
  • EnableSidInHttpHeader … セッションIDをヘッダに含める(なんか意味あるのかな)
  • SessionNameInHttpHeader … ↑の際の名前
  • EnableSidInUrlQuery … URLにセッションIDを含める(やらないほうが良いが)

キャッシュ

var beegoCache cache.Cache
func init() {
    var err error
    beegoCache, err = cache.NewCache("memory", `{"interval":60}`)
    if err != nil {
        log.Fatal(err)
    }
}

とかやっておくと、

data := beegoCache.Get(key)
beegoCache.Put(key, data, timeout)

のように使えます。

memory 以外にも、file, redis, memcached なども使えます。
https://github.com/beego/beedoc/blob/master/en-US/module/cache.md

i18n

国際化は、下記に書かれている通り、やればOK。
https://beego.me/docs/module/i18n.md

コピペで書かないといけないところが多くて面倒でしたが、無いのに比べれば助かりますね。

下記のような関数をFuncMapに登録しておけば、{{ l . "何かしらの日本語" 引数}} が langに従って翻訳されるようにできます。

func l(dot map[interface{}]interface{}, format string, args ...interface{}) string {
    lang, _ := dot["Lang"].(string)
    return i18n.Tr(lang, format, args)
}

翻訳ファイルは、iniファイルで、下記のように。

よくあるご質問="Frequency asked question"
%dポイント="%d point"

なお、Trに渡している format ですが、. が入っていると、セクションとフォーマットに分かれてしまいます。iniファイルで、

[mypage]
日本語=英語

のように書いていれば、

i18n.Tr("en-US", "mypage.日本語")

のように渡せるようですが、めんどくさかったので、formatの前に、. を無理矢理つけました。

func l(dot map[interface{}]interface{}, format string, args ...interface{}) string {
    lang, _ := dot["Lang"].(string)
    format = "." + format // .を先頭に付けてセクションなしにする
    return i18n.Tr(lang, format, args)
}

この場合、空のセクション(=セクションなし)となります。

ルーティング

  • 正規表現使えます
  • パスに対してフィルターもかけられる
  • admin 以下は、みたいなグルーピング(namespace)が切れる

基本の書き方

    // RootController の Index メソッドが呼ばれます
    beego.Router("/", &controllers.RootController{}, "get:Index")
        // RootController の Qaメソッドが呼ばれます
    beego.Router("/qa", &controllers.RootController{}, "get:Qa")
    // 正規表現も書けます
    beego.Router("/oauth/:sns(facebook|line|google)", &controllers.OauthController{}, "get:Sns")

フィルター

        // 全体に対して、RunModeをテンプレートに渡すとか
    beego.InsertFilter("*", beego.BeforeRouter, runModeFilter)
        // admin 以下は、loginが必要だとか
    beego.InsertFilter("/admin/*", beego.BeforeRouter, loginFilter)

AfterRouter もありますが、こちらは、そのまま、ルーティングの後に呼ばれます。

なぜか、フィルターを使うのに、sessionを有効にしろと書いている

beego.BConfig.WebConfig.Session.SessionOn = true
// 設定ファイルに、SessionOn = true と同じです。

謎。

ネームスペース

/admin/login, /admin/users とか、パスを全部書くのだるいとか言う時に使えます。

    ns := beego.NewNamespace("/admin",
        beego.NSRouter("/login", &controllers.LoginController{}, "get:Index;post:Login"),
                // 略

                // さらに切れます(/admin/users)
        beego.NSNamespace("/users",
            beego.NSRouter("/", &controllers.AdminUserController{}, "get:Index"),
                        // 略
                ),
        )
    beego.AddNamespace(ns)

エラーを返す

c.Abort("コード")

で、エラーを返せますが、なんで、文字列なのか(内部的にAtoi使って変換しているのに)。レスポンスボディも”コード”になります。

変更したい場合は、

c.CustomAbort(status, body)

を使うと良いです。ここの status はintです。

ちなみに、Abortに渡すコードが数字じゃない場合(strconv.Atoiでエラーになる場合)、200を返します。

// Abort stops controller handler and show the error data if code is defined in ErrorMap or code string.
func (c *Controller) Abort(code string) {
    status, err := strconv.Atoi(code)
    if err != nil {
        status = 200
    }
    c.CustomAbort(status, code)
}

なんでやねん。

OR Mapper

ORMもあります。

    orm.RegisterDriver("mysql", orm.DRMySQL)
    orm.RegisterDataBase("default", "mysql", "root:root@/orm_test?charset=utf8")

default というのは、driver(mysql)で、この設定を使うものを”default”と名付けていると思えばよいかと。

  user := User{Id: id, IsLoggedIn: true}
  err := orm.Read(user)

とか。

  qb := orm.NewQueryBuilder("mysql")
  qb.Select("*").From("user").
                 Where("id = ?").
                 And("is_logged_in = ?").
                 OrderBy("id")

※順番重要
とか、

  user := new(User)
  orm.QueryTable("user").Fitler("id", id).
                         Filter("is_logged_in", true).
                         One(user)

とかもある。

複雑な条件の場合は

  user := new(User)
  orCond := orm.NewCondition().
            Or("age", 20).
        Or("height", 170)
  cond := orm.NewCondition().
      AndCond(orCond)
  err = o.QueryTableAll("user").
    SetCond(cond).
    Filter("id__in", userIds).
    One(user)

みたいなこともできます。

下記のようにすれば、sql/db を生で使えます。

    db, err := orm.GetDB("default")

QueryTableの時は、joinする際は、RelatedSel() とか呼ぶとよいです。
QueryBuilderの時は、InnerJoin()とか自分で書けばよいです。

テンプレート

テンプレートファイルの場所

デフォルトだと、コントローラのメソッド名ベースでファイルの場所が決まります。
AddController の Post メソッドであれば、下記のようなパスになります。

 /viewpath/addcontroller/post.tpl

HTMLコーダーと連携する際に、ここのページのメソッド名は…などと説明するのもナンセンスなので、
下記のようにして、URLのパスでテンプレートの場所がわかるようにしました。

func (c *Controller) CreateTplPath(paths ...string) string {
    var path string
    cn := paths[0]
    cn = regexp.MustCompile("Controller$").ReplaceAllString(cn, "")
    path = regexp.MustCompile("^Root").ReplaceAllString(cn, "")
    if len(cn) > 1 {
        an := paths[1]
        path = path + "/" + an
    }
    path = regexp.MustCompile("([A-Z])").ReplaceAllString(path, "/$1")
    path = regexp.MustCompile("//").ReplaceAllString(path, "/")
    path = regexp.MustCompile("^/").ReplaceAllString(path, "")
    return strings.ToLower(path)
}

func (c *Controller) SetTplName(args ...string) {
    var path string
    if len(args) == 0 {
        path = c.CreateTplPath(c.controllerName, c.actionName) + ".tpl"
    } else {
        path = c.CreateTplPath(c.controllerName, args[0])
    }
    c.TplName = path
}

レイアウト

共通のレイアウトに関しては別のファイルに書いて、特定のパス用のファイルには、それらは含まないといったことができます。

this.layout = "site/layout.tpl"

のようにしてあげれば、”site/layout.tpl” が共通で使われます。layout.tpl 内で、

{{.LayoutContent}}

と書いた場所に特定の内容が展開されます。

楽ですね。

ですが、LayoutContent用のテンプレートから、layout.tpl を制御することはできないため、コーダーがちょっとした制御を共通処理で行いたいといった場合や、titleに各ページ毎に特定の内容を入れたいといった場合には不向きです。

解決法というわけではありませんが、設定ファイルにパスと設定したい情報を書き、表示しようとしているパスとマッチした場合に、それらの情報をviewに渡すという感じでやりました。
微妙ではありますが、場所的(パスを割り出しているところなので)に、上述の、SetTplName で行いました。

func (c *Controller) SetTplName(args ...string) {
    var path string
    if len(args) == 0 {
        path = c.CreateTplPath(c.controllerName, c.actionName) + ".tpl"
    } else {
        path = c.CreateTplPath(c.controllerName, args[0])
    }
        // GetPageMetaData で 設定ファイルから各種変数を読み取ってセットした
    if pageMetaSet[path] == nil {
        pageMetaSet[path] = c.GetPageMetaData(path)
    }
    c.Ctx.Input.Data()["meta"] = pageMetaSet[path]
    c.TplName = path
}

pageMtaData[path] の構造体は、こんな感じです。

type PageMetaData struct {
    Title       string
    Description string
    Keyword     []string
    Breadcrumbs bool
    Header      bool
    Footer      bool
}

これで、設定ファイルに

[meta/index]
title="インデックスページ"
description="何かの説明"
keyword=キーワード1;キーワード2

とかやると、そのページにメタ情報を渡せるといった風に出来ます。
(ちなみに、配列にしたい場合の区切りは、上記の通り、”;”です)

紹介終わり

時間が来たので(過ぎてしまったけど)、ここで終わりますが、特にまとめはありません。
微妙なところはあるものの、全部揃ってて便利っちゃ便利かなぁという感じです。
今後も使っていくかもしれないし、使わないかもしれない。