はじめまして。RevComm の渋谷です。MiiTel Phone Mobile のバックエンドや E2E テストなどを主に担当しています。2021 年 6 月に入社し、在籍がちょうど 1 年経過したところです。 それとは別に、TechTalk (エンジニア主体の技術共有の場) 運営にも 2021 年 8 月頃から参加しております。 今回は RevComm における TechTalk の立ち位置や意義、どのような発表が行われてきたか、そして続けていくコツなどをお話してみたいと思います。 RevComm における TechTalk とは RevComm では毎週水曜日に 15:30 〜 16:00 の 30 分枠で TechTalk を開催しています。 エンジニアの発表体験を増やす場 エンジニアの技術スキルを底上げする場 プロダクトに関する技術共有・相談の場 エンジニア個人の技術的(に限らない)興味に関する発表の場 エンジニア同士のコミュニケーションの場 自社プロダクトにユーザーとして触れる場 といった場を提供することを目的として活動を続けています。 参加は任意で、 RevComm に所属しているメンバーであれば役職や部署に関係なく、カジュアルに発表・質疑応答などが行われています。 発表は弊社のプロダクトである MiiTel for Zoom と連携されたZoom上で行っており、日本や世界各地に点在している RevComm エンジニアたちが時間になると集まってきます。 RevComm の Technology 本部所属メンバーは 2022 年 6 月現在 64 名おり、コンスタントに 20 〜 30 名ほどが参加しております。 発表は録画され、これも弊社プロダクトである MiiTel Analytics で後から振り返る事ができます。 弊社プロダクト「MiiTel Analytics」による解析画面 こちらは私が先日発表した「モバイルチームの CI / CD 」の MiiTel Analytics の解析結果ですが、自分の発表を客観的に振り返ることによって発表スキルの向上が見込めますし、プロダクトへの理解を深めることもできます。 個人的にはフィラー(つなぎ言葉)を除かないままの文字起こし結果を見て、「えーと」や「あー」などがとても多いな…というのが反省点となりました。 発表資料もまとめておりますので、後から他のメンバーが閲覧することが可能です。 発表者は主に自発的な参加(立候補)で決まりますが、その他に運営が発表を依頼(スカウト)する場合もあります。 スカウトは Slack 上での発言を元に依頼したり、CTO が他部署へ行った技術解説の紹介を依頼したりしています。 TechTalk 発表例 では実際にどのような発表を行っているのか、これまで実施した内容を少しご紹介いたします。 スタートアップの知財戦略 知財とは何か、プロダクトにとってどのような意味・メリットを持つのかを、過去の事例や今後の戦略を交えて紹介 Interspeech 2021 参加報告 Interspeech (音声言語処理分野の国際会議)における RevComm の研究成果の発表報告や興味深かった発表の紹介 「Interspeech 2021 参加報告」スライド 配信サービス作ってみた話 WebRTCや配信技術に関心を持った発表者が独自の配信サービスシステムを構築してみた体験談 「配信サービス作ってみた話」スライド RevComm のおすすめの開発方針のアップデート RevComm における開発方針を見直し、より良いプロダクトを生み出せるような方針の相談会 また RevComm における技術的ルールについても改めて周知 TechTalk横断企画〜各プロジェクトの自動化について〜 TechTalk 運営主催による「各プロジェクトに同一テーマについて発表してもらう」横断企画 Analytics・Corporate Engineering・Mobile・Softphone の4チームが取り入れている自動化のツールや取り組みなどを発表 Analytics … 忘れやすい作業や属人化を防ぐための自動化の導入について( 当TechBlog でも発表) Corporate Engineering … BackOffice 系システムの構築に使用している CI / CD ツールやそのフローについて Mobile … モバイルアプリビルド用 CI / CD の流れや取り組み中の E2E テスト自動化について Softphone … ライブラリアップデートの自動化について 心理的安全性とは 書籍情報に基づき、心理的安全性とはどういう概念か、心理的安全性が高いチームのメリット、などを解説 「心理的安全性とは」スライド TechTalkを続けていくコツ このように RevComm の TechTalk では、技術的な話からマネジメントや知財といったバラエティに富んだ発表が行われています。 ちなみに、2021 年 8 月 1 日 から 2022 年 6 月 15 日 まで水曜日は 52 日(祝日は 3 日)あり、うち 32 回開催しております(開催率 65 %)。 TechTalk 運営に 9ヶ月ほど携わっていく中で「継続していくコツ」として下記のような取り組み方があるのではないか、と感じるようになりました。 運営だけで頑張りすぎない 運営がアンケートを取ったり、資料を作成するなど、様々な準備をして臨まれる社内勉強会の形式もとても有意義だとは思いますが、続けていくには負荷が高くなってしまう場合があります。 例えば RevComm 内の横断企画の場合ですと、運営が立案はしましたが、発表者の選定や発表内容・資料は各プロジェクトにお任せしました。 出来るだけ運営外のエンジニアも巻き込んでいった方が、負荷が集中しないのかな、と思います。 気軽に参加できる雰囲気を作る 発表を含めた参加は出来るだけ気楽な感じで出来ると良いと思います。 そのため、1 回の発表の分量や責任を重くしすぎない事も重要です。 RevComm の TechTalk は 30 分という短時間スケジュールとなっており、実際の発表は 15 分程度(残りは質疑応答や雑談)というライトなものになっています。 TechTalk に参加するということは、エンジニアのその時間のスケジュールを押さえることにもなりますので、その点でも 30 分という短時間開催は続けやすいと思います。 同じ時間帯で予定を組む 定期的に開催し、参加してもらいやすくするためには、ルーチンとして同じ時間帯のスケジュールを押さえておいた方が良さそうです。 RevComm では同じ水曜日の 13〜18 時にもくもく会(エンジニア主体で同じスペースに参加しながら個々人の業務を執り行うオンラインミーティング)が開催されているのですが、TechTalk はその中間に位置しているため、息抜きを兼ねて参加するメンバーも多いようです。 失敗も良い経験 人があまり集まらなかった、発表者がいなくて開催できなかった、といった失敗も、「こういう事もあるよね」と(ある意味で)割り切る淡白さもあるといいかもしれません。 失敗した理由の振り返りはもちろん行いますが、失敗したり成功したりを繰り返しつつ続けていくのが、最終的に長続きさせることが出来るように感じます。 まとめ 私は TechTalk 運営に携わる事ができてとても良かったと思っています。 TechTalk の運営であることでたくさんの社内エンジニアと関わるきっかけも増えましたし、自分の存在も知ってもらうことができて、業務を円滑に進められるようになりました。 RevComm 内には様々なエンジニアがそれぞれのペースで業務に携わっていますが、その中でオアシスのようにちょっとした気分転換や息抜きの場として TechTalk を提供できていたらいいな、と考えながら運営に携わっています。 いつか様々な会社の TechTalk (やそれに類する企画)の運営に携わってる方々とも交流してみたいですね。 そんな RevComm ではエンジニアを募集しております。 この記事で RevComm に興味を持ってくださった方がいらっしゃいましたら、ぜひ奮ってご応募ください。 www.revcomm.co.jp
こんにちは、株式会社RevCommでAndroidアプリ開発を担当している吉村です。 私が開発を担当しているアプリに MiiTel Phone Mobileというものがあります。このアプリはスマートフォンでインターネット回線を介して発着信ができる通話アプリです。日々の業務において機能追加・機能改修をする場面は多いのですが、その中でも通話部分の実装には通話アプリ特有の実装の難しさがあります。 今回は、その一例として、通話やビデオチャットを実装するときに用いられる テレコムフレームワーク の導入方法について、実装例を交えて紹介していきます。 目次 目次 テレコムフレームワークとは なぜ通話状態を共有する必要があるのか テレコムフレームワークの導入手順 AndroidManifestにPermissionとConnectionServiceを指定 ConnectionServiceの実装 Connectionの実装 Connectionの状態を変更・監視する機能の実装 Connectionの状態(通話状態)の監視 終わりに 参考 テレコムフレームワークとは テレコムフレームワークとは一言で言うと「Android OSに通話状態を伝えることにより、OSを介して他の通話アプリと通話状態を共有するための機能」です。 とはいえこの一言では、なぜテレコムフレームワークを用いて他の通話アプリと通話状態を共有する必要があるのか分からないと思います。したがって、「他の通話アプリと通話状態が共有されていないとどういった不具合が発生するか」を考えてみましょう。 なぜ通話状態を共有する必要があるのか 結論としては「通話を排他制御するため」です。 仮に、端末に通話状態をOSに共有する機能がない通話アプリAと、通常の通話アプリBがそれぞれインストールされているとします。そして、アプリAで通話中にアプリBに向けて電話がかかってきたら、アプリBはアプリAが通話中であることを知らないので「保留」という選択肢は出てきません。「電話に出る」か「着信を拒否する」の二択になります。電話に出てしまった場合、一対二で通話が成立してしまうこともあり得ます。 アプリで通話中に他の通話アプリに着信が発生したら「保留して電話に出る」「着信を拒否する」などの選択肢が表示されるのが一般的な電話のイメージかと思います。電話として当たり前の機能のようですが、通話アプリがOSを介して「他の通話アプリで既に通話中である」と知っていて初めてこれらの選択肢を出すことができます。 このように、アプリ間で通話の状態を共有することは、通話を排他制御するために重要となってきます。Androidにおいては、テレコムフレームワークを利用することで、他の通話アプリの通話状態を取得することができます。 テレコムフレームワークの導入手順 それでは、他の通話アプリと通話状態を共有するために、テレコムフレームワークを通話アプリに実装してみましょう。 公式ドキュメントによると、テレコムフレームワークを通話アプリに組み込むオプションとして self-managed型 managed型 の二つが存在していますが、本稿は、self-managed型のオプションを選択した場合について説明しています。 なお、self-managed型を選択した場合、着信時・発信時のUIなども含めて全て自前で実装することが可能です(MiiTel Phone Mobileにおいては発着信時のUIを自前で実装しているため、こちらを採用しています) managed型を選択した場合、Androidデフォルトの着信時・発信時のUIを利用することで、self-managed型と比較して、簡便に実装することが可能です。 まずはテレコムフレームワークの導入の流れを簡単に説明します。 ※公式提供のライブラリのメソッド名やクラス名は斜体表記とします。 AndroidManifestにPermissionと ConnectionService の指定 テレコムフレームワークを扱うために必要なPermissionを追加し、通話開始時に起動するServiceクラスを指定します。 ConnectionService の実装 ConnectionService とは、発信時 or 着信時に起動するServiceクラスです。発着信時に発信中または着信中の状態を持った Connection のインスタンスを生成し、それをアプリケーションのスコープで保持するようにします。 Connection インスタンスの通話状態の監視の実装 OSと通話状態を共有するための Connection クラスを実装し、その通話状態の変更を監視するlistenerを実装します。 Connection の状態を変更・監視するための機能の実装 アプリ側からテレコムフレームワークを使用するためのinterfaceを定義し、実装例を解説します。 Connection の状態(通話状態)の監視 Connection のstateを監視するlistenerの実装例を解説します。 上記のような流れとなります。それでは詳細に入っていきます。 AndroidManifestにPermissionと ConnectionService を指定 OSから通話状態を取得したり、通話状態を変更するための権限が必要なので、下記の三つのパーミッションを追加します。 <uses - permission android:name= "android.permission.CALL_PHONE" / > <uses - permission android:name= "android.permission.MANAGE_OWN_CALLS" / > <uses - permission android:name= "android.permission.READ_PHONE_STATE" / > また、通話を開始するときに立ち上げる ConnectionService というサービスクラスもAndroidManifestにて指定する必要があります。( ConnectionService の実装については後述) <service android:name= "jp.co.sample.telecom.MyConnectionService" android:exported= "false" android:permission= "android.permission.BIND_TELECOM_CONNECTION_SERVICE" > <intent - filter> <action android:name= "android.telecom.ConnectionService" / > < / intent - filter> < / service> ConnectionService の実装 前節でAndroidManifestにて ConnectionService の実装クラスを指定しました。 ConnectionService とはざっくり言うと「通話開始」をOSに伝えるためのServiceクラスです。 発信するときに TelecomManager.placeCall() をコールすると ConnectionService の onCreateOutgoingConnection() が呼ばれます(着信を受ける場合は TelecomManager.addNewIncomingCall() をコールすると onCreateIncomingConnection() が呼ばれます)。 onCreateOutgoingConnection() 内で Connection という通話状態を伝えるためのインスタンスを生成して、それを返すとOSに通話開始を知らせることができます。 ConnectionService を継承したクラスを作成し、発信時に TelecomManager.placeCall() を呼ぶことで呼び出されるメソッド onCreateOutgoingConnection() を実装します。 // class MyConnectionService : ConnectionService() override fun onCreateOutgoingConnection( connectionManagerPhoneAccount: PhoneAccountHandle?, request: ConnectionRequest? ): Connection { // TelecomManager.placeCall()を呼ぶとこのメソッドが呼ばれます // ↓また、placeCall()の引数に渡したbundleから値を受け取れます val name = request?.extras?.getString( "name" ) val connection = MyConnection(stateChangedListeners).apply { // 発信者名をセットしています setCallerDisplayName(name, TelecomManager.PRESENTATION_ALLOWED) // Connectionのstateをdialingに設定しています setDialing() } return connection } 次に、上記の発信時とほぼ同様の着信時のコードを追加します。 // class MyConnectionService : ConnectionService() override fun onCreateIncomingConnection( connectionManagerPhoneAccount: PhoneAccountHandle?, request: ConnectionRequest? ): Connection { val bundle = request?.extras?.getBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS) val name = bundle?.getString( "name" ) val connection = MyConnection(stateChangedListeners).apply { setCallerDisplayName(name, TelecomManager.PRESENTATION_ALLOWED) setRinging() } return connection } そして上記の発着信をしたときに生成したConnectionに渡すlistenerの管理をするメソッドを追加します。 // class MyConnectionService companion object { // リスナーが複数セットされるため private val stateChangedListeners = mutableListOf<ConnectionStateChangedListener>() fun addConnectionStateChangedListener(listener: ConnectionStateChangedListener) { stateChangedListeners.add(listener) } fun removeConnectionStateChangedListener(listener: ConnectionStateChangedListener) { stateChangedListeners.remove(listener) } } Connection の実装 次に Connection を実装します。 Connection はOSとアプリを繋ぐ重要な役割を持ったクラスです。 Connection を実装するにあたってすべきことは二つあって、一つ目はstateの変化を取得することです。例えばstateがIncomingになったら着信画面を表示したり、stateがOutgoingになったら発信画面を表示したり、stateに応じて行いたいことがあるかと思います。そのためにstateの変化を取得します。 二つ目は、OSに由来したポップアップに対してユーザーがアクションしたときに、OSからそのアクションを受け取ることです。 Connection の onAnswer() , onDisconnect() などが呼ばれるので、その結果に応じて Connection のstateを変更します。 これらを実装することによって、OSにこのアプリの通話状態が伝わったかどうかという情報や、他アプリの通話状態からどのような影響を受けたか(例えば他アプリで着信を受けたから受電するために、このアプリの通話は切電しようとした、などのアクション)、などの情報を得られます。 具体的な実装に移ります。 まずは Connection を継承し、初期化処理を書きます。通話状態を監視するためのlistenerのリストをコンストラクタに渡しています。(複数箇所で監視する必要がある場合のためにlistenerを複数セットできるようにリスト形式にしています) interface ConnectionStateChangedListener { fun onStateChanged(state: Int , connection: MyConnection) } class MyConnection( private val stateChangedListeners: MutableList <ConnectionStateChangedListener> = mutableListOf() ) : Connection() { init { audioModeIsVoip = true // 自己管理型の通話アプリの場合は必要です // (これを設定しないと発信時にデフォルトの発信画面が表示されます) connectionProperties = PROPERTY_SELF_MANAGED setInitializing() } 通話状態の変更があった場合にlistenerに伝えます。 // class MyConnection override fun onStateChanged(state: Int ) { super .onStateChanged(state) stateChangedListeners.map { listener -> listener.onStateChanged(state, this ) } } また、他のアプリとの通話状態の兼ね合いで、OS由来のポップアップが出てくることがあり、そのポップアップへのアクション結果が下記の三つのメソッドにコールバックされます。 まずは onDisconnect() についてですが、一例として、このアプリで通話中に他のアプリから発信しようとすると「この通話を発信すると、このアプリの通話が終了します。」という旨のポップアップが表示され、OKをタップすると onDisconnect() がコールされます。下記にサンプルとしてPixel5(Android 11)のポップアップを載せますが、OSや機種によって若干の文言の違いがあったり、挙動が異なる場合があります。 ※「TelecomFrameworkSam...」という表示は、アプリ名であるTelecomFrameworkSampleが略されて表示されたものです。 次に、 onAnswer() と onReject() についてです。一例として、このアプリで着信中に他のアプリから着信があると「応答すると、進行中の通話は終了します」というポップアップが出て、「応答」か「拒否」かをタップすると上記メソッドがコールされます。下記がサンプルとなります。 ※「test incoming user」というのは Connection に設定したもので、「Telecom..」というのはアプリ名であるTelecomFrameworkSampleの略となります。 // class MyConnection override fun onAnswer() { super .onAnswer() // OS由来のポップアップに対して電話に出るという類のアクションをするとコールされます setActive() } override fun onReject() { super .onReject() // OS由来のポップアップに対して拒否するという類のアクションをするとコールされます setDisconnected(DisconnectCause(DisconnectCause.REJECTED)) } override fun onDisconnect() { super .onDisconnect() // OS由来のポップアップに対して切電するという類のアクションをするとコールされます setDisconnected(DisconnectCause(DisconnectCause.UNKNOWN)) } Connectionの状態を変更・監視する機能の実装 前節においてConnectionの状態を取得するところまで実装できました。 ただ、上述の一通りの実装を読んでみても「結局のところどのクラスをどうやって使えば良いの?」としっくりこないかと思います。より簡潔にテレコムフレームワークを扱えるようにしたいです。 冒頭の説明を繰り返すと、そもそもテレコムフレームワーク本来の目的は「他の通話アプリと通話状態を共有すること」でしたよね。 では、そのために必要なことは何なのか。 発信をOSに伝えること 着信をOSに伝えること 通話開始をOSに伝えること 保留中をOSに伝えること 通話終了をOSに伝えること Connectionの状態(通話状態)を監視すること 上記を満たせば、OSに十分に通話状態が伝わるかと思います。上記の箇条書きをinterfaceにしてみます。 interface TelecomHelper { @RequiresPermission (Manifest.permission.READ_PHONE_STATE) // 発着信の際に必要なため fun initPhoneAccount(): PhoneAccount? @RequiresPermission (Manifest.permission.CALL_PHONE) fun startOutgoing(number: String , name: String , accountHandle: PhoneAccountHandle) fun startIncoming(name: String , accountHandle: PhoneAccountHandle) fun activate() fun hold() fun disconnect() fun firstConnectionOrNull(): MyConnection? fun addConnectionStateChangedListener(listener: ConnectionStateChangedListener) fun removeConnectionStateChangedListener(listener: ConnectionStateChangedListener) } ほとんど箇条書きそのままのinterfaceを定義できました。このinterfaceを実装して必要に応じて呼び出すことができれば、アプリのソースコードがすごくクリーンに保たれるかと思います。モジュール化しても良いと思います。 続いて、interfaceの実装サンプルです。やや実装量が多いのでメソッドごとに分解して紹介していきます。 まずクラスの構造については、 TelecomHelper を継承してコンストラクタにContextと TelecomManager をインジェクトし、 Connection を保持するリストをメンバに置きます。 class TelecomHelperImpl @Inject constructor ( @ApplicationContext private val context: Context, private val telecomManager: TelecomManager ) : TelecomHelper { private val connections = mutableListOf<MyConnection>() } 続いて各メソッドについてです。 まずは発着信時に、 ConnectionService において生成されたConnectionをリストに追加したり、stateがdisconnectedになった時にリストから除外するためのlistenerをセットします。 // class TelecomHelperImpl init { // Connectionのaddやremoveをするためにリスナーをセット val listener = object : ConnectionStateChangedListener { override fun onStateChanged( state: Int , connection: MyConnection ) { when (state) { Connection.STATE_RINGING -> { startConnection(connection) } Connection.STATE_DIALING -> { startConnection(connection) } Connection.STATE_DISCONNECTED -> { endConnection(connection) } } } } MyConnectionService.addConnectionStateChangedListener(listener) } listenerをadd or removeするためのメソッドを追加します。 // class TelecomHelperImpl override fun addConnectionStateChangedListener(listener: ConnectionStateChangedListener) { MyConnectionService.addConnectionStateChangedListener(listener) } override fun removeConnectionStateChangedListener(listener: ConnectionStateChangedListener) { MyConnectionService.removeConnectionStateChangedListener(listener) } Connection をメンバのリストにadd or removeするためのメソッドです。 endConnection() においては Connection の破棄も行っています。 // class TelecomHelperImpl private fun startConnection(connection: MyConnection) { connections.add(connection) } private fun endConnection(connection: MyConnection) { connections.remove(connection) connection.setDisconnected(DisconnectCause(DisconnectCause.UNKNOWN)) connection.destroy() } 次に、 PhoneAccount を取得するためのメソッドたちを実装します。 PhoneAccount とは、通話時の通信プロトコルの指定やself-managed形式の指定などをするためのクラスです。常に新規にアカウントを作ることはせず、 ConnectionService に PhoneAccount が既に紐付いていた場合はそれを取得します。 // class TelecomHelperImpl @RequiresPermission (Manifest.permission.READ_PHONE_STATE) override fun initPhoneAccount(): PhoneAccount { return findExistingAccount(context) ?: return createAccount(context) } private fun createAccount(context: Context): PhoneAccount { val accountHandle = PhoneAccountHandle( ComponentName(context, MyConnectionService :: class .java), context.packageName ) val account = PhoneAccount.builder(accountHandle, "test" ) .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED) .setSupportedUriSchemes(listOf(PhoneAccount.SCHEME_SIP)) .build() telecomManager.registerPhoneAccount(account) return account } @RequiresPermission (Manifest.permission.READ_PHONE_STATE) private fun findExistingAccount(context: Context): PhoneAccount? { val connectionService = ComponentName( context, MyConnectionService :: class .java ) val targetPhoneAccountHandle = telecomManager.selfManagedPhoneAccounts.firstOrNull { phoneAccountHandle -> phoneAccountHandle.componentName == connectionService } return telecomManager.getPhoneAccount(targetPhoneAccountHandle) } 続いて、発信を開始するときに startOutgoing() をコールします。その内部で TelecomManager.placeCall() を呼びます。これにより ConnectionService が立ち上がって Connection が追加されます。 // class TelecomHelperImpl @RequiresPermission (Manifest.permission.CALL_PHONE) override fun startOutgoing(number: String , name: String , accountHandle: PhoneAccountHandle) { telecomManager.placeCall( Uri.fromParts( "tel" , number, null ), Bundle().apply { putParcelable( TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, Bundle().apply { putString( "name" , name) } ) putParcelable( TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, accountHandle, ) } ) } そして着信時に startIncoming() をコールします。その内部で TelecomManager.addNewIncomingCall() を呼ぶことで ConnectionService が立ち上がって、 Connection が追加されます。 // class TelecomHelperImpl override fun startIncoming(name: String , accountHandle: PhoneAccountHandle) { telecomManager.addNewIncomingCall( accountHandle, Bundle().apply { putParcelable( TelecomManager.EXTRA_INCOMING_CALL_EXTRAS, Bundle().apply { putString( "name" , name) } ) } ) } そして最後に、通話状態が変更された際に、そのことをアプリからOSに伝えるために Connection インスタンスのstateを変更します。 activete() メソッドは、通話開始時および保留解除時、 hold() メソッドは、保留開始時、 disconnect() メソッドは切電時にコールするようにします。 実装例は以下の通りです。 // class TelecomHelperImpl override fun activate() { connections.lastOrNull { it.state == Connection.STATE_DIALING || it.state == Connection.STATE_RINGING || it.state == Connection.STATE_HOLDING }?.setActive() } override fun hold() { connections.lastOrNull { it.state == Connection.STATE_ACTIVE }?.setOnHold() } override fun disconnect() { connections.lastOrNull { it.state == Connection.STATE_DIALING || it.state == Connection.STATE_RINGING || it.state == Connection.STATE_ACTIVE || it.state == Connection.STATE_HOLDING }?.let { endConnection(it) } } 以上がTelecomHelperの実装例となります。 Connection の状態(通話状態)の監視 前節の序盤で、 Connection のstateを監視するためのメソッドを追加しました。 // class TelecomHelperImpl override fun addConnectionStateChangedListener(listener: ConnectionStateChangedListener) { MyConnectionService.addConnectionStateChangedListener(listener) } override fun removeConnectionStateChangedListener(listener: ConnectionStateChangedListener) { MyConnectionService.removeConnectionStateChangedListener(listener) } 上記の二つのメソッドです。 本節では、このlistenerのセット方法と、監視方法についての実装サンプルをご紹介します。 // class SomeViewModel: ViewModel() private val listener = object : ConnectionStateChangedListener { override fun onStateChanged( state: Int , connection: MyConnection ) { STATE_INITIALIZING -> {} STATE_NEW -> {} STATE_RINGING -> {} STATE_DIALING -> {} STATE_ACTIVE -> {} STATE_HOLDING -> {} STATE_DISCONNECTED -> {} STATE_PULLING_CALL -> {} } } init { telecomHelper.addConnectionStateChangedListener(listener) } override fun onCleared() { // メモリリークしないように telecomHelper.removeConnectionStateChangedListener(listener) super .onCleared() } 上記で Connection のstateの変化を監視できます(メモリリークしないよう、ライフサイクルに応じて適切にlistenerを取り外してください)。 これによって、例えば発信がスタートしたときに STATE_DIALING の部分を通るので発信画面を出したり、着信なら STATE_RINGING を通るので着信画面を出したり、 STATE_DISCONNECTED なら通話中画面を閉じたりといった実装ができます。 以上で実装方法については終わります。Helperクラスを介して、アプリとOS間で通話状態の共有ができました。 終わりに テレコムフレームワークの導入に関する情報はニッチすぎてなかなか見つからないので、今後実装に取り組まれる方にとってこの記事が少しでもお役に立てれば幸いです。 また、RevComm ではエンジニアを募集しています。技術好きな方々が多く在籍しており、モブプロや勉強会などが盛んに行われています。ぜひぜひ奮ってご応募ください。 www.revcomm.co.jp 参考 公式ドキュメント: Telecom フレームワークの概要 linphone-android
こんにちは! RevComm に2022年1月に入社したフロントエンドエンジニアの小山 (koji-koji) です。 RevComm では、 React を採用しているサービスの状態管理に Recoil を使っています。今回は Recoil の理解をより深めるために Context と比較してみました。 Context では小さく状態管理できる。 Recoil でもできないか? Context の状態管理のスコープは <Context.Provider> で括った対象であり、一方 Recoil では <RecoilRoot> で括った対象です。 Context では、ルートコンポーネント以外を <Context.Provider> で括った場合、子コンポーネントでの変更はグローバルには反映されません。 ローカルではないけれど、グローバルというほど大きくなく状態管理をしたいときに使えます。それが Recoil でもできるのか?と考えました。 Recoil では atomFamily() を使って atom の key を動的に生成する方法があるようです。これはリストを扱うときは良さそうです。一方、リストでない場合もあるので、もう少し調べてみました。 <RecoilRoot> をネストさせると小さく状態管理できる <RecoilRoot> のドキュメント によると、ネストさせるとスコープを調整できることがわかりました。 ルートコンポーネントだけでなく子コンポーネントでも使うと、子コンポーネントの中だけで状態管理ができます。 親コンポーネントで状態を変更しても、子コンポーネントには反映されません。 子コンポーネントで状態を変更しても、親コンポーネントには反映されないという形でスコープが狭まります。 サンプルです。 サンプルコード function MyApp ( { Component , pageProps } : AppProps ) { return ( < RecoilRoot > < Component { ...pageProps } id = "modalId" / > < /RecoilRoot > ); } const ParentComponent: React.FC = () => { const [ sample , setSample ] = useRecoilState ( sampleState ); return ( < div className = "bg-blue-100 p-2" > ParentComponent: { sample } < button className = "ml-3 bg-gray-100 p-3" onClick = { () => setSample ( sample + 1 ) } > ボタン < /button > < div className = "mt-2 flex" > < RecoilRoot > < NestChildComponentA / > < NestChildComponentB / > < /RecoilRoot > < ChildComponentC / > < /div > < /div > ); } ; const NestChildComponentA: React.FC = () => { const [ sample , setSample ] = useRecoilState ( sampleState ); return ( < div className = "bg-pink-300 p-2" > < RecoilRoot > NestChildComponentA: { sample } < button className = "ml-3 bg-gray-100 p-3" onClick = { () => setSample ( sample + 1 ) } > ボタン < /button > < NestGrandChildComponent / > < /RecoilRoot > < /div > ); } ; const NestChildComponentB: React.FC = () => { const [ sample , setSample ] = useRecoilState ( sampleState ); return ( < div className = "bg-green-300 p-2" > < RecoilRoot > NestChildComponentB: { sample } < button className = "ml-3 bg-gray-100 p-3" onClick = { () => setSample ( sample + 1 ) } > ボタン < /button > < /RecoilRoot > < /div > ); } ; const ChildComponentC: React.FC = () => { const [ sample , setSample ] = useRecoilState ( sampleState ); return ( < div className = "bg-yellow-300 p-2" > < RecoilRoot > ChildComponentB: { sample } < button className = "ml-3 bg-gray-100 p-3" onClick = { () => setSample ( sample + 1 ) } > ボタン < /button > < /RecoilRoot > < /div > ); } ; const NestGrandChildComponent: React.FC = () => { const [ sample , setSample ] = useRecoilState ( sampleState ); return ( < div className = "mt-2 bg-blue-300 p-2" > < RecoilRoot > NestGrandChildComponent: { sample } < button className = "ml-3 bg-gray-100 p-3" onClick = { () => setSample ( sample + 1 ) } > ボタン < /button > < /RecoilRoot > < /div > ); } ; export const sampleState = atom < number >( { key: 'SampleAtom' , default : 0 , } ); </div> <RecoilRoot> を適切にネストさせることで状態管理のスコープを調整できそうですね。 なぜ <RecoilRoot> をネストさせるとスコープが閉じるのか <RecoilRoot> をネストさせたときの挙動はわかったのですが、なぜこうなるのかあまりよくわかりませんでした。 そこで RecoilRoot のコード を見てみました。 Flow で書かれていますが、あまり知識がなくても読めそうです。 553 行目から RecoilRoot の記述があります(今回参照した Recoil のバージョンは 0.7.2 です)。 function RecoilRoot ( props: Props ) : React. Node { const { override , ...propsExceptOverride } = props ; const ancestorStoreRef = useStoreRef (); if ( override === false && ancestorStoreRef.current !== defaultStore ) { // If ancestorStoreRef.current !== defaultStore, it means that this // RecoilRoot is not nested within another. return props.children ; } return < RecoilRoot_INTERNAL { ...propsExceptOverride } / >; } ストアを比較して異なっている場合はネストしているので、処理を分けているという感じです。 ネストしている場合は、別のストアを作るという処理をしています。 なお Recoil_RecoilRoot.js の中のコードで気がついたのですが、 useContext を使っていました。 私は Recoil を触る前は、 Context vs Recoil というイメージを持っていました。しかしその理解は少し違っていたみたいです。 Recoil は Context を内部的に使っていて、 atom と selector をうまく活用することで再レンダリングを抑えているといった方が正しそうです。 RecoilRoot の挙動についての記事は以上です。 今回はドキュメントを読むのに加えて、実際に挙動を確認したり、コードリーディングをしました。 コードリーディングをすると、やはり理解が深まったり発見があったりしますね! 今後も気になるところがあったら、どんどんコードリーディングしていきたいです。
RevComm で Software Engineer をやっております、佐藤と申します。 現時点で弊社には4名の佐藤が在籍していますが、今のところ全員の所属部門が大きく違うため、苗字で呼ばれてもあまり困らない日々を過ごしております。 今回は弊社で利用している E2E テストツールの Autify についてのお話です。実際の運用について少しだけ踏み込んだ話になりますので、Autify についての紹介・説明は省いている場合があります。Autify を利用している・利用を検討している方に読んでいただけたら嬉しいです。 最初の実行回数オーバーが契機に ある月の半ばに「このままいくと Autify の実行回数が当初購入分を超えるかも?」という状況が発生しました。これは弊社の製品 MiiTel の E2E テストツールとして Autify を導入してから初めてのことです。 その時点で、完成形のシナリオ数と現在回している定期実行のペースを元に月当たりの実行ペースを算出したところ、今のプランでは明らかに実行回数が足りないことが判明しました。 そこで Autify の利用方針を定めて最適な実行回数を見積もり、それを持って Autify 社と実行回数を増やす相談をしよう、ということになりました。 導入初期を思い出すと、実行回数は今のプランで足りるのか?ということを検討できるくらいシナリオが出揃った事はかなり感慨深いものがあります。 必要な消費回数の見積もり MiiTel にはさまざまなマイクロサービス(以下サービス)が存在し、それぞれの実装を行うチームが Autify のシナリオ実装も行っています。また、SLA や障害時のリスクといった必要な実行回数の根拠となる考え方も、サービスによって異なります。 一方、Autify の実行回数は会社単位の数字です。何の制限もなく使っていった結果、あちらのチームが使いすぎて別のチームが困った...という事態は防ぎたいところです。 最終的には、以下のような体系で指針を示し、それに沿って各チームが見積もった数字を合算して全体の消費回数見積とすることにしました。 製品をテストするための実行 自サービスリリース時の実行 リリース前にステージング環境で実行 リリース後にプロダクション環境で実行 他サービスの変更に影響を受けるシナリオの実行 MiiTel 以外のサービスに影響を受けるシナリオの実行 MiiTel の他サービスの変更に影響を受けるシナリオの実行 それ以外の実行 シナリオ実装時の実行 シナリオメンテナンス時の実行 製品をテストするための実行 数字を見積もる上では、シナリオ実行を「製品をテストするための実行」と「それ以外の実行」に分ける必要があります。 「製品をテストするための実行」はMiiTel の E2E テストとして実行することです。普通に考えたらここについてだけ数字を考えれば良さそうな気がしてしまいます。 一方、「それ以外の実行」とは、シナリオを作成する際に試しに回す実行や、Autify の運用中にテストがコケてしまいメンテナンスする時の実行などです。ここを極力減らす方法も考えがちですが、人間が手を動かす時間を削減したくて導入した Autify の実行回数を減らすために人手を割くようになると本末転倒ですから、大まかにでもこの数字を見積もっておく必要があります。 自サービスリリース時の実行 自分のチームが担当するサービスに対するテストは、リリース時だけ行えば良さそうです。リリース前に不具合をキャッチできるよう、ステージング環境で網羅的な回帰テストを実行。また、プロダクションでも同じ内容でもう一度実行します。同じテストを二度実施するのは、環境差異が動作に影響する可能性を潰したい、お客様に使っていただいている環境でテストが終わっていれば安心という理由です。 つまり、シナリオ数の見積もりは以下のようになります。 フルテストのシナリオ数 × 月当たりのリリース回数 × 2 他サービスの変更に影響を受けるシナリオの実行 MiiTel のサービスの中には、リリース時に他のサービスの変更に影響を与えてしまうものがあります。他のチームのサービスのテストを行うことを考える場合、それに伴うリスクやコストも考える必要があります。そこで、MiiTel 内の他サービス・他社のサービスに変更があった際に影響を受ける箇所のテストを毎日定期実行で回す方針にしました。リリースを行う(影響を与える)側のチームではなく、影響を受ける側がテストについて責任を持てる形になります。 シナリオ数の見積もりは以下のようになります。 影響を受けるテストのシナリオ数 × 31 また、MiiTel 内のサービスであればリリースが行われる可能性が低い曜日が決まっていますから、MiiTel 内の他サービスに影響を受けるテストの場合は以下のようになります。 影響を受けるテストのシナリオ数 × 20 それ以外の実行 すでに説明した、製品をテストするための実行以外のテストです。こちらは実績から見積もるしかありません。現時点ではそういった画面は用意されていないため、テスト結果検索画面で YYYY-MM(対象の年月)を検索してその月を検索して件数を確認しました。今回調べたところでは、全体の実行回数に対し約30%が「それ以外の実行」によるものでした。 シナリオ作成作業のボリュームは作成済みのシナリオ数には関係がないため厳密ではありませんが、いったんこの方法でとりあえずの最大値は決めることにしました。 今後の課題 以上の方法で、弊社では Autify の実行回数を大まかに見積もることができました。他社で Autify を導入する際にそのまま使えるやり方ではないかもしれませんが、同じ考え方で適切な実行数を保てると思います。 とはいえ、まだまだ課題はたくさんあります。現在は、Autify のシナリオレビューをどう管理していこうかという課題に取り組んでいます。今後徐々に手動テストを削っていくにあたって、自動テストが適切に作成されていることを担保したいからです。 RevComm は高い安定性を求められるプロダクトにおける E2E テストの実装や運用に興味のあるエンジニアを募集しております。ご興味ある方のご応募、お待ちしております。 hrmos.co
こんにちは。 Infrastructure (インフラチーム) 所属の小門です。 RevComm (レブコム) では、電話営業や顧客対応を可視化する音声解析 AI 搭載型のクラウド IP 電話 MiiTel (ミーテル) を提供しています。 miitel.com はじめに MiiTel を含め RevComm ではクラウドプラットフォームに AWS を利用しています。 環境や用途に応じて複数の AWS アカウントを保有しています。 各 AWS アカウントの特権ID (※) を使用できるのは、業務上必要とするごく少数のメンバーのみに制限しており、必要が生じたメンバーには一時的に権限付与する運用をしています。 ※特権ID…ルートユーザーや AdministratorAccess IAMポリシーがアタッチされたユーザーID (= Admin ロール) 特権IDをやみくもに使用することはセキュリティガバナンス面でリスクが高いため、原則必要十分な権限を持つユーザーIDで操作するルールとしています。 例えば、環境の確認をする場合には ReadOnlyAccess、リリース作業をする場合には PowerUserAccess を使用するというイメージです。 今回は、やみくもな特権IDの使用に対するけん制やアカウント流出のリスクに備えるために特権IDの使用を検知する仕組みを構築しました。 また特権ID使用時には用途や作業内容を記載する運用ルールとし、監査証跡として残せるようにしています。 全体構成 ステップ1. ログイン検知 (メール通知) ルートユーザー Admin ロール 通知結果 ステップ2. 個別メンション付きリプライ Slack App 作成 メンション用のユーザーID取得 ボットのコア処理 デプロイ 動作確認 まとめ 全体構成 ステップ1. ログイン検知 (メール通知) ステップ2. 個別メンション付きリプライ 大きく2段階に構成になっています。 ステップ1. ログイン検知 (メール通知) ルートユーザー AWS ルートユーザーがログインしたことを通知するための CloudFormation (以下、CFn) テンプレートが公開されており、これを参考にしました。 CloudTrail を介してルートユーザーのログインを検知し EventBridge、SNS 経由でメールが送信されます。 参考: ルートユーザーアカウントが使用されたことを通知する EventBridge イベントルールを作成する ルートユーザーがログインした CloudTrail イベントレコードは下記のようになり、これが上記 CFn テンプレートの EventPattern に対応しています。また IAM はグローバルリージョンサービスのため CloudTrail イベントデータが us-east-1 リージョンに集約される点に注意します。 CloudTrail イベントレコード (抜粋) { " eventVersion ": " 1.08 ", " userIdentity ": { " type ": " Root ", ... } , " awsRegion ": " us-east-1 ", " eventType ": " AwsConsoleSignIn ", ... } CFn テンプレート (抜粋) Resources : ... EventsRule : Type : AWS::Events::Rule Properties : Description : Events rule for monitoring root AWS Console Sign In activity EventPattern : detail-type : - AWS Console Sign In via CloudTrail detail : userIdentity : type : - Root ... Admin ロール 上記を参考にして特定の IAM ロールが使用されたことを検知するイベントパターンも作成しました。 RevComm では、Google Workspace を用いた AWS SSO によって開発者が IAM ロールを使用するルールになっています。 Google アカウント認証後に IAM ロールを利用すると、裏側で AssumeRoleWithSAML というアクションが実行され CloudTrail で検知できます。 コンソールログイン AWS CLI などによる認証情報取得 ( aws sts assume-role-with-saml ... ) の両操作をカバーできます。 CloudTrail イベントレコード(抜粋) { " eventVersion ": " 1.08 ", " userIdentity ": { " type ": " SAMLUser ", " userName ": " xxx@revcomm.co.jp ", ... } , " eventSource ": " sts.amazonaws.com ", " eventName ": " AssumeRoleWithSAML ", " awsRegion ": " us-east-1 ", " requestParameters ": { ... " roleArn ": " arn:aws:iam::012345678910:role/AdministratorRole ", ... } , ... } CFn テンプレート(抜粋) Mappings : AccountAdminRoleNameMapping : AdminRoleArn : # account-1 "012345678910" : arn:aws:iam::012345678910:role/AdministratorRole # ... Resources : ... AssumeRoleWithSAMLEventsRule : Type : AWS::Events::Rule Properties : EventPattern : detail-type : - "AWS API Call via CloudTrail" detail : eventSource : - "sts.amazonaws.com" eventName : - AssumeRoleWithSAML requestParameters : roleArn : - !FindInMap [ AccountAdminRoleNameMapping, AdminRoleArn, !Ref "AWS::AccountId" ] ... 通知結果 正しく設定できると、下記のようにメール送信することが確認できます (Slack) 。 また、EventBridge の「入力トランスフォーマー」を使用して各種情報を抜粋しています。 ルートユーザーのログイン通知 Admin ロールのログイン通知 ステップ2. 個別メンション付きリプライ ログイン検知から通知までの仕組みができましたが、一歩踏み込んで個別メンションするようにしました。 ステップ1 で Slack に送信されたメールをトリガーにした Slack App (ボット) を作成し、AWS Lambda で動作させています。 ボットの実装には Slack が公式に公開しているフレームワークである Bolt for Python を使用しました。 slackapi/bolt-python: A framework to build Slack apps using Python ドキュメント: Slack | Bolt for Python Lambda ランタイムは Python 3.9、ライブラリは下記のバージョンを使用しています。 slack-bolt==1.11.2 slack-sdk==3.13.0 Slack App 作成 Slack App コンソール から「Create New App」を選び、新規アプリを作成します。 Slack App がメッセージの受信をハンドルできるよう「Event Subscriptions」をオプトインします。※Slack App 自体の詳細な手順は本記事では割愛させて頂きます。 Lambda 関数で利用するトークン情報の取得方法のみ記載します。 SLACK_BOT_TOKEN OAuth & Permissions > OAuth Tokens for Your Workspace > Bot User OAuth Token SLACK_BOT_SIGNING_SECRET Settings > App Credentials > Signing Secret メンション用のユーザーID取得 ボットから送信するメッセージでメンションを付けるには単に @xxx とするのではなく、 <@[user-id]> とする必要があります。 [user-id] は Slack 内部で管理されている固有値です( "W012A3CDE" のような形式)。 個別メンションのためにユーザーIDをメールアドレスから取得する必要があります。 from typing import Optional from slack_sdk import WebClient SLACK_BOT_TOKEN = 'xxx' def get_user_id_by_email_address (email_address: str ) -> Optional[ str ]: client = WebClient(token=SLACK_BOT_TOKEN) response = client.users_lookupByEmail(email=email_address) if not response.data.get[ "ok" ]: return if response.data.get( "error" ) == "users_not_found" : return return response.data[ "user" ][ "id" ] 参考: users.lookupByEmail method | Slack ボットのコア処理 Bolt App を初期化すると @app.event のようにデコレーターで Slack 上のイベントをリッスンして処理を実装できます。 またエントリーポイントとなる lambda_handler もフレームワークのメソッドが用意されているため簡潔に実装することができます。 import json import re from typing import Union from slack_bolt import App from slack_bolt.adapter.aws_lambda import SlackRequestHandler from slack_sdk import WebClient SLACK_BOT_TOKEN = 'xxx' SLACK_BOT_SIGNING_SECRET = 'xxx' app = App( process_before_response= True , token=SLACK_BOT_TOKEN, signing_secret=SLACK_BOT_SIGNING_SECRET ) @ app.event ({ "type" : "message" , "subtype" : "file_share" , }) def reply_to_sns_email (body, logger): """Amazon SNS からのメール通知に対してメンション付きスレッド返信を行う""" attachment = body[ "event" ][ "files" ][ 0 ] # メール以外のイベントはスキップする if attachment[ "filetype" ] != "email" : return { 'statusCode' : '200' , 'body' : json.dumps({ 'message' : 'This message is not email.' }) } # ... email_body = attachment[ "plain_text" ] # " -- " 以降の改行を含む全ての文字を除去する login_info_text = re.sub( "--.*" , "" , email_body, flags=re.DOTALL).replace( " \r\n " , "" ) login_info = json.loads(login_info_text) arn = login_info[ "RoleArn" ] if arn.endswith( "root" ): """root ユーザーが使用された場合 e.g. arn:aws:iam::012345678910:root """ user_id = None message_abstract = "rootユーザーによるコンソールログインが行われました。" else : """Assumed Role Arn からログイン者のメールアドレスを取得する""" email_address = login_info[ "UserName" ] message_abstract = "権限の強いアカウントが使用されました。" # メールアドレスから Slack ユーザID を紐づける user_id = get_user_id_by_email_address(email_address) if user_id is None : mention = "<!here>" else : mention = f "<@{user_id}>" message_text = \ mention + " \n " + message_abstract + " \n " \ "- 作業目的や内容 \n " \ "- タスクチケットURL \n " \ "などを記入してください。" client.chat_postMessage( channel=body[ "event" ][ "channel" ], thread_ts=body[ "event" ][ "event_ts" ], text=message_text ) return { 'statusCode' : '200' , 'body' : json.dumps({ 'message' : 'ok' }) } def lambda_handler (event, context): slack_handler = SlackRequestHandler(app=app) return slack_handler.handle(event, context) デプロイ 上記の Lambda 関数をデプロイし、API Gateway の配下に配置します。 API Gateway は「REST API」ではなく「HTTP API」で作成する必要があるので注意します。 デプロイ後、API Gateway の URL を Slack App のリクエスト URL に登録します。 ※Features > Event Subscriptions > Request URL 動作確認 ここまでの手順が正しく設定できていれば、ルートユーザー/Admin ロールのログインを契機に一連処理を通してボットによる個別メンション付きリプライが届きます。 まとめ 特権IDの使用を通知し、セキュリティガバナンスを改善する取り組みについてご紹介しました。 本記事の取り組みでは下記のメリットを実現しました。 特権IDがやみくもに使用されることをけん制しつつ、使用状況をモニタリングする 特権IDを用いる作業内容の監査証跡を残す RevComm ではお客様に安心して製品をご利用頂けるよう、引き続きセキュリティの向上に力を入れてまいります。 Infrastructure では、プロダクト横断で考慮するセキュリティの管理や IaC 、また一部オンプレ環境の管理・運用を行っています。ご尽力頂けるエンジニアを探しています。 インフラ以外にも全方位でエンジニアを積極的に採用中ですのでぜひ採用サイトをご覧ください! www.revcomm.co.jp
こんにちは、RevComm の玉城です。MiiTel Analytics のサーバーサイドの開発を主に担当しています。MiiTel Analytics は通話や会議の履歴・音声解析結果を集約し可視化する製品です。 MiiTel Analytics では以下の2つを提供するために Amazon OpenSearch を運用しています。 全文検索機能 使用単語頻度レポート この Amazon OpenSearch のシャード数を切り替えるタイミングがありそちらを私が担当しましたので、その時の内容を紹介したいと思います。 シャード数切り替えに至った経緯 リインデックスとは リインデックスの高速化 MiiTel における OpenSearch へのデータ登録の仕組み まとめ シャード数切り替えに至った経緯 OpenSearch には MiiTel Analytics で解析された応対履歴が保存されます。そのため、MiiTel の利用ユーザーが増えると保存されるデータも増大し、書き込み/検索に掛かる負荷が大きくなっていきます。参考として、記事執筆時点では 1 億近いドキュメント数と 300 GB を超えるデータサイズとなっています。 このようにデータ数増大による負荷増大への対応として、OpenSearch にはインデックス毎にシャード数を設定します。シャードとは、OpenSearch でクラスターにデータを分散するときの単位です。 切り替え前のシャード数は 2 で各シャード数の容量は約 65 GB でした。AWS 側では適切なシャードのサイズは 10 〜 50 GiB にすることを推奨しており、既に推奨値を超えている状態でしたので早めの対応が必要でした。 そのため、今回の対応ではシャード数を 2 -> 4 に増やしてデータを分散させることで各シャード毎のサイズを 10 〜 50 GiB 以内に収めました。 参考: Amazon OpenSearch Service ドメインのサイジング - Amazon OpenSearch Service リインデックスとは OpenSearch のシャード数設定はインデックス作成の際に行われます。既存のインデックスのシャード数を途中で変更することはできません。そのため、今回のシャード数切り替えのためにリインデックスを行いました。 リインデックスとは既存インデックスとは別に新しくインデックスを作成し、既存インデックスのデータをまるまる新しいインデックスにコピーすることです。この新しくインデックスを作成する際に、シャード数を 4 に設定することでシャード数切り替えの対応を行います。 新インデックスを作成します。その際に既存インデックスから更新したい設定値を新たに設定します。今回の場合はシャード数です。 既存インデックスから新インデックスに向けて Reindex API を実行します。 既存インデックスから新インデックスへエイリアスを変更し既存インデックスを削除します。 リインデックスの高速化 リインデックスを行う上で注意したい設定があります。それは refresh_interval です。refresh_interval はインデックスのドキュメント更新を検索に反映する操作の実行間隔になります。refresh_interval が 10 秒だった場合、下記の図のようにインターバルの間に起こった変更は次のインターバルにて検索ができるようになります。 refresh_interval の設定を無効にすることでリインデックスの実行時間を早く終わらせることができます。詳細は後述しますが、今回のリインデックス作業中はデータの更新を一時的に止めることで、新しくデータが投入されない状況にすることができました。そのため、refresh_interval の設定を無効にすることができました。 例として約 110 万のドキュメント数に対して refresh_interval を -1(無効)と 100 秒で比較検証したところ、-1 の場合は 5 分 50 秒だったのに対して 100 秒の場合は 11 分 19 秒と倍近くの差が出ました。 MiiTel における OpenSearch へのデータ登録の仕組み MiiTel Analytics で解析された応対履歴は Amazon RDS へ保存されます。OpenSearch へはこの Amazon RDS へデータが保存されたタイミングでデータが同期されます。 応対履歴を RDS へ保存/更新 保存/更新された応対履歴 ID を SQS に送信 Lambda が応対履歴 ID を SQS より取得 Lambda にて応対履歴を OpenSearch へ同期する API を呼び出す 同期 API のリクエストの応対履歴 ID を元に RDS より応対履歴を取得 取得した応対履歴を OpenSearch 用に変換し登録 今回のリインデックス作業では上図の定期実行される Lambda を一時的に止めます。それによりリインデックス中は上図の 3 〜 6 が止まり、OpenSearch へのデータ追加/更新が行われないのでデータの不整合を防ぐことができます。 また、上図の 1 〜 2 はリインデックス中も稼働しているので SQS には新規または更新された応対履歴の ID が溜まっていきます。これらの応対履歴はリインデックスが完了した後に Lambda を再稼働することで OpenSearch へ登録されますので、リインデックス中の応対履歴も取り零すことなくシャード数の切り替えが行えます。デメリットは一点で、データ同期を一時的に止めるのでリインデックス中は最新のデータを扱えなくなります。 *1 上記で説明してきたように、今回のリインデックスはサービスのダウンタイムなしで行えます。しかしながら最新のデータを扱えないままサービスを提供するのはあまり宜しくないため、顧客に事前にメンテナンス時間を通知し、その時間内にシャード数の切り替えを行いました。 データ数が多いのでリインデックスが完了するまでに約 8 時間も掛かりました。お客様にご協力いただいたおかげで refresh_interval を無効にすることができたのでこの時間で収まったことになります。もしも refresh_interval を 100 秒に設定していたら倍の 16 時間は掛かっていたかと思うと恐ろしいです。 まとめ 今回のシステム要件とデータ量の場合、ダウンタイムなしの約 8 時間でリインデックスが完了しました。しかし、近い将来にまた負荷を緩和するためのシャード数の切り替えを行わなければなりません。なぜなら、このリインデックスを行った当時のドキュメント数は約 6,600 万で現在は約 9,300 万弱とありがたいことに利用者は順調に増えているからです。 今回の対応が次回も丸々使えるかと言うと規模が増大しているため使えないと思います。しかし、今回の対応でシャード数切り替えやリインデックスに対する知見は得られたので、今後の Amazon OpenSearch の運用をより良くしていくために模索していきたいと思います。 リインデックスは、リインデックス中のデータ登録/更新/検索が行なえます。しかし、リインデックス中のコピー元のデータ登録/更新はコピー先へ反映されないため、コピー元とコピー先のインデックス内容に差異が生じます。 *1 : リインデックスは、リインデックス中のデータ登録/更新/検索が行なえます。しかし、リインデックス中のコピー元のデータ登録/更新はコピー先へ反映されないため、コピー元とコピー先のインデックス内容に差異が生じます。
はじめまして。 株式会社 RevComm でバックエンドエンジニアをしている近藤です。主に MiiTel Analytics や外部連携の開発に携わっています。 MiiTel Analytics とは、電話やビデオ会議のデータ可視化や MiiTel を構成するマイクロサービスに API を提供するプロダクトです。 執筆時点で 20 名弱ほどのエンジニアメンバーで構成されている、RevComm 内では比較的大きなチームです。 CI / CD ツールとしては GitHub Actions と AWS CodeDeploy を利用しています。 またこれらのツールを ビルド・テスト・リリースフェーズを自動化 ルールの普及 の2つの目的で活用しています。 ここでいうルールの普及とは、開発におけるツールの運用や気をつけるべきことを開発メンバー全体に周知・徹底することです。 前述の通り MiiTel Analytics チームは比較的大きく、さらに拡大し続けています。 このような状況で、例えば GitHub Issue 管理方法、pull request の出し方などのルールを普及させるのは難しいです。そこで、CI / CD ツールを利用してこの課題を解決しようとしています。 この記事では GitHub Issue 管理 GitHub pull request 管理 開発知識の共有 という 3 つの具体的なケースについて、GitHub Actions の定義ファイルも合わせて紹介します。 GitHub Issue 管理 Analytics チームでは GitHub projects を利用しています。いわゆるカンバンというものです。 大きく Todo, In Progress, Done というカテゴリがあり、Todo に関してはさらにプロジェクトごとに分割されています。 「Issue を作ったら対応する Todo に入れてください」とアナウンスしても、なかなか徹底されないのは想像できますね。そこで以下のような GitHub Actions を用意しています。 概要 Issue を新規作成したらラベルを見て Todo に追加する ※ ラベルは 手動 もしくは、 Issue テンプレート機能 のメタ情報にて追加できます 利用 Actions GitHub Project Automation+ https://github.com/marketplace/actions/github-project-automation コード name : Manage Issues for Project on : issues : types : [ labeled ] jobs : automate-project-columns : runs-on : ubuntu-latest steps : - name : to feature1 # NOTE : もしラベルが 'feature1' だったらプロジェクトの Todo - feature1 カラムに追加 if : github.event.label.name == 'feature1' uses : alex-page/github-project-automation-plus@v0.8.1 with : project : miitel-analytics column : Todo - feature1 # NOTE : personal access token repo-token : ${{ secrets.GITHUB_TOKEN }} - name : to feature2 # NOTE : もしラベルが feature2' だったらプロジェクトの Todo - feature2 カラムに追加 if : github.event.label.name == 'feature2' uses : alex-page/github-project-automation-plus@v0.8.1 with : project : miitel-analytics column : Todo - feature2 # NOTE : personal access token repo-token : ${{ secrets.GITHUB_TOKEN }} GitHub pull request 管理 次に、コードを書き終えて pull request を作成した時は 自分を Assignee にする プロジェクトの In Progress に pull request を追加する というのがルールになっています。 こちらも pull request を出せた達成感から忘れてしまいそうなルールです。そこで、以下のような GitHub Actions を用意しています。 概要 pull request を作成した人を Assignee として追加する pull request を Project の InProgress (PR) カラム に追加する 利用 Actions Auto Assign Action https://github.com/marketplace/actions/auto-assign-action GitHub Project Automation+ https://github.com/marketplace/actions/github-project-automation コード pull request を作成した人を Assignee として追加する name : Auto Author Assign on : pull_request : branches : - main types : - opened - ready_for_review jobs : auto-author-assign : runs-on : ubuntu-latest steps : - name : add reviewers uses : kentaro-m/auto-assign-action@v1.1.2 with : # NOTE : Auto Assign Action 専用の config ファイルを利用 # refs: https://github.com/marketplace/actions/auto-assign-action#single-reviewers-list configuration-path : '.github/config_auto_assign.yml' # NOTE : personal access token repo-token : ${{ secrets.GITHUB_TOKEN }} pull request を Project の InProgress (PR) カラムに追加する name : Pull Request Opened on : pull_request : branches : - main types : - opened - reopened jobs : automate-project-columns : runs-on : ubuntu-latest steps : - name : to miitel-analytics uses : alex-page/github-project-automation-plus@v0.7.1 with : project : miitel-analytics column : InProgress (PR) # NOTE : personal access token repo-token : ${{ secrets.GITHUB_TOKEN }} 開発知識の共有 pull request を出した後には、コードレビューがあります。 そこで、開発知識の共有ができる GitHub Actions を用意しています。 MiiTel Analytics はマイクロサービス化されています。しかし、別のプロジェクトに依存してしまっている箇所があり、あるファイルを変更した際には別のプロジェクトのコードも修正する必要があります。 こうしたチームの中だけの暗黙の知識を、漏れなく共有することができます。 概要 file A に変更があった際に、「project B の target.txt も書き換えてください」というコメントを追加する 利用 Actions Create or Update Comment https://github.com/marketplace/actions/create-or-update-comment コード name : Detect File Changes on : pull_request : branches : [ main ] paths : - '**/apps/specific/file/fileA.py' jobs : comment : runs-on : ubuntu-latest steps : - name : Create comments id : create-comments run : | echo "この Pull Request は fileA.py の変更を含んでいます。" >> comments echo "project B の target.txt も書き換えてください" >> comments comments=$(cat comments) comments="${comments//$'\n'/%0A}" echo "::set-output name=values::$comments" - name : Post multi-line comments uses : peter-evans/create-or-update-comment@v1 with : token : ${{ secrets.GITHUB_TOKEN }} issue-number : ${{ github.event.pull_request.number }} body : ${{ steps.create-comments.outputs.values }} まとめ いかがでしたでしょうか。 Analytics チームでの、「複雑な作業の自動化」「ルールの普及」を目的とした CI / CD ツール 活用例について紹介しました。 特に「ルールの普及」については GitHub Actions を利用することで、簡単に自動化することができます。開発体験の向上だけでなく、コミュニケーションコストの削減に役立ちます。 記事中のコード例などが、みなさんのチームの課題解決のアイディアの元となれば幸いです。 一緒に働きませんか? RevComm では、「少ない人数で最大の開発効率を」という意識のもと CI / CD ツールの活用が行われています。 アプリケーションの開発に加えてチームでの開発効率を最大化させることに興味のある方、この記事で RevComm の開発に興味を持ってくださった方、ぜひ採用ページをご覧ください! www.revcomm.co.jp
こんにちは、RevComm の小島です。MiiTel Analytics のサーバーサイドの開発を主に担当しています。MiiTel Analytics は通話や会議の履歴・音声解析結果を集約し可視化する製品です。 MiiTel Analytics の画面例。録音再生、音声解析結果の閲覧などの機能があります。 MiiTel Analytics のサーバーサイドは Python で実装されており、Web フレームワークとして Django を利用しています。今回の記事では Django の ORM を利用している際のアンチパターンである first メソッドの乱用について解説します。 first メソッドの一般的なユースケース アンチパターンの場合 想定されるトラブル例 アンチパターンが発生する理由 解決案 まとめ first メソッドの一般的なユースケース まずは first メソッドの通常のユースケースを確認しましょう。first は Django の QuerySet の先頭のオブジェクトを返すメソッドです。例えば # Post は記事を表すモデル Post.objects.order_by( 'published_at' ).first() などとすれば公開日の一番新しい記事を取得することができます。 つまり複数の候補のうち最新のものや最も古いものなど、順番に意味がある時にこのメソッドは使われます。 アンチパターンの場合 一方、このメソッドを、順番に意味がない時であっても乱用しているコードを目にすることがあります。例えば、次のようなコードです。 # メールアドレスで User テーブルを検索 user = User.objects.filter(email=email).first() if not user: raise NotFound() # 以下 user を使った処理... 上記のコードはメールアドレスでユーザーを絞り込んで first メソッドを用いて先頭のモデルを取り出す、もしユーザーがなかったら例外を返すという挙動をするコード例です。こうした使い方は複数のプロジェクトで目にしたことがあり実際動くのですが、実はこのコードは潜在的な問題を抱えています。 通常、同じメールアドレスを持つユーザーが複数いることを許容しない場合が多いですし、 許容したとして同一メールアドレスのユーザーの取得順には意味がありません 。メールアドレスが同じユーザーが複数いたとしても、そのうちのどれでもいいからひとつを取得したいというケースは考えにくいです。 したがって、 User.objects.filter(email=email) というコードは 2 つ以上のオブジェクトを返すことを想定していないと考えられます。 ところが、上記の絞り込みを実行してもデータがひとつだけ返ってきたのか、複数返ってきたのかについては何の情報もありません。そのため、first メソッドでユーザーを取得すると、意図せず複数のユーザーオブジェクトがデータベースに存在していても、不整合に気づけないのです。 想定されるトラブル例 このようなコードがトラブルを引き起こし得る具体的な状況としては、初期の実装では一意だったものが、要件や仕様の変更で一意性が担保されなくなるケースです。 例えば論理削除をあとで導入する場合が該当します。削除済みレコードと削除済みでないレコードを区別する必要があり、多くのクエリ(filter メソッド)において削除フラグを検証することが必要になります。 1箇所誤って削除フラグを検証してないクエリが残ってしまったとします。この時、メールアドレスが同じで論理削除済みのユーザーと削除されていないユーザーのどちらもテーブルにある場合、コードで削除済みユーザーと削除されていないユーザーのどちらを取得できるかは DB の実装依存になります。場合によっては 本来は取得されるべきでないユーザーの情報を取得することになります 。 さらに怖いことに、first メソッドはエラーを出さないため、本来取得すべきユーザーとは異なるまま処理が継続されます。これは 不正な認証やデータの漏洩、意図しないデータの更新など大きなトラブル につながりかねません。 アンチパターンが発生する理由 このように、場合によっては大きなトラブルを引き起こしかねない first メソッドですが、なぜ様々な場面で使われてしまうのでしょうか。 本来であれば、ただひとつのオブジェクトを取得するメソッドには get というメソッドがあり、こちらを使うのが一般的です。 user = User.objects.get(email=email) ではなぜ get ではなく filter と first を使って書きたくなるのかと言うと、それは条件を満たすオブジェクトが存在しなかった時の挙動の違いにあります。 get は存在しなかった時に例外を送出するため、例外処理を書く必要があります。 try : user = User.objects.get(email=email) except User.DoesNotExist: user = None 一方で filter は、存在しなかった時は単に None を返します。 user = User.objects.filter(email=email).first() 比較すれば明らかですね。後者の方が 簡潔なコードのように見える のです。 どちらも条件に合うオブジェクトがただひとつ、もしくは存在しないのであれば同じ挙動をします。同じ挙動であれば簡潔なコードを書こうとする心理が働き、filter と first を使った書き方をする方が多いのだと思われます。 解決案 アプリケーションで対応できる最もシンプルな解決策は get メソッドを使うことです。 get が返しうる例外は 2 種類あり、ひとつは上記の例にも挙げた DoesNotExist で、もうひとつは MultipleObjectsReturned です。 MultipleObjectsReturned は 2 つ以上の条件を満たすオブジェクトが存在する場合に送出される例外です。よって、もし get を使えば意図せず 2 つ以上のデータがあった場合はシステムエラーとなり、データ不整合に気づくことができます。 しかし、単に get を使うようルール化するだけではアンチパターンを解消するには至りません。このアンチパターンが発生するのは fitler と first を使ったほうが簡潔に書けるという(一見もっともらしい)メリットがあるからです。であれば、単に get を使うというルール化をするだけでなく、コードの簡潔さを維持しつつ get を使えるようにしなければなりません。 その手段として、 MiiTel Analytics では Django の組み込みの QuerySet API をモデルの定義ファイル以外から直接呼ぶことを禁止し、次のようにモデルの定義ファイルに QuerySet に必要なメソッドを追加する形式をとっています。 # models.py from django.db import models class UserQuerySet (models.QuerySet): def get_by_email (self, email: str ) -> Optional[ "User" ]: try : return self.get(email=email) except User.DoesNotExist: return None class User (BaseModel): email = models.CharField(max_length= 256 ) # other fields... objects: UserQuerySet = UserQuerySet.as_manager() ※Django のユーザーモデル (auth_user テーブル) を使っている場合には上記のコードはそのままでは使えません。 このようにすれば、メールアドレスで User を取得するコードは user = User.objects.get_by_email(email=email) となり、filter と first を使うのと同じように簡潔に書くことができます。それに加えて、データ不整合時にエラーが出るため、問題の早期発見につながり、アプリケーションが不正な状態で動き続けることを避けることができます。 まとめ オブジェクトがただひとつ欲しい場合に filter と first を利用するのはデータ不整合を見逃す可能性がある filter と first が使われてしまう理由は簡潔に書けるから ただひとつのオブジェクトを取得するには原則として get を使おう QuerySet に必要なメソッドを定義すれば filter と first を使った方法と同等に簡潔に同じ処理を実装できる first はその名前の通り、あくまで QuerySet の先頭のオブジェクトをとるためのメソッドです。順番に重要性がない、もしくは複数のオブジェクトが返ってくる想定をしていない時の利用は想定されていません。 組み込みのシンプルなメソッドで簡潔に処理が書けるのはたしかに魅力的なことです。しかしこのケースのようにリスクがあるケースもあり、たとえ実装が煩雑になっても用途に合致したメソッドを採用することはとても重要だと思います。 アプリケーションのコードの品質を担保するために、こうした小さな努力の積み重ねを徹底することが非常に重要だと考えています。そして、こうした努力の結果として MiiTel というサービスの安定性や信頼の獲得に繋がるのだと信じています。
はじめに 株式会社 RevComm の Software Engineer 宇佐美です。 RevComm では、電話営業や顧客対応を可視化する音声解析 AI 搭載型のクラウド IP 電話 MiiTel (ミーテル) を提供しています。 miitel.com MiiTel の中核プロダクトである MiiTel Analytics は、フロントエンドが React ・バックエンドが Python (Django) という構成の Web アプリケーションです。メイン言語は日本語を想定していますが、ユーザーが設定言語を変更することで英語で利用することも可能です。 今回、従来はバックエンドで行っていたWebアプリケーションの 国際化対応 (internationalization, i18n) をフロントエンドに移行するという作業を行いました。 この過程でわかった国際化対応の方法や、国際化対応をバックエンドで行う場合とフロントエンドで行う場合それぞれのメリット・デメリットなどを紹介したいと思います。 はじめに 前提・本記事のスコープ 国際化対応とは? 国際化とローカル化 バックエンドでの i18n 対応 事前準備 翻訳文字列の指定 言語ファイルの作成 言語ファイルのコンパイル フロントエンドでの i18n 対応 react-i18next インストールから導入まで ディレクトリ構成 フロントエンドで国際化対応するメリット・デメリット メリット ユーザーに見える部分を柔軟にカスタマイズしやすい 言語ファイルのコンパイルが不要でデプロイが容易 言語ファイルをコンテキストごとに分けて管理しやすい デメリット サーバーサイドに閉じた文言は対応できない パフォーマンスが落ちることがある SEO に弱くなる可能性がある まとめ 補足 参考資料 前提・本記事のスコープ 国際化対応とは? 一口に国際化対応といっても、この言葉が指すスコープはコンテキストやビジネス要件によって大きく変わってきます。 一般的に Web アプリケーションの国際化対応といったときに含まれるものとしては、以下のようなものがあります。 多言語対応 (文字セット、文言、書式など) 日時情報の時差 (Time zones) 通貨情報 Django はこれらすべてを含む国際化対応をフルサポートしていますが、この記事のスコープとしては 多言語対応、それも日英の 2 言語のみの対応 が中心となります。 Webシステム・アプリケーションの国際化対応は非常に幅広く奥深い分野なので、すべてを完璧に対応することは事実上不可能ですが、英語だけでも対応しておくと国際展開の可能性が広がります。 国際化対応の深淵な世界に関心がある方は、以下の動画をご覧になるとその一端がわかるかと思います。 www.youtube.com 国際化とローカル化 国際化と類似の概念として、ローカル化 (localization, l10n) というものがあります。この2つはしばしば混同されがちなので、Django の公式ドキュメント内にある定義を添付します。 国際化 (internationalization) ソフトウェアをローカル化に備えさせることです。通常、開発者によって行われます。 ローカル化 (localization) 翻訳およびローカルな表示形式を記述することです。通常、翻訳者によって行われます。 https://docs.djangoproject.com/ja/3.2/topics/i18n/#definitions 実務ではローカル化も含めて国際化対応と呼ばれることがあったり、開発者がローカル化の作業 (翻訳テキストの用意) まで行うこともあるかと思います。 ただ、最低でもユーザーの目に見える部分に関するローカル化はネイティブレベルのチェックが必要になるでしょう。 バックエンドでの i18n 対応 ここからは実際の i18n 対応のやり方について紹介していきます。まずはバックエンド (Django) での対応です。 前述のとおり、MiiTel Analytics ではもともとバックエンドで国際化対応を行っていました。 Django は非常に多機能なフレームワークなので、国際化対応向けの機能ももちろん用意されています。 国際化とローカル化: https://docs.djangoproject.com/ja/3.2/topics/i18n/ 翻訳: https://docs.djangoproject.com/ja/3.2/topics/i18n/translation/ 事前準備 Django で多言語対応を行う場合に必要となる事前準備がいくつかあります。主に settings.py への値の設定です。 # デフォルトの言語コード LANGUAGE_CODE = ‘en’ # 翻訳用 Middleware を追加 MIDDLEWARE = [ ... 'django.middleware.locale.LocaleMiddleware' , ... ] # 言語ファイルのパス LOCALE_PATHS = ( os.path.join(BASE_DIR, 'locale' ), ) なお、LocaleMiddleware は SessionMiddleware の後かつ CommonMiddleware の前に置く必要があります。 https://docs.djangoproject.com/ja/3.2/topics/i18n/translation/#how-django-discovers-language-preference 翻訳文字列の指定 Django で多言語対応を行う場合、アプリケーション内で翻訳を行う対象となるテキストに対して 翻訳文字列 (translation string) を指定し、翻訳対象の文言であることをマークします。 Django はこの翻訳文字列を見て、後述する言語ファイルの内容に従って翻訳対象となる言語に翻訳します。翻訳文字列の指定を行うときは、django.utils.translation から gettext や gettext_lazy モジュールをインポートして使います。 from django.http import HttpResponse from django.utils.translation import gettext as _ def my_view (request): output = _( "TEXT_TO_BE_TRANSLATED" ) return HttpResponse(output) # 翻訳後の結果が HTTP レスポンスに渡される ここでは TEXT_TO_BE_TRANSLATED という文字列が翻訳文字列として指定されています。 テンプレートを使う場合はシンタックスがやや異なり、i18n をロードしてから trans タグを使って翻訳文字列を指定します。 {% load i18n %} <h1>{% trans "TEXT_TO_BE_TRANSLATED" %}</h1> # 翻訳後の結果が HTML ファイルで表示される デフォルト言語を設定しておけば、その言語をそのまま翻訳文字列として利用することも可能です。こうすると、ユーザーの利用言語がデフォルト言語を利用する場合は翻訳文字列がそのまま出力されます。 ただ、本来は翻訳文字列は Django に翻訳対象の文言であることを知らせるための ID としての役割を担っているので、文章よりはユニークな ID として成立する文字列が望ましいでしょう。 翻訳を利用する側の views や templates ですることは、基本的にはこれだけです。 言語ファイルの作成 翻訳文字列の指定ができたら、これに対応した言語ファイル (language file) を作成します。 言語ファイルは、翻訳文字列を実際に各言語にどのように翻訳するかを指示するためのファイルです。 言語ファイルという形式自体は Django や Python に固有のものではなく、UNIX-like な OS で使用されている翻訳システム gettext の一実装である GNU gettext で定められているものです。 https://www.gnu.org/software/gettext/manual/gettext.html#PO-Files 拡張子は .po です。 言語ファイルの構造は大きく分けて以下のようになっています。 メタデータ エントリー msgid (メッセージの ID。翻訳文字列と一致している必要がある) msgstr (翻訳後の文言) このファイルを日本語、英語などの翻訳対象の言語ごとに作成します。 言語ファイルの作成は、Django がコマンドから自動で行ってくれます。 $ django-admin makemessages -l ja makemessages コマンドを実行すると、Django はソースコード全体から翻訳文字列を探し出して言語ファイルを作成します。 言語ファイルはそれぞれの翻訳文字列があるアプリケーション内の locale ディレクトリ内に作成されますが、settings.py 内の文言やアプリケーションに属さないファイルに関するものは LOCALE_PATHS で定義したディレクトリに作成されます。 https://docs.djangoproject.com/ja/3.2/ref/django-admin/#makemessages なお MiiTel Analytics ではバックエンドのアプリケーションで使う言語ファイル以外に、フロントエンドで使うための言語パック API を用意しています。 フロントエンドではこの API から取得した言語パックを state として保持して、メッセージの表示などフロントエンド特有のユースケースで使用していました。 言語ファイルのコンパイル 言語ファイルはそれ自体ではアプリケーションから読み込むことができず、翻訳言語ごとにコンパイルする必要があります。 これも Django のコマンドから行うことができます。 $ django-admin.py compilemessages -l ja コンパイルされた結果、.po ファイルと同じディレクトリに .mo ファイルが生成されます。 これでアプリケーションに翻訳が反映されることになります。 フロントエンドでの i18n 対応 上記のとおり、従来はバックエンド (Django) で処理してきた i18n 対応ですが、今回これをフロントエンドの React に移行しました。 とはいえ、バックエンドで使用していた言語ファイルは膨大な数があるため、すべてを一度に置き換えることは難しく、インクリメンタルに移行していっているという状況です。 react-i18next フロントエンド (React) での i18n 導入にあたって、ライブラリは react-i18next を選びました。 https://react.i18next.com/ 選定の基準としては、ダウンロード数・GitHub Stars 数・更新頻度の多さなどでしたが、すでに RevComm 内の他プロジェクトでも採用していることもありスムーズに導入できると考えました。 参考: npm trends React の i18n 関連ライブラリ (2022 年 1 月時点) ダウンロード数を見ると i18next が一番多いですが、これは Node.js などのバックエンドでの国際化対応も含むライブラリで、react-i18next は i18next をもとにできた React 用ライブラリです。 react-i18next is a powerful internationalization framework for React / React Native which is based on i18next. Check out the history of i18next and when react-i18next was introduced. https://react.i18next.com/#what-is-react-i18next インストールから導入まで react-i18next はドキュメントが充実しているので、基本的にはドキュメントどおりに進めていくと簡単に導入することができます。 https://react.i18next.com/getting-started まずはアプリケーションのルートディレクトリでライブラリをインストールします。 $ npm install react-i18next i18next 翻訳用の JSON ファイルを作成します。これはバックエンドでの言語ファイルにあたります。 " en ": { " test ": { " Welcome to React ": " Welcome to React and react-i18next " } } , " ja ": { " test ": { " Welcome to React ": " React と react-i18next へようこそ " } } 利用側の React コンポーネントでは以下の 2 つを行います。 react-i18next の初期化 翻訳文字列の指定 まず react-i18next を初期化します。一般的にはトップレベルのコンポーネント (index.js や App.js など) で行います。 // App.js import i18n from ‘i18next’; import { initReactI18next } from 'react-i18next' ; import resources from './i18n/resources.json' ; i18n.use(initReactI18next) .init( { lng, fallbackLng: ‘en’, debug: false , resources, } ); i18n.init() 関数に渡す Options は色々と設定できるので、詳細は公式ドキュメントを参照してください。 https://www.i18next.com/overview/configuration-options ユーザーの使用言語は lng で指定します。ブラウザの設定言語を動的に取得したり、後からi18next.changeLanguage() を使って言語を変更することもできます。 (ユーザーの locale に応じて言語を変更する場合などで使えます) 実際に翻訳を行うときは Django で行っていたのと同様に翻訳文字列を指定して、 useTranslation() フックから取得した t 関数を使います。 useTranslation() の引数には JSON ファイル内のキーを指定します。 import React from "react" ; import { useTranslation } from "react-i18next" ; function SomeComponent() { const { t } = useTranslation(‘test’); return <h2> { t( 'Welcome to React' ) } </h2>; // Output: <h2>Welcome to React and react-i18next</h2> } ディレクトリ構成 MiiTel Analytics のフロントエンドでのディレクトリ構成はざっくりと以下のようになっていて、components ディレクトリ内でそれぞれ i18n 用の JSON ファイルを持っています。 ディレクトリ構成 このようにすることで追加や修正などの管理もしやすくなり、グローバルでのメッセージ ID の重複を考慮する必要もなくなります。 ディレクトリごとに i18n の JSON ファイルを管理する場合、 i18n.addResourceBundle() を使ってリソースを読み込みます。 https://www.i18next.com/overview/api#addresourcebundle deep と overwrite オプションを省略した場合、このファイル内では引数に渡された resource で言語ファイルが置き換えられます。 lng (言語) や ns (namespase) などをコンポーネントごとに毎回設定するのは不便なので、下記のように言語ファイルからこれらの情報を抽出する util 関数を作っておくと便利です。 export const I18nUtil = { addResourceJson: (i18n, json) => { Object .entries(json).forEach(( [ lng, value ] ) => { Object .entries(value).forEach(( [ ns, resources ] ) => { i18n.addResourceBundle(lng, ns, resources); } ); } ); } , } ; フロントエンドで国際化対応するメリット・デメリット あくまでも今回の技術スタックを使ったうえでの内容ですが、移行する中で気づいたメリット・デメリットは以下のようなものがありました。 メリット ユーザーに見える部分を柔軟にカスタマイズしやすい バックエンドで対応する方法だと、API レスポンス以外の国際化対応は何らかの形でまとめてフロントエンドに JSON を渡すような方法になってしまいがちです。 フロントエンドで対応することによって、対象のコンポーネントに限って国際化対応を行ったり、ディレクトリ構成を分けて管理しやすくなります。 言語ファイルのコンパイルが不要でデプロイが容易 Django では言語ファイルの更新があったときに再コンパイルが必要だったので、デプロイのタイミングでコマンドを実行する必要がありました (MiiTel Analytics では CI/CD に組み込んで自動実行していました) 。 フロントエンド移行に伴って、言語ファイルの JSON を更新するだけでよくなるので、何かの原因でコンパイルが漏れていて追加したエラーメッセージが翻訳されないといったことがなくなりました。 言語ファイルをコンテキストごとに分けて管理しやすい フロントエンドで使うための言語ファイルをバックエンドから取得する場合、巨大な管理しづらいオブジェクトになってしまいやすいですし、コンポーネント間での props の受け渡しなども発生します。 コンポーネントごとに切ったディレクトリ内で言語ファイルを作ることで、コンテキストを絞って限定された namespace の中で管理することができます。 デメリット サーバーサイドに閉じた文言は対応できない 外部公開している API のエラーメッセージなど、サーバーサイドに閉じていて UI を持たない、かつ国際化対応が必要なものについては、やはりサーバーサイドで対応するしかありません。 パフォーマンスが落ちることがある バックエンドで国際化対応を行う場合はコンパイル済みのメッセージを表示するだけでよくキャッシュも効きやすいですが、フロントエンドでは JavaScript で処理する分のオーバーヘッドが多少なりともあります。 SEO に弱くなる可能性がある Google などの検索エンジンの Bot やクローラーは JavaScript を適切に解釈するとされていますが、サーバーサイドで表示するのと同じレベルでクライアントサイドでの翻訳後の文言を見てくれるかどうかははっきりしません。 ただ、Next.js などで Server-side rendering を行っている場合は、react-i18next でも SSR 対応することが可能なようなので、SEO 重視のサービスであれば導入するのも手です。 https://react.i18next.com/legacy-v9/serverside-rendering まとめ 以上のようにメリット・デメリットはありますが、デメリットの部分がサービス運営上クリティカルでない場合は、フロントエンドで国際化対応を行うというのはアリな選択肢だと思います。 MiiTel Analytics ではまだバックエンドで国際化対応を行っているものも多々ありますので、今後はできるだけフロントエンドに移行していく予定です。 RevComm としてもこれから海外展開に積極的に取り組んでいく時期ですので、興味がある方はぜひ採用ページをご覧ください! www.revcomm.co.jp 補足 余談ですが、日本語から英語の翻訳を行うときに適切な文言を作るちょっとしたコツを共有したいと思います。 ユーザーの目に見える部分に関してはネイティブチェックを必須とすべきですが、内部的に利用するメッセージやコメントなどは開発者自身が英語を考える場面も現実的にはあります。 一番いいのは (身も蓋もないですが) 英語を勉強することですが、即席の対策としては Google 翻訳や DeepL などの自動翻訳サービスを使った方法がおすすめです。 ポイントとしては、単に日本語から英語の翻訳を行うのではなく、 自分で考えた英語訳を Input に入力する 上部の矢印をクリックして日本語と英語を反転させる 日本語に誤りや違和感があれば修正する Output の英語が問題ないか確認する のような順番で行うのがおすすめです。 単語単位よりもなるべく長いセンテンスを入力したほうが、コンテキストがあるので正確で意図したものに近い訳になります。 英語を入力してから言語を反転させる Google 翻訳などの自動翻訳も完璧ではありませんが、日本語話者が翻訳する場合と比較して、英語として成立しない文章や不自然な文章などは生成されにくいです。 サービス展開も開発チームもいつ多国籍になるかわからないので、開発者としては英語力は常に向上させていきたいですね。 参考資料 https://docs.djangoproject.com/en/3.2/topics/i18n/ https://react.i18next.com/ https://www.i18next.com/ https://stackoverflow.com/questions/49137654/internationalization-backend-or-frontend www.youtube.com www.youtube.com
自己紹介 MiiTel Live と MiiTel for Zoom のプロジェクトマネージャーをしている持田と申します。 プロダクトの実装を行いつつ、メンバーが活躍できる環境を整える仕事をしています。 今年の目標は猫背を治すことです。よろしくお願いします。 今回はタイトルの通り音響構成についてのお話なので、ご興味あれば是非最後まで読んでいってください。 自己紹介 オフサイトミーティングの開催 前置き 予備知識 実際の構成 配置図 構成図 この構成で実現できること レンタル・購入機器 オペレーション リハーサルの様子 感想・懸念点 オフサイトミーティングの開催 RevComm は創業当時からフルリモート・フルフレックスを導入しておりますが、対面のコミュニケーションも軽視していない会社です。 (好きな時に出社していいように渋谷にオフィスがあります) 時には対面で社員と顔を合わせて話すことで、より強いチームになっていけると信じています。 そこで、お互いの事をよく知り、働きやすく、ポテンシャルを最大化できるチームにするためにオフサイトでのミーティングの実施をすることになりました。 しかし、必ずしも全員同じ場所に集まれるかというと、海外や国内の遠方で働いてるメンバーもいるため、オフサイトとオンサイトの混合した参加者を許容することが求められました。 そのオフサイトミーティングの運営において、音響・映像の配信を担当することになり、「これは他社でも使える汎用的な構成と運用を考えることができたのではないか」と思い、記事にしました。 前置き 以降のセクションで構成やセットアップに必要な予備知識を記載しますが、後述する構成を決める際に念頭にあった課題は、 「オフサイト/オンサイトメンバーが分け隔てなくコミュニケーションを取れる環境を作る」ということでした。 というのも、今まで経験したオフサイト/オンサイトのメンバーが入り混じる MTG では、リモートで参加しているメンバーとしては、音がよく聞こえなかったり、顔が見れないことにより置いてけぼりになった経験がありました。 この問題を解決したい!という個人的な思いもあり、モチベーションが高い状態で準備を進めることができました。 なお、リハーサルでは期待通りに機能したものの、結局緊急事態宣言で開催には至らなかったことを前もってお伝えさせていただきます😭 予備知識 ミキサーやアンプについて全く知らない場合は入門書を読んでおくことをお勧めします。 自分の場合は、準備のため下記の PA の本を読み、ミキサーやアンプについて軽く理解しました。 忙しくて本を読む暇がない人向けのサマリー (知っておけばいいところだけ超簡単に書いてます) ミキサーで、複数の入力をまとめたり、分けて出力することができる ミキサーで、特定の周波数を絞ることでハウリングを低減させることができる また、実際に使うミキサーの仕様書を読んでおきましょう。 実際にミキサーを見てテンパると多くの時間の損失につながるので、目を瞑っても対象のミキサーが浮かんでくるまで仕様書を読んだら ok です。 自分の場合は、上記に加えてレンタル業者からマイクとミキサーをレンタルして自宅で検証しました。 当日は多くの関係者が集まると思うので、その人たちの1分1秒のコストを考えると、やっておいて損は無いのではないでしょうか。 実際の構成 配置図 構成図 この構成で実現できること 上記の構成にすることで、全ての参加者が、必要な情報を見聞きすることができます。 なお、もし聴衆 (現地) の姿を聴衆 (リモート)に見せてあげたい場合は、PC をさらにもう一台用意してカメラを聴衆 (現地) に向けたものを Zoom に配信してあげれば可能です。 レンタル・購入機器 無線マイク ミキサー アンプ / スピーカー ビデオカメラ (購入済) プロジェクター / スクリーン 各種ケーブル ②で、PC からミキサーに繋ぐ オーディオケーブル (購入) ④で、ミキサーから PC に繋ぐ オーディオインターフェース・ケーブル (購入) ⑤で、ビデオの映像を PC に入れる HDビデオキャプチャーボード オペレーション 映像を中継する人と、音声を中継する人で分かれる。 音声を中継する人は、誤ってノイズが入っている人をミュートにする。 音声を中継する人はハウリングしているマイクをミキサーで切断する。(リハーサル時はハウリングしなかった) 登壇者は、自分の PC で Zoom に参加し、資料を投影する。 リハーサルの様子 80名を収容する部屋の規模感 ミキサーに入出力端子を繋ぐ様子 映像の配信を担う PC 感想・懸念点 映像・音響機器が正常に動いてくれないと、想定していたコンテンツが実施できないため、大きなプレッシャーを感じていました。普段エンジニアとして仕事をする上で、安心して進めるにはやはり検証を重ねることが重要だと理解し、今回は実践できていたいので、リハーサル当日はある程度の自信を持って参加することができました。 上記の写真の通り、ミキサーには多くの端子があり、初めて使うのであればスムーズに接続できないことも考えられるので、時間には余裕を持っておいたほうが良さそうです。 (TKP 日本橋さんの手厚い協力にも助けられました!) 自分の場合は、自宅での検証が効いたのかスムーズに接続できました。多分 30 分くらいあれば足りました。 なお、音声を中継する PC と、映像を中継する PC はそこそこ熱を持っていたので、スペックには気をつけましょう。今回は、MacBook Pro (13-inch, M1, 2020, 16GB) で実施しました。 Windows でも特に困らないはずです。 それと、登壇/聴衆という形式ならば今回の構成で困りませんが、各グループに分けてチームディスカッション等をする場合は、チーム分け時点で、オンラインとオフラインでチームをまとめるように配慮しておく必要がありますのでご注意ください。
はじめまして。 RevComm でエンジニアリングマネージャーを担当している瀬里 (せり) です。 記念すべきテックブログの最初の記事となりますが、この記事を読んでいる方の中にはそもそもRevComm をご存知でない方もいらっしゃると思うのでまずは会社紹介からさせてください。 RevComm とエンジニアリング組織について RevComm では「コミュニケーションを再発明し、人が人を想う社会を創る」というミッションのもと、第一弾サービスとして MiiTel という電話業務向けサービス、直近リリースされたものでは会議などの可視化を行う MiiTel for Zoom というサービス等を提供しています。 miitel.com miitel.com サービスの技術を支えているエンジニアは全体で現在約60名。 3年ちょっと前に私が参加したときには全社員が5名いるかいないかみたいな感じだったので結構人数が増えましたね。集まったエンジニアのバックグラウンドは多岐にわたっており、コンサルタントやSIer、サイト制作会社、元公務員などさまざまです。 部署は大きくテクノロジーとリサーチという2つに分かれており、それぞれ下記のような役割で動いています。 テクノロジー部署:インフラから Web 、モバイルという、お客さんに安定して使いやすいサービスを届ける部署 リサーチ部署:新規技術の研究開発を行い、新たな価値を創出する部署 どういう人が働いているかとか、どんな環境で仕事しているかについてはまた別の機会に発信しようと思っているので楽しみにしていてください。 なぜブログ?今後どんな記事が書かれるの? 今回ブログをはじめることにした主な目的は「エンジニア界隈での知名度アップ」です。 RevComm という会社名はスタートアップ界隈では多少知名度が上がってきてはいるものの、世間的にはまだまだ認知度が低い状況。中で働く者としてはちょっと寂しい。 人数が増えたとはいえ、優秀なエンジニアの方にどんどん入ってきてもらいたいので、まずはエンジニアの方にだけでもちらっと名前くらい覚えていただければなぁというのが願いです。 今後アップされる記事としては、社内で使われている技術から最先端の技術の研究について、といったテック系の内容はもちろんですが、働いている環境や評価制度といったカルチャー的側面まで幅広く記事として公開していく予定です。 終わりに ちょっと調べものをしていたらこのテックブログの記事をみつけて解決できた、というような形で少しでも皆様の役に立てればと思っています。 また、掲載される記事を通して、少しでも皆様に RevComm って技術力高いな、一緒に働いてみたいな、とか、働く環境として面白そうだけど、まだまだ組織として未完成だから自分が入っていろいろ活躍できるのでは?とか感じて頂ければ幸いです。その際には是非応募してください。 hrmos.co それでは今後とも宜しくお願いいたします。