クラシル開発ブログ

クラシル開発ブログ

エンジニアが始めるプロダクトマネジメント最初の一歩

こんにちは、delyでクラシルのiOSエンジニア兼PdMをしているtakao(takaoh717)です。

この記事は「dely #1 Advent Calendar 2020」の19日目の記事です。

昨日はデザイナーredさんの「Material DesignでUIデザインをブーストしよう」という記事でした。

adventar.org

adventar.org

はじめに

delyに入社して3年が経過し、僕は今iOSエンジニア兼PdMとしてプロダクトに関わっていますが、今の役割になるまでにこれまでいろいろな立ち回りをしてきました。 最近弊社で開催したPdMに関するイベントで「コードを書くことを主としているエンジニアがどうやってPdMとしての能力を磨くべきか」という質問をいただきましたが、イベント内では具体的な話をあまりできなかったので本記事で僕自身の事例を交えながら紹介したいと思います。 この記事がPdMとしてのキャリアを検討しているエンジニアの方やそういった動きを今後していきたい方の参考になる話ができればと思います。

プロダクトマネジメントのスタートは社内のボール拾いから

プロダクトマネジメントの最初の一歩としては社内で転がっているボール拾いをすることから始めるのが良いかなと思っています。 ボール拾いというのは、誰もディレクションする人がいなかったり宙に浮いてしまっている状態のプロジェクトや課題がある場合にそれらを自分から巻き取りに行くようなことを指しています。

自分からボールを拾って物事を進めることができれば、PdMに必要なスキルが徐々に身につけられると思います。

ボールを拾うためにはプロダクトに関するあらゆる物事を自分ごとに捉える必要がある

普段コードに向かっているとなかなか他のことに目を向けられないなんてことはエンジニアであればよくあることだと思います。 もちろん集中してコードを書かないといけないときはありますし、それはそれでとても良いことだと思います。 しかし、社内でボールを拾っていくためには受け身の状態でいるのではなく主体的に動いていく必要があります。 どこに課題があるか、どこが改善できそうか、なにかおかしいところがないかなど常にアンテナを張って、情報収集を続けることで気付けることがあると思います。 そうしていると、自分が介入することでうまく回る部分を見つけられ、次のアクションに繋げられるようになります。

部署間のステークホルダーを繋ぐハブになる

誰かが舵取りをしたほうが良さそうだけれど、誰も舵取りをしなくてなんとなく現場メンバーでふわっと進めてしまったり、全体を把握できている人がいないことで無駄なコミュニケーションが大量に発生する、なんてことはないでしょうか。 関わっているプロジェクトがもしそういった状況に陥っている場合は率先して舵取りをしていけると良さそうです。

delyでは今でこそスクワッド体制になって各プロジェクトの舵取りをするPdMがいるのであまりそういった問題は起こりにくくなっていますが、PdMをそもそも明確に立てていなかったときやPdM1人体制だった頃などはプロジェクトの振り返りをしたときに責任者不在による問題の発生についてよく話が上がっていました。

f:id:takaoh717:20201218134855p:plain
スクワッド体制

PdMになるまで

僕は2017年11月にdelyにiOSエンジニアとして入社し、今も(頻度はかなり減りましたが)iOSのコードを書いていますが、どちらかというとPdMとしての役割をメインで担っています。明確にPdMという役割になったのは今からちょうど一年前くらいだったと思いますが、それまでもPdMっぽい動きを度々することがありました。 今考えると、僕の場合はボール拾いを何度も繰り返すことで少しずつエンジニアリング以外のスキルの幅を広げられたのかなと思っているので、その際にやっていたことや意識していたことなどを紹介します。

CSチームとの連携の改善

入社して1ヶ月くらいが経過したころ、ユーザーからの問い合わせの対応に課題を感じました。 当時感じた課題としては以下のようなものがありました

  • CS対応が属人化していた
    • 開発部のサーバーサイドエンジニア1人 ⇔ CS担当だけでクローズドにコミュニケーションしていた
  • 伝言による無駄なコミュニケーションが発生していた
    • 問い合わせ内容はアプリに関することがほとんどだけどサーバーサイドエンジニアが対応していたので、都度アプリエンジニアに質問や確認をするコミュニケーションが発生
    • 無駄なコミュニケーションが発生することにより返答に時間がかかってしまうことがあった
  • 開発チームが不具合を認識するまでに時間がかかっていた
  • 開発チームがユーザーの声を拾えていなかった
  • CS担当者が対応するためのドキュメントやテンプレートなどが整備されていなかった

改善に向けて起こしたアクション

こういった課題を解決するためにまずはドキュメントの作成と周知を行い、改善に向けて以下のようなアクションをしました。

  • CS担当の人にプロダクトの基本的なことに関しては内部の仕組みも理解してもらえるようにする
  • 一次回答はなるべくCSチームで対応できるような対応集を用意する
  • 開発チームで他のメンバーも対応できる仕組みを作る
  • 問い合わせの状況、開発の状況をお互いに見やすい環境を作る
  • 問い合わせの一次回答で必要な情報はできるだけユーザーに聞いておく
  • アプリ内によくある質問を掲載する

当時はまだステークホルダーもCS担当の方と当時数名のエンジニアだけだったのでそんなに難しいことはしていませんでしたが、こういったことをやり始めた結果その後も1年くらいはCSチームとの連携を続けることができ、ゆくゆくはプロダクトに変更を加えるような施策やCSチーム全体の運用の改善につながるような施策も色々と進められました。 (今は別のメンバーが担当しています)

上記のようなアクションを通して、例えば以下のようなスキルを身に着けていくことができました。

  • ドキュメントを使った言語化力
  • 部署を跨いだステークホルダーとのコミュニケーション力
  • ユーザの行動を知るためのデータ分析力(+SQL)

これらのスキルはPdMとして課題を解決していくためにどれも必要なものだと思いますが、職種に閉じたエンジニアリングをやっているだけではなかなか身につけるのは難しいと思います。 しかし、少し視野を広げて自分から動いていけば自然と身についていくことが多いと思います。

まとめ

今回この記事で特にお伝えしたかった内容は以下の2点です。

  • プロダクトマネジメントはボール拾いから始める
  • プロダクトに関わるあらゆる出来事を自分ごととして捉えることが重要

今現在エンジニアとしてガリガリコードを書いていて、キャリアとしてPdMとしての道を考えている人の参考になれば幸いです。

おわりに

明日のアドベントカレンダーの記事はジョンさんの「Xcodeプロジェクト管理ツール「Tuist」を試している」です。ぜひ御覧ください!

また、dely ではエンジニア/デザイナーを絶賛募集中です。 ご興味ある方はこちらのリンクからお気軽にエントリーください!

delyではエンジニアを絶賛募集中です。ご興味のある方はぜひこちらのリンクを覗いてみてください。 カルチャーについて説明しているスライドや、過去のブログもこちらから見ることができます。

join-us.dely.jp

delyの開発部では定期的にTechTalkというイベントを開催し、クラシル開発における技術や組織に関する内容を発信しています。
クラシルで使われている技術や、エンジニアがどのような働き方をしているのか少しでも気になった方はぜひお気軽にご参加ください!

bethesun.connpass.com

Firebase Remote ConfigのConditionsでちょっと複雑な振り分け方を設定する

f:id:kenzo_aiue:20201215144809p:plain

こんにちは。delyでAndroidエンジニアをしているkenzoです。

この記事は「dely #1 Advent Calendar 2020」の17日目の記事です。

昨日はサーバサイドエンジニア高松さんの「バンディットアルゴリズムをライトに解説」という記事でした。
A/Bテストとバンディットアルゴリズムを用いた施行が進む様子を並べて見比べられるのが面白かったです。ご興味ある方はぜひこちらも御覧ください!

adventar.org

adventar.org

今回はFirebaseのRemote Configを用いて一定割合のユーザーに値を振り分ける(A/Bテストをする)ときの設定方法についてお話します。

delyではクラシルを使用してくれているユーザーにより良い料理体験を届けられるよう、日々の機能開発・改善やキャンペーン施策等に対して開発部・マーケティング部のメンバーがFirebase Remote ConfigやA/B Testingを使ってユーザーに値を振り分け、機能の出し分け等を行うことが頻繁にあります。
今回少し複雑な設定を試す必要があり、改めて設定方法について検証したので、その内容をご紹介します。

今回は主にFirebaseのコンソール画面におけるRemote ConfigのConditionsタブでの値の振り分けの設定の仕方と反映のされ方についてご紹介します。
Remote Configと組み合わせて使用するA/B Testingも便利な機能でよく利用していますが、本記事ではあまり触れません。
また、今回はアプリ側の実装についても触れません。

こちらの順で説明していきます。

今回主に用いる設定

Remote Config画面のConditionsタブ右上の「条件を追加」ボタンを押して表示されるポップアップにおいて、

f:id:kenzo_aiue:20201214140257p:plain

「ユーザー(ランダム %)」と、

f:id:kenzo_aiue:20201214134440p:plain

「%」の右のボタンで設定できる「キー」を利用してユーザーを分けていきます。

f:id:kenzo_aiue:20201214134643p:plain

シンプルに振り分ける例

2分割

50:50のユーザーに振り分ける場合はこちらのように <= 50%> 50% の条件を作成します。
キーにはどちらの条件にも同じ値をセットします(ここでは test_a )。
(条件を1つだけにしてデフォルトを利用することもできます)

f:id:kenzo_aiue:20201214134802p:plain

作成した条件を用いてRemote Configのパラメータを作成します。

f:id:kenzo_aiue:20201214142430p:plain

作成したパラメータを公開して少し経つとパラメータの値が振り分けられたユーザーの割合が表示されます。
このように50:50のユーザーに値を振り分けることができました。

f:id:kenzo_aiue:20201214134920p:plain

3分割以上

3つ以上のグループに値を振り分けたい場合はこちらのように複数の条件を作成します。
もちろんこれらの条件のキーは揃えます。
この場合は <= > のどちらかに統一するのがわかりやすくておすすめです。

f:id:kenzo_aiue:20201214135209p:plain

こちらの設定ではデフォルトも含め4グループに値を振り分けています。

f:id:kenzo_aiue:20201214135249p:plain

少し複雑になるので、ユーザー群に対してどのように値が振り分けられるのか説明します。

どのように振り分けられるのか

引き続き上記の3分割以上の例について見ていきます。

f:id:kenzo_aiue:20201214135209p:plain

Conditionsにある条件は上にあるものが優先されるので(画像だと A_3 > A_2 > A_1)、下記の順に条件を満たしたユーザーに値が振り分けられていきます。*1

  • random_user_test_A_3 で対象のユーザー100%のうち25%以下に当たる25%のユーザーに値 3 が振り分けられる
  • random_user_test_A_2 残りの75%(25~100%)のうち50%以下(25~50%)に当たる25%のユーザーに値 2 が振り分けられる
  • random_user_test_A_1 残りの50%(50~100%)のうち70%以下(50~70%)に当たる20%のユーザーに値 1 が振り分けられる
  • 残りの30%のユーザーにはデフォルトの空の文字列が振り分けられる

図にするとこんな感じです。

f:id:kenzo_aiue:20201214145823p:plain

その結果このように値が振り分けられています。

f:id:kenzo_aiue:20201214135249p:plain

具体的なユースケースでの設定例

今度は架空のユースケースにおける設定を試してみます。

  • 新機能をリリースし、その機能を30%のユーザーに対してのみ表示させる
  • 新機能が表示されているユーザーの中でもプレミアムユーザーにのみ、新機能の特別な使い方をお知らせするページへ飛べるバナーを表示させる
    • プレミアムユーザーかどうかはユーザープロパティを使用して判定 *2
    • この検証の環境ではプレミアムユーザーの割合は50%程度

Conditionsにてこのような条件を作成します。

f:id:kenzo_aiue:20201214190307p:plain

作成した条件を用いてRemote Configのパラメータを設定します。

f:id:kenzo_aiue:20201214194431p:plain

これで上記の仕様通りにパラメータが function banner に割り振られますが、今回はRemote Configの画面を見るだけでは正しく割り振られたことが確認できません。
クラシルではRemote Configで設定した値がどのように割り振られたのか知るために、ログ基盤にどんな値が割り振られたのかを送るようになっています。
今回はこのようにログ基板に送られたログを確認することで、仕様通りにパラメータが割り振られたのかを確認します。

このような感じのSQLで実際に振り分けられた結果のログを確認します。
AB_TEST_LOGテーブルにユーザー毎に割り振られた値が入っているものとします。

f:id:kenzo_aiue:20201214195722p:plain

実際に上記の仕様と同様の設定をしてログに溜まった値を計測した結果がこちらです。*3

f:id:kenzo_aiue:20201216110204p:plain

  • 新機能はおよそ30%(0.87 + 13.29 + 15.86 = 30.02)がonで、およそ60%(33.15 + 36.83 = 69.98)がoff
  • バナーは新機能がonのユーザーのみがonで、かつ、プレミアムユーザーのみがon

