Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Studyplusアプリでダークモード・ダークテーマに対応しました

こんにちは、モバイルクライアントグループの明渡です。

8月31日、StudyplusのiOS版でダークモードに、Android版でダークテーマに対応したバージョンをリリースしました🎉

Studyplus iOS版のダークモードキャプチャStudyplus Android版のダークモードキャプチャ
左がiOSのダークモード、右がAndroidのダークテーマ

今回は、私も一部を担当したiOSアプリの実装の話を僅かに混えつつ、Studyplusのダークモード・ダークテーマ対応(以降はダークモード対応と表記)の進め方をご紹介します。

前提

方針

  • 9割以上、ダークモード・ダークテーマへ最適化された状態を目指す
    • できるだけ対応が漏れないよう善処はする
    • 100%対応完了したと判断できるところまで時間をかけるより、少々の漏れがある状態でもリリースする方がユーザーにとって嬉しいはずと判断

期間

期限

  • 7〜9月中にリリース目標
    • 9月以降はiOS・Android共にOSのメジャーバージョンアップデート対応が多かれ少なかれ控えており、できれば8月中にリリースまでこぎつけたい

開始

  • 2020年7月中旬〜
    • Studyplusとしてダークモード対応より高い優先度で走っていたプロジェクトの数々がひと段落した頃合い

開発体制

対応期間中、ダークモード対応と比較すると粒度が小さいが優先度の高いタスクが発生すればそちらを優先しています。 以下のメンバーはMAXで動いていた際のものです。

なお、全メンバーリモート勤務です。

メンバー

  • デザイナー
    • 1名
      • 仕様の策定、対応する・しないの意思決定を行うプロジェクトリーダー(以降はPLと表記)を兼務
  • iOSエンジニア
    • 2名
      • うち1名、明渡がタスク進行スケジュール管理を行うプロジェクトマネージャー(以降はPMと表記)を兼務
  • Androidエンジニア
    • 2名

対応の流れ

開発工数の見積もり

普段プロジェクトを進行する際行う開発にかかる工数見積もりは、諦めました。理由は以下の通りです。

  • 影響範囲がアプリ全体に渡り、長年の歴史的経緯の都合でレイアウトの組み方・色の指定方法が入り混じっている状態を解消し切れていない
    • 修正が必要な箇所の洗い出しは事前にできても、実際に着手してみないとかかる工数の読めないタスクが相当数発生する予測
  • 上記の状態で正確な工数を見積もる場合、具体的に必要な作業の調査を細かくする必要があり時間を要する
    • そこまで掘り下げて調査するくらいなら最早手を動かした方が良いだろうという判断

スケジュールは大まかな単位で対応目標期日のみ設定し、洗い出したタスクの進捗を定期的に確認したり、メンバーの休暇予定を鑑みたりして実態に合わせて少し調整しました。

「手出してみないと具体的にどのくらい時間かかるか全然分からないけど、とりあえずこのくらいの目標で進めますね!」 という見様によっては雑と捉えられる場合がある進め方を、何の軋轢もなく許容してくれる環境で本当に良いなと思います。

手順

iOS・Android共に以下の流れで進めました。

  1. 画面に跨がり使い回している共通UIパーツにて、対応が必要なものを洗い出し
  2. 共通UIパーツ対応
  3. 個別に対応が必要な画面を洗い出し
  4. 画面個別対応
  5. 担当外のメンバーに協力を仰ぎ、考慮漏れや改善点の洗い出し
  6. 考慮漏れや改善点を対応

画面に跨がり使い回している共通UIパーツにて、対応が必要なものを洗い出し

共通UIパーツを最初に対応すると、必然的に個別対応の必要な箇所が分かりやすくなるので早い段階で対応することにしました。

共通UIパーツ対応

iOSの場合だと、枠線・塗りつぶしボタンやそのハイライト、キーボードのinputAccessoryViewなどがありました。

一部、全く同じ見た目かつソースコードも使い回されているUIが見つかり、切り出して共通化する対応も併せて行いました...

f:id:m_yamada1992:20200831144653p:plainf:id:m_yamada1992:20200831144709p:plain
例として、左が塗りつぶしボタン、右のキーボード上部に「閉じる」ボタンを含む領域がinputAccessoryView

個別に対応が必要な画面を洗い出し

対応が必要な背景色やラベルなどの情報をチェックリストにして添えつつ、画面単位でタスクを起票しました。

アプリの全画面を網羅しているドキュメントなどは特に存在しないため、iOSについては見渡す限り片っ端から画面を開いて確認する形で進めました。

当然ながら、そんな作業で起票したタスクを元に実装を進めるともちらほら漏れがあり追加で対応しながら進めることになったので、このやり方をお勧めはできません...

対応の種別

以下の2種類に分けることができ、前者は実装タスクとして対応を粛々と進め、後者を検討タスクとして起票してデザイナーに依頼しておきます。

  • エンジニアの判断で対応を進められる
    • OS標準色に準拠させると違和感が解消できる
      • iOSでいうところの、iOS 13以上向けに追加されているSystem Colors・Dynamic System Colors*1
      • AndroidではMaterialDesignComponentライブラリを利用しているため、MaterialDesignのカラーパレットや、The color system*2に則りパレットツールによる色の作成
  • デザイナーの判断を仰いでから対応を進める
    • もともとこだわりの色指定をしているが、ダークモードでは違和感が出てしまう画面やUIパーツ

画面個別対応

タスクを起こしさえすれば手分けして作業しやすくなるので、淡々と消化します。

タスク管理ツール上で、現在誰が何のタスクを対応中かさえ見えるようにしておけば重複して作業してしまうこともありませんでした。

また、洗い出し時点でデザイナーさんに検討を依頼したタスクの方針が固まり次第、随時実装タスクを起票してそちらも併せて粛々と対応します。

