gRPC の Server streaming と AVSpeechSynthesizer を使って iPhone に Slack の投稿を読み上げてもらう

## はじめに

gRPC の Stream API を触ってみたかったので、 Slack の投稿を Server streaming で配信する Go サーバーと、それを受信する iOS アプリを実装しました。

実装する中で得た知識をまとめます。

ちなみに、受け取った文字を表示するだけでなく、 AVSpeechSynthesizer を使って読み上げるようにしてみたところ、Slack のやり取りを目ではなく耳で追うことができ、新鮮な感覚を味わうことができました。

日々 Slack の流れを追うのに疲れている人は試してみてください。

## gRPC サーバー(Go)

### 事前準備(grpc-go)

https://grpc.io/docs/quickstart/go/

に従って gRPC を利用する準備をします。

### proto ファイルの用意

プロジェクトのルートに `proto` ディレクトリを作成し proto ファイルを配置します。

```
.
├── proto
│   ├── comment.proto
```

Server streaming を使うため、レスポンスにだけ `stream` 指定をつけた RPC を定義します。

```proto
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` コマンドのオプション指定が煩雑なので、シェルを作っておきます。

```bash
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 のソースが生成されます

```bash
./protoc.sh
```

### サーバーの実装

[RouteGuide のサンプル](https://github.com/grpc/grpc-go/blob/master/examples/route_guide/server/server.go)をベースに実装しました。

ポイントを以下にまとめます。

#### 認証

gRPC の認証についての[ドキュメント](https://grpc.io/docs/guides/auth/)をみると、SSL通信のための `ChannelCredentials` と RPC ごとのクライアント認証のための `CallCredentials` 、そしてそれらを組み合わせた `CompositeChannelCredentials` が出てきます。

`grpc-go` ではこれらに対応する interface が定義されています。

gRPC ドキュメント | grpc-go
--- | ---
ChannelCredentials | [TransportCredentials](https://godoc.org/google.golang.org/grpc/credentials#TransportCredentials)
CallCredentials | [PerRPCCredentials](https://godoc.org/google.golang.org/grpc/credentials#PerRPCCredentials)
CompositeChannelCredentials | [Bundle(※)](https://godoc.org/google.golang.org/grpc/credentials#Bundle)

(※)Bundle については `TransportCredentials` と `PerRPCCredential` を組み合わせるだけでなく、独自に定義したモードによるクレデンシャルポリシーの切り替えも念頭に置かれているようです(参考: [GCPのBundleの切り替え実装](https://github.com/grpc/grpc-go/blob/master/credentials/google/google.go#L102-L124))。単に組み合わせるだけであればダイヤル時に 2 種類のクレデンシャルをそれぞれ指定したり、 RPC 実行ごとに `PerRPCCredentials` を指定することができます。

今回はクレデンシャルの組み合わせを試すため、サーバーオプションとして `TransportCredentials` と `grpc_auth` ミドルウェアを指定します。
参考: [go-grpc-middlewareを一通り試してみる](https://qiita.com/Morix1500/items/7a20d76a931af68d860d#grpc_auth)

```go
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`関数を実装します。

```go
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 のドキュメント](https://github.com/grpc/grpc-go/blob/master/Documentation/grpc-metadata.md)や[E2E テストの実装](https://github.com/grpc/grpc-go/blob/master/test/end2end_test.go)が参考になりそうです。

#### Slack の Event を受信

https://api.slack.com/events-api

の通りに利用します。

特筆すべきことはそんなにないです。Event のパースが面倒だとぼやいたら同僚から、ライブラリあるでしょ、と指摘されたので [slack-go](https://github.com/slack-go/slack) を使いました。

また、テストでは ngrok 使いましたが、コネクション数の制限があるので Slack が活発な時間帯だと割とすぐに制限にかかりました...

#### 複数クライアント対応

サーバーの RPC のハンドラーではリクエストを受けるたびにクライアント用のチャネルを生成し、接続が切れるまでメッセージを取得 & クライアントへ送信し続けるようにします。

```go
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` です。

```go
type clientsManager struct {
	sync.Mutex
	chans map[string]chan *comment
}
```

ランダムな文字列(uuid)をキーにして各クライアント用のチャネルを保持しておき、 Slack から Event を受け取った時にそれらのチャネルに詰めていくことでブロードキャストします。

接続と切断が繰り返されたり、大量に接続されたり、 Slack から Event が大量に通知された場合など、ちゃんと対応すると並行処理の知見が得られそうなので、気が向いたらやろうと思いました。
(小並感)

gRPC streaming を使ったサーバープッシュミドルウェア[plasma](https://github.com/opensaasstudio/plasma)の実装がとても参考になりそうです。見てみようと思います。

## gRPC クライアント(iOS - Swift)

### 事前準備(grpc-swift)

https://github.com/grpc/grpc-swift/tree/nio#getting-the-protoc-plugins

に従って gRPC を利用する準備をします。
上記のリポジトリを clone し、リポジトリのルートディレクトリで以下を実行。

```bash
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` に以下を追記します。

```bash
protoc -I proto proto/*.proto --swift_out=grpcvoiceover/grpcvoiceover/Proto --grpc-swift_out=grpcvoiceover/grpcvoiceover/Proto
```

`grpcvoiceover/grpcvoiceover/Proto` は `protoc.sh` からの相対パスです。以下のようなディレクトリ構成を前提にした記述です。

```bash
.
--- 省略 ---
├── 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 のサンプル](https://github.com/grpc/grpc-swift/blob/nio/docs/basic-tutorial.md)をベースに実装しました。

ポイントを以下にまとめます。

#### 認証(クライアントサイド)

`ChannelCredentials` の指定は[Using TLS](https://github.com/grpc/grpc-swift/blob/nio/docs/tls.md)のドキュメントが参考になります。

以下のソースでは、あくまで動作確認用として Go のライブラリで用意されている testdata の証明書 `ca.pem` をプロジェクトに配置して使っています。(本番運用を考えた時にどう管理すべきかについては、未検討)

```swift
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 ソース](https://github.com/grpc/grpc-swift/blob/nio/Examples/Google/NaturalLanguage/Sources/main.swift#L92-L93)が参考になります。
以下のように指定します。

```swift
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】](https://program-life.com/761)を参考にしました。読み上げの音声がすべて同じだとイマイチなので Slack の User ID を元に適当に pitch が変わるようにします。

```swift
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 の動画が見れます…が、しょぼい…)

[![IMAGE ALT TEXT HERE](http://img.youtube.com/vi/zsLtxXmmupI/0.jpg)](http://www.youtube.com/watch?v=zsLtxXmmupI)

## Slack の読み上げについて

やり方は違いますが、すでに取り組んでいる方がいました。

[Slackの投稿内容を読み上げる](https://qiita.com/KeitaMoromizato/items/2e23d1ecc12e79e18644)
[slackのメッセージをひたすら読み上げさせるコマンドを作った](https://ikawaha.hateblo.jp/entry/2019/04/14/214119)

## まとめ

- gRPC の Server stream を Go サーバー & iOS クライアントで扱った
- gRPC の 2 種類の認証タイプを組み合わせてみた(証明書の管理方法は別途調査が必要)
- 延々と Slack の投稿が読み上げられるのを聞いていると精神が蝕まれる

ソースは GitHub に上げてあります。
https://github.com/hmarui66/grpc-sample-voice-over