となっており、上記の仕様を満たしています。
(プレミアムユーザーで新機能がon、バナーがoffのユーザーが少しの割合存在していますがこれはログ送信のタイミングによって生じている誤差です。*4

注意事項

設定の際に気を付けておくことをご紹介します。

キーが異なる条件の組み合わせだとうまくいかない

下記のように設定されたキーが別の条件を組み合わせてパラメータを作成すると、

f:id:kenzo_aiue:20201215104255p:plain

条件ごとにユーザーのマッピングが変わってしまうため、このように意図しないユーザー群に振り分けられてしまいます。

f:id:kenzo_aiue:20201216113237p:plain

実際に反映された値はこちらのようになっていました。

f:id:kenzo_aiue:20201215104526p:plain

条件の順番を間違えるとうまくいかない

3分割以上の場合、上の説明のように値が割り振られていくため、条件に設定するユーザーの割合は狭い範囲の条件から順に反映されるように設定します。
Conditionsにある条件は上にあるものが優先されるので、狭い範囲の条件から順に並べておきます。

逆に、こちらのように広い範囲の条件が優先されるように設定してしまうと、

f:id:kenzo_aiue:20201215110522p:plain

このように広い範囲の条件のみに値が割り振られてしまいます。

f:id:kenzo_aiue:20201215111438p:plain

まとめ

Firebase Remote ConfigのConditionsを用いた少し複雑な設定をする方法をご紹介しました。
ユーザーに値を振り分けるのは今回の方法の他にも同じFirebaseのA/B Testingや他社サービスでも実現できますが、今回のように具体的なユースケースとして紹介した振り分け方も知っておくと、選択肢が1つ増えると思います。

また、今回の方法は設定が少し複雑になるため運用上のミスも発生しやすい箇所となりますので、実際に振り分けられても影響のない値で検証してから利用することをおすすめします。

今回の内容が皆様の日々の改善の一助になれば幸いです。

おわりに

明日はデザイナーredさんの「Material DesignでUIデザインをブーストしよう」です。ぜひ御覧ください!

また、dely ではエンジニアを絶賛募集中です。 ご興味ある方はこちらのリンクからお気軽にエントリーください!

delyではエンジニアを絶賛募集中です。ご興味のある方はぜひこちらのリンクを覗いてみてください。 カルチャーについて説明しているスライドや、過去のブログもこちらから見ることができます。

join-us.dely.jp

delyの開発部では定期的にTechTalkというイベントを開催し、クラシル開発における技術や組織に関する内容を発信しています。
クラシルで使われている技術や、エンジニアがどのような働き方をしているのか少しでも気になった方はぜひお気軽にご参加ください!

bethesun.connpass.com

*1:参照: Remote Config のパラメータと条件

*2:参照: Remote Config とユーザー プロパティ

*3:実際はプレミアムユーザーではなく50%くらいとなる条件を設定し、そのログを計測した結果です

*4:「非プレミアムユーザーがプレミアムユーザーになったタイミング」と「それがFirebaseにユーザープロパティとして反映されて値を再取得するまで」の間にログ送信のタイミングがきてしまったことによるものです

C# 9.0時代のnull判定解剖

f:id:meilcli:20201211111840j:plain

どうもC#erの@MeilCliです。仕事ではAndroidエンジニアしてますがC#erなのでアドベントカレンダーではC#について書きます

今回参加してるアドベントカレンダーはこちらです。16日目の記事になります

adventar.org

あと同様なカレンダーがもう1つあります

adventar.org

また、この記事の一部をクイズにしたものも投稿していますのでよろしければそちらもご覧ください

祝: C# 9.0リリース

さて、つい先日.NET 5と共にC# 9.0がリリースされました。C# 9.0の新機能は多々あるのですがその中でパターンマッチングの強化の一貫でvalue is not nullのようにnot条件が追加されました。この新機能によってC# 8.0のようにnot null判定をするためにvalue != nullvalue is T nonNullvalue is {}を書かずとも自然言語的な文章で書けるようになりました

C#では前述のようにバージョンアップに連れ様々なnot null判定ができる構文が追加され、どの構文を使えばいいのか迷うところでもありました。null判定も同様に様々な構文があり、場合によっては特定の構文は使わないほうがパフォーマンス的に良いということさえありました

というわけで今回はC#における歴代のnot null・null判定の構文紹介とC# 9.0時代の最適な判定方法を探していこうと思います

様々なnull・not null判定方法

null判定

null判定はC#バージョンによっての判定方法の追加が少なく、よく使われるものだと2種類あるかと思います*1

// 素直に==比較
bool isNull = value == null;

// C# 7.0のパターンマッチング(定数パターン)
bool isNull = value is null;

それ以外のものだと以下のような方法が考えられると思います*2

bool isNull = object.Equals(value, null);

// 参照型の場合
bool isNull = object.ReferenceEquals(value, null);

bool isNull = EqualityComparer<T>.Default(value, null);

null判定に関しては方法が少ないためパフォーマンスが同じならば書き手の好きな方を選べばいいとなるかと思いきや、==演算子がオーバーロード可能なため型によってはnullと==比較しているのにfalseを返すような邪悪なことをされる恐れがあります*3。より意図した通りのコードにしたいならばvalue is nullの判定方法が一択になるでしょう

not null判定

not null判定はnull判定よりも方法が多く、自分が知っているだけでもよく使われるもので5種類あります

// 素直に!=比較
bool isNotNull = value != null;

// is演算子
bool isNotNull = value is string; // string?の場合、int?ならばvalue is intになる

// C# 7.0のパターンマッチング(型パターン)
bool isNotNull = value is string notNullValue;

// C# 8.0のパターンマッチング(プロパティーパターン)
bool isNotNull = value is { };

// C# 9.0のパターンマッチング(not expression + 定数パターン)
bool isNotNull = value is not null;

それ以外にもnull許容値型の場合はHasValueプロパティによる判定もできます

// null許容値型の場合
bool isNotNull = value.HasValue;

さて、not null判定でもより意図した通りのコードにしたいならば前述のように!=演算子を避けた判定方法を取るといいのですが、それ以外の選択肢がたくさん存在しています。これは実際にパフォーマンスを計測してみるしかありませんね

ベンチマーク

パフォーマンスを測るためのベンチマークツールはいつも通りBenchmarkDotNetを使います

計測対象のプロジェクトでは.NET5でnull許容参照型を使ったりするので以下のようなcsprojにします

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.12.1" />
  </ItemGroup>

</Project>

また計測対象となるクラスの基本構成はこんな感じです

[SimpleJob]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class Bench
{
    [Params(null, "")] // or null, 1
    public string? Value { get; set; } // or int?

    [Benchmark]
    public bool Method()
    {
        bool result = false;
        for (int i = 0; i < 10; i++)
        {
            result = Value is null; // ここを計測したいケースごとに変える
        }
        return result;
    }
}

本来ならば計測対象のメソッドにforループで演算回数の水増しをしないほうがいいのですが、単純に1回限りの演算だと実行時間が早すぎて計測できないため10回ループさせています*4

ベンチマークケース

今回は値型と参照型の基本的な想定ケースとしてstring?int?をnullと空文字または1の値のときを計測しました

まとめると4回の計測になります

  • 参照型(string?)のnull判定するとき
  • 参照型(string?)のnot null判定するとき
  • 値型(int?)のnull判定するとき
  • 値型(int?)のnot null判定するとき

また、null・not null判定は!演算子やfalse比較で反転した結果にすることで同様な判定方法を記述できるため計測パターンは!演算子を使いつつnull判定とnot null判定でほぼほぼ同じ判定式になるようにケースを作成しました

参照型・null判定

メソッド名 判定式
EqualOperator Value == null
IsOperator !(Value is string)
PatternMatchNull Value is null
PatternMatchNotNull7 !(Value is string notNullValue)
PatternMatchNotNull8 !(Value is { })
PatternMatchNotNull9 !(Value is not null)
ObjectEquals object.Equals(Value, null)
ObjectReferenceEquals object.ReferenceEquals(Value, null)
EqualityComparer EqualityComparer<string?>.Default.Equals(Value, null)

参照型・not null判定

メソッド名 判定式
EqualOperator Value != null
IsOperator Value is string
PatternMatchNull !(Value is null)
PatternMatchNotNull7 Value is string notNullValue
PatternMatchNotNull8 Value is { }
PatternMatchNotNull9 Value is not null
ObjectEquals !(object.Equals(Value, null))
ObjectReferenceEquals !(object.ReferenceEquals(Value, null))
EqualityComparer !(EqualityComparer<string?>.Default.Equals(Value, null))

値型・null判定

メソッド名 判定式
EqualOperator Value == null
HasValue !(Value.HasValue)
IsOperator !(Value is int)
PatternMatchNull Value is null
PatternMatchNotNull7 !(Value is int notNullValue)
PatternMatchNotNull8 !(Value is { })
PatternMatchNotNull9 !(Value is not null)
ObjectEquals object.Equals(Value, null)
EqualityComparer EqualityComparer<int?>.Default.Equals(Value, null)

値型・not null判定

メソッド名 判定式
EqualOperator Value != null
HasValue Value.HasValue
IsOperator Value is int
PatternMatchNull !(Value is null)
PatternMatchNotNull7 Value is int notNullValue
PatternMatchNotNull8 Value is { }
PatternMatchNotNull9 Value is not null
ObjectEquals !(object.Equals(Value, null))
EqualityComparer !(EqualityComparer<int?>.Default.Equals(Value, null))

結果

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i9-10900K CPU 3.70GHz, 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=5.0.100
  [Host]     : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
  DefaultJob : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT

計測環境はこんな感じです*5

f:id:meilcli:20201128201025p:plain
参照型・null判定

f:id:meilcli:20201128201217p:plain
参照型・not null判定

f:id:meilcli:20201128201347p:plain
値型・null判定

f:id:meilcli:20201128201447p:plain
値型・not null判定

また、計測コードなどの詳細はGitHubに公開しているのでそちらを参照ください

github.com

要約

ちょっと結果が多すぎるためそれぞれの平均値を取ってみます*6

メソッド名 参照型 値型
EqualOperator 3.106 3.282
HasValue N/A 3.25
IsOperator 3.129 226.055
PatternMatchNull 2.65 3.746
PatternMatchNotNull7 2.621 6.292
PatternMatchNotNull8 3.097 3.274
PatternMatchNotNull9 2.631 3.275
ObjectEquals 14.37 248.335
ObjectReferenceEquals 3.075 N/A
EqualityComparer 16.75 52.655

総合的にはPatternMatchNotNull9がよく、それ以外の場合ではそれぞれ参照型・値型で長短があったりそもそも遅かったりという感じでしょうか

ちなみに

EqualityComparerのケースではEqualityComparer<T>.Defaultを取得する時間が影響を与えてる可能性があったため、string?とint?それぞれのインスタンスを取得する時間のベンチマークを取りました

[SimpleJob]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class EqualityComparerBench
{
    [Benchmark]
    public IEqualityComparer<string?> StringEqualityComparer()
    {
        return EqualityComparer<string?>.Default;
    }

    [Benchmark]
    public IEqualityComparer<int?> IntEqualityComparer()
    {
        return EqualityComparer<int?>.Default;
    }
}

f:id:meilcli:20201125091317p:plain

結果としては計測できないほど早い処理が行われてそうという感じでした。.NET Core 2.1におけるDevirtualization関連の最適化によってランタイム側でEqualityComparer<T>.Defaultをすり替えて仮想メソッド呼び出しのコストを回避したという話もあるようなのでそのあたりが影響してるのではないかなと思います

こういうこともあったり、どこからどこまで*7をベンチマーク対象として捉えればいいのかややこしくなってくるということもあるので今回の計測ではIEqualityComparer<T>.Default.Equals(Value, null)を計測することにしました

ベンチマーク結果の解剖

さて、ベンチマークを出して終わりではありません。.NET 5(on Windows)の結果はわかりましたのでそれぞれのベンチマークケースでなぜ差が生じたのかを紐解いていきます

C#でこのようなベンチマークケースの差を探るにはまずC#のコンパイル結果となるCIL*8を見るのが手っ取り早いです。今回はILSpyを使ってデコンパイルしました

また、CILは中間言語ということもあってコードが長くなる傾向になります。この場ではできる限り省いたものを載せるので全文を読みたい方はGitHubリポジトリーを参照してください

参照型・null判定

// Value == null
// !(Value is string)
// Value is null
// object.ReferenceEquals(Value, null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
ceq

object.ReferenceEquals(Value, null)がコンパイル時の最適化によってあとかたもなくなっていることには驚きですね。ldarg.0でスタックから引数0(つまりこのクラスのインスタンス)を読み込んでcall instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()でプロパティの値を読み込み、ldnullで読み込んだnull参照とプロパティの値をceqで等値比較するという感じです

// !(Value is string notNullValue)
// !(Value is { })
// !(Value is not null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
cgt.un
ldc.i4.0
ceq

こちらはcgt.unで大小比較し、cgt.unでint32の1か0がstackにpushされているのでldc.i4.0(つまりint32の0)とceqで等値比較しています。前述のValue == nullなどのケースより命令数が多くなってるのでパフォーマンス的に不利かと思いきや、ベンチマーク結果的にはあまり差がないようです

// object.Equals(Value, null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
call bool [System.Runtime]System.Object::Equals(object, object)

object.Equalsの場合はメソッドの呼び出し結果をそのまま使うという感じでした。あまり面白みがないのですがあとでobject.Equalsの実装を探ってみます

// EqualityComparer<string?>.Default.Equals(Value, null)
call class [System.Collections]System.Collections.Generic.EqualityComparer`1<!0> class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::get_Default()
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::Equals(!0, !0)

EqualityComparer<string?>.Default.Equals(Value, null)に関してはCIL的にはcallvirtで仮想メソッド呼び出しを行っている箇所がコストになりそうなものの、ランタイム側で最適化されるという話もあるためCILレベルではあまり判断できそうにないですね。ここに関しては実装を深堀っていこうと思います

参照型・not null判定

// Value != null
// Value is string
// Value is string notNullValue
// Value is { }
// Value is not null
// !(object.ReferenceEquals(Value, null))
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNotNullBench::get_Value()
ldnull
cgt.un

こちらはnull判定の時とは逆にcgt.unで大小比較するという形になっていますね。ベンチマーク結果的にはValue != nullValue is stringValue is { }が他のケースよりちょっと遅いかな(?)という印象もありましたがCIL的には同じ結果にコンパイルされているので誤差の範囲なのでしょうか…(MinとMaxでも明らかに差がついているのでランタイム側でなんらかの最適化が入ってそうな気がしないこともないですがこれ以上はわかりませんね)

// !(Value is null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNotNullBench::get_Value()
ldnull
ceq
ldc.i4.0
ceq

このケースのみ他のパターンマッチングなどと違い、ceqでnull参照と等値比較し、さらにその結果をldc.i4.0と等値比較しています。否定演算子を正直に変換している感じがしますね。こちらはCILレベルでは命令数的に不利ですが前述のケースとの差はなさそうです

!(object.Equals(Value, null))!(EqualityComparer<string?>.Default.Equals(Value, null))に関してはnull判定のときからldc.i4.0ceqで結果を反転してるだけなので省きます

値型・null判定

// Value == null
// !(Value.HasValue)
// Value is null
// !(Value is { })
// !(Value is not null)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
stloc.2
ldloca.s 2
call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
ldc.i4.0
ceq

さて値型の場合ですが、null許容値型は内部的にはNullable<T>構造体で表現されています。プロパティから値を取ってきたあとはstloc.2でローカル変数に保存し、ldloca.s.2でそのローカル変数のアドレスを取得しています。そしてそのアドレスに対しNullable<T>構造体のHasValueプロパティのgetter実装であるget_HasValueメソッドを呼び出しています。そのあとは値を反転するためにldc.i3.0ceqを使っていますね

// !(Value is int)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
box valuetype [System.Runtime]System.Nullable`1<int32>
isinst [System.Runtime]System.Int32
ldnull
cgt.un
ldc.i4.0
ceq

IsOperatorのケースが値型で極端に遅いというベンチマーク結果が出ていましたが、原因はboxでボックス化しているからですね。この遅さはC# 7.3の頃に調べてみた結果と同様なままのようです。コンパイラーの最適化次第な領域ではありますが、現時点でボックス化される形にコンパイルされることを鑑みると値型においてはvalue is intみたいな形式は避けておいたほうが無難でしょう

!(Value is int notNullValue)
IL_0006: ldarg.0
IL_0007: call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
IL_000c: stloc.2
IL_000d: ldloca.s 2
IL_000f: call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
IL_0014: brfalse.s IL_0021

IL_0016: ldloca.s 2
IL_0018: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::GetValueOrDefault()
IL_001d: pop
IL_001e: ldc.i4.1
IL_001f: br.s IL_0022

IL_0021: ldc.i4.0

IL_0022: ldc.i4.0
IL_0023: ceq

今まで行数は省略して紹介してきましたが、今度のはジャンプする命令があるため行数も書いています。brfalse.sではstackの値(ここではget_HasValueの結果)がfalse(つまり0の値)の場合に引数の行数であるIL_0021にジャンプさせています、つまり早期リターンのようなものですね。trueだった場合はその直後の命令が実行されていき、GetValueOrDefaultを呼び出しています。しかし、C#コード上では宣言したnotNullValue変数をしていない箇所が直訳されてるようで、popによってGetValueOrDefaultの結果を捨てています。このような無駄な命令があるため、他の早いケースと比べるとちょっと遅くなってしまっています

参照型の場合では跡形もなく消えていた未使用変数部分が値型の場合では直訳されるようなのでまだ少し最適化の余地があるという感じのようです

// object.Equals(Value, null)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
box valuetype [System.Runtime]System.Nullable`1<int32>
ldnull
call bool [System.Runtime]System.Object::Equals(object, object)

object.Equalsの場合もボックス化が走っているようですね。これが遅い原因だとは思いますがあとでobject.Equalsの実装を覗けたらなと思います

// EqualityComparer<int?>.Default.Equals(Value, null)
call class [System.Collections]System.Collections.Generic.EqualityComparer`1<!0> class [System.Collections]System.Collections.Generic.EqualityComparer`1<valuetype [System.Runtime]System.Nullable`1<int32>>::get_Default()
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
ldloca.s 2
initobj valuetype [System.Runtime]System.Nullable`1<int32>
ldloc.2
callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<valuetype [System.Runtime]System.Nullable`1<int32>>::Equals(!0, !0)

EqualityComparerのケースは少しややこしいですね*9 1スタック目*10ldarg.0callによってValueプロパティの値をpushし、2スタック目にldloca.s 2でローカル変数のNullable構造体のアドレスをpushし、initobjでNullable構造体の初期化を行っています(ここでスタックは消費している)。そして2スタック目にldloc.2で初期化したNullable構造体のローカル変数の値をpushし、callvirtでそれらの値を使ってEqualityComparerのメソッドを呼んでいます

EqualityComparerのケースがボックス化してるケースよりは早いけど時間がかかっているのはcallvirtしてるからという可能性もありますが、Devirtualizationされてると思われる箇所なのでEqualityComparerの実装体が少し遅い処理ということなんじゃないかなと想像できますね

値型・not null判定

// Value != null
// Value.HasValue
// Value is { }
// Value is not null
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNotNullBench::get_Value()
stloc.2
ldloca.s 2
call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()

こちらは値型・null判定でのValue is nullなどのケースからldc.i4.0ceqをしなくなったバージョンです。単純にbool値の反転がなくなったということですね

Value is intValue is int notNullValueでも同様にldc.i4.0ceqの命令がなくなっていました

// !(Value is null)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNotNullBench::get_Value()
stloc.2
ldloca.s 2
call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
ldc.i4.0
ceq
ldc.i4.0
ceq

!(Value is null)のケースではValue is nullのケースからさらにldc.i4.0ceqで値の反転をしています

これと同様に!(object.Equals(Value, null))!(EqualityComparer<int?>.Default.Equals(Value, null))もC#コードの通りにldc.i4.0ceqによる値の反転がされていました

Object.Equalsの実装

さて、Object.Equalsの実装が気になるので調べてみましょう。Objectは.NETの基礎となる型です。そのためソースコードを見るならば.NET5や.NET Coreのランタイム側を見るとよさそうです

.NET5や.NET Coreのランタイムはdotnet/runtimeに公開されています*11

GitHubの左上にある検索ボックスでfilename:Objectと検索してみます。すると大量のファイルがマッチするのでその中からObject.csを探し出します

src/libraries/System.Private.CoreLib/src/System/Object.csが検索結果の3ページ目ぐらいのところにあるのでそこからたどっていくことにします

public virtual bool Equals(object? obj)
{
    return RuntimeHelpers.Equals(this, obj);
}

public static bool Equals(object? objA, object? objB)
{
    if (objA == objB)
    {
        return true;
    }
    if (objA == null || objB == null)
    {
        return false;
    }
    return objA.Equals(objB);
}

コードを読むstatic bool Equalsのほうで早期リターンを行っている部分があるものの、早期リターンできなかった場合はRuntimeHelpers.Equalsを呼び出していることがわかります。そのままだと闇雲に探すことになってしまうのでヘッダー部分のusingされている名前空間を見ておきましょう

using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;

名前的にSystem.Runtime.CompilerServicesSystem.Runtime.InteropServicesにありそうですね

今度はfilename:RuntimeHelpersで検索してみます。すると8件ほどヒットするので目星をつけた名前空間に着目すると

の3つが該当しました。src/mono/netcoreというMonoなのか.NET Coreなのかよくわからないディレクトリーがありますが、まずはObject.csと同様な場所にあるsrc/librariesから読もうとなりましたがこのファイルでは定義されていません

partial classとなっているのでどうやらプラットフォームごとの別のソースコードを参照する形で実装されている様子です

[MethodImpl(MethodImplOptions.InternalCall)]
public static extern new bool Equals(object? o1, object? o2);

src/coreclrのほうを見てみるとこのようになっているため、どうやらCの世界まで潜らないといけないようです

検索ボックスでRuntimeHelpersをCやC++に絞って検索してみるとそれっぽいものが2つありました

src/coreclr/vm/corelib.hではDEFINE_CLASS(RUNTIME_HELPERS, CompilerServices, RuntimeHelpers)と記述されているだけなのでどうやらクラスを宣言してるだけのようです(CやC++詳しくないので間違ってるかもしれません)

src/coreclr/vm/ecalllist.hのほうを見てみます

FCFuncStart(gRuntimeHelpers)
    /* 略 */
    FCFuncElement("Equals", ObjectNative::Equals)
    /* 略 */
FCFuncEnd()

するとなにやら関数を登録してそうな処理が入っています。ObjectNativeに答えがありそうです

ObjectNativeで検索するとsrc/coreclr/classlibnative/bcltype/objectnative.cppsrc/coreclr/classlibnative/bcltype/objectnative.hが引っ掛かりますが、.hはヘッダーファイルなので.cppに実装がありそうです

.cppのほうでEqualsと検索するとこの処理がヒットしました

FCIMPL2(FC_BOOL_RET, ObjectNative::Equals, Object *pThisRef, Object *pCompareRef)
{
    CONTRACTL
    {
        FCALL_CHECK;
        INJECT_FAULT(FCThrow(kOutOfMemoryException););
    }
    CONTRACTL_END;

    if (pThisRef == pCompareRef)
        FC_RETURN_BOOL(TRUE);

    // Since we are in FCALL, we must handle NULL specially.
    if (pThisRef == NULL || pCompareRef == NULL)
        FC_RETURN_BOOL(FALSE);

    MethodTable *pThisMT = pThisRef->GetMethodTable();

    // If it's not a value class, don't compare by value
    if (!pThisMT->IsValueType())
        FC_RETURN_BOOL(FALSE);

    // Make sure they are the same type.
    if (pThisMT != pCompareRef->GetMethodTable())
        FC_RETURN_BOOL(FALSE);

    // Compare the contents (size - vtable - sync block index).
    DWORD dwBaseSize = pThisRef->GetMethodTable()->GetBaseSize();
    if(pThisRef->GetMethodTable() == g_pStringClass)
        dwBaseSize -= sizeof(WCHAR);
    BOOL ret = memcmp(
        (void *) (pThisRef+1),
        (void *) (pCompareRef+1),
        dwBaseSize - sizeof(Object) - sizeof(int)) == 0;

    FC_GC_POLL_RET();

    FC_RETURN_BOOL(ret);
}
FCIMPLEND

C#erには少し厳しいC++です…頑張って読み解いていきましょう

最初のCONTRACTLのところはおそらく防衛をしてるだけなのでスキップ。if (pThisRef == pCompareRef)trueを返してるので真っ先に参照を比較していますね

次にif (pThisRef == NULL || pCompareRef == NULL)でnullチェックをしています

そのあとのif (!pThisMT->IsValueType())ではコメントに値クラスではない場合は値で比較しないでください的なコメントが書かれています。参照型の場合は最初の参照比較で等値比較を終わらせていて、ここで値型以外はfalseとして返すようにしてるようです

そのあとのif (pThisMT != pCompareRef->GetMethodTable())では同じ型でなければfalseにするという処理が入っています

// Compare the contents (size - vtable - sync block index).
DWORD dwBaseSize = pThisRef->GetMethodTable()->GetBaseSize();
if(pThisRef->GetMethodTable() == g_pStringClass)
    dwBaseSize -= sizeof(WCHAR);
BOOL ret = memcmp(
    (void *) (pThisRef+1),
    (void *) (pCompareRef+1),
    dwBaseSize - sizeof(Object) - sizeof(int)) == 0;

さて、最後のこの比較が難関な匂いがします

まず真っ先に出てくるDWORDとはなんぞやというところからなので、グーグル大先生でclr dwordとググってみます。検索にヒットしたVB.NET/VB6.0/CLR/C/C++/Win32API 型一覧表 - 山崎はるかのメモによるとDWORDはC#でいうところのuintのようです、またそのあとに出てくるWCHARはC#でいうところのcharのようです

dwBaseSizeはMethodTableから取得しているようなのでおそらくその型が使用するメモリー量かなと思います。そのあとMethodTableがg_pStringClassだったらWCHARのサイズ分小さくしてるのはString末尾のヌル文字分減らしてるんじゃないかなと思いますが、値型でなかったらここまで到達しないはずでは…?というのもあるので謎ですね

そのあとはコメントの通りにmemcmpで参照先のメモリーブロックを比較しているという感じだと思います。(dwBaseSizeからsizeof(int)を減らしてる理由はわからないです)

Object.Equalsが遅かった理由

さて、本題に戻りObject.Equalsが参照型の場合はだいたい10ns、値型の場合はだいたい250nsかかっていたことについてですが、値型の場合はBenchmarkDotNetの結果をみるとAllocatedされているので明らかなのですが、Object.Equalsを呼び出すときにボックス化が行われていることがコストになっているようです。それを抜きにしても参照型・値型双方で通常の比較よりも多少の時間がかかっています

nullの場合はstatic bool Equalsのほうで早期リターンされるので早く終わってもいいはずですが、ベンチマーク結果的にはnullと空文字の場合であまり時間差がないため、メソッドの呼び出しコストに7nsぐらいかかってるんじゃないかなという匂いがします。一方で値型の場合は躊躇に差が表れているのでnullの場合は早期リターンされ、1の場合*12はObjectNativeの処理のあたりまで行ってるんじゃないかなと創造できます

真相は不明です、コード上からだとここが限界どころですね

ちなみにsrc/mono/netcoreのほうは

public static new bool Equals(object? o1, object? o2)
{
    if (o1 == o2)
        return true;

    if (o1 == null || o2 == null)
        return false;

    if (o1 is ValueType)
        return ValueType.DefaultEquals(o1, o2);

    return false;
}

値型以外の場合は単純な処理のようです。値型の場合だとValueType.DefautEqualsで比較するようです

internal static bool DefaultEquals(object o1, object o2)
{
    RuntimeType o1_type = (RuntimeType)o1.GetType();
    RuntimeType o2_type = (RuntimeType)o2.GetType();

    if (o1_type != o2_type)
        return false;

    object[] fields;
    bool res = InternalEquals(o1, o2, out fields);
    if (fields == null)
        return res;

    for (int i = 0; i < fields.Length; i += 2)
    {
        object meVal = fields[i];
        object youVal = fields[i + 1];
        if (meVal == null)
        {
            if (youVal == null)
                continue;

            return false;
        }

        if (!meVal.Equals(youVal))
            return false;
    }

    return true;
}

DefautEqualsに関してはこのメソッド名で検索するとこのファイルしか候補に上がらないため比較的楽に見つかりましたがInternalEqualsが嫌な予感しますね

[MethodImplAttribute(MethodImplOptions.InternalCall)]
private static extern bool InternalEquals(object o1, object o2, out object[] fields);

同じファイルに宣言されてましたが、どうやらまたCの世界に行くようです

InternalEqualsで調べるとsrc/coreclrのファイルも引っ掛かりますが、今回はsrc/monoコンテキストなのでその配下にあるそれっぽい結果のsrc/mono/mono/metadata/icall-def.hに探りをいれるとHANDLES(VALUET_1, "InternalEquals", ves_icall_System_ValueType_Equals, MonoBoolean, 3, (MonoObject, MonoObject, MonoArrayOut))とあるのでves_icall_System_ValueType_Equalsが本命のようです

同じように検索するとsrc/mono/mono/metadata/icall.cが出てきました。目的のメソッドは170行ぐらいあるのでここでは割愛しますが、リフレクションのような処理を行っているように見えます

さてC#erのみなさん、ここで思い出しましょう、値型のEqualsメソッドでは規定でリフレクションが使われるので遅いということを。このことは公式リファレンスの型の値の等価性を定義する方法 (C# プログラミング ガイド)でも書かれてることなので知ってる方も多いことでしょう*13。リファレンスに書かれてる通りのような実装をされているのでsrc/monoのほうは納得できるでしょう、しかしここまで読んでいただけた方はsrc/coreclrのほうではリフレクションではなくメモリブロックの比較を行っていたことにお気づきだと思います。自分のソースコード探索が間違っていなければいつの間にかにより高速だと思われる比較に変わっているということになりますね*14

Runtimeによる差を確認

前述のとおりsrc/monoはどうやら値型でEqualsメソッドを使うとリフレクションで遅いようだということがわかりました。本当にそうなのか比較したいところですがdotnet/runtimeのsrc/monoは謎の存在です(たぶんXamarin.Androidあたりのためにmono/monoからクローンしてるんじゃないかなと想像)

mono/monoでdotnet/runtimeのsrc/monoにあったDefaultEqualsを検索するとほぼ同様なコードがありましたので前述のとおりにmonoでは値型のEqualsメソッドでリフレクションが使われるという前提のもとその違いが出ないかの調査をします*15

調査と言ってもRuntimeによってEqualsの速度差が出るはずなのでBenchmarkDotNetによって差を計測していくことにします

今のcsprojファイルではそのまま計測することができないので少し手を加えます

<TargetFrameworks>net5.0;net48</TargetFrameworks>
<PlatformTarget>AnyCPU</PlatformTarget>
<LangVersion>9.0</LangVersion>

csprojのPropertyGroupにほとんどの場合ではTargetFrameworkが記述されてるかと思いますが、それはTargetFrameworksに変え.NET Framework 4.8であるnet48を記載します。それと同時にPlatformTargetLangVersionを指定します。ここでは.NET 5に合わせて9.0にしていますが.NET Framework 4.8やMonoだと対応するC#バージョンが異なりますので一部C# 9.0の機能が使えなくなります(検証では関係ありませんが)

次に.NET Framework 4.8とMonoを準備します。.NET FrameworkはDeveloper Packを入れ、Monoは公式サイトからインストーラーを入れて環境変数にPathを通せばいいです

[SimpleJob(RuntimeMoniker.Net48)]
[SimpleJob(RuntimeMoniker.NetCoreApp50)]
[SimpleJob(RuntimeMoniker.Mono)]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class ValueTypeEqualsBench
{
    private object expect = 1;

    [Params(null, 1)]
    public object? Value { get; set; }

    [Benchmark]
    public bool ValueTypeEquals()
    {
        return object.Equals(Value, expect);
    }
}

まず検証するのはこちらのベンチマークケースです。単純にメソッドの実行速度の差を計測したいのであらかじめボックス化をさせておきobject.Equalsを呼び出すだけのコードにしています

計測に関してですがMonoが.NET FrameworkのBCLを参照する必要があるらしくHost Processを.NET Framework 4.8にするためにdotnetコマンド(dotnet run -c Release -f net48)でHost Processを指定することで実施しています

そして結果がこう:

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042, VM=Hyper-V
Intel Core i9-10900K CPU 3.70GHz, 1 CPU, 16 logical and 8 physical cores
  [Host]        : .NET Framework 4.8 (4.8.4250.0), X64 RyuJIT
  .NET 4.8      : .NET Framework 4.8 (4.8.4250.0), X64 RyuJIT
  .NET Core 5.0 : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
  Mono          : Mono 6.12.0 (Visual Studio), X64 

f:id:meilcli:20201213185456p:plain

.NET Framework 4.8が.NET 5より少し早いという結果になりましたがMonoが予想通り遅そうな結果が出ていますね。リフレクションで比較しているということは比較対象のフィールドが多くなればなるほど差が躊躇に現れるはずです

[SimpleJob(RuntimeMoniker.Net48)]
[SimpleJob(RuntimeMoniker.NetCoreApp50)]
[SimpleJob(RuntimeMoniker.Mono)]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class ValueTypeLongStructEqualsBench
{
    public struct BigStruct
    {
        public long Value1;
        public long Value2;
        public long Value3;
        public long Value4;
        public long Value5;
        public long Value6;
        public long Value7;
        public long Value8;
    }

    private object expect = new BigStruct();

    public IEnumerable<object> Source()
    {
        yield return new BigStruct();
    }

    [Benchmark]
    [ArgumentsSource(nameof(Source))]
    public bool ValueTypeEquals(object value)
    {
        return object.Equals(value, expect);
    }
}

今度は無理やり肥大化させた構造体でベンチマークをしてみます

f:id:meilcli:20201213190231p:plain

結果としては予想通りMonoが躊躇に遅くなりました。どうやらいつかわからないタイミングで値型のEqualsのパフォーマンスチューニングが施されていたようです

EqualityComparer<T>.Defaultの実装

さてEqualityComparer<T>.Defaultの実装を深掘っていこうと思いますが、すでにEqualityComparer.Defaultの実装を追ってみる。 - ねののお庭。で先駆者の方が実装を追っているようです。どうやら正攻法でコードを読んでいくと沼になるようなので趣向を変えてDevirtualizationが実装されたPullRequestを見ていこうと思います

EqualityComparer<T>.DefaultのDevirtualizationが実装されたのは.NET Core 2.1の頃なのでdotnet/runtimeリポジトリーではなくdotnet/coreclrリポジトリー*16を探すことになります

PullRequestを検索するとJIT: devirtualization support for EqualityComparer.Default #14125というそれっぽいPullRequestが見つかります

中身を見てみるとDevirtualizationはIntrinsicAttributeをC#コードに付けそれをJITが見つけると特殊対応をする構造になっているようです

EqualityComparer<T>.Defaultの場合は元々は

public static EqualityComparer<T> Default { get; } = (EqualityComparer<T>)ComparerHelpers.CreateDefaultEqualityComparer(typeof(T));

というコードだったものが

public static EqualityComparer<T> Default { [Intrinsic] get; } = (EqualityComparer<T>)ComparerHelpers.CreateDefaultEqualityComparer(typeof(T));

というコードに変更されています

それと同時にsrc/mscorlib/src/System/Collections/Generic/ComparerHelpers.csのCreateDefaultComparerメソッドにand in vm/jitinterface.cpp so the jit can model the behavior of this method.というドキュメントコメントが追記されているためJIT側でDefaultのEqualityComparerを作成してるようです

JIT側のコードはC++でよくわからないので割愛するとして、Devirtualizationの特殊対応をする対象メソッドはsrc/jit/namedintrinsiclist.hのNamedIntrinsic列挙型で管理されているようです

enum NamedIntrinsic
{
    NI_Illegal                                                 = 0,
    NI_System_Enum_HasFlag                                     = 1,
    NI_MathF_Round                                             = 2,
    NI_Math_Round                                              = 3,
    NI_System_Collections_Generic_EqualityComparer_get_Default = 4
};

このPullRequestの時点では対象となるメソッドは少ないようですが気になるので現在の.NET 5の実装を見ましょう

dotnet/coreclrのコードはdotnet/runtimeだとsrc/coreclrの中に移ってるはずなのでそのディレクトリーからそれっぽいところを探すとnamedintrinsiclist.hが見つかりました

enum NamedIntrinsic : unsigned short
{
    NI_Illegal = 0,

    NI_System_Enum_HasFlag,
    NI_System_Math_FusedMultiplyAdd,
    NI_System_Math_Sin,
    NI_System_Math_Cos,
    NI_System_Math_Cbrt,
    NI_System_Math_Sqrt,
    NI_System_Math_Abs,
    NI_System_Math_Round,
    NI_System_Math_Cosh,
    NI_System_Math_Sinh,
    NI_System_Math_Tan,
    NI_System_Math_Tanh,
    NI_System_Math_Asin,
    NI_System_Math_Asinh,
    NI_System_Math_Acos,
    NI_System_Math_Acosh,
    NI_System_Math_Atan,
    NI_System_Math_Atan2,
    NI_System_Math_Atanh,
    NI_System_Math_Log10,
    NI_System_Math_Pow,
    NI_System_Math_Exp,
    NI_System_Math_Ceiling,
    NI_System_Math_Floor,
    NI_System_Collections_Generic_EqualityComparer_get_Default,
    NI_System_Buffers_Binary_BinaryPrimitives_ReverseEndianness,
    NI_System_Numerics_BitOperations_PopCount,
    NI_System_GC_KeepAlive,
    NI_System_Threading_Thread_get_CurrentThread,
    NI_System_Threading_Thread_get_ManagedThreadId,
    NI_System_Type_get_IsValueType,
    NI_System_Type_IsAssignableFrom,
    NI_System_Type_IsAssignableTo,

    /* 略 */

現在だとかなりのメソッドが特殊対応の対象のようですがほとんどがMathクラスのものですね

EqualityComparer<T>.Defaultの実装の調査に関しては沼なのでここまでにしておきます

結局のところなにがいいのよ

総合評価(安全性・可読性・速度)をするとnull判定はvalue is null、not null判定はvalue is not nullが無難という感じでしょうか。もちろん他の表現方法でも気にする必要がないケースがほとんどだろうのでなんでもいいっちゃいいという感じではありますが

ちなみに

ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
cgt.un
ldc.i4.0
ceq

最初のほうで!(Value is not null)の場合は上記のようなCILになってたよと紹介しましたが

public class ReferenceNull
{
    public bool PatternMatchNotNull9(string? value)
    {
        return !(value is not null);
    }
}

というコードのCILを見ると

.method public hidebysig 
    instance bool PatternMatchNotNull9 (
        string 'value'
    ) cil managed 
{
    // Method begins at RVA 0x2102
    // Code size 5 (0x5)
    .maxstack 8

    IL_0000: ldarg.1
    IL_0001: ldnull
    IL_0002: ceq
    IL_0004: ret
} // end of method ReferenceNull::PatternMatchNotNull9

というコードになっていました。式も文脈によっては異なるCILに変換されるようなので今回のベンチマークケースでこうなったからといってif文やreturn文で同様になるとは限らなさそうです。ボックス化している箇所についてはほぼ確実にどのような場所でもおきそうですがCILの命令数的な誤差は目をつぶるしかなさそうです

おまけ

public class Evil
{
    public static bool operator ==(Evil left, Evil right) => true;
    public static bool operator !=(Evil left, Evil right) => false;
}

public class Program
{
    public void Example()
    {
        var evil1 = new Evil();
        var evil2 = new Evil();
        bool isEquality = evil1 == evil2;
    }
}

C#的には合法(コンパイル可能)で邪悪なコードですがExampleメソッドのCILに変換されたコードを見るとオーバーロードした==演算子が呼ばれていることがわかります

.method public hidebysig 
    instance void Example () cil managed 
{
    // Method begins at RVA 0x2060
    // Code size 22 (0x16)
    .maxstack 2
    .locals init (
        [0] class Evil evil1,
        [1] class Evil evil2,
        [2] bool isEquality
    )

    IL_0000: nop
    IL_0001: newobj instance void Evil::.ctor()
    IL_0006: stloc.0
    IL_0007: newobj instance void Evil::.ctor()
    IL_000c: stloc.1
    IL_000d: ldloc.0
    IL_000e: ldloc.1
    IL_000f: call bool Evil::op_Equality(class Evil, class Evil)
    IL_0014: stloc.2
    IL_0015: ret
} // end of method Program::Example

Evilの変数をobject型として受け取ればobjectの規定の動作通りに==演算子で比較したようになったりしますが、たいていの場合はそういうわけにもいかないので==演算子のオーバーロードは注意が必要だったりします

おわりに

この記事はアドベントカレンダーだしC#の記事書いとくか〜と書き始めたら止まらなくなり肥大化してしまったものです(スコープの管理ができてない)。最後の方はダレてしまって手抜き感がありますがご了承ください、気になればいつの日か調査するかもしれません

昨日の「dely #2 Advent Calendar 2020」はnancyさんの「iOSのサブスクリプション機能 プロモーションオファーを触ってみた」でした

明日は永井さんの「Merged Manifest を使って uses-permission を調査した話」ですお楽しみに!


join-us.dely.jp

bethesun.connpass.com

*1:サンプルコードはすべてC# 9.0ベースです

*2:ポインターとか参照をUnsafeに比較する方法とかあるかもしれません

*3:普通はそんなオーバーロードをしないので普通のプラットフォーム向けのコードの場合は気にしなくていいですが、気にしないといけないプラットフォームがあるので闇です

*4:こういう場合のスマートな解決策があれば教えてください

*5:i9-10900Kは10core20threadなCPUですが、計測はHyper-V上のWindowsで行ったため8core16threadです。フルパフォーマンスとは言えませんが同環境での比較となるためベンチマーク結果としては有効かと思います

*6:ガチで判断するならnullの頻度分布によって調整をかけないといけませんがここでは手抜きということで平均値です

*7:たとえばIEqualityComparerのインスタンスが用意できてるという前提でcomparere.Equals(Value, null)を計測するのかとか

*8:Common Intermediate Language、共通中間言語、一部からはMSILとも呼ばれる

*9:ここのCILを理解するのに5分考えこみました

*10:EqualityComparerは0スタック目という数え方

*11:ちょっと前まではdotnet/coreclrで公開されていましたね

*12:nullと対比するための値として設定した1のことです

*13:自分はすっかり忘れてました

*14:それでもボックス化で遅いのは変わらず

*15:すべてのコードを確かめたわけではありませんが雰囲気的にはdotnet/runtimeのsrc/monoはmono/monoから手書きクローンしてそうな感じがしました

*16:今はdotnet/runtimeに移行されてコードがほとんどない状態ですがCommitやPullRequestは残っています

バンディットアルゴリズムをライトに解説

f:id:takarotoooooo:20201214110014p:plain

こんにちは! dely開発部の高松です。

この記事は「dely #1 Advent Calendar 2020」の16日目の記事です。

昨日はクラシルのUIデザイナーをされているymdskoさんの「UIデザイナーとして働く私が就活生に戻ったら絶対やること5つ」でした。 是非こちらもご覧ください。

note.com

「dely #1 Advent Calendar 2020」 adventar.org

 「dely #2 Advent Calendar 2020」もありますので、是非そちらもご覧ください。 adventar.org

さて、いきなりですが質問です。

目の前にそれぞれ決められた一定の確率で報酬を得ることができるボタンが4つあります。 合計で1000回ボタンを押して、4つの内どれが一番当たる確率が高いボタンかを検証してみてください。
なお、1000回ボタンを押したことで得た報酬は全て差し上げます。

さあ、どうしましょう。

4つ全てのボタンを250回ずつ押して、一番当たりの多いボタンを探しますか?

きっと一番当たる確率が高いボタンは見つかりますが、その分確率の低いボタンもたくさん押しているので、実はもっと多くの報酬が得られたかもしれません。

では、それぞれ100回ずつ押してみて、その時点で一番当たりが多かったボタンを残りの600回押しますか?

100回押した時点で一番当たる確率が高いボタンが本当に一番当たる確率が高いボタンなのでしょうか。

このように、最適な選択肢を探しつつ、その間に得られる報酬を最大にする決定問題をバンディット問題と呼び、この問題に対するアルゴリズムが今回紹介するバンディットアルゴリズムです。

実際どのように選択をするのか

今回はバンディットアルゴリズムのUCB(Upper Confidence Bound)という方策をご紹介します。

UCB方策は、期待値の高い選択肢を選ぶ一方で、それまで施行数が少ない選択肢を優先的に選択されるようにする方策です。

具体的には下記の数式にて算出される値が一番大きい選択肢を逐次選んでいきます。

 \mu + \sqrt{ \dfrac{2\times{\log{N}}}{n} }

記号 意味
 \mu 選択肢の期待値
 N 全選択肢の選択回数の合計
 n その選択肢の選択回数

期待値というのはその選択肢を1回選ぶことでどれくらいの報酬が得られるかを表した値です。 今回の例で言えば、その時点でより当たりが出ているボタンほど期待値が高いということになります。

各選択肢の期待値に下記の補正項が上乗せされています。

 \sqrt{ \dfrac{2\times{\log{N}}}{n} }

上の式は、総選択数 N に対して、選択数 n が少ない選択肢ほど値が高くなるようになっています。
この補正項のおかげで、他の選択肢に対して検証ができていない選択肢が優先的に選ばれることになります。

検証してみる

今回は、全ての選択肢を同じ回数施行するいわゆるA/Bテストを行った場合とUCB方策のバンディットアルゴリズムを用いて施行を行った場合を比較します。

class Arm
  attr_accessor :name              # 選択肢名
  attr_accessor :num_of_run        # 施行回数
  attr_accessor :num_of_conversion # 報酬獲得回数
  attr_accessor :unit_reward       # 1回分の報酬(今回は固定で1)
  attr_accessor :probability       # 報酬が得られる確率

  def initialize(
    name, 
    unit_reward: 1, 
    probability: 1.0, 
    num_of_run: 1, 
    num_of_conversion: 0
  )
    @name = name
    @num_of_run = num_of_run
    @num_of_conversion = num_of_conversion
    @unit_reward = unit_reward
    @probability = probability
  end

  def run!
    self.num_of_run += 1
    return false unless (1..100).to_a.sample(probability * 100).include?(1)

    self.num_of_conversion += 1
    return true
  end

  def total_reward
    unit_reward * num_of_conversion
  end

  def expectation
    total_reward / num_of_run.to_f
  end

  def ucb_weight(total_num_of_run)
    Math.sqrt(
      (2 * Math.log(total_num_of_run)) / num_of_run.to_f
    )
  end

  def upper_confidence_bounce(total_num_of_run)
    expectation + ucb_weight(total_num_of_run)
  end
end

class ArmSelector
  attr_accessor :arms        # 選択肢Armの配列
  attr_accessor :select_type # 選択方法

  def initialize(arms, select_type: 'ab')
    @arms = arms
    @select_type = select_type
  end
  def select
    case select_type
    when 'ab'
      return arms.sort_by { |arm| arm.num_of_run }.first
    when 'ucb'
      unverified_arm = arms.find { |arm| arm.num_of_run == 0 }
      return unverified_arm if unverified_arm
      total_num_of_run = arms.map(&:num_of_run).sum
      sorted_arms = arms.sort_by do |arm|
        arm.upper_confidence_bounce(total_num_of_run)
      end
      sorted_arms.last
    end
  end
end

上記のコードを基に逐次選択される過程を表したのが下記です。

今回は説明を簡単にするために、a, b, c, dの選択肢はそれぞれ一定の確率で当たり(当たりなら1、ハズレなら0)が出るような形とし、 a < b < c < d の順で確率が大きいとします。

左が全ての選択肢を同じ回数施行した際の検証です。
右が上でご紹介したUCB方策のバンディットアルゴリズムを用いた検証です。

グラフの紫のバーが施行回数、緑のバーが報酬を得た回数、黄色の点がその時点の選択肢の期待値を表しています。

左は全ての選択肢を同じ回数施行するので、確率に基づいて選択肢「d」の報酬を得た回数が一番大きくなっているのがわかります。

一方バンディットアルゴリズムの方は選択肢毎に施行回数が違っています。
施行を始めたばかりは、期待値にばらつきが出るので全ての選択肢を満遍なく施行しますが、ある程度施行を重ねると選択肢「d」が多く選択されていくのがわかります。
また、一番期待値の低いと思われる選択肢「a」に対しても、完全に施行されなくなるわけではなく、頻度は少なくなるものの施行が続いていることもわかります。

そして、同じ試行回数で報酬を得られた回数を比較するとバンディットアルゴリズムの方がより多く報酬を得られました。

このように、バンディットアルゴリズムを用いることでより多くの報酬を得ることを確認できました。

まとめ

バンディットアルゴリズムは、選択を続け報酬を得るプロセスに於いてその報酬の合計を最大にするためのアルゴリズムです。
今回比較にも利用したA/Bテストと呼ばれる「最適椀識別」のように特定の選択肢を見つけることが主の目的ではありません。

今回は時間が足りず紹介出来ませんでしたが、バンディットアルゴリズムにて最適な選択肢を誤識別する様なシチュエーションも存在します。

また、今回はUCB方策を例に取りご紹介しましたが、他にも様々な選択方法が存在します。
こちらもまた時間があれば紹介できればと思います。

今回のこの記事が少しでもバンディットアルゴリズムの理解の助けになると嬉しいです。

さいごに

dely ではエンジニアを絶賛募集中です! ご興味ある方はこちらのリンクからお気軽にエントリーください!

join-us.dely.jp

さらに、定期的に TechTalk というイベントを通じて、クラシルで利用している技術や開発手法、組織に関する情報も発信しております。
ノウハウの共有だけでなく、クラシルで働くエンジニアがどんな想いを持って働いているのかや、働く人の雰囲気を感じていただけるイベントになっていますので、ぜひお気軽にご参加ください!

bethesun.connpass.com

iOSのサブスクリプション機能 プロモーションオファーを触ってみた

f:id:nakanishi-w:20201211093438p:plain

こんにちは! dely で iOS エンジニアをしている nancy です。

はじめに

この記事は「dely #2 Advent Calendar 2020」の15日目の記事です。

adventar.org

adventar.org

昨日はクラシルのフロントエンドを担当されている しらりん さんの「ウェブの未来を描く Project Fugu」という記事でした。

tech.dely.jp

ウェブとネイティブアプリとの操作性のギャップを埋めるプロジェクト、 Project Fugu について書かれています! ウェブの開発をされている方だけでなく、ネイティブアプリの開発をされている方にもオススメの記事なので、興味のある方は是非ご覧ください!


本日は WWDC 2019 で発表された iOS の自動更新サブスクリプション機能の一つ、 「プロモーションオファー」について書きたいと思います。

プロモーションオファーとは

プロモーションオファーとは、WWDC 2019 で発表された iOS の自動更新サブスクリプション機能の一つで

過去にサブスクリプションに登録していた、もしくは現在サブスクリプションに登録しているユーザに対し、 「1ヶ月無料」や「1ヶ月100円引き」といった値引きしたプランを提供できるような機能です。

お試しオファーとプロモーションオファーの違い

プロモーションオファーが実装されるより以前は「お試しオファー」という初めてサブスクリプションに登録するユーザ向けの機能を使用してユーザへサブスクリプションへの登録を行っていました。

どのサブスクリプションでもよく見る「初めて登録される方なら nヶ月無料!」みたいなやつですね。

このお試しオファーは新規サブスクリプション登録者の獲得には有用ですが、一度しか利用できないため、解約ユーザへの訴求、現在サブスクリプションに登録してくれているユーザへの訴求には使用できませんでした。

これに対し「プロモーションオファー」では、解約したユーザや現在サブスクリプションに登録してくれているユーザに対して再度お得なプランを訴求することができるため、一度解約してしまったユーザに再登録を促すことができたり、現在サブスクリプションに登録してくれているユーザの長期継続につながったりすることが期待できます。

下の画像にもありますが、iOS 12.2 以上のユーザのみ利用可能な機能であるため、導入する際は注意が必要です。

f:id:nakanishi-w:20201205215400p:plain
https://developer.apple.com/jp/app-store/subscriptions/#providing-subscription-offers

※オファーコードも記載されていますが、今回の記事ではこの部分には触れません。

実装のはなし

プロモーションオファーは以下のような流れで実装していきます

  • プロモーションオファーの作成
  • プラン/プロモーションオファーの詳細を取得
  • 署名を生成
  • 購入処理を実行
  • レシート検証
  • トランザクションを完了

プロモーションオファーの作成

実装に入っていく前にプロモーションオファーの準備を App Store Connect 上で行います。

秘密鍵の生成

まずはプロモーションオファーの課金に使用する秘密鍵の生成を行います。

こちらに記載の流れのように進めていきます

  1. 「ユーザとアクセス」→「キー」をクリックし、「サブスクリプション」を選択
  2. +ボタンをクリック
  3. 任意の名前を設定し、サブスクリプションキーを生成
  4. 生成した秘密鍵をダウンロード

※秘密鍵のダウンロードは1回しかできないため、ご注意ください。

プロモーションオファーの設定

秘密鍵が生成できたら、次にプロモーションオファーのプランを作成していきます。 プロモーションオファーは既存のプランに紐づく形で作成するため、元となるプランがまだ存在しない場合はそちらから作成するようにしてください。

プロモーションオファーの設定も Apple のドキュメント通りに進めていきます

  1. 「マイ App」から、設定する App を選択
  2. サイドバーの「App 内課金」で、「管理」をクリック
  3. 自動更新登録タイプのプロダクトをクリックし、「登録価格」セクションに移動して、「追加」ボタン(+)をクリック
  4. 「プロモーションオファーの作成」を選択 f:id:nakanishi-w:20201213171312p:plain
  5. 内部参照名とオファーコードを入力 f:id:nakanishi-w:20201213171342p:plain
  6. 「都度払い」、「前払い」、「無料」のいずれかを選択した後、適切な期間、通貨、価格を選択f:id:nakanishi-w:20201213171403p:plain

ちなみに、都度払いを選択した場合は「適用期間」「価格」を選択できるようになり、特定の期間だけ○円みたいな設定ができます。 f:id:nakanishi-w:20201213171421p:plain

以上でプロモーションオファーの作成は完了です。

以降は実装に入っていきます。

プラン/プロモーションオファーの詳細を取得

まずは以下のような処理でプランの詳細(SKProduct)のリクエストを行います。

var purchaseProductIdentifier: String?

func fetchProduct(id: String) {
    purchaseProductIdentifier = id
    let productIdentifiers = Set<[purchaseProductIdentifier!]>
    request = SKProductsRequest(productIdentifiers: productIdentifiers)
    request.delegate = self
    request.start()
}

次に SKPaymentQueueDelegate にて SKProduct の取得完了通知を受け取ります。この辺りは既に自動更新サブスクリプションを導入されている場合は同じ処理になるかと思います。

ただ、プロモーションオファーが紐づいているプランを取得した場合、 SKProductdiscounts: [SKProductDiscount] に紐づいているプロモーションオファーが全て入った状態で取得できます。

SKProductDiscount にはプランの料金や適用期間などが入っているので、 SKProduct の情報を使用して View を更新する場合は discounts を参照するようにしてください。

public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
    guard let purchaseProductIdentifier = purchaseProductIdentifier,
          let product = response.products.first(where: { $0.productIdentifier ==  purchaseProductIdentifier }) else {
        // エラー処理
        return
    }
    // .discounts にプロモーションオファーの情報が入った状態で取得できる
    print(product.discounts)
    // 課金処理へ
}

署名を生成

参考:Generating a Signature for Promotional Offers

プロモーションオファーを導入するには既存の自動更新サブスクリプションとは異なり「署名の生成」を行い、得られた文字列を購入リクエストに含める必要があります。

署名の生成に必要な情報以下の通りです。

名称 説明
appBundleID アプリの Bundle Identifier
keyIdentifier App Store Connect で生成した秘密鍵を識別する ID
productIdentifier プランの ID
offerIdentifier プロモーションオファーの ID
applicationUsername サービス内でユーザを一意に識別する文字列(任意)
nonce サーバーサイドで生成する UUID(小文字)
timestamp サーバーサイドで生成する UNIX タイムスタンプ(ミリ秒)

node.js を用いて署名生成を行うサンプルコードを Apple が公開しているので、こちらを例に出しておきます。

一部変更を加えている部分もあるので、元コードを参照したい方は下記のリンクからご参照ください。

Generating a Subscription Offer Signature on the Server

router.get('/offer', function(req, res) {
    // App Bundle Identifier. ここではパラメータから取得しているが、環境変数として保持しても良さそう.
    const appBundleID = req.body.appBundleID;
    // プランの ID
    const productIdentifier = req.body.productIdentifier;
    // プロモーションオファーの ID
    const subscriptionOfferID = req.body.offerID;
    // ユーザを識別する文字列
    const applicationUsername = req.body.applicationUsername;

    // 環境変数等で保持している、秘密鍵の ID
    const keyID = 'xxxxx';
    // 秘密鍵の中身(こちらも本来は環境変数として持つべき)
    const keyString = '-----BEGIN PRIVATE KEY-----xxxxx-----END PRIVATE KEY-----';

    // UUID を生成.
    const nonce = uuidv4();
    
    // タイムスタンプを生成.
    const currentDate = new Date();
    const timestamp = currentDate.getTime();

    // 全ての文字列を不可視の分離文字列('\u2063')で結合
    const payload = appBundleID + '\u2063' +
                    keyID + '\u2063' +
                    productIdentifier + '\u2063' +
                    subscriptionOfferID + '\u2063' +
                    applicationUsername  + '\u2063'+
                    nonce + '\u2063' +
                    timestamp;

    // 秘密鍵を使用して楕円曲線デジタルアルゴリズム(ECDSA)オブジェクトを生成
    const key = new ECKey(keyString, 'pem');

    // SHA-256 署名アルゴリズムを使用するよう設定
    const cryptoSign = key.createSign('SHA256');

    // 結合した文字列を追加
    cryptoSign.update(payload);

    // 署名を生成し、base64 でエンコード.
    const signature = cryptoSign.sign('base64');
    
    // 生成した署名が正しいものなのか検証.
    // 署名の処理が正しく完了しているかを検証するもので、生成した署名で課金処理が正しく行えるかを検証するものではないので注意.
    // ex)timestamp を "ミリ秒" ではなく "秒" で作成していると課金時にエラーになるが、この部分での検証には成功する
    const verificationResult = key.createVerify('SHA256').update(payload).verify(signature, 'base64');
    console.log("Verification result: " + verificationResult)

    // アプリにレスポンスを返却.
    res.setHeader('Content-Type', 'application/json');
    res.json({ 'keyID': keyID, 'nonce': nonce, 'timestamp': timestamp, 'signature': signature });
});  

購入処理を実行

通常の自動更新サブスクリプションでは SKPayment を使用して購入リクエストを作成しますが、プロモーションオファーで課金する場合は SKMutablePayment を使用します。

また、課金するプロモーションオファーの ID などの情報は SKPaymentDiscout という型が用意されているので、こちらに必要な情報を入れ、 SKMutablePaymentpaymentDiscount プロパティにセットします

func purchase(product: SKProduct, username: String, offerIdentifier: String) {
    // サーバーに署名の生成をリクエスト
    YourServer.createSignature(username: username, productIdentifier: product.productIdentifier, offerIdentifier: offerIdentifier, completion: { (nonce: UUID, timestamp: NSNumber, keyIdentifier: String, signature: String) in
        // プロモーションオファーでは SKMutablePayment を使用
        let payment = SKMutablePayment(product: product)
        // プロモーションオファーの情報
        let discountOffer = SKPaymentDiscount(identifier: offerIdentifier, // プロモーションオファーの ID
                                              keyIdentifier: keyIdentifier, // 秘密鍵を識別する ID
                                              nonce: nonce, // サーバー側で生成する UUID
                                              signature: signature, // サーバー側で生成した署名文字列
                                              timestamp: timestamp) // サーバー側で生成したタイムスタンプ
        // SKMutablePayment に プロモーションオファーの情報を追加
        payment.paymentDiscount = discount
        // (任意)ユーザを識別する文字列を追加
        payment.applicationUsername = username

        SKPaymentQueue.default().add(payment)
    })
}

レシート検証 && トランザクションを完了

こちらは既存の自動更新サブスクリプションと変わらないため割愛します

実装していてハマったところ

過去課金経験がないとエラーになる

プロモーションオファーは過去に課金経験があること前提の機能なので、 新たに作成したテストアカウントで課金しようとすると当然エラーになります。

Apple からその旨のエラーが表示されるのでこの点で詰まったと言う訳ではないんですが、お試しされる際は過去に課金経験のあるアカウントで実施するようご注意ください。

f:id:nakanishi-w:20201213155822p:plain

ちなみに、iOS 14 から Sandbox アカウントで「利用資格のリセット」というものができるようになりましたが、過去課金経験のあるアカウントでこれを実行しても上記のエラーメッセージが表示されませんでした。

~identifier が多く混乱してくる

実装を進めている中で、~identifier と言う名称のものが多く、混乱してくる時がありました。

また、デバッグの際、プランの ID とプロモーションオファーの ID を似たものにしてしまっていたため、正しい値がセットされているのかを判断し難くなってしまっていたので、プロモーションオファーの ID には promotion_offer_~ のように接頭辞などを付けるようにすると分かりやすくなりそうでした。

署名生成時のデバッグがやりづらい

ここが最も詰まったポイントなんですが、署名生成のデバッグが辛かったです。

当然と言えば当然なんですが、署名生成時に改行文字列等の不要なものが含まれていたりすると決済に失敗してしまいます。

また、この際、ID/Password を入力する決済画面は問題なく表示され、ID/Password 入力後の決済時にエラーになると言う挙動になります。

そのため、当初は署名の生成自体は問題なく、アプリ側のロジックの不備を疑っていたので回り道をする結果となりました。署名生成時に検証を行っていましたが、あくまで正常に署名処理が完了できているかを確認するもので、Apple が定める条件通りに署名が行われているかを判定するものでは無かったのも落とし穴でした。

その他、気をつけたほうが良さそうなポイントを記載したので、参考にしていただければと思います。

項目 気をつけるところ
nonce の生成 アルファベットは必ず小文字である必要があるので注意
timestamp の生成 単位は "秒" ではなく "ミリ秒" なので注意
署名後の文字列 使用する言語やライブラリの仕様によっては勝手に "\n" を挿入したりすることがあるので注意

おわりに

いかがでしたでしょう?

プロモーションオファーを導入することで 今までアプローチできなかったユーザ層にアプローチすることが可能になるので、 既に自動更新サブスクリプションを実装されている場合は導入を検討してみると良いかもしれません!

明日、16日は弊社の Android エンジニアの meil さんによる「C# 9.0時代のnull判定解剖」です! お楽しみに!

また、dely では一緒にサービスを成長させていく仲間を募集中です!

www.wantedly.com

www.wantedly.com

定期的にイベントも開催しているので、dely のことを知りたいという方は是非是非ご参加お待ちしています!

bethesun.connpass.com

Vue 2で書かれた個人プロジェクトをVue 3に書き換えてみた

f:id:alluser:20201213191103p:plain

はじめに

こんにちは!
クラシルWebのフロントエンドを担当しているall-userです。

今回は、とあるプロジェクトをVue 2からVue 3に書き換えてみたので、その過程と所感についてまとめたいと思います。

この記事はdely #1 Advent Calendar 2020 14日目の記事です。

adventar.org

adventar.org

昨日はfunzinさんのCarthageで生成したframeworkの管理でRomeを導入してみたでした。
元々使用していたcarthage_cacheをRomeに置き換える過程が分かりやすく解説されています。ぜひこちらも覗いてみてください🙌

さて、今回題材に選んだプロジェクトは小規模なVue 2で書かれたアプリケーションですが、そのスタック構成はかなりクラシルWebに近いものとなっており、今後クラシルWebへの導入を検討する上での良い足がかりにできればと考えております。

目次

Vue 3について知る

なにはともあれ、書き換えるにあたりまずはVue 3のキャッチアップから始めなければなりません。
マイグレーションガイドを読んで解釈した内容を残しておきます。

Vue 3の目玉。Composition APIとは

Vue 3で導入された新しいコンポーネント実装のためのAPIです。
Reactのhooksにインスパイアされた機能で、コンセプトもとても似ています。

Composition APIで何が変わる?

これまでのViewModelインスタンスを起点にしたロジックの記述(this.xxvm.xxな記述)ではなく、関数の組み合わせによる記述が可能になります。

たとえばコンポーネントのローカルステートを定義する時、Vue 2(Options API)ではdataを使いました。

// DisplayCount.vue
import { defineComponent } from "vue";

export default defineComponent({ // Vue.extendは廃止されたためdefineComponentを使用します
  data() {
    return {
      count: 0
    };
  },
  methods: {
    increment() {
      this.count += 1;
    },
    decrement() {
      this.count -= 1;
    }
  }
});

テンプレート部分は以下のようになります。

<template>
  <span v-text="count" /> <!-- Vue 3ではFragmentがサポートされルート要素が単一の要素である必要がなくなりました -->
  <button @click="increment">+</button>
  <button @click="decrement">-</button>
</template>

レンダリング結果はこんな感じ。

f:id:alluser:20201213141331p:plain

Vue 3(Composition API)ではref関数を使います。

// DisplayCount.vue
import { defineComponent, ref } from "vue";

export default defineComponent({
  setup() {
    const count = ref(0);
    const increment = () => (count.value += 1); // thisが消えた
    const decrement = () => (count.value -= 1); // thisが消えた

    return {
      count,
      increment,
      decrement
    };
  }});

テンプレートの内容は同じです。
setup関数ではcountincrementが生えたオブジェクトを返していますが、これらのプロパティをテンプレート内で参照できるようになります。

ref関数が返すRefオブジェクトにはvalueというプロパティが生えており、このプロパティを通じて現在の値を取得することができます。
このように値をRefオブジェクトでラップすることで、ローカルステートの読み書きを捕捉できるようになり、リアクティブにDOMの更新を行えるようになります。

従来はこのラッパーの役割をViewModelインスタンス(this)が担っていました。

そして、increment, decrementからthisが消えています。
これは、countというローカルステートおよび+1、-1するメソッドの定義が、特定のコンポーネントに属さなくなったと考えることができます。
そのため、以下のように書き換えることができます。

// useCount.ts
import { ref } from "vue";

export const useCount = () => {
  const count = ref(0);
  const increment = () => (count.value += 1);
  const decrement = () => (count.value -= 1);

  return {
    count,
    increment,
    decrement
  };
};

// DisplayCount.vue
import { defineComponent } from "vue";
import { useCount } from "../hooks/useCount";

export default defineComponent({
  setup() {
    return {
      ...useCount()
    };
  }
});

「countというローカルステートを持ち、+1、-1することができる」機能をuseCountという関数に切り出し、さらにuseCount.tsという別ファイルに切り出すことができました。
次に、これが出来るようになることで、どんなうれしいことがあるのかを考えてみます。

Options API(従来のコンポーネント定義)の課題

ViewModelを起点にした記述では、data, computed, methodsなどの制約により、関心事の異なるロジック同士が一箇所に束ねられ、逆に関心事を同じくするコードが分散してしまうという問題がありました。

次のコードは先ほどのサンプルに、displayというローカルステートとtoggleDisplayTextという算術プロパティを加えたものです。

関心事を次の2つとし、

  1. 数をカウントしたい
  2. 表示を切り替えたい

コード上の対応する部分にコメントを入れると次のようになります。

// DisplayCount.vue
import { defineComponent } from "vue";

export default defineComponent({
  data() {
    return {
      count: 0, // a. 数をカウントしたい
      display: true // b. 表示を切り替えたい
    };
  },
  computed: {
    toggleDisplayText(): string { // b. 表示を切り替えたい
      return this.display ? "hide" : "show";
    }
  },
  methods: {
    increment() { // a. 数をカウントしたい
      this.count += 1;
    },
    decrement() { // a. 数をカウントしたい
      this.count -= 1;
    },
    toggleDisplay() { // b. 表示を切り替えたい
      this.display = !this.display;
    }
  }
});

アプリーケーションが大きくなりコンポーネントが複雑化すると、このように分散した関心事を頭の中でマッピングしながら読み解いていくコストが大きくなってきます。

テンプレートも更新します。

<template>
  <template v-if="display">
    <span v-text="count" />
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
  </template>
  <button @click="toggleDisplay" v-text="toggleDisplayText" />
</template>

レンダリング結果はこんな感じ。

f:id:alluser:20201213141512p:plain

hideをクリックすると以下のようになります。

f:id:alluser:20201213141633p:plain

次にこれをComposition APIに置き換えてみます。

Composition APIによる関心事の分離

Composition APIではロジックの記述がViewModelに依存しなくなり、data, computed, methodsなどの制約から開放されます。

computed関数が登場しましたが、コンセプトはrefの時と同じです。
ViewModelへの参照を無くしたバージョンの算術プロパティと考えればOKです。

  1. 数をカウントしたい
  2. 表示を切り替えたい

コード上の対応する部分にコメントを入れると次のようになります。

// DisplayCount.vue
import { defineComponent } from "vue";

export default defineComponent({
  setup() {
    const count = ref(0); // a. 数をカウントしたい
    const increment = () => (count.value += 1); // a. 数をカウントしたい
    const decrement = () => (count.value -= 1); // a. 数をカウントしたい

    const display = ref(true); // b. 表示を切り替えたい
    const toggleDisplay = () => (display.value = !display.value); // b. 表示を切り替えたい
    const toggleDisplayText = computed(() => (display.value ? "hide" : "show")); // b. 表示を切り替えたい

    return {
      count, // a. 数をカウントしたい
      increment, // a. 数をカウントしたい
      decrement, // a. 数をカウントしたい
      display, // b. 表示を切り替えたい
      toggleDisplay, // b. 表示を切り替えたい
      toggleDisplayText // b. 表示を切り替えたい
    };
  }
});

関心事ベースでコードをまとめることができていることが分かります。

最初のサンプル同様に、setup関数の外にロジックを切り出し、useCount.tsuseToggleDisplay.tsという別ファイルに切り出してみます。

// useCount.ts
// a. 数をカウントしたい
import { ref } from "vue";

export const useCount = () => {
  const count = ref(0);
  const increment = () => (count.value += 1);
  const decrement = () => (count.value -= 1);

  return {
    count,
    increment,
    decrement
  };
};

// useToggleDisplay.ts
// b. 表示を切り替えたい
import { computed, ref } from "vue";

export const useToggleDisplay = () => {
  const display = ref(true);
  const toggleDisplay = () => (display.value = !display.value);
  const toggleDisplayText = computed(() => (display.value ? "hide" : "show"));

  return {
    display,
    toggleDisplay,
    toggleDisplayText
  };
};

// DisplayCount.vue
import { defineComponent } from "vue";
import { useCount } from "../hooks/useCount"; // a. 数をカウントしたい
import { useToggleDisplay } from "../hooks/useToggleDisplay"; // b. 表示を切り替えたい

export default defineComponent({
  setup() {
    return {
      ...useCount(), // a. 数をカウントしたい
      ...useToggleDisplay() // b. 表示を切り替えたい
    };
  }
});

それぞれの関心事がDisplayCountコンポーネントから完全に分離されています。1
これは、これらのロジックが特定のコンポーネントに依存していないことを示しています。
ロジックが特定のコンポーネントに依存していないため、移植性・再利用性を高めることができます。

複数コンポーネント間でロジックを共通化しようとして、extendやmixinsを使って無茶をしたことがあるのは僕だけではないはずです。

Composition APIを使えば、より自然にロジックの共通化を表現できます。

TypeScriptサポートの改善

Composition APIによりTypeScriptの型定義もかなり改善しました。

従来のOptions APIの型定義は、thisdata, computed, methodsなどの定義を生やすためのコードがとても複雑で、型定義を見に行っては迷子になることもしょっちゅうでした。

ViewModel(this)への参照を無くし、関数の合成によってロジックを表現出来るようになったことで無理なく型を表現できているため、TypeScriptのコードが理解しやすくなりました。

個人プロジェクトについて

今回書き換えるのはrxjs-stream-editorというRxJSの非同期処理を可視化するツールです。
Vue 2, Vuex, TypeScriptを使用しており、コンポーネントの数もルートコンポーネントを入れて8つ、Vuex moduleの数も3つと検証にはもってこいの大きさです。

クラス記法コンポーネント、vue-property-decoratorvuex-smart-moduleを使用しており、現時点ではVue 3未対応なライブラリなため、今回の検証ではいったん外しつつなるべくVueの素のAPIを使う方針でいきます。

memowomome.hatenablog.com

書き換え作業ログ

github.com

nodeとyarnを最新にアップデート

  • node 👉 15.3.0にアップデート
  • yarn 👉 1.22.10にアップデート

ビルド設定をVue CLIで一気に最新に書き換える

rxjs-stream-editorはVue CLIを使用したプロジェクトなので、今回もVue CLIを使用してアップデートします。
vue upgradeというコマンドも用意されていますが、規模も小さいので今回はvue createで上書く方法でやってみました🙌

# プロジェクトのひとつ上のディレクトリに移動
cd ..

# 同名のディレクトリを指定して上書き
# オプションでGitコミット無し、既存ファイルとマージするように指定
vue create -n --merge rxjs-stream-editor

スタックを選択

  • スタックをマニュアルで選択
  • TS, Vuex, Stylus, ESLint, Prettierを使用
  • Vue 3を使用
  • クラス記法のコンポーネント定義ではなく素のAPIを使用
  • TypeScriptと一緒にBabelを使用
  • 保存時にLintを実行
  • ESLint等のコンフィグファイルはpackage.jsonにまとめず、専用のファイルを使用
Vue CLI v4.5.9
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Vuex, CSS Pre-processors, Linter
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Stylus
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

いったんビルドしてみる

エラーがたくさん出ます。
このまま一旦コミットしておきます。

エラーログ全文

ERROR in src/App.vue:21:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof App': extend, set, delete, directive, and 6 more.
    19 | import { ColorDefinition } from './core/ColorDefinition';
    20 |
  > 21 | @Component({
       | ^^^^^^^^^^^^
  > 22 |   components: {
       | ^^^^^^^^^^^^^^^
  > 23 |     AppHeader,
       | ^^^^^^^^^^^^^^^
  > 24 |     StreamEditor,
       | ^^^^^^^^^^^^^^^
  > 25 |     BottomNav,
       | ^^^^^^^^^^^^^^^
  > 26 |   },
       | ^^^^^^^^^^^^^^^
  > 27 | })
       | ^^^
    28 | export default class App extends Vue.extend({
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapState(['colorMatcherSourceCode']),

ERROR in src/App.vue:21:2
TS2345: Argument of type 'typeof App' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof App' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    19 | import { ColorDefinition } from './core/ColorDefinition';
    20 |
  > 21 | @Component({
       |  ^^^^^^^^^^^
  > 22 |   components: {
       | ^^^^^^^^^^^^^^^
  > 23 |     AppHeader,
       | ^^^^^^^^^^^^^^^
  > 24 |     StreamEditor,
       | ^^^^^^^^^^^^^^^
  > 25 |     BottomNav,
       | ^^^^^^^^^^^^^^^
  > 26 |   },
       | ^^^^^^^^^^^^^^^
  > 27 | })
       | ^^^
    28 | export default class App extends Vue.extend({
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapState(['colorMatcherSourceCode']),

ERROR in src/App.vue:28:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    26 |   },
    27 | })
  > 28 | export default class App extends Vue.extend({
       |                      ^^^
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapState(['colorMatcherSourceCode']),
    31 |     ...domainStreamColorizerModule.mapGetters(['colorDefinitions']),

ERROR in src/components/AppHeader/AppHeader.ts:3:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type '<VC extends VueClass<any>>(target: VC) => VC' is missing the following properties from type 'typeof AppHeader': extend, nextTick, set, delete, and 9 more.
    1 | import { Component, Vue } from 'vue-property-decorator';
    2 |
  > 3 | @Component
      | ^^^^^^^^^^
    4 | export default class AppHeader extends Vue {}
    5 |

ERROR in src/components/AppHeader/AppHeader.ts:3:2
TS2769: No overload matches this call.
  Overload 1 of 2, '(options: ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>): <VC extends VueClass<any>>(target: VC) => VC', gave the following error.
    Argument of type 'typeof AppHeader' is not assignable to parameter of type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>'.
      Type 'typeof AppHeader' is not assignable to type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}>'.
        Types of property 'call' are incompatible.
          Type '<T, A extends any[]>(this: new (...args: A) => T, thisArg: T, ...args: A) => void' is not assignable to type '(this: unknown, ...args: unknown[]) => never'.
            The 'this' types of each signature are incompatible.
              Type 'unknown' is not assignable to type 'new (...args: unknown[]) => unknown'.
  Overload 2 of 2, '(target: VueClass<any>): VueClass<any>', gave the following error.
    Argument of type 'typeof AppHeader' is not assignable to parameter of type 'VueClass<any>'.
      Type 'typeof AppHeader' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    1 | import { Component, Vue } from 'vue-property-decorator';
    2 |
  > 3 | @Component
      |  ^^^^^^^^^
    4 | export default class AppHeader extends Vue {}
    5 |

ERROR in src/components/AppHeader/AppHeader.ts:4:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    2 |
    3 | @Component
  > 4 | export default class AppHeader extends Vue {}
      |                      ^^^^^^^^^
    5 |

ERROR in src/components/BottomNav/BottomNav.ts:9:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof BottomNav': extend, set, delete, directive, and 6 more.
     7 | import StreamColorizer from '../StreamColorizer/StreamColorizer.vue';
     8 |
  >  9 | @Component({
       | ^^^^^^^^^^^^
  > 10 |   components: {
       | ^^^^^^^^^^^^^^^
  > 11 |     MessageOutput,
       | ^^^^^^^^^^^^^^^
  > 12 |     StreamColorizer,
       | ^^^^^^^^^^^^^^^
  > 13 |   },
       | ^^^^^^^^^^^^^^^
  > 14 | })
       | ^^^
    15 | export default class BottomNav extends Vue.extend({
    16 |   computed: {
    17 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),

ERROR in src/components/BottomNav/BottomNav.ts:9:2
TS2345: Argument of type 'typeof BottomNav' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof BottomNav' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
     7 | import StreamColorizer from '../StreamColorizer/StreamColorizer.vue';
     8 |
  >  9 | @Component({
       |  ^^^^^^^^^^^
  > 10 |   components: {
       | ^^^^^^^^^^^^^^^
  > 11 |     MessageOutput,
       | ^^^^^^^^^^^^^^^
  > 12 |     StreamColorizer,
       | ^^^^^^^^^^^^^^^
  > 13 |   },
       | ^^^^^^^^^^^^^^^
  > 14 | })
       | ^^^
    15 | export default class BottomNav extends Vue.extend({
    16 |   computed: {
    17 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),

ERROR in src/components/BottomNav/BottomNav.ts:15:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    13 |   },
    14 | })
  > 15 | export default class BottomNav extends Vue.extend({
       |                      ^^^^^^^^^
    16 |   computed: {
    17 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),
    18 |     ...uiBottomNavModule.mapState(['enabled']),

ERROR in src/components/MessageOutput/MessageOutput.ts:4:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type '<VC extends VueClass<any>>(target: VC) => VC' is missing the following properties from type 'typeof MessageOutput': extend, nextTick, set, delete, and 9 more.
    2 | import { domainStreamEditorModule } from '../../store/modules/internal';
    3 |
  > 4 | @Component
      | ^^^^^^^^^^
    5 | export default class MessageOutput extends Vue.extend({
    6 |   computed: {
    7 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),

ERROR in src/components/MessageOutput/MessageOutput.ts:4:2
TS2769: No overload matches this call.
  Overload 1 of 2, '(options: ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>): <VC extends VueClass<any>>(target: VC) => VC', gave the following error.
    Argument of type 'typeof MessageOutput' is not assignable to parameter of type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>'.
      Type 'typeof MessageOutput' is not assignable to type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}>'.
        Types of property 'call' are incompatible.
          Type '<T, A extends any[]>(this: new (...args: A) => T, thisArg: T, ...args: A) => void' is not assignable to type '(this: unknown, ...args: unknown[]) => never'.
  Overload 2 of 2, '(target: VueClass<any>): VueClass<any>', gave the following error.
    Argument of type 'typeof MessageOutput' is not assignable to parameter of type 'VueClass<any>'.
      Type 'typeof MessageOutput' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    2 | import { domainStreamEditorModule } from '../../store/modules/internal';
    3 |
  > 4 | @Component
      |  ^^^^^^^^^
    5 | export default class MessageOutput extends Vue.extend({
    6 |   computed: {
    7 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),

ERROR in src/components/MessageOutput/MessageOutput.ts:5:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    3 |
    4 | @Component
  > 5 | export default class MessageOutput extends Vue.extend({
      |                      ^^^^^^^^^^^^^
    6 |   computed: {
    7 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),
    8 |   },

ERROR in src/components/StreamColorizer/StreamColorizer.ts:2:10
TS2305: Module '"../../../node_modules/vue/dist/vue"' has no exported member 'VueConstructor'.
    1 | import { Component, Vue } from 'vue-property-decorator';
  > 2 | import { VueConstructor } from 'vue';
      |          ^^^^^^^^^^^^^^
    3 | import { domainStreamColorizerModule } from '../../store/modules/internal';
    4 | import { Photoshop } from 'vue-color';
    5 | import { ColorDefinition } from '../../core/ColorDefinition';

ERROR in src/components/StreamColorizer/StreamColorizer.ts:7:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof StreamColorizer': extend, set, delete, directive, and 6 more.
     5 | import { ColorDefinition } from '../../core/ColorDefinition';
     6 |
  >  7 | @Component({
       | ^^^^^^^^^^^^
  >  8 |   components: {
       | ^^^^^^^^^^^^^^^
  >  9 |     PhotoshopPicker: Photoshop as VueConstructor,
       | ^^^^^^^^^^^^^^^
  > 10 |   },
       | ^^^^^^^^^^^^^^^
  > 11 | })
       | ^^^
    12 | export default class StreamColorizer extends Vue.extend({
    13 |   computed: {
    14 |     ...domainStreamColorizerModule.mapState([

ERROR in src/components/StreamColorizer/StreamColorizer.ts:7:2
TS2345: Argument of type 'typeof StreamColorizer' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof StreamColorizer' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
     5 | import { ColorDefinition } from '../../core/ColorDefinition';
     6 |
  >  7 | @Component({
       |  ^^^^^^^^^^^
  >  8 |   components: {
       | ^^^^^^^^^^^^^^^
  >  9 |     PhotoshopPicker: Photoshop as VueConstructor,
       | ^^^^^^^^^^^^^^^
  > 10 |   },
       | ^^^^^^^^^^^^^^^
  > 11 | })
       | ^^^
    12 | export default class StreamColorizer extends Vue.extend({
    13 |   computed: {
    14 |     ...domainStreamColorizerModule.mapState([

ERROR in src/components/StreamColorizer/StreamColorizer.ts:12:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    10 |   },
    11 | })
  > 12 | export default class StreamColorizer extends Vue.extend({
       |                      ^^^^^^^^^^^^^^^
    13 |   computed: {
    14 |     ...domainStreamColorizerModule.mapState([
    15 |       'colorMatcherSourceCode',

ERROR in src/components/StreamEditor/StreamEditor.ts:7:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof StreamEditor': extend, set, delete, directive, and 6 more.
     5 | import debounce from 'lodash-es/debounce';
     6 |
  >  7 | @Component({
       | ^^^^^^^^^^^^
  >  8 |   components: {
       | ^^^^^^^^^^^^^^^
  >  9 |     StreamEditorItem,
       | ^^^^^^^^^^^^^^^
  > 10 |   },
       | ^^^^^^^^^^^^^^^
  > 11 | })
       | ^^^
    12 | export default class StreamEditor extends Vue.extend({
    13 |   computed: {
    14 |     ...domainStreamEditorModule.mapGetters(['streamDatasets', 'sourceCode']),

ERROR in src/components/StreamEditor/StreamEditor.ts:7:2
TS2345: Argument of type 'typeof StreamEditor' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof StreamEditor' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
     5 | import debounce from 'lodash-es/debounce';
     6 |
  >  7 | @Component({
       |  ^^^^^^^^^^^
  >  8 |   components: {
       | ^^^^^^^^^^^^^^^
  >  9 |     StreamEditorItem,
       | ^^^^^^^^^^^^^^^
  > 10 |   },
       | ^^^^^^^^^^^^^^^
  > 11 | })
       | ^^^
    12 | export default class StreamEditor extends Vue.extend({
    13 |   computed: {
    14 |     ...domainStreamEditorModule.mapGetters(['streamDatasets', 'sourceCode']),

ERROR in src/components/StreamEditor/StreamEditor.ts:12:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    10 |   },
    11 | })
  > 12 | export default class StreamEditor extends Vue.extend({
       |                      ^^^^^^^^^^^^
    13 |   computed: {
    14 |     ...domainStreamEditorModule.mapGetters(['streamDatasets', 'sourceCode']),
    15 |   },

ERROR in src/components/StreamEditor/StreamEditor.ts:25:10
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    23 | }) {
    24 |   @Watch('sourceCode')
  > 25 |   public watchSourceCode() {
       |          ^^^^^^^^^^^^^^^
    26 |     this.evaluateSourceCodeDebounced();
    27 |   }
    28 |

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:23:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof StreamEditorItem': extend, set, delete, directive, and 6 more.
    21 | };
    22 |
  > 23 | @Component({
       | ^^^^^^^^^^^^
  > 24 |   components: {
       | ^^^^^^^^^^^^^^^
  > 25 |     StreamEditorTextarea,
       | ^^^^^^^^^^^^^^^
  > 26 |   },
       | ^^^^^^^^^^^^^^^
  > 27 | })
       | ^^^
    28 | export default class StreamEditorItem extends Vue.extend({
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapGetters([

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:23:2
TS2345: Argument of type 'typeof StreamEditorItem' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof StreamEditorItem' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    21 | };
    22 |
  > 23 | @Component({
       |  ^^^^^^^^^^^
  > 24 |   components: {
       | ^^^^^^^^^^^^^^^
  > 25 |     StreamEditorTextarea,
       | ^^^^^^^^^^^^^^^
  > 26 |   },
       | ^^^^^^^^^^^^^^^
  > 27 | })
       | ^^^
    28 | export default class StreamEditorItem extends Vue.extend({
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapGetters([

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:28:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    26 |   },
    27 | })
  > 28 | export default class StreamEditorItem extends Vue.extend({
       |                      ^^^^^^^^^^^^^^^^
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapGetters([
    31 |       'colorCodeGetter',

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:39:18
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    37 |   },
    38 | }) {
  > 39 |   @Prop() public dataset: StreamDataset | undefined;
       |                  ^^^^^^^
    40 |   @Prop({ required: true }) public index!: boolean;
    41 |   @Prop({ default: false }) public disabled!: boolean;
    42 |

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:40:36
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    38 | }) {
    39 |   @Prop() public dataset: StreamDataset | undefined;
  > 40 |   @Prop({ required: true }) public index!: boolean;
       |                                    ^^^^^
    41 |   @Prop({ default: false }) public disabled!: boolean;
    42 |
    43 |   get events() {

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:41:36
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    39 |   @Prop() public dataset: StreamDataset | undefined;
    40 |   @Prop({ required: true }) public index!: boolean;
  > 41 |   @Prop({ default: false }) public disabled!: boolean;
       |                                    ^^^^^^^^
    42 |
    43 |   get events() {
    44 |     return this.dataset ? this.dataset.events : [];

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:5:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type '<VC extends VueClass<any>>(target: VC) => VC' is missing the following properties from type 'typeof StreamEditorTextarea': extend, nextTick, set, delete, and 9 more.
    3 | import { domainStreamEditorModule } from '../../store/modules/internal';
    4 |
  > 5 | @Component
      | ^^^^^^^^^^
    6 | export default class StreamEditorTextarea extends Vue.extend({
    7 |   methods: {
    8 |     ...domainStreamEditorModule.mapMutations(['setSourceCode']),

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:5:2
TS2769: No overload matches this call.
  Overload 1 of 2, '(options: ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>): <VC extends VueClass<any>>(target: VC) => VC', gave the following error.
    Argument of type 'typeof StreamEditorTextarea' is not assignable to parameter of type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>'.
      Type 'typeof StreamEditorTextarea' is not assignable to type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}>'.
        Types of property 'call' are incompatible.
          Type '<T, A extends any[]>(this: new (...args: A) => T, thisArg: T, ...args: A) => void' is not assignable to type '(this: unknown, ...args: unknown[]) => never'.
  Overload 2 of 2, '(target: VueClass<any>): VueClass<any>', gave the following error.
    Argument of type 'typeof StreamEditorTextarea' is not assignable to parameter of type 'VueClass<any>'.
      Type 'typeof StreamEditorTextarea' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    3 | import { domainStreamEditorModule } from '../../store/modules/internal';
    4 |
  > 5 | @Component
      |  ^^^^^^^^^
    6 | export default class StreamEditorTextarea extends Vue.extend({
    7 |   methods: {
    8 |     ...domainStreamEditorModule.mapMutations(['setSourceCode']),

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:6:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    4 |
    5 | @Component
  > 6 | export default class StreamEditorTextarea extends Vue.extend({
      |                      ^^^^^^^^^^^^^^^^^^^^
    7 |   methods: {
    8 |     ...domainStreamEditorModule.mapMutations(['setSourceCode']),
    9 |   },

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:11:18
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
     9 |   },
    10 | }) {
  > 11 |   @Prop() public dataset: StreamDataset | undefined;
       |                  ^^^^^^^
    12 |   @Prop({ default: false }) public disabled!: boolean;
    13 |
    14 |   get sourceCode() {

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:12:36
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    10 | }) {
    11 |   @Prop() public dataset: StreamDataset | undefined;
  > 12 |   @Prop({ default: false }) public disabled!: boolean;
       |                                    ^^^^^^^^
    13 |
    14 |   get sourceCode() {
    15 |     return this.dataset ? this.dataset.sourceCode : '';

ERROR in src/main.ts:6:5
TS2339: Property 'use' does not exist on type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")'.
    4 | import VueTextareaAutosize from 'vue-textarea-autosize';
    5 |
  > 6 | Vue.use(VueTextareaAutosize);
      |     ^^^
    7 | Vue.config.productionTip = false;
    8 |
    9 | new Vue({

ERROR in src/main.ts:7:5
TS2339: Property 'config' does not exist on type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")'.
     5 |
     6 | Vue.use(VueTextareaAutosize);
  >  7 | Vue.config.productionTip = false;
       |     ^^^^^^
     8 |
     9 | new Vue({
    10 |   store,

ERROR in src/main.ts:9:5
TS2351: This expression is not constructable.
  Type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")' has no construct signatures.
     7 | Vue.config.productionTip = false;
     8 |
  >  9 | new Vue({
       |     ^^^
    10 |   store,
    11 |   render: h => h(App),
    12 | }).$mount('#app');

ERROR in src/main.ts:11:11
TS7006: Parameter 'h' implicitly has an 'any' type.
     9 | new Vue({
    10 |   store,
  > 11 |   render: h => h(App),
       |           ^
    12 | }).$mount('#app');
    13 |

ERROR in src/store/index.ts:6:5
TS2339: Property 'use' does not exist on type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")'.
    4 | import { rootModule } from './modules';
    5 |
  > 6 | Vue.use(Vuex);
      |     ^^^
    7 |
    8 | export default createStore(rootModule);
    9 |

.babelrcを削除

Babel最新ではbabel.config.jsを使用するように変わったようなのですが、Vue CLIでディレクトリをマージした際に.babelrcが残っており、そちらの設定を見に行ってしまいエラーが出ていました。
これを削除します。

f:id:alluser:20201213165103p:plain

vue-property-decoratorを外す

デコレータを使ったコンポーネント定義をdefineComponentに置き換えていきます。

f:id:alluser:20201213164743p:plain

vuex-smart-moduleを外す

Vue 3対応のために素のVuexに書き換えます。

vuex-smart-moduleを通してstoreにアクセスしていた部分は、useStore関数を使用して置き換えます。
この時点でcommit, dispatch, gettersに付いていたメソッドの型は、stringであればなんでも受け入れるようになってしまいます。

やはりまだvuex-smart-moduleは外せないという所感。
Composition API対応に関するIssueでは対応予定とのコメントもあり今後の動きに期待です。

f:id:alluser:20201213165416p:plain

テンプレートから参照するものは従来通りmapStatemapGettersを使用し、コンポーネント定義内で参照するものはuseStoreを使用しています。
この辺りの書き方はどうするのが良いだろう、という感じでまだ模索中です。

その他のライブラリ

  • vue-textarea-autosize
    • テキストの入力に合わせて自動的に高さを調整してくれる
    • Vue 3未対応
    • とりあえず普通のtextareaに置き換えてしのぐ
  • vue-color
    • カラーピッカー
    • Vue 3未対応
    • いったんあきらめる

関心事を整理してhooksディレクトリに切り出す

setupにまとめて書かれていた初期化処理を3つのファイルに分割し、各関数内でそれぞれuseStoreを使用するように変更します。

f:id:alluser:20201213235518p:plain

カラーパレットの初期化処理。

f:id:alluser:20201213235804p:plain

カラーパレットとイベントをマッピングするためのソースコードの初期化処理。

f:id:alluser:20201213235824p:plain

可視化するRxJS Observableを生成するソースコードの初期化処理。

f:id:alluser:20201213235844p:plain

実際に書き換えてみての所感

コードの見通しはとても良くなる

関心事の分離がうまく表現できるようになり、コードの見通しが良くなりました。
規模の大きいプロジェクトであればより効果を発揮できるのではと感じました。

Vuexの使い方はまだ模索中

今回はテンプレートから参照する値を従来のOptions APIで記述しましたが、Composition APIに最適化されたヘルパーについての議論が行われていていました。
近いうちにベストプラクティスが発明されそうです。

github.com

VuexのTSサポートはこれから

Vuex 4でTypeScriptのサポートは強化されたものの、state以外の型周りはまだサポートされていません。
現時点ではvuex-smart-moduleなどのTypeScriptサポートのライブラリは必要だと感じました。

こちらのIssueを見ると、本格的なTSサポートの強化はVuex 5を予定しているようです。

github.com

また、それに伴うBreaking Changeを検討している模様。

一方で、TS 4.1というゲームチェンジャーの登場によりVuex 4もサポートされる可能性が出てきたようです。

github.com

TS 4.1で導入されたTemplate Literal Typesにより文字列の柔軟な型検査が可能になり、これまで難しかったnamespaceをスラッシュで繋いだ文字列に対しての静的な型検査が実装可能になりました。
近い将来TS完全対応が実現するかもしれません。

プロダクションに投入できるタイミング

上記を踏まえると今すぐのプロダクション投入は難しいものの、確実にメリットを感じたので、少しづつ移行に向けて準備を進めていきたいと思います。

まだ試せていないこと

  • ローカルステートを定義しているコンポーネントが無かったため、ref, reactiveを使った複雑な実装はまだ試せていないです
  • vue-routerも使用していないためこちらもまだ未検証です
  • 引き続き検証していきたいと思います

さいごに

以上Vue 3への書き換えを通しての所感をまとめてみました。
この記事では紹介できなかった細かいAPIの変更などもありますが、暗黙的な挙動の削除やパフォーマンス改善のための変更など確実にパワーアップしています。
個人的にはFragmentが使えるようになったのが最高です。
これからのVue 3を楽しんでいきましょう!

そして明日はsakoさんの「UIデザイナーとして働く私が就活生に戻ったら絶対やること5つ」です!
めちゃくちゃ知りたい!

delyではエンジニアを全方位絶賛募集中です🚀
こちらのリンクからお気軽にエントリーください🙌

join-us.dely.jp

また、delyでは普段表に出ない開発チームの裏側をお伝えするイベントをたくさん開催しております!
こちらもぜひ覗いてみてください!

bethesun.connpass.com

ではまた!


  1. toggleDisplayTextについてはUI都合な部分が大きいため、useToggleDisplayには含めずDisplayCountコンポーネント側に寄せたいところですが、今回は分かりやすさのためにこのようにしています。

ウェブの未来を描く Project Fugu🐡

f:id:srrn:20201214080858j:plain

目次

はじめに

こんにちは、dely株式会社でエンジニアをしているしらりんです。4月に立ち上げられたリテール事業部という部署で、主にウェブフロントエンド・サーバーサイド領域の開発を担当しています。 先日初めてのぎっくり腰を経験し、それ以降日々腰を曲げるのに恐怖を感じています。

そんなことはさておき、この記事は「dely #2 Advent Calendar 2020」の14日目の記事です。

adventar.org adventar.org

昨日は開発部GM 井上さん(@gomesuit)の「技術だけではもう足りない?エンジニアとしての成長のために避けては通れない4つの領域とは!」という記事でした。 tech.dely.jp

ウェブのこれからを追いかける

記事のテーマに「ウェブの未来」とスケール大きめな表現が含まれていますが大した内容ではありません。 Project Fuguで考えられているこれからのウェブでどんなことができるようになっていくのか、今どんなことが進んでいるのかをfugu-tracker-apiを少し眺めてみるといった内容になります。

Project Fugu とは

ウェブは汎用的なプラットフォームとしてとても優秀ですが、汎用的であることもあり少し凝ったこと(特化したこと)をやろうとすると、ネイティブアプリではできるがウェブでは難しいということが多く存在します。アプリではよくみるシェア機能をウェブで提供するWeb Share APIが使えるようになったのもつい最近のことになります。

Web Share APIの対応状況 caniuse.com

そういったネイティブアプリとウェブのギャップを埋めるための取り組みがProject Fugu(Web Capabilities Project)です。

www.chromium.org

そんなProject Fuguで考えられている機能がいつ提供されるのかが把握しやすくまとまっているのがfugu-api-trackerです。

docs.google.com

ここでは現在どのようなエコシステムが試験・検討されているのか、いくつかみてみることにします。

検討されているエコシステム

User Idle Detection API

現在origin trialとして提供されている機能。 マウスやキーボード、タッチスクリーンに対する操作が行われていないことや、別のタブやウィンドウを開いているなどユーザーのアイドル状態を検知することができます。 例えばフィードバックのタイミングをユーザーがアクティブな状態に戻ったときに行うなどといった活用が考えられます。

bugs.chromium.org

macOS Touch Bar API

中にはこんなものもあります。

bugs.chromium.org

名前の通り、最近のmacbook(pro)に付いているタッチバーを活用するAPIです。 例えばこれを使ってタッチバーでプログレスバーを表現したり、簡単なゲームを作ったりとすることができます。 ドキュメント内から参照できるelectronのAPIでは、electronと以下のコードを利用してタッチバーのついているmacbookで動かせるスロットゲームを作るサンプルがのっています。 将来的にこれがweb apiとしても利用可能になるかもしれません。

// touchbar.js
const { app, BrowserWindow, TouchBar } = require('electron')

const { TouchBarLabel, TouchBarButton, TouchBarSpacer } = TouchBar

let spinning = false

const reel1 = new TouchBarLabel()
const reel2 = new TouchBarLabel()
const reel3 = new TouchBarLabel()

const result = new TouchBarLabel()

const spin = new TouchBarButton({
  label: '🎰 Spin',
  backgroundColor: '#7851A9',
  click: () => {
    if (spinning) {
      return
    }

    spinning = true
    result.label = ''

    let timeout = 10
    const spinLength = 4 * 1000
    const startTime = Date.now()

    const spinReels = () => {
      updateReels()

      if ((Date.now() - startTime) >= spinLength) {
        finishSpin()
      } else {
        timeout *= 1.1
        setTimeout(spinReels, timeout)
      }
    }

    spinReels()
  }
})

const getRandomValue = () => {
  const values = ['🍒', '💎', '7️⃣', '🍊', '🔔', '⭐', '🍇', '🍀']
  return values[Math.floor(Math.random() * values.length)]
}

const updateReels = () => {
  reel1.label = getRandomValue()
  reel2.label = getRandomValue()
  reel3.label = getRandomValue()
}

const finishSpin = () => {
  const uniqueValues = new Set([reel1.label, reel2.label, reel3.label]).size
  if (uniqueValues === 1) {
    result.label = '💰 Jackpot!'
    result.textColor = '#FDFF00'
  } else if (uniqueValues === 2) {
    result.label = '😍 Winner!'
    result.textColor = '#FDFF00'
  } else {
    result.label = '🙁 Spin Again'
    result.textColor = null
  }
  spinning = false
}

const touchBar = new TouchBar({
  items: [
    spin,
    new TouchBarSpacer({ size: 'large' }),
    reel1,
    new TouchBarSpacer({ size: 'small' }),
    reel2,
    new TouchBarSpacer({ size: 'small' }),
    reel3,
    new TouchBarSpacer({ size: 'large' }),
    result
  ]
})

let window

app.whenReady().then(() => {
  window = new BrowserWindow({
    frame: false,
    titleBarStyle: 'hiddenInset',
    width: 200,
    height: 200,
    backgroundColor: '#000'
  })
  window.loadURL('about:blank')
  window.setTouchBar(touchBar)
})
./node_modules/.bin/electron touchbar.js

(一瞬ラッキーセブンが揃った🤑 )

その他

その他にも上の方で少し触れたWeb Share APIのデスクトップ対応だったり、input type="file" などから選択されたファイルのリサイズ機能(将来的には動画のサイズ変更にも対応)といった様々な機能の提供が検討されています。

bugs.chromium.org

bugs.chromium.org

おわりに

いかがでしたでしょうか。 ブラウザやOSの種類、バージョンなど多くの壁が存在するウェブですが、他のどんなネイティブアプリケーションにもなれる可能性を秘めていることがウェブの魅力の1つだと僕は思っています。

fugu-api-trackerを見れば今後どのようなエコシステムが試験され、提供されていくのかを把握しやすく眺めているだけでも楽しいです。ここで紹介したものは極一部のものでしかないので、是非一度目を通してみてください。

明日はnancyさんの「iOSのサブスクリプション機能 プロモーションオファーを触ってみた」です。お楽しみに!

最後になりますが、delyではエンジニア・デザイナーを積極的に採用しています。 興味がある方は是非エントリーしてください!

join-us.dely.jp

また、定期的にテックトークイベントを開催しています。 delyについてちょっと興味ある・もっと知ってみたいという方は、お気軽にご参加ください。

bethesun.connpass.com