実装が完了した後

実装まで完了したタスクはすべて、社内向けに開発環境アプリを配信した後にレビューを依頼するステータスでデザイナーをアサインしました。

これにより、デザイナーが全く把握していない変更が入ってしまうことを防ぎました。

担当外のメンバーに協力を仰ぎ、考慮漏れや改善点の洗い出し

Studyplus事業部では、プロジェクトの終盤に"デバッグ大会"という形でプロジェクトを担当してないメンバーも募ってアプリを触ってもらう文化ができています。

今回はリリース予定日の1週間前に設定しました。

通常は、プロジェクトで開発した新しい機能などを触ってもらうために予めテスト項目を準備します。

ですが、今回は開発した箇所ベースで項目を用意すると考慮漏れを発見するには逆効果になると判断。 2時間以内で思い思いに触ってもらい、気になったことを起票してもらう形を取りました。

考慮漏れや改善点を対応

上記のデバッグ大会で気になった点を起票してもらう際に、優先度となる度合いも併せて記入してもらいました。

  • 優先度: 高
    • 読み取り・利用が困難
  • 優先度: 中
    • 読みにくい・利用しにくい
  • 優先度: 低
    • 支障はないがより改善したい

優先度高〜中の項目はダークモード対応初期リリース時点で含める前提、優先度低は次回以降のリリースでも構わないという形でタスクを起票して対応しました。

対応してみての感想

「9割以上対応された状態を目指そう!」といいつつ、いざ終わってみると両OSとも見渡す限りダークモードに最適化されており、感慨深いものがありますね。

個人的には、PM引き受けたのが初めてだったのでリリースまでこぎつけられてホッと一安心です。

他のプロジェクトでPMを引き受けていた方々がどう立ち回っていたか思い出しながら見様見真似で進めましたが、今まで自分が参画したプロジェクトの中で一番雑な管理だった自覚はあります。

やったことないからこの機会にやってみるかと軽いノリで引き受けたのですが、なんとかなるものですね! いや、メンバー個々の戦闘力が高いからなんとかできたんですけども。自分もメンバーの時にPL、PMの人がスムーズにプロジェクト進行していけるようサポートも頑張ります...

さいごに

以上、Studyplusアプリでのダークモード対応の進め方でした。

iOS・AndroidのOSで正式にサポートされてから対応まで比較的遅いほうだったとは思うのですが、「これから対応を進めたいけどどこから手をつけよう?」という方がいらっしゃったら参考になれば幸いです。

Studyplus for Schoolの1人目のQAエンジニアを募集中

Studyplus for Schoolの開発チームのリーダーをしている@atomiyamaです.

Studyplus for Schoolでは現在1人目のQAエンジニアを募集しています. 募集ページでは説明できていない現状や課題,QAエンジニアの方と実現していきたいことなどを詳しく書いていきたいと思います. この記事を読んで少しでも興味を持ってくれた方がいれば気軽に応募していただけると嬉しいです.

Studyplus for Schoolの現状と課題

Studyplus for Schoolの開発チームでは現在テストなど品質管理を専門とするエンジニアはいませんでした.
その中で品質を保証するため,新規機能開発プロジェクトなどではリリース前に「デバッグ大会」と呼ばれるテストを行う会を開いたり,新しいテストツールを導入したり色々と品質を担保するための取り組みを行ってきました. また開発チームのエンジニアが開発業務の傍らテスト項目書作成を作成するなど品質を担保するための取り組みも行っています.

しかしコロナ禍の影響でユーザーが大幅に増加したり,ローンチから時間が経ち機能が増えてきたこともありテストを行う上で考慮しなければならないことが増えてきた状況で,デグレを起こしてしまったりリリース後に不具合を発生させてしまったりと多くの問題とぶつかりながらもこれまでなんとかやって来ました.

今後,よりサービスを成長させていくためにリリーススピードを上げつつも品質も保証していくためには開発チームが片手間でやっていくことは厳しく,品質に責任を持って動いてくれる専門のユニットが必要だと考えています.
そこで,機能追加や品質の保証といったサービスの成長を支えるQAユニットを作ることに決め,1人目となるメンバー募集をすることになりました.

求める役割

Studyplus for Schoolのプロダクト開発の中では「開発チームとプロダクトオーナーとの橋渡し」のような役割を担っていただきたいと思っています.

機能開発の計画に対した遅れや見えない仕様がでてきた時,当初の受け入れ可能な状態と現実との差分をプロダクトオーナーと相談し新しい計画を提案したり, 変更があれば開発チームに達成するべき条件を伝えるような働き方をお願いしたいです.
大規模な機能のリリースになればテストの計画・実施をメンバーを巻き込んで推進したり,テストの自動化などを行い,日頃の軽微な修正などで問題が発生するまえに未然に防げる仕組みを作っていくような動きもしていただきたいと考えています.

またこれまで弊チームにはQAを専門としてきたメンバーがおらず私自身もQAエンジニアとして働いた経験が無いので, 一緒にQAとはどういったものなのか,どういったことをするのかといったQAの役割や文化の浸透,評価など組織へ浸透させていく活動も行っていきたいと思っています.

入ってからお願いしたいこと

現在立て続けに新規機能開発プロジェクトが立ち上がる状態にあるので,まず最初はテストの計画・実施を行い機能開発プロセスの中に浸透させていくことをお願いすると思います.
スクラムで開発を行っているためスプリントプランニングをはじめスクラムイベントへ参加をしていただき仕様の決定段階や,プロダクトの受け入れ判断に対してQA視点から意見を貰いたいと考えています.

その後はテストの拡充や自動化といった取り組みをしながら,長期的にはQAエンジニアやSETなどの採用支援などもお願いすることになると思います.

