はらへり日記

腹に弾丸

Firebase Dev Summitに行ってきた #FirebaseSummit

行ってきた

f:id:sota1235:20171105220724p:plain

firebase.google.com

今は会社でメルカリチャンネルというサービスのチームにいるのだが、そこでFirebaseを利用している。

その関係で出張として行かせてもらえることになった。ありがとうございます弊社。

技術関連は会社ブログに書くのでその他雑感だけ。

続きを読む

ISUCON7に出た #isucon

ISUCON7に参加しました

今年は前職の先輩と現職の先輩と組んで参加しました。

isucon.net

結果

f:id:sota1235:20171023011636p:plain

予選突破の20万台には遠く及ばず…

点数のブレが大きすぎてどの対策が効いたか全然わからなかったけど時系列でやったこととかやりたかったけど諦めたことをメモる。

タイムライン

私の担当はアプリケーションだった。

  • INDEX追加
    • (user.name)
    • (message.id, message.channel_id)
    • (haveread.user_id, haveread.channel_id)
  • SELECT *の撲滅
  • get_channel_list_infoの置き換え
    • 最終的に一箇所しか残らなかった
  • DBアクセスする前に結果返せるところは返す
    • DBアクセスした後にリクエストパラメータの有無チェックしてるようなロジックがちょいちょいあった
  • COUNT(*)COUNT(1)
  • チャンネルごとのメッセージ数カウントを専用テーブル用意して置き換え
    • 毎回COUNTせずにSELECTするだけでよくなった
    • 最初の段階で2番めに遅いクエリだったので手をつけた
  • php-fpmのチューニング
    • TCPやめてunix socketにしたり
    • 子プロセスを増やしたり
  • 使わないPHP extensionの削除
    • xdebug.so, お前のことやで
  • N+1をいくつか潰す

他のお二人には

とか諸々やってもらった。感謝…

やりたかったけどできなかった

  • /register, /loginページを静的コンテンツに
    • ロジック見ると分かるけどこの2つはPHPに捌かせる必要がない
    • HTMLの配置は一瞬だったけど優先度低かったので後回しにしたのと、nginxの設定でハマってやれなかった
  • キャッシュミドルウェアの追加
    • ログイン判定の度にselect * from userしてるのとか
    • 今回は削除更新が無かったので整合性の担保も難しくなかったはず…
  • メッセージ取得ロジック改善
  • 未読数カウントロジック修正
  • LIMIT, OFFSET殺す
  • php-fpmのさらなる調整
    • 時間無くてかなり雑に調整した
  • PHPMySQLのコネクション最適化

他にも色々アイディアはあって

  • DBサーバにもアプリを立てる
  • キャッシュ用サーバとアプリを分ける

なんて案もあったけどとにもかくにもボトルネック/iconsから他に移らなくて死んだ。

所感

とにもかくにも/iconsのリクエストを捌けて無くてしんどかった。

一番最初はDBが完全にサチっててその壁は一瞬で越えられたけどその後すぐに画像が死んでることが分かってなるほどという感じだった。

画像の脱DB化をしてもそこまでスループットが上がらず、なかなか苦しかった。

今回はそこを抜けたチームが10万の壁を越えていったのではという推測。

スギャブロエックスチームと同じ会場で解いてたので効いたところCache-Controlヘッダあたりをいじって2回目以降リクエストさせないようにしてたらしい。ぐぬぬ

一昨年、昨年参加して今年3回目だけど計測してボトルネック特定して確実に芽をつぶしていく力はついていると感じてて、それゆえに結構悔しい。

あとはぱっと直したコードにバグがあることが多くて結構時間を取られた。

今回はほぼ何も準備できなかったけど来年は手元で簡単にアプリが動かせるような環境を作る秘伝のタレを用意してそこでデバッグしたい。

とにかくですね

来年こそ100万円使って温泉行きたいです。

PHPカンファレンス2017に登壇しました #phpcon2017

資料はこちら

雑感

  • 初めて技術シェアというよりも体験シェア系の発表をした
    • スライドづくりがかなり難航した
    • それなりにストーリーを保てた発表ではなかったんじゃなかろうか
  • アーキテクチャ図を書くのが難しい…
    • みんなどんなものを使ってるのか知りたい
  • Cloud Firestoreによるスライド修正がちょっと大変だった
    • まだβではあるがRealtime Databaseの問題をいくつか解決してるので非常に楽しみ

最後に

次は東京Node学園2017に登壇する予定です。

JavaScript系で大きなカンファレンスは初めてなので頑張るぞ〜〜〜

