Laravel5.5でのテストでミドルウェアを...

GLUGの熊谷です。
弊社で作成しているサービスでは、PHP7とフレームワークにLaravel5.5を採用しています。

Laravelの便利機能の一つに、ミドルウェアがあります。
ルーティングに組み込めば管理も便利になったりするのですが、便利だからこそうっかり自動テストでハマったりします。
Featureテストではミドルウェアが原因で期待値と違った結果になる事も。それに気づかずに、テスト対象のコードを追いかけたり、テストコード自体を追いかけたり。
それなので、テストの場合のみミドルウェアを無効にしたりした方が良い場合があります。

ミドルウェアが有効になっているかのテストと、ミドルウェアを一時無効にして行うテストのサンプルを掲載します。
サンプルのテスト対象の事前条件は下記になります。

// テスト対象のルーティング設定
Route::view('/', 'index')->middleware('my_middleware');

ミドルウェアが有効か判定

<?php
namespace Tests\Feature;

use Tests\TestCase;

/**
 * ミドルウェアが有効かテストするサンプル
 *
 */
class SampleWithMiddlewareTest extends TestCase
{
    public function testSample()
    {
        $this->get('/');
        $this->assertContains('my_middleware', \Route::current()->middleware(), 'my_middlewareが有効である');
    }
}
  • \Route::current() で直前に実行されたHTTPテストのルーティング情報が取得できる
  • \Route::current()->middleware() 取得できたルーティング情報からミドルウェア情報が取得できる
    • $this->assertMiddleware() の様な標準メソッドはないのだろうか・・・
  • 取得できたミドルウェア情報に期待値が含まれているか検証する

ミドルウェアを一時無効にしてテストを実行する

<?php
namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\WithoutMiddleware;

/**
 * 特定のミドルウェアを無効にしてテスト実行サンプル
 *
 */
class SampleWithoutMiddlewareTest extends TestCase
{
    use WithoutMiddleware;

    public function testSample()
    {
        $this->withoutMiddleware(\App\Http\Middlewae\MyMiddleware::class);

        // テストさせたい内容をこれ以降に実装(サンプルとして200ステータス検証)
        $this->get('/')->assertSuccessful();
    }
}
  • \Illuminate\Foundation\Testing\WithoutMiddleware トレイトの withoutMiddleware() を実行する
  • withoutMilldeware() でミドルウェアを無効にした後にテストを実装

PHP7とFormDataの組み合わせで文字化け

GLUGの熊谷です。
久々に文字化けに遭遇しました。
データベース・PHP環境共にUTF-8で扱っていましたが、 INSERT または UPDATE をした日本語の内容が文字化けで崩壊しています。 文字化けの内容も、昔よく見たSJISへコード変換ミスの様な化け方ではなく見慣れない文字化けです。
ただし、全ての日本語が文字化けしているのではなく、問題なく扱えているレコードと文字化けしているレコードが混在している状況でした。

文字化けを起こしたと想定される処理を追いかけると、FormDataを利用したAjaxのPOST送信された内容が文字化けを起こしていました。
FormDataはmultipart/form-data形式固定で送信されます。それとPHPのマルチバイト処理の組み合わせで発生です。
開発を行なっているローカル環境で問題が発生しなかったので、PHP側のmbstrting辺りに問題がある事が予測されます。

この辺りの内容でネット情報を追いかけていくと、mbstring.http_inputの存在を多く目にします。
実際に問題が発生しないローカル環境とサーバ環境を比較してみました。

mbstring.http_input = auto
入力されたマルチバイトの処理の設定を行う項目です。 問題が発生したサーバー環境ではauto設定になっていました。
マニュアルhttps://www.php.net/manual/ja/mbstring.configuration.php#ini.mbstring.http-input を参照すると、PHP5.6以降で非推奨となっています。

mbstring.http_input = pass

とする事で無事に解決されました。

EATASのシステム構成

はじめに

GLUGの小林です。 飲食関連メディアのEATASのシステム構成についてご紹介します。 見ての通りAWS上です。比較的オーソドックスなので面白味はないかもしれないです。

構成図

f:id:n06utk:20190305191530p:plain
EATAS構成図

各サービスの説明

細かいところはまた別の記事を書いていきますが、ざっくりと全体的なところを記述します。 LambdaはGoで書いています。特に(深い)理由はありません。

CloudFront