ここに関しては選考フローの中でお話しながら決めて行けたらと思っています.

Studyplus for SchoolでQAをする楽しさ

現チームメンバーの間では現在のプロダクトにはテストを始めとした品質を保証する仕組みが不足しているという共通認識があります.「品質を保証するためには何をすればいいのか」「品質が保証されている状態とはどういったものなのか」といった共通認識まではできていない状態にありますが, 専門の知識を持った方を中心にそういった取り組みをしていきたいとは全員が思っておりチーム内でも手探りではありますが取り組みを重ねている段階です.

なので「QAを通してプロダクトを成長させたい」「QAの価値をより広めて行きたい」などと思っている方にとってはチャレンジングな環境を提供できるのではないかと思います.

弊社はValueの1つに「Fail Forward」というものがあり新しいことに挑戦することを歓迎する文化があります. 開発チームのなかでも毎週勉強会を行ったり,新しいツールを積極的に試してみたりと色々なことに挑戦しやすい環境ではないかと思っています.


もしこの記事を読んで「興味を持ったから話だけでも聞いてみたい」「QAの事について教えて上げてもいい」「QAエンジニアとして働いてみたい」と思った方がいれば気軽に連絡いただけるとすごく嬉しいです. ぜひご応募お待ちしております!!

speakerdeck.com

Ruby+CloudSearchを用いた検索機能の実装をCloudSearch初心者が説明してみた

はじめまして、今年の5月に中途入社したサーバーサイドエンジニアの葉坂です。最近、弊社のサービスの検索改善を行ったのですが、その際にCloudSearchを初めて触りました(検索エンジンサービス自体触るのが初でした)。なので私の復習も兼ねてRuby+CloudSearchを用いたデータの検索機能の実装について説明していきたいと思います。

そもそもCloudSearchとは?

ご存知の方も多いかと思いますが、AWSが提供する検索機能を手軽に構築、実装できるクラウド型のサービスです。全文検索はもちろんのこと、ブール型検索(ANDやOR、NOTを用いた絞り込み)、プレフィックス検索(前方一致で該当する文字列の検索)、サジェスト検索などたくさんの機能があります。

Ruby + CloudSearchでデータの検索ができるようになるまでの流れ

  1. Amazon CloudSearch ドメインの作成
  2. Amazon CloudSearch 用にデータを準備
  3. Amazon CloudSearch ドメインにデータをアップロード
  4. 検索機能の実装

1~3に関してはすばらしい公式のドキュメントがあるので、リンクだけ貼らせていただきました。さて今回は公式のドキュメントはあるものの、個人的に苦戦した、4.検索機能の実装の部分をメインでお話しさせていただきます。

※1~3、AWS SDK for Rubyの設定に関してはもうすでに完了しているという前提で進めていきます。

検索機能の実装

これ以降は例として、Userテーブル(カラム:id, username, nickname, created_at)のデータをCloudSearchの検索ドメインにアップロードしてあるものとします。

gem

公式のgemがあるのでこちらを使用します。

まずはクライアントの生成から

検索機能の実装では主にClass: Aws::CloudSearchDomain::Clientを使用します。

endpointはCloudSearchのダッシュボードにあるsearch-endpointを使用します。

CloudSearchのダッシュボード

client = Aws::CloudSearchDomain::Client.new(endpoint: "http://<your endpoint>")

全文検索をしたい場合

指定された検索条件に一致するドキュメントのリストを取得するためAws::CloudSearchDomain::Client#serchを使用します。

response = client.search(
    query: 'hoge',
    query_parser: 'simple',
    return: '_no_fields',
    start: 0,
    size: 3
)

# 検索条件に一致したドキュメントのコレクションを取得
response.hits
=> #<struct Aws::CloudSearchDomain::Types::Hits found=15, start=0, cursor=nil, hit=[#<struct Aws::CloudSearchDomain::Types::Hit id="148", fields=nil, exprs=nil, highlights=nil>, #<struct Aws::CloudSearchDomain::Types::Hit id="144", fields=nil, exprs=nil, highlights=nil>, #<struct Aws::CloudSearchDomain::Types::Hit id="5109", fields=nil, exprs=nil, highlights=nil>]>

# 検索条件に一致したドキュメントを取得
response.hits.hit
=> 
[
    #<struct Aws::CloudSearchDomain::Types::Hit id="148", fields=nil, exprs=nil, highlights=nil>, 
    #<struct Aws::CloudSearchDomain::Types::Hit id="144", fields=nil, exprs=nil, highlights=nil>, 
    #<struct Aws::CloudSearchDomain::Types::Hit id="203", fields=nil, exprs=nil, highlights=nil>
]

# 検索条件に一致したドキュメントの総数を取得
response.hits.found
=> 15

# 取得したドキュメントのidをもとに下記のような使い方もできます
user_ids = response.hits.hit.map(&:id)
User.where(id: user_ids)

Aws::CloudSearchDomain::Client#serchに上記のような引数を指定すると、hogeという文字列を検索ドメインにあるすべてのフィールドで検索し、hogeに一致・部分一致したドキュメントが返却されます。

指定した引数については下記にまとめました。

  • query:検索条件を指定する。
  • query_parser:使用するクエリパーサーを指定する。Amazon CloudSearchには4種類のクエリパーサーがあります。クエリパーサーを指定しない場合はsimpleクエリパーサーがデフォルトで使用されます。
  • return:レスポンスに含めるフィールドと式の値を指定する。_no_fieldsを指定すると一致するドキュメントのドキュメントIDのみを返します。_scoreを指定するとドキュメントの関連性スコアを参照することもできます。
  • start:オフセットを指定する。下で説明しているsizeと一緒に使うとlimit/offset形式のページングを行うことができます。ただ、取得するデータが10,000件を超える場合は速度的に問題があるのでcursorを使用する方法をAWSが推奨しています。(詳しくはディープページ分割を参照してください。)
  • size:レスポンスに含める検索条件の一致したドキュメントの最大数を指定する。

