2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-03-09

はじめに

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 については TransportCredentialsPerRPCCredential を組み合わせるだけでなく、独自に定義したモードによるクレデンシャルポリシーの切り替えも念頭に置かれているようです(参考: GCPのBundleの切り替え実装)。単に組み合わせるだけであればダイヤル時に 2 種類のクレデンシャルをそれぞれ指定したり、 RPC 実行ごとに PerRPCCredentials を指定することができます。

今回はクレデンシャルの組み合わせを試すため、サーバーオプションとして TransportCredentialsgrpc_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/Protoprotoc.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 の動画が見れます…が、しょぼい…)

IMAGE ALT TEXT HERE

Slack の読み上げについて

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

Slackの投稿内容を読み上げる
slackのメッセージをひたすら読み上げさせるコマンドを作った

まとめ

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

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

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?