動画のアップロード以外は基本的にCloudFrontで受けています。 画像やjs、cssなどのアセットはキャッシュていますが、会員限定の記事とかを考えると(でもそういう記事は今はないという)キャッシュしづらいのでキャッシュしていません。 今の構成で動的コンテンツでも100request/secは余裕でさばけそうなので、重くなってきたら考えます。

S3+Lambda+MediaConvert

動画アップロードを署名付きURLでS3バケットにアップロード、putObjectのイベントでLambda Functionを起動してMediaConvertでHLS形式に変換しています。 プライベートな動画コンテンツがないためyoutube埋め込みで用が足りてしまい、使われていません...

S3+Athena

CloudFrontのアクセスログをS3に保存してAthenaで解析しています。 パーティションとかはこれでやっています。

EC2

今の所トラフィックがあまりないので特にオートスケールはしてないですが、落ちたときに復活させたいのでオートスケーリンググループで起動しています。 t2.nano*2とかだったりします...

ECS、Fargate

Webアクセスはnginx+php-fpmなタスク定義のサービスで受けています。 オーソドックスにLaravelです。 メール送信などのキュージョブについてはキュージョブ用のタスク定義で起動しています。 こちらもLaravelです。 ジョブについては、元がLaravelなので、Fargateでartisan起動しています。ちょっと前はCloudWatchEventからFargateのタスクを起動しようとするとVPC設定ができないため、Lambda挟んでいましたが、今は直接起動しています。Lambdaにしたい。

RDS

MySQLです。特に珍しくもなんともないです。 memcachedを有効にしてセッション管理に使っています。カテゴリとかのサーバ間で共有する必要のないデータはAPCuに保存しています。 db.t2.microだったりします...

Elasticsearch Service

最初はMySQL全文検索していましたが、検索結果が納得行かないとの意見がありElaticsearch Serviceに変更。

Backlogからの一連の部分

GitHubにしたいところですが、諸事情によりBacklogを使っているため、gitもBacklogなので、API GatewayとLambdaでS3にファイルをおいてCodePipelineを起動しています。 SNSからメールで送信しています。(ここは手抜き) 終了したらCloudWatchEvent経由でSlackへ通知するLambda Functionを起動。 この辺の詳細は別記事にて。

テストサーバを社内からのアクセスに制限したい

f:id:n06utk:20190308165040p:plain

はじめに

GLUGの小林です。 テストサーバなどで、社内からのアクセスに制限したいときってよくありますよね。

あたりがよくありますが

  • IPアドレス制限
    • 社屋からアクセスするとは限らない
    • とはいえVPNサーバ立てるのも面倒
  • Basic認証
    • ユーザ管理したくない

というところで難点が。 そこで署名付きCookieと、弊社ではGoogle G Sutieを使っているので、Google認証を組み合わせて制限をかけます。

仕組み

  1. 認証用のエンドポイントへアクセス
  2. Googleの認証画面へリダイレクト
  3. Googleでの認証
  4. 認証完了後のcallback
  5. tokenを使ってGoogleのユーザー情報取得
  6. 署名付きCookieを発行

1と5のURLにはアクセス制限をかけないようにしてください。

事前準備

  1. リンク先を確認してCloudFrontで制限をかけます
  2. 1.で取得したprivate keyをKMSでエンコードし、結果の文字列を保存
  3. GoogleOAuth2認証の設定を行います。

認証用のURLは http(s)://{hostname}/oauth/login CallbackのURLは http(s)://{hostname}//oauth/callback です。

デプロイ

ソースはGitHubにあります。 Goがインストール済みである必要があります。

ツールの入手

$ git clone git@github.com:n06uk06a/oid_connect.git
$ cd oid_connect

ビルド

GOOS=linux go build -o build/oidc

環境変数の設定

名前 内容
KMSPrincipal KMSマスターキーのARN
KMSAliasParameter KMSマスターキーを格納しているパラメータのAlias
OAuthClientID GoogleのOAuth2のClient ID
OAuthClientSecret GoogleのOAuth2のClientシークレット
CFKeyID CloudFrontに設定したキーペアのキーのID
CFKey CloudFrontに設定したキーペアのキー
URLPattern Cookieに設定する許可するURLのパターン
JWSKey 仕組みの2でJWSを発行し、5で確認するためのキー(適当なもので)
HostedDomain G Suiteのドメイン

デプロイ

$ aws cloudformation package \
    --template-file template.yaml \
    --s3-bucket ${BUCKET_NAME} \
    --output-template-file package.yaml
