LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


line-bot-sdk-go での Flex Message

この記事はLINE Engineering Blog 「夏休みの自由研究 -Summer Homework-」の10日目の記事です。

京都開発室の @sugyan です。京都の夏を初体験していますが、いやー凄まじい暑さですね。

さて、2018年6月、LINE Messaging APIで利用できるMessageタイプとして「Flex Message」が新たに加わり、複雑なレイアウトも可能なカスタマイズ性の高いメッセージを送信することができるようになりました。

公式SDKでも早速Flex Messageをサポートをしていこう、と実装を始めました。

今回はGo SDK担当としてFlex Messageのための機能実装で得た知見などを書いていこうと思います。

Go SDKでのFlex Messageの使い方

送信されてきたメッセージに対して Flex Message を使った返信を行う場合、以下のようなコードになります。

import "github.com/line/line-bot-sdk-go/linebot"

func reply(replyToken string) error {
    container := &linebot.BubbleContainer{
        Type: linebot.FlexContainerTypeBubble,
        Body: &linebot.BoxComponent{
            Type:   linebot.FlexComponentTypeBox,
            Layout: linebot.FlexBoxLayoutTypeHorizontal,
            Contents: []linebot.FlexComponent{
                &linebot.TextComponent{
                    Type: linebot.FlexComponentTypeText,
                    Text: "Hello,",
                },
                &linebot.TextComponent{
                    Type: linebot.FlexComponentTypeText,
                    Text: "World!",
                },
            },
        },
    }
    if _, err := client.ReplyMessage(
        replyToken,
        linebot.NewFlexMessage("alt text", container),
    ).Do(); err != nil {
        return err
    }
    return nil
}

*linebot.ClientReplyMessage(replyToken string, messages ...Message) メソッドには Message interface を実装したオジェクトを渡すようになっています。
Flex Message を送信する場合は func NewFlexMessage(altText string, container FlexContainer) 関数で作成される *FlexMessageMessage interface を実装したオブジェクトとなります。

NewFlexMessage() 関数に渡す2つ目の引数は同様に FlexContainer interface を実装したオブジェクトを渡すようになっており、ここでは *BubbleContainer もしくは *CarouselContainer がそれを実装していますので、どちらかを渡すことになります。上記例では *BubbleContainer を使っていますね。

いちいちすべての struct を記述するのも面倒なので、所謂Builderを利用する仕組みを用意してみようかとも考えましたが、結局メリットは Type 要素を省略できる程度であまり便利になりそうにもなく、SDKのメンテナンスの負担が増えるだけだと判断し、敢えて採用しませんでした。

その代わりに、より実用的に使用できる方法として、JSON文字列から FlexMessage オブジェクトを生成できる関数 func UnmarshalFlexMessageJSON([]byte) を用意しました。

Simulatorから生成されたJSONを利用する

実際には FlexMessage は特定のJSONデータをリクエストのbodyに含めてPOSTするようになっています。そのJSONと描画結果を確認するためのツールとしてSimulatorが提供されています。

例えば "Restaurant" というサンプルメッセージから「作成する」を選択すると、以下のようなものが生成されます。左に描画結果が表示されていて、右側のJSONを編集して「プレビュー」ボタンを押すと変更が描画結果に反映されます。

このSimulatorで正しく描画できたJSONは、そのままリクエストのbodyでPOSTできるvalidなデータとなります。SDKとしてもこういったJSONデータをそのまま FlexMessage として送信できるようにしたいところですが、それはつまり []byte (もしくは string) を引数で渡せるようにするしかなく、それではデタラメでinvalidなJSONデータ(そもそもJSONとして正しくないものですら)渡せるようになってしまいます。

なので、SDKでは JSONデータを受け取り FlexContainer interface を実装したオブジェクトに変換する UnmarshalFlexMessageJSON([]byte) 関数を用意しました。
これを利用することで、プログラムは正しいJSONデータだけを受け取り、前述の例と同じ仕組みで FlexMessage を送信できるようになります。

    jsonData := []byte(`{
  "type": "bubble",

  ...

}`)
    container, err := linebot.UnmarshalFlexMessageJSON(jsonData)
    if err != nil {
        // 正しくUnmarshalできないinvalidなJSONであればerrが返る
        ...
    }
    message := linebot.NewFlexMessage("alt text", container)

