grpcでバラバラなslack botが1つになる

この記事は、LINE Engineering Blog 「夏休みの自由研究 -Summer Homework-」 の8日目の記事です。
はじめまして。LINEのIT戦略室という部署で社内システムの開発・運用を担当しております、suzuki-shunsukeです。

最近複数のSlack Botをgrpcを使って1つのBotに統合している話を書こうと思います。

経緯

自分は社内のSlackを管理するチームに所属し、そこでSlack Botを幾つか作成し、運用していますが、それらのBotを1つに統合してほしいという要望がありました。理由としては

  • Botを幾つも招待するのが面倒
  • どんなBotがいるのか把握できないので、1つにしてほしい
  • どんどんBotを追加していくより、1つのBotの機能を追加していくほうが、よりユーザーがBotに愛着がもてる

というものでした。

どうなるのか

例えば、社内のメーリングリストに所属するユーザーを招待できる @spinviteというBotがそれぞれいるとしましょう。

# チャンネル名を foo に変更
@renamechan foo
# foo@example.com に所属するユーザーを招待
@spinvite foo@example.com

これがこのように1つのBot @satosan に統合されます。

# チャンネル名を foo に変更
@satosan rename foo
# foo@example.com に所属するユーザーを招待
@satosan invite foo@example.com

その他のBotも同じ要領でどんどんしていきます。

Bot統合の課題

実はBotを1つにするという構想はBot開発当初からありました(リポジトリもありました)。しかし、

  1. モノリシックになると開発しづらくなる
  2. 複数人で開発する場合、言語やフレームワークの統一が難しい
  3. トークンの権限がどんどん強くなるのが気になる

といった理由で見送っていました。

そこで、今回はgrpcを使って1, 2の問題をクリアしようとしています(なお、3の問題はクリアできません)。なお、ここではgrpcの説明は割愛します。

アーキテクチャ

Botの各機能をgrpcサーバとして実装し、1つのSlack BotをRTM APIで起動し、ユーザーのリクエストに応じてgrpcサーバにリクエストを送ります。つまり、Slack Botはgprcクライアントになります。
各grpcサーバにはSlack Botのトークンを配布し、そのトークンを使ってgrpcサーバは内部でSlack APIをコールします。

こうして機能ごとに別のシステムとして開発することでモノリシックにならず、言語やフレームワークも自由に選択できます。

基本的にBot側は出来るだけgrpcサーバにリクエストをするだけで薄い実装にするように心がけます。
ただし、メッセージの投稿を完全にgrpcサーバに任せてしまうと、メッセージのフォーマットが機能ごとにバラバラになってしまい、
ユーザーからすると良くないかもしれません。
また、メッセージを投稿する場合grpcサーバ側だとchat.postMessage APIを使うことになりますが、Bot側ならwebsocketで投稿が可能です。

https://api.slack.com/rtm#sending_messages

websocketで返せばrate limitや権限の問題で投稿できないということはありませんので、Bot側で返したほうが良いかもしれません。
このへんは今後検討の余地があるかと思います。

トークンを分けるとどうなる?

各grpcサーバごとにSlack Appを作成しトークンを生成するとトークンごとの権限を必要最低限に出来ますし、トークンの配布などをする必要もなくなりますが、ユーザーが招待したBotとは別のBotとしてメッセージを投稿したりすることになりますし、private channelへ投稿したい場合、Botをチャンネルに招待しないといけないので1つのBotに統合した意味がなくなります。

なぜgrpcか

REST APIのようなものでなく、grpcを選んだ理由は以下になります。

  • Protocol Bufferという統一的なフォーマットでAPIを記述できる
    • 機能ごとにAPIのドキュメントの品質やフォーマットに差が出るということがない
  • Client, Server双方のコードをProtocol Bufferを元に自動生成できる
    • APIの仕様に変更にも対応しやすい

具体的な実装の話

ここからはより具体的な実装の話をしようと思います。
言語はGolangで実装します。サンプルコードではエラー処理を省略しています。

今回はチャンネル名を変更するだけの簡単な機能をgrpcサーバとして実装します。

slack-bot-satosan/  # Bot。grpcクライアント
slack-bot-grpc-renamechan/  # チャンネルをリネームするgrpcサーバ
  renamechan.proto  # Protocol Buffer
  protobuf/
    renamechan.pb.go  # 自動生成されたコード

Protocol Buffer からコードの生成

https://github.com/google/protobuf/releases/latest から最新のprotocのzipをダウンロードし、protocのバイナリをPATHの通っているディレクトリに配置し、インストールしてください。

次に次のようにAPIの仕様をProtocol Bufferで記述します。

syntax = "proto3";

option go_package = "protobuf";

service Renamechan {
  rpc Rename (RenameMessage) returns (RenameResponse) {}
  rpc Help (HelpMessage) returns (HelpResponse) {}
}

