OTOBANK Engineering Blog

オトバンクはコンテンツが大好きなエンジニアを募集しています!

エンジニアじゃなくたって現在時刻を自由自在に操ってテストがしたいよ〜〜 with Symfony

こちらは Symfony Advent Calendar 2021 の11日目の記事です。
昨日は @ttskch さんの [Symfony] Securityアノテーションを使って複雑な権限チェックを行う | blog.ttskch でした。


どーも @kalibora です。今回は 「エンジニアじゃなくたって現在時刻を自由自在に操ってテストがしたいよ〜〜(CV: マヂカルラブリー野田) with Symfony」 と題した記事です。

どういう話かといいますと、例えばお正月の1月1日からお正月キャンペーンのようなものをやるとして、画面が1/1の0時から切り替わるけど、それを前もってQAやPdMなどエンジニア以外の人がどうやって簡単にテストするか?みたいな話です。

前半は Symfony に関係ない一般的な話、後半はそれを Symfony でどう実装したか?という2段構えで書かせていただきます。

前段の話

現在時刻に依存するテスト

そもそも現在時刻に依存するテストというのは、テストではよくある話題です。

どのように現在時刻に依存するテストを書くかというのは超有名な 現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ - t-wadaのブログ を参照していただくとして、

私はシンプルに

アプローチ1: シンプルに対象メソッドの引数に渡す

を使うことが多いですが、 Laravel 使いの方は Carbon を使っているでしょうから、Carbon::setTestNow() を使った

アプローチ2: 組み込みクラス/メソッド/関数に介入する

に近いアプローチを取っているでしょうし、

lcobucci/clock - Packagist のようなライブラリを使って

アプローチ7: 現在時刻へのアクセスを行うインターフェイスを抽出

のようなアプローチでもいいと思います。 とにかく、システム全体の設計として現在時刻を動的に変更してテストができる設計となっている事が重要です。

毎回現在時刻が欲しいのか、それとも処理が始まった時間が欲しいのか

現在時刻に関することでもう一つ気になるのは、毎回現在時刻が欲しいのか、それとも処理が始まった時間が欲しいのか?ということです。

例えば、1つの処理の中で2回現在時刻を元に何かを判断するクラス、メソッドを呼び出したときに、それぞれの呼び出しの間に重い処理があると、1秒以上現在時刻がズレます。

例としてこんな感じ。

  • 2021/12/31 23:59:59 Foo::methodA() を呼び出す
  • 重い処理で2秒経過
  • 2022/01/01 00:00:01 Bar::methodB() を呼び出す

このときの Foo::methodA() と Bar::methodB() 内部で現在時刻を都度取得していると、日も年もまたいだ別の日時になります。 これが問題になるかどうかは要件次第ですが、1つの処理としては、処理の開始時刻が欲しいケースが多いのではないかと思います。

とくにWebアプリケーションの場合、処理の開始時刻はリクエスト時刻であり、PHP であれば

PHP: $_SERVER - Manual

'REQUEST_TIME' リクエストの開始時のタイムスタンプ。

が使えます。

PHPのWebアプリケーションで現在時刻、もとい処理の開始時刻が欲しいのであれば、この値を使うといいのではないでしょうか。

エンジニア以外がどうやって現在時刻を操るか

エンジニアが行うユニットテストでは、単純にテストコード内で現在時刻を変えれば良いですが、QAやPdMのようなエンジニア以外が行うような実際のWebブラウザを通したテストではどのように時刻を操ればいいでしょうか?

私はHTTPヘッダーがその用途に適しているかと思います。 独自のHTTPヘッダーを定義し、その値として時刻を送信するという方法です。

クエリパラメーターを使ってもいいですが、これはアプリケーションの通常の用途で使う事が多いですし、URLに露出するのでコピペ時に邪魔になることもあります。

HTTPヘッダーであれば、URLにも露出しませんし、ブラウザの拡張機能(例: ModHeader - Chrome ウェブストア)で簡単に付与することも出来ます。

Symfony での実装方法

それではここから下記の条件での Symfony での実装方法の例を書いていきます。

  • システム全体として、現在時刻を動的に変更してテストができる設計となっている
    • 今回の私のケースだと アプローチ1: シンプルに対象メソッドの引数に渡す をシステム全体を通して使っている
  • 処理の開始時刻としてリクエスト時刻を使う
  • HTTPヘッダーで任意の現在時刻(リクエスト時刻)に変更することを可能にする

