Laravel5.3でAPIを簡単に作ってみる

今年の8月にLaravel5.3がリリースしましたね。

laravel-news.com

色々な変更がありましたが、個人的にはルーティングのファイルが別れたこともなかなか嬉しかったです。

大規模なプロジェクトになるとエンドポイントがたくさん増えてroute.phpがどんどん大きくなってしまいました。またRailsであってもroutes.rbが大きくなってしまっていました。

今回開発中の弊社の新サービスのLaravelのバージョンを5.3にあげたのでLaravelでの入門的なAPIの書き方について書こうと思います。

ルーティングの設定

まず、app/Providers/RouteServiceProvider.phpへルーティングファイルを登録します。

class RouteServiceProvider extends ServiceProvider { protected $namespace = 'App\Http\Controllers';

public function boot()
{
    //
    parent::boot();
}

public function map()
{
    $this->mapWebRoutes();
    $this->mapApiRoutes();
}

protected function mapWebRoutes()
{
    // 普通のルーティング
}

protected function mapApiRoutes()
{
    Route::group([
        // Kernel.phpでapiに登録されているミドルウェアを適応
        'middleware' => 'api',
        // Controllers/Api以下にあるクラスに限定する
        'namespace' => "{$this->namespace}\Api",
        // エンドポイントを/api/somethingの形にする
        'prefix' => 'api',
    ], function ($router) {
        // api.phpを登録する
        require base_path('routes/api.php');
    });
}

}

これでroutes/api.phpへルーティングを書くことができるようになりました。

コントローラを書く

Laravelには様々な機能があり、コントローラを書く上ではValidatorが特に重宝していると思います。

qiita.com

試しに"/api/test?id=1&date=2016-09-28&title=MatchinGood"という感じでアクセスできるAPIのコントローラを描いてみます。

namespace App\Http\Controllers\Api;

use Illuminate\Http\Request; use App\Http\Controllers\Controller;