検索結果の並び替えをしたい場合

検索結果の並び替えをしたい場合はsearchメソッドの引数にsortを追加すれば、すぐに実装できます。

response = client.search(
    query: 'hoge',
    query_parser: 'simple',
    return: '_no_fields',
    start: 0,
    size: 3,
    sort: 'created_at desc'
)

上記の例ではフィールド名を指定していますが、ドキュメントの関連性スコアを表す_scoreを指定して並び替えをすることも可能です。

複合クエリを使用して検索をしたい場合

続いては複数の条件を指定したい場合に使用する複合クエリについてです。下記が実装例になります。

response = client.search(
    # boost値:検索条件に一致したドキュメントのスコアを高くすることができ、複合クエリの特定の式の重要度を他より高めることができます。
    # term:任意のフィールドで個々の用語または値を検索する(CloudSearchがサポートする専門演算子)。
    # hogeという用語がusernameフィールドもしくはnicknameフィールドに存在しているかどうかを検索し、
    # usernameフィールドに存在している方が重要度が高くなります(_scoreの値が高くなる)。
    query: "(or (term field=username boost=10 'hoge')(term field=nickname 'hoge'))",
    # structuredクエリパーサーを指定することで複合クエリを使用できるようになります。
    query_parser: 'structured', 
    return: '_no_fields',
    sort: '_score desc'
)

紹介したのは一例だけですが、or以外にもブール演算子はandやnotがありますし、 CloudSearchがサポートしている専門演算子がterm以外にもたくさんあります。 なので組み合わせ次第では様々な複合クエリの作成が可能です。 こちらが参考になります。

式を定義し、それを検索結果の並び替えに使用したい場合

上の検索結果の並び替えではsortにフィールド名や_scoreを指定できるというお話をさせていただきましたが、実は独自の計算式を定義し、その式を検索結果の並び替えに使用することも可能です。

response = client.search(
    query: 'hoge',
    query_parser: 'simple',
    # _timeは現在のエポック時刻(ミリ秒)を表す。2592000000は30日間をミリ秒で表したもの。
    # created_atなどの時間を表すものはCloudSearch上にミリ秒単位でエポック時刻として保存されます。
    # ここの計算式が行っていることはcreated_atが1ヶ月以内であれば、_scoreを10倍にして重みをつけてあげているというものです。
    expr: "{'sample_expr':'_score * ((_time - created_at) < 2592000000 ? 10 : 1)'}",
    return: '_no_fields',
    size: 10,
    sort: 'sample_expr desc'
)

上記で新しく出てきた引数のexprで計算式を定義することができます。その定義した式の名前をsortで指定すると、その式で検索結果の並び替えを行ってくれます。ちなみに式の記述に使用できる演算子に関してはこちらが参考になります。

また、公式のドキュメントでも記載されているのですが、今回のように計算式をコード上に定義すると、場合によってはリクエストのオーバーヘッドが増加します。その結果として応答時間は遅くなる可能性があります。 なので複雑な計算式を定義する際はご注意ください。CloudSearch内の検索ドメインに式を定義する場合はこちらが参考になります。

最後に

Ruby+CloudSearchを用いたデータの検索機能の実装を一部紹介させていただきました。 複合クエリの実装例も一例しか紹介できなかったですし、Aws::CloudSearchDomain::Client#serchにも紹介できていない使用法がまだまだたくさんあります。 今回は触れませんでしたが、Aws::CloudSearchDomain::Client#upload_documentsを使えば、RubyでCloudSearchにデータを挿入することも可能です。

興味がある方は、CloudSearchの設定等は決して難しくないので遊んでみてはいかがでしょうか!!

Amazon AuroraのMySQLユーザーをTerraformで安全に管理したい

SREの菅原(id:ksugahara08)です。

最近、既存のシステムをAmazon Auroraへ移行させるという作業が頻繁に発生しました。

モテ期かな?と勘違いするくらいAuroraに関しての仕事に恵まれたため、その中でも役に立ったTerraformでAmazon AuroraのMySQLユーザーを管理する方法を今回紹介します。

興味あれば最後まで読んで頂けると幸いです。

目次

Terraformでのパスワード管理の難しさ

どのように設定したかお話する前にTerraformでのパスワード管理の難しさについて少しだけ触れておきたいと思います。

Terraformでパスワードを隠すにはtfファイルだけでなく、tfstateファイルにも気を付けなければいけません。tfstateファイルは外部に漏れないように厳重に管理していても、漏れてしまう可能性は拭いきれません。したがって、tfファイルとtfstateファイルの両方に平文で保存されないことを考えなければいけないという難しさがあります。

鍵の暗号化・復号化

いくつか手段があったのですが今回はTerraformとAWS Key Management Service (KMS)を使った暗号・復号化を選択しました。

具体的にはTerraformのaws_kms_secretsとKMSのaws kms encryptで平文をカスタマーマスターキーから直接暗号化する方法を組み合わせました。この組み合わせであればtfstateファイルにもパスワー ドが平文保存されなかったため採用しました。

GPG鍵を使った方法も実装してみたのですが、マスターキーを自分で保管しなくて良いという点でKMSの方を選びました。またHashiCorp社のVaultをTerraformと組み合わせれば良いかなと調べてみたのですが、tfstateファイルには平文で保存されてしまうという記事を読み、Vaultは選択肢から外しました。

Terraformでの設定

本題のTerraformの実装方法について話していきます。

AWS Key Management Service (KMS) のマスターキーを作成

まず、KMSのCustumer Master Keyを作成します。kms.tfは設定例です。

kms.tf