PHPUnitで例外を投げた後の処理をテストする

例えばこんなクラスコードがあるとする。

<?php

namespace App;

use Psr\Log\LoggerInterface;
use App\Util\DB;
use App\Exception\DbException;

class BlogService
{
    /** @var DB */
    protected $db;

    /** @var LoggerInterface */
    protected $logger;

    public function __construct(DB $db, LoggerInterface $logger)
    {
        $this->db = $db;
        $this->logger = $logger;
    }

    public function createNewPost(int $userId, string $title)
    {
        $this->db->begin();

        try {
            $postRow = $this->db->insert('posts', [
                'title' => $title,
            ]);

            $this->db->insert('post_user_relations', [
                'user_id' => $userId,
                'post_id' => $row['post_id'],
            ]);

            $this->db->commit();
        } catch (DbException $e) {
            $this->logger->warning($e->getMessage());
            $this->db->rollback();

            throw $e;
        }

        return $postRow['post_id'];
    }
}

ブログを投稿する簡単なクラス。

INSERT文を2つ発行するのでトランザクションを貼って、もしデータベース例外が発生したらloggingした上で例外を投げ直すというよくあるコード。

これに対してテストコードをPHPUnitで書いてみる。

テスト方針

スタンダードに以下のようなテストケースを書いてみる。

  • INSERT処理が2回とも通り、期待する結果を返せばpost IDを返す
  • INSERTがどちらかコケたらログを取った上で例外を投げる

上記のクラスはきれいにDIパターンで実装されているのでいずれのテストもモックを注入し、振る舞いを指定することでロジックの保証をするテストが書ける。

後者のテストを実装するならこんな感じ。

<?php

namespace App;

use Mockery as m;
use Psr\Log\LoggerInterface;
use App\Util\DB;
use App\Exception\DbException;

class BlogServiceTest
{
    /** @var DB */
    protected $db;

    /** @var LoggerInterface */
    protected $logger;

    public function setUp()
    {
        $this->db = m::mock(DB::class);
        $this->logger = m::mock(LoggerInterface::class);
    }

    /**
     * @expectedException DbException
     */
    public function testCreateNewPostSholdThrowExceptionWithLogging()
    {
        $this->db->shouldReceive('insert')->andThrow(new DbException());
        $this->db->shouldReceive('rollback')->once(); // Rollbackすることをテスト
        $this->logger->shouldReceive('warning')->once(); // Loggingすることをテスト

        $SUT = new BlogService($this->db, $this->logger);

        $SUT->createNewPost(1235, 'title');
    }
}

色々はしょってるけどだいたいこんな感じになると思う。

ただ、現実のサービスはこんな単純な仕様であることはほぼない。

例えばここに追加仕様で「例外を投げたらloggingし、rollbackし、エラーをテキストファイルに吐き出すようにする」みたいなものが来たとする。(いい例えが思いつかなかっただけで、現実的にはもっと別のケースがあるでしょう)

こんなコード。

<?php

    /** ~ */

    public function createNewPost(int $userId, string $title)
    {
        $this->db->begin();

        try {
            $postRow = $this->db->insert('posts', [
                'title' => $title,
            ]);

            $this->db->insert('post_user_relations', [
                'user_id' => $userId,
                'post_id' => $row['post_id'],
            ]);

            $this->db->commit();
        } catch (DbException $e) {
            $this->logger->warning($e->getMessage());
            $this->db->rollback();
            // ここが追加
            file_put_contents('path/to/file.txt', json_encode($e));

            throw $e;
        }

        return $postRow['post_id'];
    }

これに対して先程のテストコードだと「エラーをテキストファイルに吐き出す」ことの確認ができない。

しかしファイル書き込みロジックは外部注入していないのでモックすることもできない。

そこでこんなテストを書いてみるとする。

<?php

namespace App;

use Mockery as m;
use Psr\Log\LoggerInterface;
use App\Util\DB;
use App\Exception\DbException;

class BlogServiceTest
{
    /** @var DB */
    protected $db;

    /** @var LoggerInterface */
    protected $logger;

    public function setUp()
    {
        $this->db = m::mock(DB::class);
        $this->logger = m::mock(LoggerInterface::class);
    }

    public function testCreateNewPostSholdThrowExceptionWithLogging()
    {
        $this->db->shouldReceive('insert')->andThrow(new DbException());
        $this->db->shouldReceive('rollback')->once(); // Rollbackすることをテスト
        $this->logger->shouldReceive('warning')->once(); // Loggingすることをテスト

        $SUT = new BlogService($this->db, $this->logger);

        // 例外投げられるとチェックできないからcatchする
        try {
            $SUT->createNewPost(1235, 'title');
        } catch (DbException $e) {
            $this->assertTrue(file_exists('path/to/file.txt'));
            return;
        }
   
        $this->fail();
    }
}