リクエスト時刻を表すクラスの作成

最初にリクエスト時刻を表す RequestTime クラスを作成します。

<?php
namespace App\Entity; // Value Object なので Entity じゃないけど、まぁどこか適当なところで

final class RequestTime extends \DateTimeImmutable
{
    public const REQUEST_ATTR_NAME = '_app_request_time';

    private $debug = false;

    public function isDebug() : bool
    {
        return $this->debug;
    }

    public function setDebug(bool $debug) : self
    {
        $clone = clone $this;

        $clone->debug = $debug;

        return $clone;
    }
}

DateTimeImmutable を継承しただけのクラスで、 REQUEST_ATTR_NAME 定数や isDebug メソッドなどありますが、それらは後で出てくるので一旦忘れてください。

リクエスト時刻を抽出するクラスの作成

先ほど作成した RequestTime を Request から抽出するクラスの Interface を定義します。

<?php
namespace App\Service\RequestTime;

use App\Entity\RequestTime;
use Symfony\Component\HttpFoundation\Request;

interface ExtractorInterface
{
    public function extract(Request $request) : RequestTime;
}

まずは通常の $_SERVER['REQUEST_TIME'] から RequestTime を抽出するクラスを作成します。 これは本番環境で使う想定です。

<?php
namespace App\Service\RequestTime;

use App\Entity\RequestTime;
use Symfony\Component\HttpFoundation\Request;

class Extractor implements ExtractorInterface
{
    public function extract(Request $request) : RequestTime
    {
        $timestamp = $request->server->get('REQUEST_TIME');
        $timezone = new \DateTimeZone(date_default_timezone_get());

        return (new RequestTime("@{$timestamp}"))->setTimezone($timezone);
    }
}

次に独自のHTTPヘッダーの値から任意の時刻の RequestTime を抽出するクラスを作成します。 これは dev や test 環境で使う想定です。 独自のHTTPヘッダー名はここでは X-Debug-Request-Time と定義しました。

<?php
namespace App\Service\RequestTime;

use App\Entity\RequestTime;
use Symfony\Component\HttpFoundation\Request;

class DebugExtractor extends Extractor
{
    public const HEADER = 'X-Debug-Request-Time';

    public function extract(Request $request) : RequestTime
    {
        if (($value = $request->headers->get(self::HEADER)) !== null) {
            $requestTime = $this->extractFromDebugHeader($value);

            if ($requestTime !== null) {
                return $requestTime;
            }
        }

        return parent::extract($request);
    }

    private function extractFromDebugHeader(string $value) : ?RequestTime
    {
        try {
            if (ctype_digit($value)) {
                $requestTime = new RequestTime("@{$value}");
            } else {
                $requestTime = new RequestTime($value);
            }

            // 独自のHTTPヘッダーから取得した場合は debug を true にしておく
            return $requestTime->setDebug(true)->setTimezone(new \DateTimeZone(date_default_timezone_get()));
        } catch (\Exception $e) {
            // 入力が不正でもエラーにせず無視する
        }

        return null;
    }
}

イベントリスナーにて抽出したリクエスト時刻を設定する

Symfony の イベントリスナー を使ってリクエストの attributes に、先程までに作った Extractor で抽出した RequestTime クラスを設定します。

Request に情報を追加するので、サブスクライブするイベントは kernel.request が良いかと思います。

<?php
namespace App\EventSubscriber;

use App\Entity\RequestTime;
use App\Service\RequestTime\ExtractorInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class RequestTimeSubscriber implements EventSubscriberInterface
{
    private $extractor;

    public function __construct(
        ExtractorInterface $extractor
    ) {
        $this->extractor = $extractor;
    }

    public static function getSubscribedEvents() : array
    {
        return [
            // Security Firewall よりは優先させる
            KernelEvents::REQUEST => ['onKernelRequest', 10],
        ];
    }

    public function onKernelRequest(GetResponseEvent $event) : void
    {
        $request = $event->getRequest();

        $requestTime = $this->extractor->extract($request);

        // リクエストの attributes に設定
        $request->attributes->set(RequestTime::REQUEST_ATTR_NAME, $requestTime);
    }
}

DIの設定をする