resource "aws_kms_key" "sample" {
  description             = "Custumer Master Key"
  enable_key_rotation     = true
  is_enabled              = true
  deletion_window_in_days = 30
}

resource "aws_kms_alias" "sample" {
  name          = "alias/sample"
  target_key_id = aws_kms_key.sample.key_id
}

AWSのKMS権限を設定したIAMを作成

AWSのEC2インスタンスを使っているのであればIAMロールを作成して割り当てます。そうでなければIAMユーザーを作成します。

IAMポリシーは以下のようなものを設定していれば暗号化・復号化ができます。(詳しくはAWSの公式ドキュメントに書いてあるので各自調整してください。)

{
  "Version": "2012-10-17",
  "Statement": {
    "Effect": "Allow",
    "Action": [
      "kms:Encrypt",
      "kms:Decrypt"
    ],
    "Resource": [
      "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab",
      "arn:aws:kms:us-west-2:111122223333:key/0987dcba-09fe-87dc-65ba-ab0987654321"
    ]
  }
}

KMSを使ってパスワードを暗号化

aws kmsコマンドで暗号化します。 先程作ったIAMロールかIAMユーザーを使用して以下を実行します。

$ vim secret.txt
# 暗号化したいパスワードや文字列を書き込みます。

$ aws kms encrypt \
    --key-id alias/sample \
    --plaintext fileb:///path/to/secret.txt \
    --query CiphertextBlob \
    --output text
# ここで出力された文字列をTerraformのtfファイルに記載します。

$ rm secret.txt
# 暗号化できたらファイルは廃棄します。

この手順でAuroraのrootユーザー名、rootパスワード、作成したいユーザーのパスワード等を暗号化しておきます。

MySQLユーザーの設定

以下のmysql_users.tfを使ってAmazon Auroraに接続して、MySQLユーザーを作成します。 このときTerraformが使っているIAMにRDSへの接続権限と接続できるNWで実行する必要があります。

mysql_users.tf

data "aws_kms_secrets" "sample_aurora" {
  secret {
    name    = "root_username"
    payload = "暗号化のときに受け取った文字列"
  }
  secret {
    name    = "root_password"
    payload = "暗号化のときに受け取った文字列"
  }
  secret {
    name    = "sample_password"
    payload = "暗号化のときに受け取った文字列"
  }
}

# Amazon Auroraへの接続はここで行っています。
provider "mysql" {
  endpoint = "sample-cluster.cluster-XXXXXXXX.ap-northeast-1.rds.amazonaws.com"
  username = data.aws_kms_secrets.sample_aurora.plaintext["root_username"]
  password = data.aws_kms_secrets.sample_aurora.plaintext["root_password"]
}

resource "mysql_user" "sample" {
  user  = "sample"
  host  = "%"
  plaintext_password = data.aws_kms_secrets.sample_aurora.plaintext["sample_password"]
}

resource "mysql_grant" "sample" {
  user       = mysql_user.sample.user
  host       = mysql_user.sample.host
  database   = "sample"
  table      = "*"
  privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"]
}

plaintext_passwordで設定すればtfstateファイルにはハッシュ化された値だけが入ります。terraform state show mysql_user.sampleterraform state pullコマンドを使えば、パスワードが見えないことを確認できます。

Auroraへの接続はprovider mysqlを使って行うのですが、KMSを組み合わせることでパスワードを平文で書かずに済みます。

KMSを使ってパスワードを復号化

後からMySQLのパスワードを知りたい場合は以下のコマンドで復号して確認することができます。

$ aws kms decrypt \
    --ciphertext-blob fileb://<(echo '暗号化のときに受け取った文字列'|base64 -d) | jq .Plaintext \
    --raw-output |base64 -d

あとがき

今まではAnsible Vault機能を使ってパスワードを保持していたのですが、今回の方法でtfファイルにもtfstateファイルにも平文で持たなくて済みました。また復号するための鍵を手元ではなくKMSに保管することができたのでかなりメリットがありました。

課題としてはKMSの復号化ができるIAMユーザーの取り扱いには注意が必要だということです。IAMユーザーが漏れると復号化ができてしまう可能性があるからです。

まだまだパスワードや鍵の管理には頭を悩ませることもありますが少しずつ改善していきたいと思います。

リモートでのペアプロにはSlack Callが便利

こんにちは。ForSchool事業部の石上です。最近はおやつに杏仁豆腐をよく食べています。甘党ではないのですが、杏仁豆腐はちょうどいい甘さなので好きです。

今回は小ネタです。友だちのエンジニアにリモートでのペアプロのやり方を聞かれたため、せっかくならTechブログ記事にしようという感じです。

スクリーンショット 2020-04-18 17.15.47.png (118.7 kB)

ペアプロに使えそうなツール

ペアプロに使えそうなツールを考えたとき、まず以下のような選択肢が浮かびました。

  • VSCode拡張のLive Share
  • 画面共有ができる各種ビデオチャットツール

最初は、VSCodeのLiveShare拡張ならば相手のエディタに入り込むような体験でペアプロができるので良いと思いました。しかし、あるプロジェクトのペアプロをしているときに、ちょっとあっちのコードも確認しようと言って別のプロジェクトのディレクトリを開くと接続が切れてしまう問題がありました。

Studyplus for Schoolはいくつかのリポジトリに分かれているため、これが頻繁に発生するとつらいので、LiveShareでペアプロをするのはやめました。

どうしてSlack Callにしたか

そこで、他のものを使おうとなったときに、最も導入が手軽なのはSlack Callでした。普段会社で利用しているチャットツールがSlackだからです。利用するには、呼びかけたいチャンネルで通話を開始するだけです。

スクリーンショット 2020-06-24 10.35.09.png (119.2 kB)

Slack Callの良いところ

