Studyplus Engineering Blog

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

RecyclerViewで Drawable に tint を設定する際は気をつけよう

こんにちは、モバイルクライアントグループの中島です。 最近健康診断で久しぶりに出社しましたが、体脂肪率が痛かったのでランニングを始めました。 頑張っていきたい。

さて、9月頭にも本ブログで紹介いたしましたが、Studyplusでは8月31日にiOS/Android両OSでダークモード/ダークテーマがリリースされました

tech.studyplus.co.jp

この開発に伴いアプリ内の画像リソースに多くのメスが入ったのですが、その際に起きたhotfixについて今回は話していきたいと思います。

プロジェクトとしてはダークテーマ対応でしたが、それに関わらず発生しうる事象だと思いますので、皆様のお役に立てれば幸いです。

何が起きたのか

Studyplusでは、登録した教材をカテゴリ分けできる機能があります。 そして、そのカテゴリのマークとして様々な色付きのアイコンを使っています。 ダークテーマ対応の際に、それらの色もダークテーマ用に調整したアイコンが必要になったのですが、各色で個別にpngの素材を持っていたためリソースファイル数が多くなってしまう問題がありました。 もともと単純なアイコン系素材はsvgで登録してtintで色分けしていきたいという気持ちもあり、この機会にデザイナーの方にも相談して黒単色のsvg素材を作ってもらいました。

8月31日のことです。 ダークテーマの対応がされたアプリを利用しているユーザーから、「カテゴリアイコンの色がチカチカ変わる」とのお問い合わせが届きました。

手元の端末(Pixel3 Android 10)では再現しなかったのですが、社内端末での再現状況から条件を絞り込んだところ、端末のOSバージョン依存ではないかと推測しました。

API 27 のエミュレータを起動してみた結果が以下の動画です。

f:id:nacatl:20200917140637g:plain

スクロールして次のカテゴリアイコンの描画が行われるたびに、画面内の全ての色が変わっている様子がわかります。 バージョンごとに検証をしてみましたが、 API 24 ~ 28 で同様の現象を確認しました。

調査とその結果

ConstantState

この現象を見たとき、ふと思い出したことがありました。 つい4日前に見たばかりである、DroidKaigi 2020 Liteで公開された、HiroYUKI Seto さんの発表で紹介されていた Drawable の ConstantState です。

MDCの内部実装から学ぶ 表現力の高いViewの作り方

www.youtube.com

この発表の 8:00 辺りからの情報を一部、以下に抜粋します。

  • ConstantState は Drawable の アルファ値 ColorStateList Tint の情報を保持している
  • ConstantState.newDrawable() で作成されたDrawable間で共有される
    • Resources.getDrawable() の内部で使われている
    • Drawable.mutate() を呼ぶことで状態が独立する

複数の Drawable 間で tint が共有される…今回の現象と関連がありそうです。

コードを見ていく

次に現象が起きたコードを見ていきます。

// RecyclerView.onBindViewHolder

    val drawable = ContextCompat.getDrawable(context, R.drawable.ic_bookshelf_category_24dp)
    drawable?.setTint(/* ColorInt */)
    imageView.setImageDrawable(drawable)

コードを書いたときは「Drawable を都度生成しているので問題ないだろう」と思っていたのですが、ConstantState の話を知った後ではかなり危ないように見えてきます。

API 25のAOSPで内部を確認していきましょう。

ContextCompat.getDrawable の中を見ていきますと、最終的に Drawable の生成は ResourceImpl 内で行われていました。

以下 ResourceImpl.javaより一部抜粋します。

@Nullable
Drawable loadDrawable(Resources wrapper, TypedValue value, int id, Resources.Theme theme,
        boolean useCache) throws NotFoundException {

    // ~~~~~

    // ↓同じ theme のコンテキストで同じリソースから作られた Drawable が既にあるか確認
    // 問題になった画面で、2つ目以降のアイコンはキャッシュ処理で return されている

    // First, check whether we have a cached version of this drawable
    // that was inflated against the specified theme. Skip the cache if
    // we're currently preloading or we're not using the cache.
    if (!mPreloading && useCache) {
        final Drawable cachedDrawable = caches.getInstance(key, wrapper, theme);
        if (cachedDrawable != null) {
            return cachedDrawable;
        }
    }

    // ここより下は新規作成時のコードなので割愛

    // ~~~~~

}

DrawableCacheの取得を見ます。

class DrawableCache extends ThemedResourceCache<Drawable.ConstantState> {

    public Drawable getInstance(long key, Resources resources, Resources.Theme theme) {
        final Drawable.ConstantState entry = get(key, theme);
        if (entry != null) {
            return entry.newDrawable(resources, theme);
        }

        return null;
    }

    // ~~~~

}

entry.newDrawable(resources, theme);

確認できました。

これで「同じ ConstantState から生成された Drawable なので tint が共有されている」ことが原因だと確定できました。

普段なら問題なくキャッシュ取得で運用できるのだと思います。事実、私も今まで特に気にした覚えはありませんでした。 今回は「同じ画面上に同じ画像リソースで違う色のものを多く並べる」画面が発生したことで、 ConstantState を意識する必要が生まれた形になります。

修正

原因がわかったところで修正方法を考えます。

mutate() を追加する