ひと手間かかってはしまいますが、Simulatorで確認したJSONをそのままコピーして利用することができます。

UnmarshalJSON するために

さて、その UnmarshalFlexMessageJSON([]byte) の実装です。

基本的には json.Unmarshal を使ってJSONデータを Unmarshal してそれぞれの型にマッピングしていくのですが、それほど単純ではありません。
何故ならマッピングすべき型が決まっていないからです。まず FlexContainer interface は上述したように *BubbleContainer もしくは *CarouselContainer のどちらかのオブジェクトですし、その中に含まれる header, body, footer*BoxComponent 要素がそれぞれ持つ contents 要素はまた *BoxComponent*ButtonComponent など、 FlexComponent interface として定義したものを実装したオブジェクトの配列となります。
また さらに、幾つかの FlexComponent が含む action 要素は Template Message などでも利用しているオブジェクトで、これも TemplateAction interface を実装している複数のタイプのものが存在します。

ただし、JSON内の各オブジェクトにはそれぞれ必ず type 要素が含まれており、どの型のデータであるかは示されています。

{
  ...

    "contents": [
      {
        "type": "icon",
        "size": "sm",
        "url": "https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gray_star_28.png"
      },
      {
        "type": "text",
        "text": "4.0",
        "size": "sm",
        "color": "#999999",
        "margin": "md",
        "flex": 0
      }
    ],

  ...
}

type 要素を見れば、contents の内容の1つ目は sizeurl を含む *IconComponent 型、2つ目は colormargin などを含む *TextComponent 型にUnmarshalしていけば良い、ということが分かるわけようになっています。

Unmarshal用の type を使う

とはいえ json.Unmarshal は先にUnmarshalする先の型が決まっていないと呼び出すことができません。
しかし type の中身を読まないとUnmarshalすべき型が分からないので、呼び出す前にそれを定義することが出来ません。

ですので、SDKではUnmarshal用の別typeを用意し、その中でまずは type だけ先に読み取り、それから改めて型を決定してUnmarshalする、という方法を取りました。

例えば、 *FlexContainer オブジェクトに変換する部分のコードは以下のような実装になっています。

func UnmarshalFlexMessageJSON(data []byte) (FlexContainer, error) {
    raw := rawFlexContainer{}
    if err := json.Unmarshal(data, &raw); err != nil {
        return nil, err
    }
    return raw.Container, nil
}

type rawFlexContainer struct {
    Type      FlexContainerType `json:"type"`
    Container FlexContainer     `json:"-"`
}

func (c *rawFlexContainer) UnmarshalJSON(data []byte) error {
    type alias rawFlexContainer
    raw := alias{}
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
    var container FlexContainer
    switch raw.Type {
    case FlexContainerTypeBubble:
        container = &BubbleContainer{}
    case FlexContainerTypeCarousel:
        container = &CarouselContainer{}
    default:
        return errors.New("invalid container type")
    }
    if err := json.Unmarshal(data, container); err != nil {
        return err
    }
    c.Type = raw.Type
    c.Container = container
    return nil
}

Unmarshalだけのために使う rawFlexContainer という型がポイントです。
これは UnmarshalJSON([]byte) を実装しているので、 json.Unmarshal 関数でこの型のオブジェクトを引数に指定されていると、この UnmarshalJSON が呼ばれることになります。

alias type

この func (c *rawFlexContainer) UnmarshalJSON([]byte) 中の1行目で type alias rawFlexContainer として「同じ内容ではあるが別名の型」を定義します。
この alias 型は rawFlexContainer とは異なるので json.Unmarshal 関数の引数で指定されても (*rawFlexContainer) UnmarshalJSON を呼ばれることはありません。

`json` tag を見ていただくと分かる通り、この alias 型のオブジェクトへの json.Unmarshaltype 要素しかUnmarshalしません。
しかし、それによってマッピングすべき型を判別できるので、その type の値に応じた型のオブジェクトを用意し、改めて 渡されてきたデータを目的の型へ json.Unmarshal で変換することができます。

こうして 内部的には渡されたデータを 2回 json.Unmarshal にかける形になるのですが、正しくマッピングすべき型を取得しながらUnmarshalしていくことができるわけですね。

FlexComponent interfaceのオブジェクトへのUnmarshalも同様に、 rawFlexComponent という型を用意し、 alias type を利用してマッピングすべき型のオブジェクトを用意して処理します。