例外を投げられた後だとプログラムが続行できないので例外をcatchしてassertionする。

また、例外をcatchできなかったらテストをコケるようにして例外を投げることを保証する。

これで一件落着、と思いきやこの書き方には2つ問題がある。

  • テストの書き方によってはスタックトレースが表示されず何がなんだか分からない
  • テストの中にロジックが混ざって可読性が下がる

前者に関しては以下のようなテストの書き方をしてると起きる。

<?php

// 例外投げられるとチェックできないからcatchする
try {
    $SUT->createNewPost(1235, 'title');
} catch (\Exception $e) {
    $this->assertInstanceOf(DbException::class, $e);
    $this->assertTrue(file_exists('path/to/file.txt'));
    return;
}

こんな書き方しなきゃいいじゃんって話なんだけど、時たまやるしテストやロジックが複雑化していけばなおさら起きる。

とはいえこういうことは往々にして起きるのでもう少し違うアプローチを考えることにした。

テストを分ける

シンプルにテストを分けてコケたときのエラートラッキングを分けるようにする。

具体的には「意図した例外が投げられること」と「例外が投げられた後の状態が望みどおりであること」の2ケースに分ける。

イメージ的にはこんな感じ。

<?php

namespace App;

use Mockery as m;
use Psr\Log\LoggerInterface;
use App\Util\DB;
use App\Exception\DbException;

class BlogServiceTest
{
    /** @var DB */
    protected $db;

    /** @var LoggerInterface */
    protected $logger;

    public function setUp()
    {
        $this->db = m::mock(DB::class);
        $this->logger = m::mock(LoggerInterface::class);
    }

    /**
     * @expectedException DbException
     */
    public function testCreateNewPostSholdThrowExceptionWithLogging()
    {
        $this->db->shouldReceive('insert')->andThrow(new DbException());
        $this->db->shouldReceive('rollback')->once(); // Rollbackすることをテスト
        $this->logger->shouldReceive('warning')->once(); // Loggingすることをテスト

        $SUT = new BlogService($this->db, $this->logger);

        $SUT->createNewPost(1235, 'title');
    }

    public function testCreateNewPostSholdPutFileAfterThrowException()
    {
        $this->db->shouldReceive('insert')->andThrow(new DbException());

        $SUT = new BlogService($this->db, $this->logger);

        try {
            $SUT->createNewPost(1235, 'title');
        } catch (DbException $e) {
            // 例外をthrow後の状態をテストする
            $this->assertTrue(file_exists('path/to/file.txt'));
        }
    }
}

こうすることで意図しない例外が発生しても原因の切り分けが多少やりやすくなる。

また、もう少し改善する点としてこの2ケースは前者がコケた時点で後者をテストする必要がない。

そこで@dependsアノテーションを利用して前者がコケたら後者がコケるようにする。

<?php

namespace App;

use Mockery as m;
use Psr\Log\LoggerInterface;
use App\Util\DB;
use App\Exception\DbException;

class BlogServiceTest
{
    /** @var DB */
    protected $db;

    /** @var LoggerInterface */
    protected $logger;

    public function setUp()
    {
        $this->db = m::mock(DB::class);
        $this->logger = m::mock(LoggerInterface::class);
    }

    /**
     * @expectedException DbException
     */
    public function testCreateNewPostSholdThrowExceptionWithLogging()
    {
        $this->db->shouldReceive('insert')->andThrow(new DbException());
        $this->db->shouldReceive('rollback')->once(); // Rollbackすることをテスト
        $this->logger->shouldReceive('warning')->once(); // Loggingすることをテスト

        $SUT = new BlogService($this->db, $this->logger);

        $SUT->createNewPost(1235, 'title');
    }

    /**
     * @depends testCreateNewPostSholdThrowExceptionWithLogging
     */
    public function testCreateNewPostSholdPutFileAfterThrowException()
    {
        $this->db->shouldReceive('insert')->andThrow(new DbException());

        $SUT = new BlogService($this->db, $this->logger);

        try {
            $SUT->createNewPost(1235, 'title');
        } catch (DbException $e) {
            // 例外をthrow後の状態をテストする
            $this->assertTrue(file_exists('path/to/file.txt'));
        }
    }
}

これで完璧!!

テストを書くのは楽しいね。