// RecyclerView.onBindViewHolder

    val drawable = ContextCompat.getDrawable(context, R.drawable.ic_bookshelf_category_24dp)
    drawable?.setTint(/* ColorInt */)

    drawable?.mutate()  // ←追加

    imageView.setImageDrawable(drawable)

HiroYUKI Seto さんが発表内で紹介されていた通り、 mutate() を呼ぶことで ConstantState が分離されるのでこれで問題が解消されます。

ただ後々、修正の意図を把握していないエンジニアの方がこのコードを見た時に、意図を調べ直す必要が出るかもしれません。 その点に関してはコメントを付ければいいですが、そもそもこのコードは「同じ画像リソースで色だけ分岐する」という目的に対し Drawable の生成まで記述しているのが冗長に思えてきます。 この処理の流れは、元々 Drawable ごと分岐させていた時の名残ですが、Drawable リソースが固定になったのならxml側で指定してしまいたいですね。

ImageView.setImageTintList() で色指定する

そもそも Drawable にではなく ImageView から tint を指定した場合はどうなるのか、 ImageView.imageTintList の中身を確認しました。

public class ImageView extends View {

    // ~~~~

    public void setImageTintList(@Nullable ColorStateList tint) {
        mDrawableTintList = tint;
        mHasDrawableTint = true;

        applyImageTint();
    }

    private void applyImageTint() {
       if (mDrawable != null && (mHasDrawableTint || mHasDrawableBlendMode)) {
           mDrawable = mDrawable.mutate();

           if (mHasDrawableTint) {
               mDrawable.setTintList(mDrawableTintList);
           }

           if (mHasDrawableBlendMode) {
               mDrawable.setTintBlendMode(mDrawableBlendMode);
           }

           // The drawable (or one of its children) may not have been
           // stateful before applying the tint, so let's try again.
           if (mDrawable.isStateful()) {
               mDrawable.setState(getDrawableState());
           }
       }
    }

    // ~~~~

}

mDrawable = mDrawable.mutate();

ImageView が内部で mutate() を呼んでくれていることが確認できましたので、この方針で修正していきます。 (API 24~29でこの処理に変更がないことも確認しています)

<!-- ImageView内 -->

    app:srcCompat="@drawable/ic_bookshelf_category_24dp"
// RecyclerView.onBindViewHolder

    imageView.imageTintList = ColorStateList.valueOf(/* ColorInt */)

これならシンプルでコメントも要らないですね。 現在この修正を行なったコードでリリースされていますが、新たな不具合などもなくほっとしています。

なお、Studyplusでは現在 minSdkVersion 23 で開発を行なっているため、ImageViewCompat は必要ありませんでした。 ImageViewCompat.setImageTintList では (Build.VERSION.SDK_INT >= 21) で内部分岐を行なっているため、環境によってはこちらを利用しましょう。

なぜ Android 10 では再現しなかったのか

この不具合が世に出てしまったのは自分がOS別検証を怠ったのが原因ではありますが、ではなぜ Android 10 では再現しなかったのか、Android Code Searchで調査してみました。 結果として明確な答えは出なかったのですが、 Drawable への理解が深まったかなとは思います。

mutateを呼ぶようになったわけではない

まずはResourcesImpl.loadDrawable())から流れをまた追ってみましたが、 mutate() が追加されているような箇所は見当たりませんでした。 VectorDrawable クラスには mMutated というBooleanメンバ変数も存在していますが、そちらもfalseのままでした。

ColorFilter の変更

VectorDrawable の draw 関数において、 PorterDuffColorFilter から、 API29で追加されたBlendModeColorFilter への参照変更が見受けられました。

API 25

private PorterDuffColorFilter mTintFilter;

// ~~~~

    final ColorFilter colorFilter = (mColorFilter == null ? mTintFilter : mColorFilter);

API 29

private BlendModeColorFilter mBlendModeColorFilter;

// ~~~~

    final ColorFilter colorFilter = (mColorFilter == null ? mBlendModeColorFilter : mColorFilter);

その後の描画は native 関数になってしまい追えませんでしたが、この描画周りに手が加わったためかなと個人的には推測しています。

終わりに

RecyclerView で アイコンを色分けする際にハマってしまった事例を紹介しました。

今回については、発覚の直前に関連した情報を得られていて運が良かったと思います。 hotfixで急ぎ修正する必要があったこともあり、DroidKaigi 2020 Liteの知見が大いに助けとなりました。 発表者の HiroYUKI Seto さん、DroidKaigi 2020 Lite を企画運営してくださった皆様に多大なる感謝をお送りしたいと思います、ありがとうございます。

本ブログをまとめるにあたり、修正時には急いでいて調べきれなかったことも調査しましたが、様々なクラスについてOSバージョンごとに比較確認する必要がありなかなか大変でした。 最初は mutate() が追加されているんじゃないかと思ったのですが探しても見つからず、改めて最初から関連コードを細かく追い直したりもしました。 修正後はコードも綺麗になり、調査も知見が深まっていい経験になったと思います。 教訓としては、OS別の検証について改めて注意していきたいと思いました。

最後までご覧いただき、ありがとうございました!

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の機能も使うことがしばらくなさそうです。 もし所属されているチームで利用し「便利だ!」となりましたら、ぜひ共有していただければと思っています!