はじめに
gRPC の Stream API を触ってみたかったので、 Slack の投稿を Server streaming で配信する Go サーバーと、それを受信する iOS アプリを実装しました。
実装する中で得た知識をまとめます。
ちなみに、受け取った文字を表示するだけでなく、 AVSpeechSynthesizer を使って読み上げるようにしてみたところ、Slack のやり取りを目ではなく耳で追うことができ、新鮮な感覚を味わうことができました。
日々 Slack の流れを追うのに疲れている人は試してみてください。
gRPC サーバー(Go)
事前準備(grpc-go)
に従って gRPC を利用する準備をします。
proto ファイルの用意
プロジェクトのルートに proto
ディレクトリを作成し proto ファイルを配置します。
.
├── proto
│ ├── comment.proto
Server streaming を使うため、レスポンスにだけ stream
指定をつけた RPC を定義します。
syntax = "proto3";
package comment;
service CommentService {
rpc GetComment(Filter) returns (stream Comment) {}
}
message Filter {
string query = 1; // 定義はしたものの今回は特に使ってない
}
message Comment {
string type = 1;
string user = 2;
string text = 3;
string timestamp = 4;
string channel = 5;
}
protoc
コマンドのオプション指定が煩雑なので、シェルを作っておきます。
cat <<EOF > protoc.sh
#!/bin/bash
set -eu
protoc -I proto proto/*.proto --go_out=plugins=grpc:proto
EOF
chmod +x protoc.sh
以下のコマンドで proto
ディレクト内に Go のソースが生成されます
./protoc.sh
サーバーの実装
RouteGuide のサンプルをベースに実装しました。
ポイントを以下にまとめます。
認証
gRPC の認証についてのドキュメントをみると、SSL通信のための ChannelCredentials
と RPC ごとのクライアント認証のための CallCredentials
、そしてそれらを組み合わせた CompositeChannelCredentials
が出てきます。
grpc-go
ではこれらに対応する interface が定義されています。
gRPC ドキュメント | grpc-go |
---|---|
ChannelCredentials | TransportCredentials |
CallCredentials | PerRPCCredentials |
CompositeChannelCredentials | Bundle(※) |
(※)Bundle については TransportCredentials
と PerRPCCredential
を組み合わせるだけでなく、独自に定義したモードによるクレデンシャルポリシーの切り替えも念頭に置かれているようです(参考: GCPのBundleの切り替え実装)。単に組み合わせるだけであればダイヤル時に 2 種類のクレデンシャルをそれぞれ指定したり、 RPC 実行ごとに PerRPCCredentials
を指定することができます。
今回はクレデンシャルの組み合わせを試すため、サーバーオプションとして TransportCredentials
と grpc_auth
ミドルウェアを指定します。
参考: go-grpc-middlewareを一通り試してみる
func main() {
// ...省略
var opts []grpc.ServerOption
if *tls {
if *certFile == "" {
*certFile = testdata.Path("server1.pem")
}
if *keyFile == "" {
*keyFile = testdata.Path("server1.key")
}
creds, err := credentials.NewServerTLSFromFile(*certFile, *keyFile)
if err != nil {
log.Fatalf("failed to generate credentials %v", err)
}
opts = []grpc.ServerOption{grpc.Creds(creds)}
}
opts = append(opts,
grpc.StreamInterceptor(grpc_auth.StreamServerInterceptor(authFunc)),
)
grpcServer := grpc.NewServer(opts...)
// ...省略
}
func authFunc(ctx context.Context) (context.Context, error) {
received, err := grpc_auth.AuthFromMD(ctx, "bearer")
if err != nil {
return nil, err
}
if received != *token {
return nil, status.Errorf(codes.Unauthenticated, "invalid token")
}
newCtx := context.WithValue(ctx, "result", "ok")
return newCtx, nil
}
authFunc
では、固定のキー result
と固定のバリュー ok
を詰めたコンテキストを返していますが、本来は認証したユーザーの情報を詰めることが多いと思います。
認証テスト用に Go でクライアント用の Bundle クレデンシャルを実装してみます。
TransportCredentials
, PerRPCCredentials
など、それぞれのクレデンシャルの配列を返す関数を実装する必要があります。
また PerRPCCredentials も用意する必要があり、こちらはトークンを含んだメタデータを返す GetRequestMetadata
関数を実装します。
type (
creds struct {
transportCreds credentials.TransportCredentials
perRPCCreds credentials.PerRPCCredentials
}
tokenCreds struct {
token string
}
)
func newCredentials(
token string,
caFile string,
serverHostOverride string,
) (
credentials.Bundle,
error,
) {
sslCred, err := credentials.NewClientTLSFromFile(caFile, serverHostOverride)
if err != nil {
return nil, fmt.Errorf("failed to new ssl credentials: %w", err)
}
return &creds{
transportCreds: sslCred,
perRPCCreds: &tokenCreds{token: token},
}, nil
}
func (c *creds) TransportCredentials() credentials.TransportCredentials {
return c.transportCreds
}
func (c *creds) PerRPCCredentials() credentials.PerRPCCredentials {
return c.perRPCCreds
}
func (c *creds) NewWithMode(mode string) (credentials.Bundle, error) {
return c, nil
}
func (t *tokenCreds) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
return map[string]string{
"authorization": "bearer " + t.token,
}, nil
}
func (*tokenCreds) RequireTransportSecurity() bool {
return true
}
クレデンシャル以外にも独自に情報を付加したい場合は、メタデータを利用します。
Metadata のドキュメントやE2E テストの実装が参考になりそうです。
Slack の Event を受信
の通りに利用します。
特筆すべきことはそんなにないです。Event のパースが面倒だとぼやいたら同僚から、ライブラリあるでしょ、と指摘されたので slack-go を使いました。
また、テストでは ngrok 使いましたが、コネクション数の制限があるので Slack が活発な時間帯だと割とすぐに制限にかかりました...
複数クライアント対応
サーバーの RPC のハンドラーではリクエストを受けるたびにクライアント用のチャネルを生成し、接続が切れるまでメッセージを取得 & クライアントへ送信し続けるようにします。
func (s *CommentServer) GetComment(req *pb.Filter, stream pb.CommentService_GetCommentServer) error {
id := uuid.NewV4()
ch := clients.add(id.String())
defer clients.delete(id.String())
for {
c := <-ch
if err := stream.Send(&pb.Comment{
Type: c.Type,
User: c.User,
Text: c.Text,
Timestamp: c.Timestamp,
Channel: c.Channel,
}); err != nil {
return status.Errorf(codes.Unknown, "failed to send a comment: %v", err)
}
}
}
上記のソースの clients
は以下の構造体 clientsManager
です。
type clientsManager struct {
sync.Mutex
chans map[string]chan *comment
}
ランダムな文字列(uuid)をキーにして各クライアント用のチャネルを保持しておき、 Slack から Event を受け取った時にそれらのチャネルに詰めていくことでブロードキャストします。
接続と切断が繰り返されたり、大量に接続されたり、 Slack から Event が大量に通知された場合など、ちゃんと対応すると並行処理の知見が得られそうなので、気が向いたらやろうと思いました。
(小並感)
gRPC streaming を使ったサーバープッシュミドルウェアplasmaの実装がとても参考になりそうです。見てみようと思います。
gRPC クライアント(iOS - Swift)
事前準備(grpc-swift)
に従って gRPC を利用する準備をします。
上記のリポジトリを clone し、リポジトリのルートディレクトリで以下を実行。
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
make plugins
sudo cp protoc-gen-swift protoc-gen-grpc-swift /usr/local/bin
また Xcode プロジェクトを作成しての Swift Packages にも grpc-swift
を追加します。(ブランチは nio
を指定)
protoc.sh
の修正
gRPC サーバーを実装する際に用意した protoc.sh
に以下を追記します。
protoc -I proto proto/*.proto --swift_out=grpcvoiceover/grpcvoiceover/Proto --grpc-swift_out=grpcvoiceover/grpcvoiceover/Proto
grpcvoiceover/grpcvoiceover/Proto
は protoc.sh
からの相対パスです。以下のようなディレクトリ構成を前提にした記述です。
.
--- 省略 ---
├── grpcvoiceover
│ ├── grpcvoiceover
│ │ ├── Proto
│ │ │ ├── comment.grpc.swift
│ │ │ └── comment.pb.swift
│ │ └── SceneDelegate.swift
│ └── grpcvoiceover.xcodeproj
│ ├── project.pbxproj
│ ├── project.xcworkspace
--- 省略 ---
├── proto
│ ├── comment.pb.go
│ └── comment.protoc
protoc.sh
を実行すると grpcvoiceover/grpcvoiceover/Proto
ディレクト内に Swift のソースが生成されます。
(comment.grpc.swift
, comment.pb.swift
)
クライアントの実装
grpc-swift のサンプルをベースに実装しました。
ポイントを以下にまとめます。
認証(クライアントサイド)
ChannelCredentials
の指定はUsing TLSのドキュメントが参考になります。
以下のソースでは、あくまで動作確認用として Go のライブラリで用意されている testdata の証明書 ca.pem
をプロジェクトに配置して使っています。(本番運用を考えた時にどう管理すべきかについては、未検討)
let path = Bundle.main.path(forResource: "ca", ofType: "pem")
let certificates: [NIOSSLCertificate] = try NIOSSLCertificate.fromPEMFile(path!)
let tls = ClientConnection.Configuration.TLS(
certificateChain: certificates.map { .certificate($0) },
trustRoots: NIOSSLTrustRoots.certificates(certificates),
certificateVerification: .fullVerification,
hostnameOverride: "x.test.youtube.com"
)
// Provide some basic configuration for the connection, in this case we connect to an endpoint on
// localhost at the given port.
let configuration = ClientConnection.Configuration(
target: .hostAndPort("localhost", 8080),
eventLoopGroup: group,
tls: tls
)
CallCredentials
を指定する方法は Google の Natural Language API 用のExample ソースが参考になります。
以下のように指定します。
let headers: HPACKHeaders = ["authorization": "Bearer \(token)"]
let callOptions = CallOptions(customMetadata: headers)
let call = client.getComment(req, callOptions: callOptions) { comment in
// ...
}
AVSpeechSynthesizer でメッセージ読み上げ
【Swift4】AVSpeechSynthesizerを使用した読み上げ機能を3分で実装する方法【Objective-C】を参考にしました。読み上げの音声がすべて同じだとイマイチなので Slack の User ID を元に適当に pitch が変わるようにします。
private func setComment(_ comment: Comment_Comment) {
DispatchQueue.main.async {
self.comment = comment
}
let utterance = AVSpeechUtterance(string: comment.text)
utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP")
utterance.rate = 0.7
utterance.pitchMultiplier = getPitch(comment.user)
self.speechSynthesizer.speak(utterance)
}
private func getPitch(_ user: String) -> Float {
let num = Array(user.utf8).reduce(0, { x,y in
x + Float(y)
})
let pitch = num.truncatingRemainder(dividingBy: 20.0) / 10
print(pitch)
return pitch < 0.5 ? pitch + 0.5 : pitch
}
その他
表示する文字数によって文字の大きさや色を変えるようにしてます。
読み上げと表示を同期させることもしたかったのですが、ちょっと考えて難しそうだったのでやってません。
成果物
Slack の内容を公開するのは差し支えありそうだったので『老人と海』の一節を gRPC でクライアントに配信して読み上げさせてみました。
(クリックしたら YouTube の動画が見れます…が、しょぼい…)
Slack の読み上げについて
やり方は違いますが、すでに取り組んでいる方がいました。
Slackの投稿内容を読み上げる
slackのメッセージをひたすら読み上げさせるコマンドを作った
まとめ
- gRPC の Server stream を Go サーバー & iOS クライアントで扱った
- gRPC の 2 種類の認証タイプを組み合わせてみた(証明書の管理方法は別途調査が必要)
- 延々と Slack の投稿が読み上げられるのを聞いていると精神が蝕まれる
ソースは GitHub に上げてあります。
https://github.com/hmarui66/grpc-sample-voice-over