message RenameMessage {
  string channel_id = 1;
  string bot_name = 2;
  repeated string args = 3;
}

message RenameResponse {
  string message = 1;
}

message HelpMessage {
  string channel_id = 1;
  string bot_name = 2;
}

message HelpResponse {
  string message = 1;
}

そして次のようなMakefileを用意します。

protobuf/renamechan.pb.go: renamechan.proto
    protoc renamechan.proto --go_out=plugins=grpc:protobuf
$ make protobuf/renamechan.pb.go

そうするとprotobuf/renamechan.pb.goが生成されているのが分かります。

grpcサーバ側ではRenamechanServerというinterfaceを実装します。

// RenamechanServer is the server API for Renamechan service.
type RenamechanServer interface {
    Rename(context.Context, *RenameMessage) (*RenameResponse, error)
    Help(context.Context, *HelpMessage) (*HelpResponse, error)
}

grpcサーバの実装

以下のようなmain関数を書いてgrpcサーバを起動します。handlerという別パッケージで上記のinterfaceを実装します。

https://grpc.io/docs/tutorials/basic/go.html#starting-the-server

func main() {
    lis, _ := net.Listen("tcp", ":5000")
    s := grpc.NewServer()
    server := handler.Server{
        Bot: slack.New(config.GetSlackAppUserToken()),
    }
    pb.RegisterRenamechanServer(s, &server)
    reflection.Register(s)
    s.Serve(lis)
}

grpcのステータスコード

404や500のようなHTTPのステータスコードと同様に、grpcでもステータスコードが定義されています。
例えばシステムのエラーならInternalを返し、ユーザーが入力したパラメータが不正ならInvalidArgumentを返せば良いと思います。

return nil, status.Error(codes.InvalidArgument, "channel name is required")

grpcクライアントの実装

https://grpc.io/docs/tutorials/basic/go.html#creating-the-client

conn, _ := infra.GetGRPCConnOfRenamechan()
c := renamePB.NewRenamechanClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err = c.Rename(
    ctx, &renamePB.RenameMessage{
        ChannelId: ev.Channel,
        BotName:   botName,
        Args:      args})

ステータスコードの取得

grpcクライアントが返してきたerrorオブジェクトをstatus.Codeに渡すとステータスコードが取得できます。

_, err = c.Rename(
    ctx, &renamePB.RenameMessage{
        ChannelId: ev.Channel,
        BotName:   botName,
        Args:      args})
code := status.Code(err)

例えばgrpcサーバがダウンしている場合などはcodes.Unavailableが返ってくるので、クライアント側でユーザーに適切なエラーメッセージを表示するようにします。

コネクションの管理

Best practices for reusing connections, concurrency

コネクションの管理ですが、grpc.ClientConnをシングルトンパターンで使いまわしています(下のサンプルは実際のコードとは結構違います)。本当にこのやり方が正しいのかよく分かっていません。

var (
    conn *grpc.ClientConn
)

func getGRPCConn(addr string) (*grpc.ClientConn, error) {
    if conn != nil {
        return conn, nil
    }
    c, _ := grpc.Dial(
        config.GetRenamechanAddress(), grpc.WithInsecure())
    conn = c
    return c, nil
}

コネクションを毎回Closeせずに使い回す場合でも、アプリケーション終了時にはCloseしないといけません。なのでmain関数でdeferで全コネクションをCloseしています。

// 全コネクションをclose
defer infra.CloseGRPCConn()

SSL/TLS化

https://grpc.io/docs/guides/auth.html#with-server-authentication-ssltls

SSL証明書を用意してgrpcクライアントとサーバにインストールします。

google.golang.org/grpc/credentials をimportし、以下のように修正します。

クライアント側

// https://godoc.org/google.golang.org/grpc/credentials#NewClientTLSFromFile
creds, _ := credentials.NewClientTLSFromFile(certFile, serverNameOverride)
c, _ := grpc.Dial(
    config.GetRenamechanAddress(), grpc.WithTransportCredentials(creds))

サーバ側

// https://godoc.org/google.golang.org/grpc/credentials#NewServerTLSFromFile
creds, _ := credentials.NewServerTLSFromFile(certFile, keyFile)
s := grpc.NewServer(grpc.Creds(creds))

最後に

今回は多機能なSlack Botをいかに効率よく開発するかという問題に対し、grpcによって機能ごとにマイクロサービス化するというアプローチを取ってみました。

今後既存のBotの機能をgrpcサーバとして実装し、どんどんBotを統合していきたいと思っています。

明日はHokuto KagayaさんとAkira Iwayaさんによる「サーバサイド Kotlin の可能性 〜Clova Skill Award挑戦とゲームプラットフォーム開発の事例から〜」です。お楽しみに!

Related Post