config/services.yaml では下記の様に、 ExtractorInterface として Extractor を使うように設定します。

    App\Service\RequestTime\ExtractorInterface:
        class: App\Service\RequestTime\Extractor

config/services_dev.yamlconfig/services_test.yaml では下記の様に ExtractorInterface として DebugExtractor を使うように設定します。

    App\Service\RequestTime\ExtractorInterface:
        class: App\Service\RequestTime\DebugExtractor

これで dev, test 環境の時のみ、独自のHTTPヘッダーを用いて、リクエスト時刻を任意の時間に変える事が出来るようになります。(本番では $_SERVER['REQUEST_TIME'] からしか抽出しない)

あとはこれを各所で使うのみ

ここまでの実装で

 $request->attributes->get(RequestTime::REQUEST_ATTR_NAME);

と書けばコントローラ等で任意のリクエスト時刻を取得できるので、現在時刻(リクエスト時刻)が必要なサービスなどのクラスに渡して使えばいいことになります。

さらに便利にする

Controller の引数にリクエスト時刻を渡せるようにする

Symfony では コントローラーのメソッドの引数に独自の引数を渡す事が出来る機能があります。 See: Extending Action Argument Resolving (Symfony Docs)

例えば、下記の様なクラスを作成すれば

<?php
namespace App\ArgumentResolver;

use App\Entity\RequestTime;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;

final class RequestTimeResolver implements ArgumentValueResolverInterface
{
    public function supports(Request $request, ArgumentMetadata $argument)
    {
        if ($argument->getType() === RequestTime::class) {
            return true;
        }

        return false;
    }

    public function resolve(Request $request, ArgumentMetadata $argument)
    {
        yield $request->attributes->get(RequestTime::REQUEST_ATTR_NAME);
    }
}
<?php
namespace App\Controller;

use App\Entity\RequestTime;

class FooController
{
    public function bar(RequestTime $now) // ここで RequestTime が受け取れる
    {
    }
}

この様にコントローラーのメソッドの引数として受け取ることが出来るようになります。

Twig テンプレートでリクエスト時刻を使えるようにする

ついでに twig でも使えるようにしましょう。 これは簡単で、先に作成した RequestTimeSubscriber で Twig の Environment に設定するだけです。

<?php
namespace App\EventSubscriber;

// 省略
use Twig\Environment as TwigEnvironment; // 追加

class RequestTimeSubscriber implements EventSubscriberInterface
{
    private $extractor;
    private $twig; // 追加

    public function __construct(
        ExtractorInterface $extractor,
        TwigEnvironment $twig // 追加
    ) {
        $this->extractor = $extractor;
        $this->twig = $twig; // 追加
    }

    // 省略

    public function onKernelRequest(GetResponseEvent $event) : void
    {
        // 省略
        // ここで Twig の global に request_time として設定
        $this->twig->addGlobal('request_time', $requestTime);
    }
}

これで twig のテンプレートのどこでも request_time としてリクエスト時刻が取得できます。

デバッグバーにリクエスト時刻を出す

Symfony には dev 環境などでページ下部に表示されるデバッグバーがあります。

これがとても便利なので、このデバッグバーに認識されたリクエスト時刻を表示するようにします。 そして独自のHTTPヘッダで任意の時刻に偽装している場合は、分かりやすく赤い表示にしてみます。

参考にする公式ドキュメントは ここ です。

まず、データを収集するデータコレクタークラスの定義。

<?php
namespace App\DataCollector;

use App\Entity\RequestTime;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;

class RequestTimeCollector extends DataCollector
{
    public function collect(Request $request, Response $response, \Throwable $exception = null) : void
    {
        $this->data = [
            'request_time' => $request->attributes->get(RequestTime::REQUEST_ATTR_NAME),
        ];
    }

    public function reset() : void
    {
        $this->data = [];
    }

    public function getName() : string
    {
        return 'app.request_time_collector';
    }

    public function getRequestTime() : ?RequestTime
    {
        return $this->data['request_time'] ?? null;
    }
}

それをDIで設定。

    App\DataCollector\RequestTimeCollector:
        tags:
            -
                name: data_collector
                template: 'data_collector/template.html.twig'
                id: 'app.request_time_collector'
                priority: -1

そして templates/data_collector/template.html.twig にてテンプレートを書きます。

{% extends '@WebProfiler/Profiler/layout.html.twig' %}