class TestController extends Controller { public function test(Request $request) { $validator = Validator::make($request->all(), [ // 必須パラメータかつ整数 'id' => 'required|integer', // 必須パラメータかつY-m-dの形 'date' => 'required|date_format:"Y-m-d"', // 必須パラメータかつ文字列 'title' => 'required|string' ]);

    if ($validator->fails()) {
        return response()->json($validator->errors(), 422);
    }

    return response()->json(['status' => 'successful']);
}

}

こんな感じでどうでしょうか。煩雑になりがちなパラメータのバリデーションはすべてLaravelのValidatorに任せられます。公式ドキュメントには色々なバリデーションの種類がありますので適切なものを選択します。

APIなのでバリデーションが失敗した時の返り値もjsonである必要がありますが、$validator->errors()を使うとエラーの内容が取得できるのでそれを使うと楽できます。

ルーティング

コントローラがかけたのでいよいよapi.phpを書きます。

<?php Route::get('test', 'TestController@test'); RouteServiceProviderでnamespaceをApp\Http\Controllers\Apiで設定してあるのでnamespaceの先頭のApiは省くことができます。

おまけ

実はLaravelには$request->wantsJson()でリクエストヘッダーに"ContentType: application/json"がセットされているかどうか調べることができます。なので単純にcompanyというリソースの詳細画面に行くURIが"/company/1"で、わざわざ別のコントローラにAPIを書きたくないと思った時は

public function show(Company $company, Request $request) { if ($request->wantsJson()) { return $company; } return view('company.detail')->with('company', $company); } というふうにすることでリクエストのヘッダーに応じてjsonで返すことができます。

ここで、なぜ単純にEloquentのモデルをコントローラの返り値として返しているかというと、LaravelのEloquentのソースでは、

abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializable, QueueableEntity, UrlRoutable { . . . public function __toString() { return $this->toJson(); } } となっていて、stringにキャストする際にtoJson()を呼んでいることがわかります。toJson()ではモデルの各カラムの値とリレーションをjsonの文字列にして返します。なのでこれでモデルをそのままjsonの形式で返すことができています。

より詳しい実装がきになる方は実際にソースコードを読むことをお勧めします。

laravel/framework Contribute to framework development by creating an account on GitHub. https://github.com/laravel/framework/blob/5.3/src/Illuminate/Database/Eloquent/Model.php

LaravelのFacadeを自前で実装してみる

今回は、SQSLoggerの開発の時に学んだことについて、少し内容を深く掘り下げて行きたいと思います。

LaravelのFacade

LaravelフレームワークにはFacadeという便利な機能があります。config/app.phpにはAliasとして各種Facadeとその短縮形の文字列が登録されています。

'aliases' => [

    'App'       => Illuminate\Support\Facades\App::class,
    'Artisan'   => Illuminate\Support\Facades\Artisan::class,
    'Auth'      => Illuminate\Support\Facades\Auth::class,
    'Blade'     => Illuminate\Support\Facades\Blade::class,
    'Bus'       => Illuminate\Support\Facades\Bus::class,
    'Cache'     => Illuminate\Support\Facades\Cache::class,
    'Config'    => Illuminate\Support\Facades\Config::class,
    'Cookie'    => Illuminate\Support\Facades\Cookie::class,
    'Crypt'     => Illuminate\Support\Facades\Crypt::class,
    'DB'        => Illuminate\Support\Facades\DB::class,
    'Eloquent'  => Illuminate\Database\Eloquent\Model::class,
    'Event'     => Illuminate\Support\Facades\Event::class,
    'File'      => Illuminate\Support\Facades\File::class,
    'Hash'      => Illuminate\Support\Facades\Hash::class,
    'Input'     => Illuminate\Support\Facades\Input::class,
    'Inspiring' => Illuminate\Foundation\Inspiring::class,
    'Lang'      => Illuminate\Support\Facades\Lang::class,
    'Log'       => Illuminate\Support\Facades\Log::class,
    .
    .
    .

Facadeというのは、GoFが提唱したデザインパターンの一つであるFacadeパターンに影響を受けているのだろうと思いますが、これらのクラスも実装としては非常に簡単です。どのぐらい簡単かというと、これだけです。

<?php

namespace Matchingood\SQSLogger\Facades;

use Illuminate\Support\Facades\Facade;

class SQSLogger extends Facade { protected static function getFacadeAccessor() { return 'sqslogger'; } }

ね?簡単でしょう?Facadeクラスを継承してFacadeAccessor()をオーバーライドするだけです。

もちろん、中身の実装はまた別に書くのですが、表面上使うのはこれだけです。

このFacadeに関する解説はネット上だと結構少なく、自分で実装する際にも苦労したので、ここに備忘録のような形で残しておこうと思います。

Facadeの仕組み

一般的に、aliasesに設定したFacadeは、staticなメソッドを持つかのように振る舞います。

SQSLogger::info("info"); SQSLogger::error("error"); SQSLogger::critical("critical");

ですが、上でも見たように、Facadeを継承したSQSLoggerにはinfoやerrorのようなstaticメソッドは定義されていません。これは、aliasesに定義された短縮名を用いてstaticなメソッドを呼んだ場合、callStaticというメソッドが呼ばれるためです。callStaticは、Facadeクラスで定義してあるものを継承したものです。

public static function __callStatic($method, $args)
{
    $instance = static::getFacadeRoot();
    if (! $instance) {
        throw new RuntimeException('A facade root has not been set.');
    }
    switch (count($args)) {
        case 0:
            return $instance->$method();
        case 1:
            return $instance->$method($args[0]);
        case 2:
            return $instance->$method($args[0], $args[1]);
        case 3:
            return $instance->$method($args[0], $args[1], $args[2]);
        case 4:
            return $instance->$method($args[0], $args[1], $args[2], $args[3]);
        default:
            return call_user_func_array([$instance, $method], $args);
    }
}

$methodには呼び出したメソッド名、$argsにはその引数が入ります。ここで、$instanceには、getFacadeRoot()の返り値が入っており、このインスタンスのメソッドを呼ぶ形になっています。

public static function getFacadeRoot()
{
    return static::resolveFacadeInstance(static::getFacadeAccessor());
}

これはresolveFacadeInstanceの返り値を返しますが、引数がgetFacadeAccessor()になっています。これはSQSLoggerクラスでオーバーライドしているので、'sqslogger'が引数ということになります。

protected static function resolveFacadeInstance($name)
{
    if (is_object($name)) {
        return $name;
    }
    if (isset(static::$resolvedInstance[$name])) {
        return static::$resolvedInstance[$name];
    }
    return static::$resolvedInstance[$name] = static::$app[$name];
}

これは、引数がobjectならそのまま返し、既に解決しているインスタンスについてはそれを返し、そうでない場合は$appから対応する名前のものを返します。このappはLaravelのサービスコンテナに当たるもので、最初に使う場合はここから取り出されることになります。つまり、Facadeを使うためにはこのサービスコンテナに使いたい機能を登録すればいいということになります。

サービスコンテナにインスタンスを登録する

Laravelのサービスコンテナにサービスを登録するには、サービスプロバイダーを使うのがベストです。一般にサービスプロバイダーは以下のように書きます。

<?php

namespace Matchingood\SQSLogger;

use Illuminate\Support\ServiceProvider;

class SQSLoggerServiceProvider extends ServiceProvider { public function register() { $this->app->singleton('sqslogger', function() { return new \Matchingood\SQSLogger\SQSLogger; }); }

public function boot()
{
    $this->publishes([
        __DIR__ . '/../config/sqslogger.php' => config_path('sqslogger.php')
    ]);
}

}

サービスコンテナにインスタンスを登録しているのは文字通りregisterメソッドで、singletonの名の通り、1度だけ依存解決した後、インスタンスを共有します。ここでは、コールバックとして渡している関数は無引数ですが、サービス間に依存関係があるときは、ここにサービスコンテナ自体を渡すことができます。

$this->app->singleton('FooBar', function ($app) { return new FooBar($app['SomethingElse']); });

この例では、FoobarはSomethingElseという別のサービスに依存しています。

bootメソッドは、すべてのサービスが登録された後に呼び出されます。ここでは$this->publishesにライブラリの中にあるconfig/sqslogger.phpファイルと、アプリケーションのconfig/sqslogger.phpのペアが登録してありますが、こうすることで、以下のコマンドを実行した時に、デフォルトの設定ファイルがアプリケーションのconfig/sqslogger.phpにコピーされます。

$ php artisan vendor:publish このようにして書かれたサービスプロバイダーは、config/app.phpのprovidersに登録することで読み込まれるようになります。

使いたいインスタンスメソッドを持つクラスを書く

これは普通に実装するだけです。外から使いたいメソッドはpublicにしておいて、staticにしないことに注意しましょう。

<?php

namespace Matchingood\SQSLogger;

use Illuminate\Http\Request; use Auth; use Log; use Aws\Sqs\SqsClient;

class SQSLogger { private $sqs; private $url;

public function __construct()
{
    if (config('sqslogger.env') === 'prod') {
        $this->sqs = new SqsClient([
            'credentials' => [
                'key' => config('sqslogger.aws.access_key'),
                'secret' => config('sqslogger.aws.access_secret')
            ],
            'version' => config('sqslogger.aws.sqs.version'),
            'region' => config('sqslogger.aws.sqs.region')
        ]);
        $obj = $this->sqs->getQueueUrl([
            'QueueName' => config('sqslogger.aws.sqs.queue_name')
        ]);
        $this->url = $obj['QueueUrl'];
    } else {
        $this->sqs = null;
        $this->url = '';
    }
}

public function access(Request $request, $extraInfo = null)
{
    $info = ['method' => $request->method(), 'accessUrl' => $request->fullUrl()];

    if (!is_null($extraInfo)) {
        $info += $extraInfo;
    }

    $body = $this->createBody('ACCESS', "", $info);

    $this->sendMessage([
        'MessageBody' => $body,
        'QueueUrl' => $this->url
    ]);
}

public function error($message, $extraInfo = null)
{
    $this->sendMessage([
        'MessageBody' => $this->createBody('ERROR', $message, $extraInfo),
        'QueueUrl' => $this->url
    ], 'ERROR');
}

public function info($message, $extraInfo = null)
{
    $this->sendMessage([
        'MessageBody' => $this->createBody('INFO', $message, $extraInfo),
        'QueueUrl' => $this->url
    ], 'INFO');
}

public function critical($message, $extraInfo = null)
{
    $this->sendMessage([
        'MessageBody' => $this->createBody('CRITICAL', $message, $extraInfo),
        'QueueUrl' => $this->url
    ], 'CRITICAL');
}

private function sendMessage($data, $level = 'debug', $extraInfo = null)
{
    if (env('APP_ENV') === 'prod') {
        $this->sqs->sendMessage($data, $extraInfo);
    } elseif ($level === 'ERROR') {
        Log::error($data['MessageBody']);
    } elseif ($level === 'INFO') {
        Log::info($data['MessageBody']);
    } else {
        Log::debug($data['MessageBody']);
    }
}

private function createBody($level, $message, $extraInfo = null)
{
    $id = null;
    if (Auth::check()) {
        $id = auth()->user()->id;
    }

    $body = [
        'level' => $level,
        'time' => date('Y-m-d H:i:s'),
        'userId' => $id,
        'message' => $message
    ];

    if (!is_null($extraInfo)) {
        $body += $extraInfo;
    }

    return json_encode($body, JSON_UNESCAPED_SLASHES);
}

}

あとは、サービスプロバイダーをconfig/app.phpのprovidersに登録します。

'providers' => [

    /*
     * Laravel Framework Service Providers...
     */
    //Illuminate\Foundation\Providers\ArtisanServiceProvider::class,
    Illuminate\Auth\AuthServiceProvider::class,
    Illuminate\Broadcasting\BroadcastServiceProvider::class,
    Illuminate\Bus\BusServiceProvider::class,
    Illuminate\Cache\CacheServiceProvider::class,
    Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
    //Illuminate\Routing\ControllerServiceProvider::class,
    Illuminate\Cookie\CookieServiceProvider::class,
    Illuminate\Database\DatabaseServiceProvider::class,
    Illuminate\Encryption\EncryptionServiceProvider::class,
    Illuminate\Filesystem\FilesystemServiceProvider::class,
    Illuminate\Foundation\Providers\FoundationServiceProvider::class,
    Illuminate\Hashing\HashServiceProvider::class,
    Illuminate\Mail\MailServiceProvider::class,
    Illuminate\Pagination\PaginationServiceProvider::class,
    Illuminate\Pipeline\PipelineServiceProvider::class,
    Illuminate\Queue\QueueServiceProvider::class,
    Illuminate\Redis\RedisServiceProvider::class,
    Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
    Illuminate\Session\SessionServiceProvider::class,
    Illuminate\Translation\TranslationServiceProvider::class,
    Illuminate\Validation\ValidationServiceProvider::class,
    Illuminate\View\ViewServiceProvider::class,
    Collective\Html\HtmlServiceProvider::class,

    /*
     * Application Service Providers...
     */
    App\Providers\AppServiceProvider::class,
    App\Providers\EventServiceProvider::class,
    App\Providers\RouteServiceProvider::class,

     // サービスプロバイダーを挿入 Matchingood\SQSLogger\SQSLoggerServiceProvider::class, ],

aliasesにもFacadeクラスを追加します。

'aliases' => [

    'App'       => Illuminate\Support\Facades\App::class,
    'Artisan'   => Illuminate\Support\Facades\Artisan::class,
    'Auth'      => Illuminate\Support\Facades\Auth::class,
    'Blade'     => Illuminate\Support\Facades\Blade::class,
    'Bus'       => Illuminate\Support\Facades\Bus::class,
    'Cache'     => Illuminate\Support\Facades\Cache::class,
    'Config'    => Illuminate\Support\Facades\Config::class,
    'Cookie'    => Illuminate\Support\Facades\Cookie::class,
    .
    .
    .
    'SQSLogger' => Matchingood\SQSLogger\Facades\SQSLogger::class,
]

こうして、他のFacadeと同じようにSQSLoggerがつかえるようになりました。めでたしめでたし。

まとめ

あるフレームワークを使うには、そのフレームワークの仕組みをよく理解することが大事だと思いますが、これを通してLaravelの(ある種隠蔽されている)機能であるFacadeについて理解を深めることが出来ました。とはいえ、この機能の肝は「サービス間の依存関係の解決」にあるように思えるので、次に何かライブラリを書くときはその辺りを意識してみたいと思います。

いかにLaravelで外部依存しているサービスを作ったか

今回は弊社で開発しているWorkinGoodでいかに外部依存しているシステムを開発したか、簡単なサンプルのコードも交えて話をします。

WorkinGoodは派遣スタッフの方が自分で勤怠を入力したり、給与明細を確認することができるサービスです。

技術的には以下のもので構成されています。

Laravel AWS(色々) TypeScript React + flux 見た感じモダンな雰囲気がありますが、このシステムには一つ解決しなければならない問題があります。

レガシーシステムとの連携

それは弊社で開発している別のシステムをバックエンドにした構成にしなくてはなりませんでした。

f:id:cosuke2000:20190405162043j:plain

このMGというのがレガシーシステムなのですが、もともとAPIを用意していたわけではなく、WorkinGoodの開発と同時に新たにAPIをつくる必要があります。しかもこのレガシーシステムWindows上でしか動かないので以前の記事で紹介したDockerで環境を用意することもできません。

matchingood-engineer.hatenablog.com

このような状況では以下のような問題が発生します。

開発時に依存しているシステムに繋げないといけない CIでのユニットテストの時に依存しているシステムのバージョンがあわない 開発時に依存しているシステムに繋げる時は弊社が所有しているサーバーに開発用のバックエンドを立ち上げて開発を行うことで解決しました。しかし、CI時にバックエンドにつなげようとするとバージョンが合わなかったり、繰り返し使うことによってデータがユニットテストに合わなくなってきてしまいます。

どう解決したか

まず、バックエンドに依存する部分をサービス層として独立させました。全てのバックエンドへのリクエストはこのサービス層を通します。

f:id:cosuke2000:20190405161850j:plain

こうすることでDIを利用してサービス層をモックすることができます。実際にこのアイディアの簡単なコードの例を見て見ましょう。

class AssignmentService { protected $requestBuilder;

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

public function get($since, $until)
{
    $url = "http://backend.com/assignments?since={$since}&until={$until}";

    // リクエストを投げる
    $response = $this->requestBuilder
        ->setUrl($url)
        ->sendRequest();
    $result = $response->getResult();

    // あとはリクエスト結果をモデルに変換する
    .
    .
    .

     return $assignments } }

これはAssignmentというリソースをバックエンドからとってくるサービス層の架空の例です。コード中に出てくる$requestBuilderというオブジェクトが実際にHTTPリクエストを行なっています。この$requestBuilderをモックオブジェクトに差し替えることで実際の通信自体をモックすることができます。

ユニットテスト

このままだとコードを書いても実装が正しいのかどうかわかりません。そこでユニットテストを並行して書きます。

class AssignmentServiceTest extends TestCase { protected $mock; protected $result = // 想定されるリクエストの返り値

public function setUp()
{
    parent::setUp();
    $mock = $this->getMockBuilder('RequestBuilder')
        ->setMethods([
            'setUrl',
            'sendRequest'
        ])
        ->getMock();
    $mock->method('setUrl')
        ->will($this->returnSelf());
    $this->mock = $mock;
}

public function testGet()
{
    $this->mock->method('sendRequest')
        ->willReturn($this->result);
    $service = new AssignmentService($this->mock);
    $assignments = $service->get('2016-09-01', '2016-09-30');
    // あとはこの$assignmentsが想定通りかどうかチェックする
}

}

こうすることで依存しているサービスの開発状況によらずに開発を進めることができます。

フロントエンドAPIの開発

WorkinGoodではUIをReactで構築しているのでLaravel側にクライアントサイド向けのAPIを用意する必要があります。これをここではバックエンド側のAPIと区別するためにフロントエンドAPIと呼びます。サービス層を実際に使っている部分でもあります。

サービス層を呼んでいるのでこのAPIを開発するときも結果を確認するためにユニットテストを並行して書くことで開発を進めます。

class AssignmentsController extends Controller { function get(Request $request) { $validator = Validator::make($request->all(), [ 'since' => 'required|date_format:"Y-m-d"', 'until' => 'required|date_format:"Y-m-d"', ]); if ($validator->fails()) { return response()->json(['status' => 'failed', 'errors' => $validator->errors()], 422); } $since = $request->since; $until = $request->until; $service = new AssignmentService(new RequestBuilder); $assignments = $service->get($since, $until); // あとはレスポンスの形式にして返す。 } }

フロントエンドAPIを簡単にこう書いた場合、コントローラのユニットテストはこのように書くことができます。なお、簡単にするためにログイン処理などは省きます。

class AssignmentsControllerTest extends TestCase { protected $mock; protected $result;

public function setUp()
{
    parent::setUp();
    $this->mock = Mockery::mock('overload:App\Service\AssignmentService');
}


public function testGet()
{
    $this->result = // サービス層が返す期待するモデル
    $this->mock
        ->shouldReceive('get')
        ->andReturn($this->result);
    $this->visit('/assignments?since=2016-09-01&until=2016-09-30')
        ->seeJson([
            // 期待するAPIの返り値
        ]);
}

}

このMockeryというのはPHPライブラリでユニットテスト時にモックすることを可能にしてくれます。overloadというものを使うと呼び出す関数が内部的に呼び出しているクラス自体をモックできるという優れものです。

この実装とユニットテストを並行して書き進めることでバックエンドの進捗によらずスムーズに開発することができています。

現状の問題点

このサービスでは積極的にMockeryを使っていますが、ドキュメントを見ると

Prefixing the valid name of a class (which is NOT currently loaded) with “overload:” will generate an alias mock (as with “alias:”) except that created new instances of that class will import any expectations set on the origin mock ($mock).

とあります。"which is NOT currently loaded"という部分が実は厄介で、他の部分ですでにモック対象のクラスが読み込まれているとモックすることができません。これはこのユニットテストのファイル一つだけをテストする場合は問題ないのですが、CIなどで全てのユニットテストを行う時に引っかかってしまいます。同じプロセスですでに読み込まれている場合はモックできないので

/* * @runInSeparateProcess * @preserveGlobalState disabled / public function testGet()

のようにアノテーションをつける必要があります。こうするとユニットテストの実行時間が結構伸びてしまうので現在改善案を考えています。

まとめ

Laravelに寄せた話でしたが、アイディア自体はどのような環境でも適応できる話だったと思います。外部依存するサービスがあると同じPC上に環境を構築できなかったりするのでこのようなスタイルで開発することがとても有効な場合があると思います。

これらは全てアルバイトエンジニアが設計して実装してきました。これはまだまだ一例にすぎず、やる気があればもっと高度な技術的なチャレンジをやりきることもできます。プログラミング初心者の方も実際のコードを通じて得られるものがたくさんあると思います。

AWSを使ったスケーラブルなログ機能を実装した話

今回は自分が開発・オープンソース化したSQSLoggerについて、その開発の流れを書きたいと思います。

現在私達が開発している、派遣先スタッフ/担当者向けアプリケーションである勤怠入力ツールWorkinGoodでは当初、ログを取る機能はLaravelのStorageファサードを使い、Amazon S3に直接書き込む形で行っていました。書き込み頻度の低いものであれば、これでも十分実用に足りうるのですが、ことアクセスログやエラーログ等の頻度が多いと想定されるものには、あまりよい手段とは言えません。

そんな時、Sさんが良いアイディアを持ってきました。これは実装したい!と思いたち、早速開発にとりかかりました。そして一部の機能は、後にcomposerライブラリとしてオープンソースになりました。

アイディア

f:id:cosuke2000:20190405160550p:plain

現状のログ機能では、アクセスが増えた時にS3へのアクセスがネックになることが想定されたので、その負荷を軽減するのが今回の目的です。そこで、ログに取るべき情報をAWS SQS上のキューに登録する方針が提案されました。これによって、S3がGET/PUTと2つのリクエストを送らなければならないのに対し、ログ情報のPOSTのみで済むので、リソースを食わなくて済みます。

では、SQS上に溜めたログはどう処理するのか、という問題に当たりますが、これはAWS Lambdaを使えば解決できます。Lambdaには、cronのように指定したタイミングでスクリプトを実行できる機能があるので、これを用いて、一定間隔ごとに、SQSに溜まったログを指定した実行時間内にできるだけLambdaでGETし、それらを固めてS3にPOSTします。つまり、いままでアプリケーション側で行っていたS3へのPOSTを、Lambdaという別リソースで行うということです。

また、アプリケーションからPOSTするログ情報はJSON形式で送信し、S3には、各行がJSONになっているテキストとしてPOSTします。これは、Lambdaで走らせるのがJavascriptなのでJSONだと処理しやすいという点と、S3上のログを解析するときにも効率よく処理できる点からそのようにしています。

たとえば、弊社slack上ではheroku上で動いているbotがいますが、S3上にあるログを解析して様々な機能を提供しています。最近実装したものだと、アプリケーションでfatalなエラーが発生した時にslack上のWorkinGoodチャンネルにお知らせしてくれる機能があります。具体的には、

  1. heroku上にAPIを用意する(ここをPOSTで叩くとエラーの件数をslackに通知する)

  2. Lambda上に、S3上に作成されたエラーログを読み取り、1のAPIを叩くスクリプトを用意し、"S3上の特定の場所にファイルが作成された時"に発火するように設定する。

という形です。S3上のerrorディレクトリ以下にファイルが作成されたりすると、それを読みにいってslackに通知します。

そしてライブラリ化へ…

とりあえずWorkinGoodの1機能として実装したこのログ機能ですが、弊社の別プロジェクトでも需要があるようだったので、切り離してライブラリ化することになりました。とはいえ、アプリケーション側の実装はSQSにログを送信する機能だけなので、そう大変な作業ではありませんでした。一方ただ移植するだけではちょっとつまらないので、Laravelのファサードとして使えるようにしてしまおう!ということでひと工夫加えました。(その辺のことについてだけで1記事ぐらい書けそうなので、また後々書こうと思います。)

できたライブラリがこちらです。composerにも登録しているので、Laravelをつかったプロジェクトや、そうでないプロジェクトでも気軽に使ってください。issue/PRもお待ちしております。

まとめ

こうして、1つの機能を1人で丸々実装するのは結構面白かったですし、AWSの各種機能に親しむこともでました。特にLambdaは(この実装をした当初は)日本語の実用例や解説記事も少なく、結構四苦八苦した記憶があります。。。

PHP勉強会でLT発表

昨日、PHP勉強会に出席しました。 今回2回目の出席でしたが、MVCの話やCPUの話が非常に聞きごたえがあった会だと思います。

さて、LT枠を見ると分かる通り、 「Laravel×Vue.jsの切り分けで苦労していること」 というテーマでLTを発表しました。

Laravel×Vue.jsの切り分けで苦労していること

こういう勉強会に参加すると最近のPHPの話とか聞けてよいですね。大変参考になりました。

php7 + phpdbgで高速カバレッジ出力

先日は、今私が関わっているプロジェクトのphpのバージョンを挙げた際のTipsについて投稿しました。

http://blog.matchingood.com/entry/2017/03/09/202011blog.matchingood.com

今日は、php7にバージョンアップしたことによって可能になった、phpdbgによるコードカバレッジ出力について書きたいと思います。

phpdbgとは

Loading...

phpdbgは、phpのデバッガです。デバッガというとphpの場合似たようなものとしてxdebugもありますが、xdebugはあくまでphpの拡張モジュールであって、その恩恵を最大限に受けるにはIDEが必要不可欠です。

一方、phpdbgは端末上での逐次実行やブレークポイントの設定など、デバッガとしての実行を端末環境で行うことができます。C言語に馴染みがある人ならgdbライクだというとわかっていただけると思います。

phpdbgはphp5.6からphpのコアモジュールとして採用されており、phpがインストール済みなら既にインストールされています。

今回はphpdbgのデバッガとしての使用についてはひとまず置いておいて、このphpdbgを用いたカバレッジの出力についてご紹介したいと思います。

今までのコードカバレッジの出力

現在私が関わっているプロジェクトでは、CIでのテストの際にコードカバレッジを出力しています。PHPUnitを用いている場合は以下のようなコマンドです。

/path/to/phpunit --coverage-html=$CIRCLE_ARTIFACTS

これによって、$CIRCLE_ARTIFACTSの表すディレクトリにコードカバレッジがhtmlで出力されます。しかし、これがとても重い…

これまでのプロジェクト上のCI上のテスト(カバレッジ出力込)の実行時間について、過去の記録を調べてみました。CIはCircleCIを利用しています。

environment assertions time
php5.6 + phpunit 4.8.29 726 8m35s
php7.0 + phpunit 4.8.29 724 2m56s
php7.0 + phpunit 5.7.15 692 3m04s

php5.6時代は700程度のアサーションでも8分以上かかっています。それに対してphp7恐るべし…!php5.6に比べて約3倍の高速化です。phpunitのバージョンアップはLaravel5.4へのバージョンアップの際に行いましたが、これについてはあまり関係していなさそうです(むしろ遅くなってる?)

とはいえ、これらのテストはカバレッジを出力しなければ、どれも30秒程度で終わります。つまり、CIでのテストを高速化するならこのカバレッジ出力の部分を高速化したい!ということになります。これを叶えるのがphpdbgです。

phpdbgを用いたコードカバレッジ出力

従来のカバレッジ出力では、ドライバとしてxdebugを利用しています。このドライバをphpdbgに変更するには、先ほどのコマンドの先頭にphpdbg -qrrをつけるだけです。

phpdbg -qrr /path/to/phpunit --coverage-html=$CIRCLE_ARTIFACTS

phpdbgはphpのコアモジュールなので、phpにPATHが通っているならばphpdbgも通っていると思われるので、パスは省略しています。

phpdbgのコマンドオプションの説明についてはGoogle先生にお任せしますが、ここでの-qrrは、「起動時のタイトル・バージョン表示を省略し、引数に指定されたスクリプトを即時実行した後、phpdbgを終了する」という意味になります。

では、phpunitによるテスト+phpdbgによるカバレッジ出力の結果を、先ほどの結果と合わせて見てみましょう。

environment assertions time
php5.6 + phpunit 4.8.29 726 8m35s
php7.0 + phpunit 4.8.29 724 2m56s
php7.0 + phpunit 5.7.15 692 3m04s
php7.0 + phpunit 5.7.15 with phpdbg 682 37s

なんと軽々と1分を切っていきました。すさまじい速さです…

全体としても2分以上の短縮ができました。CIの高速化は熱心に取り組むにはややハードルの高いissueですが、この程度の変更で短縮できるならとても良いですね!

注意事項

phpdbgについては、php5.6からコアモジュールとして公式に配布されていますが、カバレッジの出力に関してはphp7からのサポートとなります。これについては、PHPUnitカバレッジ出力に利用しているPHP_CodeCoverageというコンポーネントの要件に依存しているためです。

最後に

コードカバレッジの出力をphpdbgを通して行うことで、CIの時間を2分以上早めることができました!導入も手軽なので、php7を用いたプロダクトでカバレッジ出力がしたいのならば是非おすすめしたい方法です!

AlpineLinux上のLaravelアプリケーションのphp5.6 -> 7.0の環境アップグレードでコケた話

現在私が携わっているプロジェクトでは、DockerコンテナとしてAlpineLinuxを用いて、PHP5.6 + Laravel5.3でアプリケーションの開発・運用をしています。

先日、PHP5.6のアクティブアップデート期間が終了しました

これに伴って本アプリケーションもPHP7に移行してみようとしたのですが、すごく微妙な落とし穴にハマってしまったので、Tipsとして書き残したいと思います。

AlpineLinuxにおけるPHP

AlpineLinuxを用いたPHPアプリケーション環境の構築には、まずイメージの選択肢があります。自分がわかる範囲では以下の2つです。

前者の場合はベースイメージにalpine以外にも選択肢があります。こちらはDockerfile上で指定されたバージョンのPHPのソースをダウンロードしてビルドしています。

後者はプレーンなOSイメージですが、公式のレポジトリでビルド済みのPHPが配布されているのでこれを利用します。もちろん自前でビルドすることもできます。

私の携わっているプロジェクトは後者を用いていますが、さいことPHPに特化するのであれば、PHPが公式にメンテナンスしている前者が良いかと思います。

Alpine公式レポジトリのPHPについて

現在、AlpineLinuxの公式レポジトリでは、PHP5.6(php)とPHP7.0(php7)がそれぞれ別パッケージとして配布されています。以前はこのPHP5.6と、各種拡張機能(php-openssl, php-pdo_mysql, etc.)をレポジトリからインストールして運用していました。以下は従来のDockerfileの一部です。apkはalpineLinuxにおけるパッケージマネージャのコマンドで、ubuntuでいうapt-get、centOSでいうyumのようなものです。

FROM alpine:3.3

RUN apk --no-cache add apache2 php-apache2 \
        php php-fpm php-json php-zlib php-xml php-pdo php-phar \
        php-openssl php-curl php-pdo_mysql php-mysql php-mysqli \
        php-gd php-ctype \
    && mkdir -p /run/apache2/ \
    && rm -rf /var/www/localhost/htdocs/index.html \
    && ln -sf /dev/stdout /var/log/apache2/access.log \
    && ln -sf /dev/stderr /var/log/apache2/error.log

各種拡張モジュールはphp-*という形式で提供されています。どうやらPHP7も同じようにphp7-*という形式であるようなので、Dockerfileを以下のように修正してみる!

FROM alpine:3.5

RUN apk --no-cache add apache2 php7-apache2 \
        php7 php7-fpm php7-json php7-zlib php7-xml php7-pdo php7-phar \
        php7-openssl php7-curl php7-pdo_mysql php7-mysqli \
        php7-gd php7-ctype \
    && mkdir -p /run/apache2/ \
    && rm -rf /var/www/localhost/htdocs/index.html \
    && ln -sf /dev/stdout /var/log/apache2/access.log \
    && ln -sf /dev/stderr /var/log/apache2/error.log \
    && ln -sf /usr/bin/php7 /usr/bin/php

…HTTP Error Code 500。。。(・・?

ひっそりと隠れていたmbstring

docker logsを通してapacheに出力されたエラーを見てみると、Mbstringの関数が未定義だということを言われている。そういえばmbstringはPHPの拡張モジュールでした。 Laravelの必要要件にもmbstringがあります。これが足りないのか…?

[Tue Mar 07 11:31:27.898014 2017] [:error] [pid 7] [client 172.17.0.1:51690]
PHP Fatal error:  Uncaught Error: Call to undefined function Symfony\\Polyfill\\Mbstring\\iconv_strpos() 
in /var/www/localhost/htdocs/vendor/symfony/polyfill-mbstring/Mbstring.php:358\nStack 
trace:
\n#0 /var/www/localhost/htdocs/vendor/symfony/polyfill-mbstring/bootstrap.php(32): Symfony\\Polyfill\\Mbstring\\Mbstring::mb_strpos(NULL, '/json', 0, 'UTF-8')
\n#1 /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Support/Str.php(72): mb_strpos(NULL, '/json')
\n#2 /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Http/Concerns/InteractsWithContentTypes.php(34): Illuminate\\Support\\Str::contains(NULL, Array)
\n#3 /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Http/Request.php(312): Illuminate\\Http\\Request->isJson()
\n#4 /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Http/Request.php(340): Illuminate\\Http\\Request->getInputSource()
\n#5 /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Http/Request.php(59): Illuminate\\Http\\Request::createFromBase(Object(Illuminate\\Http\\R in /var/www/localhost/htdocs/vendor/symfony/polyfill-mbstring/Mbstring.php on line 358
172.17.0.1 - - [07/Mar/2017:11:31:27 +0000] "GET / HTTP/1.1" 500 - "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.90 Safari/537.36"
172.17.0.1 - - [07/Mar/2017:11:31:47 +0000] "-" 408 - "-" "-"

そこで、公式レポジトリを調べてみたところ、

  • PHP5.6(php)では、mbstringがメインパッケージにバンドルされていた

  • PHP7(php7)では、別パッケージとして配布されている

ことが判明。なるほど、動かないわけだ…

さらなる追い打ち、sessionモジュール

そこでphp7-mbstringを入れて再度イメージをビルドしてコンテナ作成、アクセスしてみると、今度は画面が真っ白に…やはり内部エラー(500)が返ってきていました。

もう一回エラーログを見ると、今度はSessionHandlerInterfaceがないと言われています。これはPHPの拡張モジュールで定義されているインターフェースのようですね。

PHP: SessionHandlerInterface - Manual

[Thu Mar 09 10:46:27.561882 2017] [:error] [pid 7] [client 172.17.0.1:49232] PHP Fatal error:  Interface 'SessionHandlerInterface' not found in /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Session/CookieSessionHandler.php on line 10
[Thu Mar 09 10:46:27.585695 2017] [:error] [pid 7] [client 172.17.0.1:49232] PHP Fatal error:  Uncaught TypeError: Argument 2 passed to Illuminate\\Session\\Store::__construct() must be an instance of SessionHandlerInterface, instance of Illuminate\\Session\\CookieSessionHandler given, called in /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Session/SessionManager.php on line 169 and defined in /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Session/Store.php:56\nStack 
trace:
\n#0 /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Session/SessionManager.php(169): Illuminate\\Session\\Store->__construct('laravel_session', Object(Illuminate\\Session\\CookieSessionHandler))
\n#1 /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Session/SessionManager.php(39): Illuminate\\Session\\SessionManager->buildSession(Object(Illuminate\\Session\\CookieSessionHandler))
\n#2 /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Support/Manager.php(87): Illuminate\\Session\\SessionManager->createCookieDriver()
\n#3 /var/www/localhost/htdocs/vendor/laravel/framew in /var/www/localhost/htdocs/vendor/laravel/framework/src/Illuminate/Session/Store.php on line 56
172.17.0.1 - - [09/Mar/2017:10:46:26 +0000] "GET / HTTP/1.1" 500 1 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.90 Safari/537.36"

このパッケージもやはりmbstringと同様、5.6まではメインパッケージで既に有効になっていたものが、別パッケージになっていたようです。

mbstringはまだしも、sessionはPHP使ってれば絶対使いそうな気がするので分けなくても良いような気がしますが…その辺りのメンテナの意図が気になるところです。

まとめ

上記を踏まえて、php7-mbstringとphp7-sessionパッケージを追加してビルドしたところ、ようやくうまくいきました。

500エラーはクライアントサイドの情報量が少ないので、サーバーサイドのログを探らなくてはいけないのでちょっと大変ですね。

FROM alpine:3.5

RUN apk --no-cache add apache2 php7-apache2 \
        php7 php7-fpm php7-json php7-zlib php7-xml php7-pdo php7-phar \
        .
        .
        .
        php7-mbstring php7-session \
    && mkdir -p /run/apache2/ \
    .
    .
    .