$ aws cloudformation deploy \
    --template-file package.yaml \
    --stack-name OIDConnect \
    --capabilities CAPABILITY_IAM \
    --parameter-overrides \
        KMSPrincipal=${KMS_KEY_ADMIN_ARN} \
        KMSAliasParameter=${KMS_ALIAS} \
        OAuthClientID=${OAUTH2_CLIENT_ID} \
        OAuthClientSecret=${OAuthClientSecret} \
        CFKeyID=${CLOUDFROMT_KEY_ID} \
        CFKey=${CFKey} \
        URLPattern=${URL_PATTERN} \
        JWSKey=${JWSKey} \
        HostedDomain=${GOOGLE_HOSTED_DOMAIN}

注意点

とりあえず有効期限を1時間にしていたり、ドメイン1個だけ許可していたり、認証要求やCallbackのパスとかが固定だったりするので、その辺はカスタマイズしてください。あとCookie使うので、同じDistribution(同じドメイン)に入れておかないとハマります(ハマりました)

Eloquentの多対多関係でattach/detach/sync時にイベント発火

f:id:n06utk:20190308164355p:plain

GLUGの小林です。初めてなので軽めのネタで。 弊社の子会社が運営しているEATASというメディアで、記事にカテゴリがついており、元のカテゴリ部分は以下でした。

class Article extends Model
{
    public function category()
    {
        return $this->belongsTo('App\Models\Category');
    }

以下新しい要件が出てきました。

  • カテゴリをネストしたい
  • カテゴリを複数をつけたい。
    • パンくずは流入経路を優先
    • それ以外は優先順位に従って
  • カテゴリページが欲しい

そこで以下に変更

class Article extends Model
{
    public function categories()
    {
        return $this->belongsToMany('App\Models\Category')->withPivot('priority');
    }
class Category extends Model
{
    public function articles()
    {
        return $this->belongsToMany('App\Models\Article');
    }
    public function parentCategory()
    {
        return $this->belongsTo(self::class,'parent_category_id');
    }

    public function childCategories()
    {
        return $this->hasMany(self::class,'parent_category_id');
    }

ここまでは問題なかったのですが、以下の新しい要件でちょっと問題が

  • NEWSカテゴリについてはトップページに表示しない

今のままだとSQLで表現するとこうなるので、ちょっとパフォーマンス的に厳しい

SELECT * 
FROM articles
    INNER JOIN article_category ON articles.id = article_category.areticle_id
WHERE
    article_category.category_id = 1
    AND
    他の条件

なので、articlesテーブルにis_newsカラム作ってそれで絞り込もう、と考えましたが、

$article->categories()->attach(Category::NEWS_CATEGORY_ID);

のときにいちいち

$article->is_news = 1;
$article->save();

するわけにもいかないので、attach/detach/syncのときにイベント発生しないかあと考えました。 割とやりたい人いそうな気がしたのですが、ぐぐってもやり方出てこず。意外といないものなのですね。 ということで、

https://github.com/laravel/framework/blob/5.5/src/Illuminate/Database/Eloquent/Relations/Concerns/InteractsWithPivotTable.php#L210

public function attach($id, array $attributes = [], $touch = true)
{
    // Here we will insert the attachment records into the pivot table. Once we have
    // inserted the records, we will touch the relationships if necessary and the
    // function will return. We can parse the IDs before inserting the records.
    $this->newPivotStatement()->insert($this->formatAttachRecords(
        $this->parseIds($id), $attributes
    ));
    if ($touch) {
        $this->touchIfTouching();
    }
}

https://github.com/laravel/framework/blob/5.5/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php#L677

public function touchIfTouching()
{
    if ($this->touchingParent()) {
        $this->getParent()->touch();
    }
    if ($this->getParent()->touches($this->relationName)) {
        $this->touch();
    }
}

touch()呼んでるみたいなので、以下対応でカテゴリ追加・削除時にis_newsが変わるようになりました。

class Article
{
    public function touch(){
        $article = $this->fresh();
        if ($article->categories != null && $article->categories->find(Category::NEWS_CATEGORY_ID)){
            $article->is_news = 1;
        }else{
            $article->is_news = 0;
        }
        if ($this->is_news == $article->is_news){
            parent::touch();
        }else{
            $article->save();
        }
    }
}
class Category
{
    protected $touches = ['articles'];
}