{% block toolbar %}
  {% if collector.requestTime %}
    {% set icon %}
      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-clock"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
      {% set status_color = collector.requestTime.isDebug ? 'red' : '' %}
      <span class="sf-toolbar-value">{{ collector.requestTime|date('Y/m/d H:i:s') }}</span>
    {% endset %}

    {% set text %}
      <div class="sf-toolbar-info-piece">
        <b>リクエスト時刻</b>
        <span>{{ collector.requestTime|date('Y/m/d H:i:s') }}</span>
      </div>
      <div class="sf-toolbar-info-piece">
        <b>デバッグモード</b>
        <span class="sf-toolbar-status sf-toolbar-status-{{ collector.requestTime.isDebug ? 'red' : 'green' }}">{{ collector.requestTime.isDebug ? 'Yes' : 'No' }}</span>
      </div>
    {% endset %}

    {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: false, status: status_color }) }}
  {% endif %}
{% endblock %}

こんな感じで書くと、下記のようなデバッグツールバーが生成できます。

f:id:kalibora:20211211003308p:plain
デバッグバー

まとめ

これで、QAやPdMなどのエンジニア以外の人も、ブラウザの拡張機能を使って独自のHTTPヘッダーを設定し、任意の時刻のテストが出来るようになりました。

キャンペーン系のテストなどは捗りますし、Symfony の機能を上手く用いて無理なく便利に実装出来たのではないかと思います。

Symfony Advent Calendar 2021 明日は @77web さんです。お楽しみに!

転ばぬ先のstrace

こんにちは。@kalibora です。 私は職業プログラマー歴20年弱になるのですが、 数年に一度くらいの割合で strace のお世話になることがあります。
今日はそんな話をしたいと思います。

といっても、strace の挙動についての深い話は一切ないので、 対象者としては strace というものを知らない、もしくは名前は知っているが使ったことがないようないわゆる #駆け出しエンジニア のような方に読んでいただければ幸いです。

strace とは

そもそも strace とはなんでしょうか。wikipedia を引用します。

straceはLinuxのデバッグユーティリティであり、プログラムが使用するシステムコールおよび受け取るシグナルを監視するものである。

strace - Wikipedia

だ、そうです。

man を引いてみましょう。

$ man strace
*snip*
NAME
       strace - trace system calls and signals
*snip*

やはり、システムコールとシグナルをトレースする。と出てきました。

ここでシステムコールとはなんぞや。という疑問も出てきたかもしれません。 再度 wikipedia を引用します。

システムコール(英: system call、日: システム呼出し[1][2][3])とは、オペレーティングシステム (OS)(より明確に言えばOSのカーネル)の機能を呼び出すために使用される機構のこと。実際のプログラミングにおいては、OSの機能は関数 (API) 呼び出しによって実現されるので、OSの備える関数 (API) のことを指すこともある。

システムコール - Wikipedia

というわけで、ざっくりいうと、 strace は OS の機能呼び出しをトレースするものだと言えると思います。

よくわかりませんね。実際に使ってみましょう。

strace 使ってみる

まずは hello strace と表示するだけのコマンドを適当な linux 上で打ちます。
言語は何でもいいんですが、ここでは PHP を使いたいと思います。

$ php -r 'echo "hello strace\n";'
hello strace

表示されました。これに strace をかましてみます。 先程のコマンドの前に strace を入れるだけでOKです。

$ strace php -r 'echo "hello strace\n";'
*snip*
munmap(0x7fd31e600000, 2097152)         = 0
munmap(0x7fd320c67000, 323584)          = 0
exit_group(0)                           = ?
+++ exited with 0 +++

上記と表示は異なるかもしれませんが、ずらずらーっと謎の文字列が表示されるかと思います。 細かく内容を見ていきたいので less をつないで見てみます。(エラー出力に出るので 2>&1 します)

$ strace php -r 'echo "hello strace\n";' 2>&1 | less

いろんな文字列がたくさん出ていると思うのですが、その中に下記のような表示も見つかると思います。

write(1, "hello strace\n", 13hello strace
)          = 13

この行が実際に hello strace と画面に表示するための OS への機能呼び出しです。

はい。これが strace の機能です。

といっても、これが一体何の役に立つのだろうと思いませんか。 次節から実際に私が strace に助けられたケースを書いていきたいと思います。

ケース1: 自分の書いたテストは悪くないはずなのに・・