ふつうに使っていただけなので特筆することもないと思っていましたが、改めて考えてみると、良いところがいくつもありました。

  • 相手のスクリーンにお絵かきができる
  • Slack上で誰と誰が通話しているのかわかる

相手のスクリーンにお絵かきができる

ペアプロの際に嬉しいのはこれです。Slack Callでは、画面共有した相手のスクリーンに、お絵かきができます。これができると何が嬉しいかというと、2つあります。

  • 口頭だけでは説明しにくいことを図示して伝えられる
  • typoの指摘を一瞬で伝えられる

口頭だけでは説明しにくいことを図示して伝えられる

Slack Callを使うと、口頭だけでは説明しにくいことを図示して伝えられます。特にフロントエンドのコードを書いていてレイアウトの説明をするときとか、通信の流れを説明するときなどは、図示できるととても楽です。

スクリーンショット 2020-06-24 12.07.14.png (5.6 MB)

また、自分が説明している部分を囲って強調したり、そのソースコードの登場人物(変数や関数、型定義など)の関係性を伝えるのにも役立ちます。

誤記の指摘を一瞬で伝えられる

地味に嬉しいのが、タイポ(誤記)の指摘です。口頭だとこんなやり取りが発生します。

「あ、そのStudentsのところ、単数形が正しいです。sが多い。あ、ええと、今開いているファイルの真ん中のあたりの....あ、もうちょっと上です。そこそこ。」

お絵かきができれば、その箇所を丸で囲って「あ、ここタイポですね」で済みます。

スクリーンショット 2020-06-24 12.09.09.png (4.0 MB)

Slack上で誰と誰が通話しているのかわかる

また、ペアプロとは少しずれますが、これも嬉しいポイントでした。弊チームは4人のエンジニアが在籍しています。二手に分かれてペアプロをするタイミングがあったのですが、自分がペアプロしている相手以外の2人がペアプロをしているのか、ほかのことをしているのかがわかります。

そうすると、聞きたいことがあったときに、簡単なことであればペアプロしているところに入っていって、ちょっといいですかといって聞いてしまうこともできます。

まとめ

ペアプロをやる理由はチームや状況によって様々でしょうが、私は暗黙知を効率的に伝えるために必要に応じてやるべきものだと考えています。

そういう意味で、わかりづらい概念を図示してわかりやすくしたりタイポのような説明の不要な指摘を一瞬で終わらせられるSlack Callは、とても便利だと思いました。

GitHub Scheduled remindersにPull Pandaからさっそく切り替えてみた

モバイルクライアントグループの若宮(id: D_R_1009)です。 先日、すやすや寝ていたところGitHubから1通のメールが届きました。

f:id:D_R_1009:20200717230443p:plain

私個人の話なのですが、AndroidとiOS、そしてFlutterのコードを書いたりレビューしたりしています。 このためFlutterのコードを書いている時にAndroidのレビュー依頼が来る状況などが発生するため、自らレビュー依頼の一覧を確認しに行くだけでは難しくなっています。 こんな状況のため、Pull Pandaの恩恵を強く感じています。 もはやPull Pandaのおかげでレビューができていると言っても過言ではありません!

DMとしてアサインやコメントがSlackのDMとして送られてくるので、Macの通知に「GitHub上で動きがあったよ!」と表示されます。 またレビュー依頼を見落としていたとしても、Slackのチャンネル上にメンションが来るため、長時間レビューを放置してしまう事件を防ぐことができています。 Androidアプリを個人的に導入してみましたが、GitHub Actionsの実行に関する通知も表示されてしまいます。 このため、少々通知がうるさく感じることもあり、Slackで完結すると大変嬉しい感じです。

そんなわけで、GitHubのScheduled Reminderに移行してみました。 結論として、移行自体はとても簡単です。 しかしいくつかのハマりどころを見つけましたので、去年の続編という形で今回ブログまとめておきたいと思います。

去年のブログはこちら。

tech.studyplus.co.jp

チャンネルの設定を移行する

まずはPull Pandaにログインしてみましょう。 右上の Sign in からGitHubのアカウント連携に進みます。

pullpanda.com

Team RemindersMy DM settings 、そして Add users が表示されていると思います。

f:id:D_R_1009:20200717232440p:plain

まずは、 Team reminders から対応しましょう。 クリックすると次のような表示になっているはずです。

f:id:D_R_1009:20200717224054p:plain

Migrate to Github をクリックすると、移行工程の半分が終了です。 ブラウザはそのままにして、Slackの通知を飛ばしているチャンネルに移動してください。

すると、チャンネルに「どのチームの下にリマインダーを移行するか」という質問が投稿されています。 ここで質問されているのは「GitHub Scheduledとして、どのアカウントの下でこのリマインダーの設定を管理するか」になります。このため、例えばAndroidチームではOrganization Accountである "Studyplus" の下にリマインダーの設定を移行しています。 おそらく、同じような設定にするとPull Pandaの設定をしていた時と同じように、社内の他チームの設定を確認できるようになると思います。

移行が済むと、次のようなコメントがSlackに書き込まれます。

f:id:D_R_1009:20200718000642p:plain

manage this reminder からリマインダー設定を更新してみてください。 UIが真新しいので戸惑うかもしれませんが、Pull Pandaで設定できる項目は全て揃っています。 なお、GitHub Scheduled RemindersにすることでDraft PRを通知の対象外にすることができるようになっています。 チームのPull Requestの運用ルールに合わせて、この機会にぜひ設定してみてください。

個人の設定(Pull PandaからのDM)を移行する

続いて My DM settings を移行します。 この移行をしないと、GitHubのチャンネル通知においてGitHubアカウント名のまま投稿されてしまうので注意してください。

移行はPull Pandaの My DM settings 上部に表示されているリンクからGitHubへ移行すれば間違い無いと思いますが、うまくいかない場合は次の手順を実行してみてください。

