Azure Web PubSubのnegotiateをPHPで実装してみる

最近書いているとあるサービスでリアルタイム更新をしたいと思い、Azure SignalR ServiceAzure Web PubSubを試してます。

クイックスタートを参考にすれば、Azure Functionsで割と簡単に動作を試せます。

今回のサービスのバックエンドAPIPHPで書かれているため、 negotiate の処理をPHPAPIで実施したいと考えていたのですが、残念ながらAzure PubSubのPHP SDKは現時点で提供されていません。(多分この先も提供はされなそう... (;_; )

ということで、Azure Web PubSub service client library for JavaScript を参考に、negotiate が何をしているか調べてみました。

調べた結果、negotiate のレスポンスは以下のような内容になっていました。

{
  baseUrl: 'wss://[PubSubName].webpubsub.azure.com/client/hubs/[hubname]',
  token: 'JWT token',
  url: 'wss://[PubSubName].webpubsub.azure.com/client/hubs/[hubname]?access_token=[JWT Token]'
}

ふむふむ、JWTで認証しているよう...。生成されるJWTの中身は以下の様な内容でした。

{
  "header": {
    "typ": "JWT",
    "alg": "HS256"
  },
  "claims": {
    "iat": 1623618349,
    "exp": 1623621949,
    "aud": "https://[PubSubName].webpubsub.azure.com/client/hubs/[hubname]"
  },
  "signature": "sigunature...",
  "raw": "eyJ0eXAiOiJ..."
}

要は、接続文字列からこのJWTを生成できれば良さそうです。ということで、gree/joseを使ってざくっと書いてみたのが以下。

<?php
declare(strict_types=1);

class PubSubToken {
    protected $endpoint;
    protected $wssEndpoint;
    protected $accesskey;
    protected $version;
    protected $alg = 'HS256';

    public function __construct($connectionString)
    {
        $params = explode(';', $connectionString);
        foreach ($params as $param) {
            list($k, $v) = explode('=', $param, 2);

            $this->{strtolower($k)} = $v;
        }

        $this->wssEndpoint = preg_replace('/(http)(s?:\/\/)/i', 'ws$2', $this->endpoint);

        if ($this->endpoint === null || $this->accesskey === null || $this->version === null || $this->wssEndpoint === null) {
            throw new \Exception('Parameter error');            
        }
    }

    public function getAuthenticationToken(string $hub, string $userId = null, int $ttl = 3600): array
    {
        $now = time();

        $payload = [
            'iat' => $now,
            'exp' => $now + $ttl,
            'aud' => "{$this->endpoint}/client/hubs/{$hub}",
        ];
        if ($userId !== null) {
            $payload['sub'] = $userId;
        }

        $jwt = new \JOSE_JWT($payload);
        $jwt->header['alg'] = $this->alg;
        $jwt->header['typ'] = 'JWT';
        $jwt->sign($this->accesskey, $this->alg);
        
        $jws = new \JOSE_JWS($jwt);
        $jws = $jws->sign($this->accesskey, $this->alg);
        $token = $jws->toString();
        
        return [
            'baseUrl' => "{$this->wssEndpoint}/client/hubs/{$hub}",
            'token' => $token,
            'url' => "{$this->wssEndpoint}/client/hubs/{$hub}?access_token={$token}",
        ];
    }
}


$pubsub = new PubSubToken('Azure WebPubSubの接続文字列');
$token = $pubsub->getAuthenticationToken('test');

このtoken を使って無事subscribeできました。 ということで、このtokenをAPIで返してあげれば、クライアント側でsubscribeできそうです。

コードは、gistにも上げておきました。

CakePHP4用のOpenApi bake theme pluginを公開しました

最近は久々にガッツリPHPのコードを書いているわたなべです。

このところ、仕事でもプライベートでもPHPAPIを書いて、Next.jsでフロントのWebアプリを書くことがほとんどです。

この場合API仕様は以前ブログにも書きましたが、swagger-phpアノテーションで記述して、Swagger-UIで参照できる様にしています。

kaz29.hatenablog.com

Swagger-UI と swagger-php

最近は使われている方も多いと思いますが、簡単に説明すると、EntityとControllerに以下の様なアノテーションを記述します。

Entity/Article.php

/**
 * Article Entity
 *
 * @OA\Schema(
 *      schema="Article",
 *      title="",
 *      description="Article entity",
 *       @OA\Property(
 *           property="id",
 *           type="integer",
 *           format="int32",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="user_id",
 *           type="integer",
 *           format="int32",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="title",
 *           type="string",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="slug",
 *           type="string",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="body",
 *           type="string",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="published",
 *           type="boolean",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="created",
 *           type="string",
 *           format="datetime",
 *           description="",
 *       ),
 *       @OA\Property(
 *           property="modified",
 *           type="string",
 *           format="datetime",
 *           description="",
 *       ),
 * )

Controller/Api/ArticlesController.php

    /**
     * Index method
     *
     * @OA\Get(
     *     path="/api/articles.json",
     *     summary="Articles index",
     *     description="Articles index",
     *     @OA\Parameter(
     *         name="page",
     *         in="query",
     *         required=false,
     *         @OA\Schema(
     *             type="number",
     *         ),
     *         description=""
     *     ),
     *     @OA\Parameter(
     *         name="limit",
     *         in="query",
     *         required=false,
     *         @OA\Schema(
     *             type="number",
     *         ),
     *         description=""
     *     ),
     *     @OA\Response(
     *         response=200,
     *         description="successful operation",
     *         @OA\JsonContent(
     *              @OA\Property(
     *                  property="success",
     *                  type="boolean",
     *                  default=true,
     *              ),
     *              @OA\Property(
     *                  property="data",
     *                  type="array",
     *                  @OA\Items(
     *                      allOf={
     *                          @OA\Schema(ref="#/components/schemas/Article"),
     *                          @OA\Schema(
     *                              @OA\Property(
     *                                  property="user",
     *                                  ref="#/components/schemas/User",
     *                                  description="User Entity",
     *                              ),
     *                          ),
     *                      },
     *                  ),
     *              ),
     *              @OA\Property(
     *                  property="pagination",
     *                  ref="#/components/schemas/Pagination",
     *              ),
     *         ),
     *     ),
     * )
     * @return \Psr\Http\Message\ResponseInterface
     */

これらのコードを、以下の様なコマンドでswagger-phpを使用してビルドします。

swagger.jsonをビルドするコマンド

#!/usr/local/bin/php -q
<?php
include_once __DIR__.'/../autoload.php';

$app_path = '.';
$openapi = \OpenApi\scan(
    $app_path, 
    [
        'exclude' => [
            'vendor', 
            'tmp', 
            'logs', 
            'tests', 
            'webroot',
        ]
    ]
);
file_put_contents(dirname($app_path).'/docs/swagger.json', $openapi->toJson());

ビルドが成功すると、swagger.jsonが作成されるのでこれをSwagger-UIで読み込むと、以下の様にドキュメントを見ることはもちろん、Swagger-UI上からAPIを呼び出すこともできます。

f:id:kaz_29:20210306062925p:plain
Swagger-UI サンプル

これすごい便利なのでおすすめなのですが、記述するのが結構面倒なのと、記述方法にいろいろ癖があるので書くたびに毎回試行錯誤することになったりします。

前からなんとかしたいなぁと思っていたのですが、現在とあるリプレース案件で大量にAPIを作成する予定で、この作業を少しでも効率化したいと思いCakePHPのbakeテンプレートを書きました。

bakeテンプレートを自作すると、CakePHPを使っている方であればご存知のbakeコマンドで生成される雛形のソースコードをカスタマイズすることができます。

github.com

OpenApiTheme plugin

OpenApiTheme pluginでは、APIを作成する際には定番のfriends od cake CRUD Pluginを使うことを前提で作成しました。

今回は、以下の2つのbakeコマンドを追加しています。

  • open_api_model - モデルのbake時にEntityにOpenApiのSchema定義を自動生成する
  • open_api_controller - コントローラのbake時にCRUDAPI定義を自動生成する

実際には以下のような感じでbakeすることができます。

// モデルのbake
$ bin/cake bake open_api_model Articles

// コントローラのbake
$ bin/cake bake open_api_controller Articles --prefix Api

現在のバージョンでは、EntityのSchameにはアソシエーション先のプロパティはあえて含めないようになっています。 定義すると便利は便利なのですが、実際の利用シーンではどのアソシエーションをContainさせるかはAPIによって変わるケースが多いのでEntity側で定義してしまうと使いにくいことが多いです。 この為、OpenApiTheme pluginではEntityのSchameにはアソシエーションを含めずにControllerのAPI定義の方で複数のSchemaを合成(?)するようにしています。

公式のbakeでは、index actionではBelongsToのみcontainし、view acrionでは全てのアソシエーションをcontainするコードが生成されるので、それに倣って以下のようなレスポンスを定義しています。

index action のレスポンス定義サンプル

     *     @OA\Response(
     *         response=200,
     *         description="successful operation",
     *         @OA\JsonContent(
     *              @OA\Property(
     *                  property="success",
     *                  type="boolean",
     *                  default=true,
     *              ),
     *              @OA\Property(
     *                  property="data",
     *                  type="array",
     *                  @OA\Items(
     *                      allOf={
     *                          @OA\Schema(ref="#/components/schemas/Article"),
     *                          @OA\Schema(
     *                              @OA\Property(
     *                                  property="user",
     *                                  ref="#/components/schemas/User",
     *                                  description="User Entity",
     *                              ),
     *                          ),
     *                      },
     *                  ),
     *              ),
     *              @OA\Property(
     *                  property="pagination",
     *                  ref="#/components/schemas/Pagination",
     *              ),
     *         ),
     *     ),

view action のレスポンス定義サンプル

     *     @OA\Response(
     *         response=200,
     *         description="successful operation",
     *         @OA\JsonContent(
     *              @OA\Property(
     *                  property="success",
     *                  type="boolean",
     *                  default=true,
     *              ),
     *              @OA\Property(
     *                  property="data",
     *                  allOf={
     *                      @OA\Schema(ref="#/components/schemas/Article"),
     *                      @OA\Schema(
     *                          @OA\Property(
     *                              property="user",
     *                              ref="#/components/schemas/User",
     *                              description="User Entity",
     *                          ),
     *                          @OA\Property(
     *                              property="tags",
     *                              type="array",
     *                              @OA\Items(ref="#/components/schemas/Tag"),
     *                              description="Tag Entities",
     *                          ),
     *                      ),
     *                  },
     *              ),
     *         ),
     *     ),

bakeしたままでは実際に作成したいAPIにマッチしないケースも多々あるとは思いますが、これを元に実際のAPI定義を作成することで、記述の手間をだいぶ軽減できると思います。

以下で実際にOpenApiTheme pluginで生成したAPI仕様を確認できますので、ぜひ一度見てみてください。

petstore.swagger.io

開発環境でのSwagger-UIの利用

普段利用している開発環境では、開発中のAPIをSwagger-UIから直接叩けるように、開発環境用のdocker-compose.ymlにSwagger-UIのコンテナも含めるようにしています。

docker hub に上がっている、公式のDockerコンテナを利用しています。

まとめ

現在進行中の実案件にもOpenApiTheme pluginを導入して使い始めていますが、仕様書作成がだいぶ捗ります。

随時フィードバックして改善していくつもりですが、ぜひ使っていただいて、要望などあればIssueなりPRなりいただければと思います。

github.com

ExportしたApp Service 証明書にパスフレーズをつける

管理を手伝っている、友人のサイトでApp Service 証明書を移行する必要があってちょっとハマったので備忘録。

App Service 証明書のExport

CloudShellから以下のコマンドで、Exportできます。

$ az keyvault secret download \
    --file appservicecertificate.pfx \
    --vault-name <key-valut-name> \
    --name <保存先のシークレット名> \
    --encoding base64

vault-name には証明書作成時に設定した、キーコンテナ名を指定します。

f:id:kaz_29:20200627162614p:plain
キーコンテナ

name には証明書が保存されている、シークレットの名前を指定します。

f:id:kaz_29:20200627162651p:plain
kay-vault

このコマンドでExportした証明書には空のパスフレーズで生成されます。

このあたりの詳細は公式ドキュメントにも解説があります。

証明書のImport

で、この証明書をアップロードしようとするとアップロード画面ではパスフレーズが必須になっています。

f:id:kaz_29:20200627163303p:plain
Import Error

pfxファイルにパスワードをつける方法を探すのに少し手間取りましたが、以下で大丈夫でした。

# 一旦pem形式に変換
openssl pkcs12 -in appservicecertificate.pfx -out example.com.pem -nodes

# 再度pfx形式に変換、この際にパスフレーズの入力プロンプトが表示されます。
openssl pkcs12 -export -out example.com.pfx -in example.com.pem

ということで無事移行できました。

CakePHP4でRoutingのテスト

最近React+TypeScriptばかりで、CakePHPのコードはあまり書いていないわたなべです。

Routingのテスト

CakePHP1の頃の新原さんのブログ(なんと2009-05-25の記事、11年前!?)でも書かれているように、routes.phpの設定変更は、思わぬバグを出す可能性があるので、UnitTestでの動作確認は必須だと思っています。

CakePHP3までは、以下のような感じでテストできていましたが、CakePHP4でRoutingがmiddleware化した影響などでそのままでは動作しません。

<?php
declare(strict_types=1);
...
use Cake\Network\Request;
...

    public function testRouting($request, $expected)
    {
        $request = new Request([
            'url'         => '/api/articles.json',
            'environment' => ['REQUEST_METHOD' => 'GET']
        ]),
        $result = Router::parseRequest($request);
        $expected = [
            'pass'       => [],
            'plugin'     => null,
            'controller' => 'Articles',
            'prefix'     => 'api',
            '_ext'       => 'json',
            'action'        => 'index',
        ];
        $this->assertEquals($expected, $result);
    }
...

CakePHP4でのRoutingのテスト

最初書いたテストコードは以下のような感じ。

<?php
declare(strict_types=1);
...
use Cake\Http\ServerRequest;
...

    public function testRouting()
    {
        $request = new ServerRequest([
            'url'         => '/api/users/login.json',
            'environment' => ['REQUEST_METHOD' => 'POST']
        ]);
        $result = Router::parseRequest($request);
        $expected = [
            'pass' => [],
            'plugin' => null,
            'controller' => 'Users',
            'prefix' => 'Api',
            '_ext' => 'json',
            'action' => 'login',
            '_matchedRoute' => '/api/users/login',
            '_method' => ['POST'],
            '_middleware' => ['bodies']
        ];
        $this->assertEquals($expected, $result);
    }
...

これを実行すると、以下のようなエラーになってしまいます。もちろんroutes.phpには正しく設定されていて、Web経由でのアクセスにも問題ありません。

Cake\Routing\Exception\MissingRouteException: A route matching "/api/users/login.json" could not be found.

色々調べてみたところ、Routerがmiddleware化されたため、Applicationにmiddlewareが追加された時点でrouterを初期化するようになったようです。 このため、routingテーブルが空の状態になっていました。

解決方法

ということで、Routingテーブルを初期化する処理をtraitにしました。

<?php
declare(strict_types=1);

namespace App\Test\Utility;

use Cake\Routing\Router;

trait RoutingTestTrait {
    protected function initializeRoute()
    {
        Router::reload();
        $routes = Router::createRouteBuilder('/');
        require CONFIG . 'routes.php';
    }
}

前出のコードをこのtraitを使うようにこんな感じに修正するとroutingのテストができます。

<?php
declare(strict_types=1);
...
use Cake\Http\ServerRequest;
use App\Test\Utility\RoutingTestTrait;    // 追加
...
class UsersControllerTest extends TestCase
{
    use IntegrationTestTrait;
    use RoutingTestTrait;    // 追加
...

    public function testRouting()
    {
        $this->initializeRoute();    // 追加

        $request = new ServerRequest([
            'url'         => '/api/users/login.json',
            'environment' => ['REQUEST_METHOD' => 'POST']
        ]);
        $result = Router::parseRequest($request);
        $expected = [
            'pass' => [],
            'plugin' => null,
            'controller' => 'Users',
            'prefix' => 'Api',
            '_ext' => 'json',
            'action' => 'login',
            '_matchedRoute' => '/api/users/login',
            '_method' => ['POST'],
            '_middleware' => ['bodies']
        ];
        $this->assertEquals($expected, $result);
    }
...

まとめ

routingのテストを書いていたことで、routing変更時に何度も救われたことがあるので是非テストを書くことをお勧めします。 CakePHP4は、色々と改善されていい感じに進化しているので今後も使っていこうと思います。

以上、小ネタでした。

2020/6/25追記

RoutingのMiddleware化は、CakePHP3で実施されていました。具体的に何が理由でRoutingテーブルが初期化されていないかは不明。後日調べてみてわかれば追記します。

Github ActionsでMultiContainerなAzure WebApp for Containersをデプロイする

友達に頼まれて、@kunyamiさんのスライド 堅牢&運用楽々な WordPress を Azure App Service でを参考に、WordpressをMultiContainerなAzure WebApp for Containersで構築してたのですが、いくつかハマったのでメモとして残します。

基本的な設定は、@kunyamiさんの資料の通りに進めたのですが、今回は、一部Wordpress管理外の静的なページを含むため、独自コンテナをビルドする必要があったので、GitHub Actionsを使用して、コンテナイメージのビルド、Azure WebApp for Containersへのデプロイをすることにしました。

リポジトリは以下の様な構成になっています。

  • Dockerfile - デプロイ用Dockerfile
  • docker-compose.yml - デプロイ用 docker-composeファイル
  • 静的ファイル用のディレクト
  • その他設定ファイルなど

ベースイメージは、wordpress公式のものを使用して、静的なページは、ビルド時にコピーする様にしました

Dockerfileサンプル

FROM wordpress:latest

COPY static_webpages /var/www/html/static_webpages

事前準備

Azure WebAppデプロイ用クレデンシャルの作成

いつものですね。以下のコマンドを実行すると表示されるJSONがクレデンシャルです。

$ az ad sp create-for-rbac --name "https://[WebAppの名前].azurewebsites.net" --role contributor \
    --scopes /subscriptions/[サブスクリプションID]/resourceGroups/wetest \
    --sdk-auth

秘匿情報の設定

Github Actions内で利用する秘匿情報は、Githubの対象リポジトリ内の Settings -> Secrets で設定します。

f:id:kaz_29:20200504130051p:plain
github Secrets

今回は、ビルドしたイメージをDocker Hubに保存するので、Docker Hubアカウントパスワードと先ほど作成した、クレデンシャルをSecretsに追加します。

  • AZURE_CREDENTIALS - Azureクレデンシャル
  • DOCKER_PASSWORD - Docker Hubのパスワード

Github Actionsの設定

Github Actionsの設定は、リポジトリ内の .github/workfloes ディレクトリ以下に設定ファイルを配置します。今回作成したのは以下の様なファイルです。

name: Build And Deploy

env:
  DOCKER_USERNAME: [Docker Hubのユーザ名]
  DOCKER_IMAGENAME: [docker image名]
  AZURE_WEBAPP_NAME: [webappの名称]

on: 
  push:
    branches:
      - master
  
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v2
    
      - name: Azure authentication
        uses: azure/login@v1
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS  }}

      - name: Build & push Docker image
        uses: docker/build-push-action@v1
        with:
          image: ${{ env.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGENAME }}
          repository: ${{ env.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGENAME }}
          dockerfile: Dockerfile
          username: ${{ env.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
          tags: ${{ github.sha }} 

      - name: 'Deploy to Azure Web App for Container'   
        uses: azure/webapps-deploy@v2
        with: 
          images: ${{ env.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGENAME }}:${{ github.sha }}
          app-name: ${{ env.AZURE_WEBAPP_NAME }}
          configuration-file: docker-compose-webapp.yml

yamlファイルを見れば一目瞭然ですが、今回作成したJobには以下の4つのStepが定義されています。

  • Check out code - コードをチェックアウト
  • Azure authentication - Azureの認証処理
  • Build & push Docker image - docker imageのビルドとプッシュ
  • Deploy to Azure Web App for Container - Azure WebApp for Containersへのデプロイ

docker imageのビルドとプッシュ

docker imageのビルドとプッシュには、Docker公式のGitHub Actionを使っています。 公式サイトに詳しい解説があるので、そちらを参照してください。

今回は、ビルト時のタグにgit hashを使用しているので以下の様に指定しています。

          tags: ${{ github.sha }} 

Azureの認証とAzure WebApp for Containersへのデプロイ

Azure WebApp for Containersへのデプロイは、Azure 公式のGitHub Actionを使っています。 こちらも、公式サイトに詳しい解説があるので、そちらを参照してください。

MultiContainerなAzure WebApp for Containersのデプロイ方法

今回は、MultiContainerなAzure WebApp for Containersを使用しているのですが、公式ドキュメントにはMultiContainerの場合のデプロイ方法は書かれていないため、GitHub Actionのソースコードを確認したところ、以下の様に設定情報を初期化しているのを発見しました。

...
class ActionParameters {
    constructor(endpoint) {
        this._publishProfileContent = core.getInput('publish-profile');
        this._appName = core.getInput('app-name');
        this._slotName = core.getInput('slot-name');
        this._packageInput = core.getInput('package');
        this._images = core.getInput('images');
        this._multiContainerConfigFile = core.getInput('configuration-file');
        this._startupCommand = core.getInput('startup-command');
        this._endpoint = endpoint;
    }
...

ということで、MultiContainer時のdocker-composeファイルは configuration-file で指定できました。最終的なStepの設定が下記です。

      - name: 'Deploy to Azure Web App for Container'   
        uses: azure/webapps-deploy@v2
        with: 
          images: ${{ env.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGENAME }}:${{ github.sha }}
          app-name: ${{ env.AZURE_WEBAPP_NAME }}
          configuration-file: docker-compose-webapp.yml

docker-compose.yml 内でイメージ名を指定していますが、 images に今回ビルドしたタグを含むイメージ名を指定しています。 これもドキュメントには記載がないのですが、試行錯誤している最中に以下のエラーが発生してエラーメッセージの内容から発見したのですが、MultiContainer時にimage名を指定すると、docker-compose内のイメージ名を上書きしてくれますので、自前でdocker-composeファイルを書き換える様な処理は書かなくても大丈夫でした。

試行錯誤中に発生したエラーメッセージ

Deployment Failed with Error: Error: For single-container, just specify a valid image name. For multi-container specifying a Docker-Compose file is mandatory and specifying image names is optional. Provide image names if the tags in Docker-Compose file need to be substituted.

また、slot-name でデプロイ対象のスロット名も指定できる様なので、B/Gデプロイもできそうですね。

まとめ

マーケットプレイスで公開されているGitHub Actionはソースコードも公開されているので、ドキュメントに記載のない内容はソースコードにあたるとすんなり解決できることも多そうです。 GitHub Actionsかなり便利なので、これからも積極的に使っていきたいです。

PCOVでコードカバレッジ取得を高速化

この記事はCakePHP Advent Calendar 2019の21日目の記事です

つい先日、ついにCakePHP 4.0がリリースされましたが、CakePHP 4.0で利用しているテスティングフレームワークはもちろんPHPUnitです。CakePHP3では、PHPUnit 6.0系を使っていましたが8.5.0に更新されています。

PHPUnitで、コードカバレッジを取得するにはXdebugを使うのが定番ですが、PHPUnit8系ではXdebug以外にPCOVを利用することができます。

PCOVは、今年(2019年)リリースされたばかりのコードカバレッジドライバーで、高速かつ省メモリで動作することが特徴です。

ということで、今回は実際にどれくらい高速化できるのかを簡単に調べてみました。

計測した環境

当初、CakePHP3で作ったサンプルアプリをCakePHP4化して試そうと思っていたのですが、いろいろな問題があり断念しCakePHP4本体のテストの一部(CakePHPのUnitTestすべてを実行するとかなり時間がかかるので、ORMのテストのみ)を利用して計測しました。

計測した環境は、macOS上のDocker Containerで、PHP公式で公開されているこちらをベースに若干拡張などを追加したものを利用しています。

それぞれのバージョンは以下のとおりです。

# php -v
PHP 7.3.10 (cli) (built: Oct 10 2019 21:12:52) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.3.10, Copyright (c) 1998-2018 Zend Technologies
    with Zend OPcache v7.3.10, Copyright (c) 1999-2018, by Zend Technologies
# pecl list 
Installed packages, channel pecl.php.net:
=========================================
Package Version State
apcu    5.1.17  stable
pcov    1.0.6   stable
redis   5.0.2   stable
xdebug  2.9.0   stable    

コードカバレッジなし

まずは、Xdebugを無効にしてコードカバレッジなしでの計測結果がこちら。

# ./vendor/bin/phpunit tests/TestCase/ORM/
PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

.............................................................   61 / 1331 (  4%)
.............................................................  122 / 1331 (  9%)
...
..................................................            1331 / 1331 (100%)

Time: 7.04 seconds, Memory: 36.00 MB

OK, but incomplete, skipped, or risky tests!
Tests: 1331, Assertions: 3917, Skipped: 3, Incomplete: 2.

Xdebug

Xdebugでコードカバレッジを取得した場合がこちら。

# ./vendor/bin/phpunit tests/TestCase/ORM/
PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

.............................................................   61 / 1331 (  4%)
.............................................................  122 / 1331 (  9%)
...
..................................................            1331 / 1331 (100%)

Time: 3.68 minutes, Memory: 126.00 MB

OK, but incomplete, skipped, or risky tests!
Tests: 1331, Assertions: 3917, Skipped: 3, Incomplete: 2.

Generating code coverage report in Clover XML format ... done [1.68 minutes]

Generating code coverage report in HTML format ... done [1.8 minutes]

やはりかなり遅いですね。約31倍です。

PCOV

PCOVでの計測結果がこちら。

# php -d pcov.enabled=1 ./vendor/bin/phpunit tests/TestCase/ORM/
PHPUnit 8.5.0 by Sebastian Bergmann and contributors.

.............................................................   61 / 1331 (  4%)
.............................................................  122 / 1331 (  9%)
...
..................................................            1331 / 1331 (100%)

Time: 24.55 seconds, Memory: 126.00 MB

OK, but incomplete, skipped, or risky tests!
Tests: 1331, Assertions: 3917, Skipped: 3, Incomplete: 2.

Generating code coverage report in Clover XML format ... done [10.48 seconds]

Generating code coverage report in HTML format ... done [46.87 seconds]

なんと、Xdebugを使った場合と比べると約10分の1の時間で完了しています!カバレッジを計測していない場合と比べると、約3.5倍。Xdebugと比べると許容範囲かなと思います。しかも、メモリ使用量もXdebugと同じという結果でした

まとめ

まとめたものがこちら

実行時間 使用メモリ
カバレッジなし 7.04 s 36.00 MB
Xdebug 220.80 s 126.00 MB
PCOV 24.55 s 126.00 MB

実際のコードで実行時間に差は出るとは思いますが、かなりの高速化が期待できそうです。

CakePHP4へのアップデートはプラグインやライブラリの対応など、まだ色々とハードルはありますが、PHPUnitのバージョンアップはかなり魅力的だと思っています。PHPUnit自体の変更については全く追えていないので今後キャッチアップしていきたいと思っています。

参考

PHPでSAS tokenを使ってAzure Blob Storageにファイルをアップロードする

現在開発中の案件で、Shared Access Signature(SAS)を使ってBlobにデータを上げる必要があって、若干ハマったのでメモ。

先日プレビュー版がリリースされた user delegation SASってのもありますが、今回はストレージアカウントのキーを使う感じで。user delegation SASに関しては、亀淵さんのブログが参考になると思います。

今回使用するバックエンドはPHPなので、SASの作成には、microsoft/azure-storage-blobを使うことにします。

ざっとコード書くてみたのがこんな感じ...

<?php
...
$accountName = getenv('STORAGE_ACCOUNT_NAME');
$accountKey = getenv('STORAGE_ACCOUNT_KEY');
$containerName = getenv('CONTAINER_NAME');

$helper = new BlobSharedAccessSignatureHelper($accountName, $accountKey);
$sas = $helper->generateBlobServiceSharedAccessSignatureToken(
    Resources::RESOURCE_TYPE_BLOB,
    "{$containerName}/composer.json",
    'w',
    Chronos::now(new \DateTimeZone('UTC'))->addSeconds(60)->format(DATE_ATOM),
    Chronos::now(new \DateTimeZone('UTC'))->subSeconds(10)->format(DATE_ATOM),
    '', // リクエスト元のIPアドレス
    'https'
);

echo "sas: {$sas}\n";

これで試すと、 403 Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature. こんなエラーがでてしまいます。

散々悩んだ挙げ句、ブラウザーで JavaScript と HTML を使用して BLOB をアップロード、一覧表示、および削除するを実際に動かして、リクエスト内容などを確認した結果ようやく原因がわかりました。

PHPの日付フォーマット DATE_ISO8601 がiso 8601準拠ではないので、 DATE_ATOM を使っていたのですが、TimeZoneの書式が違うことが原因でした。

$ cat date.php
<?php
$date = new \DateTime('now', new \DateTimeZone('UTC'));

echo $date->format(DATE_ISO8601)."\n";
echo $date->format(DATE_ATOM)."\n";
echo $date->format('Y-m-d\TH:i:s\Z')."\n";
?>

$ php date.php
2019-11-01T01:46:38+0000
2019-11-01T01:46:38+00:00
2019-11-01T01:46:38Z     // -> この形式でないとだめ

'Y-m-d\TH:i:s\Z' を使うことで正常にアップロードできました。めでたしめでたし。

最終的にできあがったサンプルがこちら

<?php
require './vendor/autoload.php';

use Cake\Chronos\Chronos;
use GuzzleHttp\Client;
use MicrosoftAzure\Storage\Blob\BlobSharedAccessSignatureHelper;
use MicrosoftAzure\Storage\Blob\Internal\BlobResources;
use MicrosoftAzure\Storage\Common\Internal\Resources;

$accountName = getenv('STORAGE_ACCOUNT_NAME');
$accountKey = getenv('STORAGE_ACCOUNT_KEY');
$containerName = getenv('CONTAINER_NAME');

$helper = new BlobSharedAccessSignatureHelper($accountName, $accountKey);
$sas = $helper->generateBlobServiceSharedAccessSignatureToken(
    Resources::RESOURCE_TYPE_BLOB,
    "{$containerName}/composer.json",
    'w',
    Chronos::now(new \DateTimeZone('UTC'))->addSeconds(60)->format('Y-m-d\TH:i:s\Z'),
    Chronos::now(new \DateTimeZone('UTC'))->subSeconds(10)->format('Y-m-d\TH:i:s\Z'),
    '', // リクエスト元のIPアドレス
    'https'
);

echo "sas: {$sas}\n";

try {
    $client = new Client(['base_uri' => sprintf('https://%s.%s/%s/', $accountName, Resources::BLOB_BASE_DNS_NAME, $containerName)]);

    $response = $client->put('composer.json', [
        'body' => file_get_contents('./composer.json'),
        'headers' => [
            'x-ms-version' => BlobResources::STORAGE_API_LATEST_VERSION,
            'x-ms-blob-type' => 'BlockBlob',
        ],
        'query' => $sas,
    ]);

    echo sprintf("StatusCode: %d\n", $response->getStatusCode());
} catch(\Exception $e) {
    echo sprintf("StatusCode: %d\n", $e->getCode());
    echo $e->getMessage() . "\n";
}

サンプルコードは、こちらにもあげてあります。