私がある機能を実装し、それに対するテストも追加し GitHub に push したところ、 その後の Circle CI 上でのテストがエラーになってしまいました。

エラーの内容を確認すると

Too long with no output (exceeded 10m0s): context deadline exceeded

と出ており、テスト(この時は phpunit )実行時に10分間何の出力もないためエラーになったようです。

自分が追加したテストでそんなに変なことはしていないはずなので、
Circle CI に ssh 接続し、色々とデバッグを試みたところ、 追加したテスト単体で実行すると成功、そのテストを含むディレクトリをまるごと実行しても成功、しかしテストスイート全体を実行すると同じエラーで止まる。といった状態でした。

正直何が原因かまったく心当たりがなかったので strace を install し

$ strace ./vendor/bin/phpunit ...

のように、 テストコマンドを strace してみることにしました。 すると、

sendto(151, " \0\0\0\3TRUNCATE hoge_table"..., 36, MSG_DONTWAIT, NULL, 0) = 36
poll([{fd=151, events=POLLIN|POLLERR|POLLHUP}], 1, 86400000

のように、 TRUNCATE のSQLで MySQL から反応が返ってこない事がわかりました。

さらに mysql-client を install し、MySQL クライアント上で show processlist; したところ、 Waiting for table metadata lock が原因で反応が返ってきてこないこと、 たくさんコネクションが張られ続けること、などが分かりました。

これらの状況から、各テストコードで MySQL に接続したまま、それが閉じられていないことが原因と推測し、各テストコードでテストが終わったら接続を切るようにしたところ問題が解決しました。

ケース2: 終わらぬバッチ処理

いつもは数時間で終わる日次で実行しているバッチ処理があるのですが、それが数日経っても終わっていないということがありました。

まずは ps コマンドで該当のバッチ処理が存在し続けていること、プロセスIDを確認し、 該当のプロセスに対し strace コマンドを実行してみました。

-p オプションを使うと、処理中のプロセスに対しても strace をかけることが可能です。 (下記はプロセスID: 2354 に対して strace しています)

$ sudo strace -p 2354
strace: Process 2354 attached
*snip*
poll([{fd=11, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)
rt_sigaction(SIGPIPE, {SIG_IGN, [PIPE], SA_RESTORER|SA_RESTART, 0x7f24ff97e390}, NULL, 8) = 0
poll([{fd=11, events=POLLIN}], 1, 1000^Cstrace: Process 2354 detached
 <detached ...>

上記はその時の結果なのですが、その中の下記の表示、

poll([{fd=11, events=POLLIN|POLLPRI|POLLRDNORM|POLLRDBAND}], 1, 0) = 0 (Timeout)

fd=11(ファイルディスクリプタ=11) をpollしている(イベントを待っている)が、それがタイムアウトしているようでした。

ファイルディスクリプタ=11 の中身が何なのかを調べるには lsof コマンドを使います。 -p でプロセスID, -d でファイルディスクリプタを指定します。-a は AND 条件を表します。 (下記は一部表示を****でマスキングし、IPアドレスもダミーのものです)

$ sudo lsof -p 2354 -a -d 11
COMMAND  PID   USER   FD   TYPE   DEVICE SIZE/OFF NODE NAME
php     2354 ****   11u  IPv4 22163744      0t0  TCP ****:40922->192.0.2.1:https (ESTABLISHED)

このように表示され、3rd Party の API との https 接続の結果が返ってきていないということが分かりました。

これに関してはプログラム中で、該当するAPIとのhttp通信時に適切なタイムアウトを設定していないことが原因だったため、タイムアウトを設定することで解決しました。

まとめ

なにか問題が起きているが、実際何が起きているのかよく分からない。
そんなときに strace はとても役に立つ可能性があります。
頭の片隅に入れておくといいかも。

uniqueByから始める低計算量アルゴリズムのすゝめ

こんにちは、アプリ開発担当のエモトです。先日、弊社アプリが大型アップデートされました。我々アプリチーム含め社内メンバーと取り組んで、ダークモードなど新しい機能を追加してリリースすることができました。是非、新しくなったアプリをご利用くださいませ。

さて話は変わり、ある配列に対して、その配列の要素が重複しない配列に気軽に変換にしたい。

const array1 = [1, 1, 2, 2, 3, 3]
const array2 = uniqueBy(array1) // [1, 2, 3]

この問題、何も考えずに実装すると計算量 O(N2) となり、非常に大きい計算負荷になってしまいます。安易に実装するぐらいなら、サードライブラリを利用するのが良いと思います。

弊アプリは React Native を採用しているので、Swift などのネイティブ実装と比べると計算量はより気になる要因の1つです。アプリでは lodash の uniqBy を使用していました。ふと気になって、そのソースコード を確認すると while ループが2つ重なるパターンがあり、良い計算量の設計ではないと気づきました。また lodash の代替候補とも言われる justjust-unique は計算量は考慮されているが、重複確認は単純比較のみなので、必ずしも代わりになるわけではないとわかりました。これは何かしないといけない。

Set を利用した手法

重複なしを実現したいなら Set を利用するのが簡単ですが、要素の順番が保証されないケースがほとんどです。しかし、TypeScript (JaveScript) では Set は順序が保証されているとのことで、Set を利用するしかありません。

export const unique = <T>(args: T[]): T[] => [...new Set(args)]

上記だと、要素が Object 型の場合、比較が困難になります。そのため、特定要素を比較する場合も作成しました。

const uniqueBy = <T>(args: T[], key: keyof T): T[] => {
  const valueSet = new Set()
  return args.filter((arg) => {
    const value = arg[key]
    if (valueSet.has(value)) {
      return false
    }
    valueSet.add(value)
    return true
  })
}

検証として 0 から 4 までの数字で構成された 100 万個の要素を持つ配列を用意して、計測しました。

unique uniqueBy
対象配列 [0, 1, 2, ...] [{x: 0}, {x: 1}, {x: 2}, ... ]
計算時間 (msec) 333 21

純粋な Set を利用した unique の方が早いと想像しましたが、予想外にもその unique の方が 10 倍以上の計算時間でした。ソースコードは短いが Set に変換した後に Array に変換するという処理だから?かもしれませんが、詳しくは分かりませんでした。

自作した uniqueBy

検証結果から以下のようなコードを作成しました。

/**
 * 配列から、ユニークな(重複しない)配列を生成する
 *
 * @description
 * keyが未指定の場合、[...new Set(args)] の方が早そうだが、実際は遅かった。
 * なお、jsのSetは順番が保証されている
 */
const _uniqueBy = <T>(args: T[], key?: keyof T): T[] => {
  const valueSet = new Set()
  return args.filter((arg) => {
    const value = key ? arg[key] : arg
    if (valueSet.has(value)) {
      return false
    }
    valueSet.add(value)
    return true
  })
}

/**
 * 単純な配列から、ユニークな(重複しない)配列を生成する
 * - 単純比較できる string[] や number[] など
 */
export const unique = <T>(args: T[]): T[] => _uniqueBy(args)

/**
 * 配列から、その配列内要素の特定keyを基準に、ユニークな(重複しない)配列を生成する
 *
 * @example
 * const uniqueArray = uniqueBy([{x:1}, {x:1}, {x:2}], 'x')
 */
export const uniqueBy = <T>(args: T[], key: keyof T): T[] =>
  _uniqueBy(args, key)

既存ライブラリとの比較

lodash の uniqBy と比較しました。対象データは先ほどと同様な100万個要素の配列を用意しました。

自作/uniqueBy lodash/uniqBy
平均 (msec) 68.9 111.7
標準偏差 (msec) 38.2 13.4
最小 (msec) 24 87
最大 (msec) 141 132

10回ほど検証したところ、多くの場合で、自作の方が早かったです(うれしい)。

まとめ

uniqueBy(重複なし配列への変換)を含め、安易なアルゴリズムでコーディングすると計算量が高くなる処理があります。これらは自作せずに、サードパーティを利用した方が良いと思っています。しかし、それぞれの設計思想や目的があり、自分が使いたいシーンで必ず良い結果や計算量が与えられるとは限らない問題もあります。

今回は対象データや、Set でうまく処理できたことが重なって、自作の採用にメリットがあり挑戦できました。最適なアルゴリズムを考える時間は、とても楽しい開発でした。

最後に、オトバンクではエンジニアを募集中です。日々高速で低計算量なアルゴリズムを考えるのが好きな方、オーディオブック・React Native 開発(Swift や Kotlinのネイティブコードも書いてます)にご興味があれば、是非どうぞ。

お待ちしております。