まず、SlackとGitHubの連携状態を次のページから確認します。

slack.github.com

こちらの Add to Slack からGitHubの連携状態を更新すると、GitHubからDMが飛んできます。 DM内容は次のような「連携したよ!」というメッセージです。

f:id:D_R_1009:20200718002430p:plain

続いて、自分の設定を確認し更新します。 次のリンクから、GitHubの個人アカウントに紐づくScheduled remindersを確認することができます。

github.com

更新したいworkspaceの設定を変更したら、忘れずに更新します。 これで、Slackのチャンネル上にSlackアカウント宛の通知が飛んでくるようになります! やった!

終わりに

Slackに「移行できるよ!」という通知には気付きつつ来ていて放置していたのですが、移行してたらサクッと完了しました。 移行ツールって大事ですね。

Slack通知は過剰になりすぎると追うのも一苦労となってしまいますが、適切に設定すればチームの開発力を底上げしてくれると思います。 Scheduled RemindersとSlackを組み合わせれば、レビュー待ちの通知をしつつ、Pull Requestに対する全てのコメントをチャンネルに流すこともできます。 逆にOpenしてから数時間たったPull RequestだけをSlackチャンネルに通知し、細かな通知はDMに集約することもできます。

code review assignment については、所属しているチームが小さいこともあり、Pull PandaもGitHubの機能も使うことがしばらくなさそうです。 もし所属されているチームで利用し「便利だ!」となりましたら、ぜひ共有していただければと思っています!

Kotlin Flow+Roomで作るTimer&Stopwatch

こんにちは、モバイルクライアントグループの若宮(id:D_R_1009)です。 2月以降リモートワークで開発を続けております。会社近くのラーメン屋さんが恋しくなってきました。

今回は5月中旬にリリースした、AndroidアプリのTimer&Stopwatch改修について書きます。 Kotlin FlowとRoomを組み合わせ、複数のUIで同時にある値を表示する実装となったので、参考になれば幸いです。

Timer&Stopwatchの改修経緯

Studyplusスアプリには、学習している時間を計測するためのTimerとStopwatch機能があります。 Timerは予め時間を指定して経過時間を記録する機能、Stopwatchは計測開始から終了までの経過時間を記録する機能です。

今年の4月以降、このStopwatch機能について不具合報告が多くなっていました。 不具合を精査したところ、一部「ユーザーの利用環境変化」が理由と考えられるものが発生していることがわかりました。

利用状況の変化の詳細については、弊社の発表や過去のブログ記事をご参照ください。

info.studyplus.co.jp

tech.studyplus.co.jp

今回影響がありそうな点は、スタディプラスのユーザーがより自宅で学習する状況となったことです。 自宅で「動画教材を視聴しその時間を計測する」ユーザーが増加し、「教材の動画を視聴していると計測がストップする」という不具合が起こりやすくなったと推測できます。

開発を検討していた4月半ばごろは緊急事態宣言の話が取り沙汰されていました。 そのためこの問題が起こりやすい状況が続くことも予想され、優先度を高めて対応することとしました。

既存実装について

まず、不具合が報告された既存実装から簡単に説明します。

これまでの実装では、ForegroundService 上で時間計測用のTimerやCountDownTimerを行い、その計測結果をRxBusやIntentを用いてUIに送信する実装となっていました。 これはサービスとスレッドのどちらを選択するかを参考に、バックグラウンドでAndroid OSの許す限り計測を行うという実装方針のもとに選択したものです。 既存実装の設計時には「(物理媒体の)教材を学習中、スマートフォンで時間を計測する」ようなケースを想定していたため、この実装でも問題ないだろうと考えていました。

ただこの実装ではユーザーが例えば動画のストリーミングアプリを立ち上げた時、端末のスペックによってはStudyplusアプリがメモリから破棄されてしまいます。 この破棄時に状態を保持するためにはには SavedInstance への保存処理、そして SavedInstance からの復元処理を記述する必要があります。 しかしながら、既存実装ではこの対応が行えていませんでした。 またServiceの SavedInstance 対応は調査から始める必要があり、導入コストが高いと見積もられ、すぐに着手できる状況ではありませんでした。

不具合の原因まとめ

簡単にまとめると、下記のようになります。

  1. 既存実装は ForegroundService 上で計測処理を実施していた
  2. Activity/Fragmentは Service から送られてくる計測状態を表示していた
  3. メモリ破棄時の SavedInstance が未実装であった
  4. 外出自粛や各種休校に伴い動画視聴と並行して利用されるケースが増加した(と考えられる)

Kotlin Flow+Roomによる計測機能

新たにTimer&Stopwatchの設計をするにあたり2つの方針を立てました。

  1. 不揮発領域に計測状態を保存し、アプリの生存期間に関わりなく時間の計測ができるようにする
    1. アプリ破棄時への対応
    2. アプリ再起動時への対応
  2. Activity/FragmentとServiceを同列に扱い、どちらも"UI"としての役割とする
    1. 計測をUI層ではなくModel層で実施する
    2. DIが可能にすることでテスタビリティを向上する

なお、簡単な実装を下記のリポジトリで公開しています。 動作を見つつコードを確認したい場合は、ご活用ください。

github.com

Roomの設計

以前のエントリで紹介しているように、スタディプラスのAndroidアプリではRoomを採用しています。

tech.studyplus.co.jp

このため、今回の機能開発においてもRoomを利用することとしました。 都合の良いことにRoom 2.2よりKotlin Flowをサポートしているため、今回の用件にマッチした次第です。

medium.com

Roomに保存するテーブルは、下記のようにします。 なお説明を簡単にするため、Stopwatch機能のみの実装としています。 実際にはTimerや計測中の教材に関連する情報などのデータも含む実装です。