type rawFlexComponent struct {
    Type      FlexComponentType `json:"type"`
    Component FlexComponent     `json:"-"`
}

func (c *rawFlexComponent) UnmarshalJSON(data []byte) error {
    type alias rawFlexComponent
    raw := alias{}
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
    var component FlexComponent
    switch raw.Type {
    case FlexComponentTypeBox:
        component = &BoxComponent{}
    case FlexComponentTypeButton:
        component = &ButtonComponent{}
    case FlexComponentTypeFiller:
        component = &FillerComponent{}
    case FlexComponentTypeIcon:
        component = &IconComponent{}
    case FlexComponentTypeImage:
        component = &ImageComponent{}
    case FlexComponentTypeSeparator:
        component = &SeparatorComponent{}
    case FlexComponentTypeSpacer:
        component = &SpacerComponent{}
    case FlexComponentTypeText:
        component = &TextComponent{}
    default:
        return errors.New("invalid flex component type")
    }
    if err := json.Unmarshal(data, component); err != nil {
        return err
    }
    c.Type = raw.Type
    c.Component = component
    return nil
}

これを利用し、Contents []FlexComponent 要素を持つ *BoxComponent をUnmarshalする処理は以下のようになります。

type rawFlexComponent struct {
    Type      FlexComponentType `json:"type"`
    Component FlexComponent     `json:"-"`
}

func (c *BoxComponent) UnmarshalJSON(data []byte) error {
    type alias BoxComponent
    raw := struct {
        Contents []rawFlexComponent `json:"contents"`
        *alias
    }{
        alias: (*alias)(c),
    }
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
    components := make([]FlexComponent, len(raw.Contents))
    for i, content := range raw.Contents {
        components[i] = content.Component
    }
    c.Contents = components
    return nil
}

alias からさらに Contents の部分だけ上書きした別のstructを利用することで、Contents 要素以外の値をそのまま呼び出し元の *BoxComponent の値としてUnmarshalできます。
Contents 要素だけは上書きされて rawFlexComponent のスライスとしてUnmarshalされているので、それぞれその中身のComponentを取り出すことで、すべてのUnmarshal済みのものを取得することができます。

reflectを使用する場合

余談ですが、得られた type 要素から switch 文を使ってマッピングすべき型のオブジェクトを定義して、というところはちょっとカッコ悪い感じがしますね。

reflect を使うと、あらかじめ作るべきオブジェクトの型を値として用意しておき、その型のオブジェクトを動的に作って利用することができます。
例えば rawFlexContainer の場合、型名と reflect.Type のマッピングを用意して、以下のように書き換えることが可能です。

import "reflect"

var containerTypeMap = map[FlexContainerType]reflect.Type{
    FlexContainerTypeBubble:   reflect.TypeOf(BubbleContainer{}),
    FlexContainerTypeCarousel: reflect.TypeOf(CarouselContainer{}),
}

func (c *rawFlexContainer) UnmarshalJSON(data []byte) error {
    type alias rawFlexContainer
    raw := alias{}
    if err := json.Unmarshal(data, &raw); err != nil {
        return err
    }
    container := reflect.New(containerTypeMap[raw.Type]).Interface()
    if err := json.Unmarshal(data, container); err != nil {
        return err
    }
    if flexContainer, ok := container.(FlexContainer); ok {
        c.Container = flexContainer
    }
    return nil
}

switch 文が消えて幾分スッキリしますが、今回SDKではこの方法は敢えて採用しませんでした。
reflect は便利ですが使い方を誤ると黒魔術的なものになりやすく、メンテナンス性も下がる可能性があるからです。
reflect を使わずに素朴な switch 文などで目的の動作を達成できるならわざわざ導入する必要はなく、どうしても refrect を使わないと実現できない機能があるという場面でもない限りは基本的に使わないようにしよう、という判断です。

まとめ

Messaging APIのGo SDKでのFlex Messageの実装について紹介しました。
型付き言語であるGoで 様々な型の可能性を持つJSONデータを扱うのは多少大変ですが、少し工夫すれば問題なく実現できることが示せたかと思います。

(本記事は2018年8月現在でのものです。より良い方法などがありましたら皆様からのPull-Requestをお待ちしています。)

Special thanks to @mokejp!

明日は Lee Jihoon による「Best practices to secure your SSL/TLS Implementation」です。お楽しみに!