@Entity(tableName = "measurement")
data class MeasurementEntity(
    @PrimaryKey
    @ColumnInfo(name = "entity_id")
    val entityId: Int = 0,
    val state: MeasurementState,
    @ColumnInfo(name = "start_date_time")
    val startDateTime: String, // 2020-04-20'T'08:20:00+09:00
    @ColumnInfo(name = "elapsed_sec")
    val elapsedSec: Long = 0L
) {
    companion object {
        val INIT_OBJECT = MeasurementEntity(
            state = MeasurementState.INIT,
            startDateTime = ""
        )
    }
}

MeasurementState は次の通りです。 Stopwatchはユーザーがボタンを操作して状態が変化する仕組みとなるため、シンプルにRUNとSTOPの2つの状態だけを想定します。

enum class MeasurementState {
    INIT,
    STOPWATCH_RUN,
    STOPWATCH_STOP
}

これでStopwatchの状態、計測開始時刻、前回までの計測で加算された時間を保存することができるようになります。 続いて、経過した時間について開発を進めていきます。

Kotlin Flowによる計測

ここからはDaoの定義を行い、ブログタイトルの通りKotlin Flowでデータを取得します。 Flowを使うことで「rowを更新する」処理と「rowが更新された」処理を分離し、特に「rowが更新された」処理をリアクティブな実装とすることができます。

RoomはSELECTメソッドの返り値を Flow<MeasurementEntity?> とすることで、rowが存在しない時に null となるFlowを作ることができます。 後述のRepositoryでnon-nullに変換していますが、 Flow<List<MeasurementEntity>> とすることでnon-nullにすることもできるので、お好みで選択してください。

@Dao
interface MeasurementDao {

    @Query("SELECT * FROM measurement WHERE entity_id=0")
    fun findFlow(): Flow<MeasurementEntity?>

    @Query("SELECT * FROM measurement WHERE entity_id=0")
    suspend fun find(): MeasurementEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(entity: MeasurementEntity)
}

最後にFlowで受け取った MeasurementEntity に合わせて、描画更新用のイベント clock を作成します。 サンプルでは1つのActivityでしか描画を受け取っていませんが、同期イベントをRepositoryから作成することで、複数のUIで同時に描画を更新することができます。

transformLatest を利用することで MeasurementEntity が変更された時に、rowの更新があったとき clock が更新されるようになります。 このため clock 内で while(true) のループを回す実装を採用しています。

@Singleton
class StopwatchRepository @Inject constructor(private val dao: MeasurementDao) {

    val entity = dao.findFlow().map {
        it ?: MeasurementEntity.INIT_OBJECT
    }

    private val clock = entity.transformLatest {
        emit(OffsetDateTime.now())
        when (it.state) {
            MeasurementState.STOPWATCH_RUN -> {
                while (true) {
                    delay(INTERVAL_MILLI)
                    emit(OffsetDateTime.now())
                }
            }
            else -> {
                // nop
            }
        }
    }

    companion object {
        private const val INTERVAL_MILLI = 500L
    }
}

計測時間の表示

ここまで計測のベースを作成したので、リアルタイムで更新される描画を考えていきます。 経過時間は現在時刻と計測開始時刻の差と前回までの計測で加算された時間の合計と考えることができます。

@Singleton
class StopwatchRepository @Inject constructor(private val dao: MeasurementDao) {

    val elapsedTime = combine(entity, clock) { entity, event ->
        when (entity.state) {
            MeasurementState.INIT -> {
                0L
            }
            MeasurementState.STOPWATCH_RUN -> {
                entity.secUntil(event) + entity.elapsedSec
            }
            MeasurementState.STOPWATCH_STOP -> {
                entity.elapsedSec
            }
        }
    }
}

ViewModelではこの値を表示させるため、LongからStringへの変換を行います。 どのようなStringへ変換するかはUI依存のため、今回はViewModelで変換するのが良さそうと判断しています。

class MainViewModel @Inject constructor(private val repository: StopwatchRepository) : ViewModel() {

    val elapsedTime = repository.elapsedTime.map { formatMeasureTime(it) }.asLiveData()
}

fun formatMeasureTime(duration: Long): String {
    val hour = duration / 3600L
    val minute = duration % 3600L / 60L
    val second = duration % 60L

    return String.format(Locale.US, "%02d:%02d:%02d", hour, minute, second)
}

あとは各ボタンの有効状態を repository.entityMeasurementState を基に調整することで、Stopwatch機能が完成です。 現実装では StopwatchRepository に作成した操作用メソッドをViewModelから操作することで、Roomの状態を更新しています。

不具合の改修状況

新たな実装ではRoomに保存されている状態を見ればいつでも計算を行うことができるようになりました。 このため、既存の Service のライフサイクルに依存することにより発生した問題を解消しています。

また、「Stopwatchを止めずに端末の電源をOFFにした」ケースにも対応することができました。 サンプルのコードを動かしている場合は、ぜひ1度お試しください。*1

時間計測は考慮することが多く大変なのですが、基本的なユースケースにシンプルなコードで対応できたように思います。 なお端末の時間を変更した場合などのエッジケースが存在するのですが、そちらの考慮は割愛させていただきます。

終わりに

5月半ばよりリリースした、RoomとKotlin Flowを組み合わせて時間計測機能を追加する実装を紹介しました。 現時点まで大きな不具合報告もなく、市場で動作しているようで安心しています。

Roomから簡単にKotlin Flowを取得することができるので、Roomを中心とした設計にするとFlowが扱いやすいなと感じました。 ちょうどRxJavaをアプリから削除し終わったので、今後もFlowを活用していきます。

最後までお読みいただきありがとうございました!

*1:もしもビルドがめんどくさければ、是非Studyplusをダウンロードしてみてください!