TSとPHPでFormDataの型を共有する

目次

    背景

    https://twitter.com/did0es/status/1401471990259613697

    サーバーサイドで Node.js が動かない環境(某レンタルサーバー)でアプリケーションを実装する必要が生じたため、渋々 PHP を書いていたんですが、クライアントサイドは React で書きたいという我儘が出てきてしまい、その欲望に従った結果型を共有出来ないゆえの地獄を見たのでその脱出を図りました。

    概観

    筆者は FormData を扱う際に以下のような型の拡張を書いたd.tsファイルを使うことがあります。

    interface FormData {
      append(
        name: 'foo' | 'piyo' | 'fuga',
        value: string | Blob,
        filename?: string
      ): void;
    }
    

    append の name を文字列リテラルで縛っています。サーバーサイドも TypeScript の場合この型定義を共有することで、クライアントサイドから送信される FormData の name がどのようなものか簡単にわかるようになっています。また VSCode などでは補完機能により、タイポを防いで無駄な確認の手間を省くという意味合いもあります。

    ただサーバーサイドが PHP の場合(TypeScript 以外の言語でもそうですが)、d.ts ファイルを共有出来ないため、何かしら別の手段を用意する必要があります。

    今回は PHP なので、PHPDoc Types と PHPStan の静的解析による型チェックを活用していきます。

    用意するもの

    クライアントサイドは TypeScript で書かれている前提で、他にサーバーサイドでは以下のものを用意します。

    どちらも composer でインストール出来ます。

    実装

    FormDataを拡張した型を取り出してJSONにする

    FormData を拡張した型定義は以下のようになっています。

    formdata.d.ts

    
    interface FormData {
      append(
        name: 'audio_data' | 'user_dir_name' | 'data_map' | 'result_data',
        value: string | Blob,
        filename?: 'data_map' | 'listen_result'
      ): void;
    }
    
    

    上のコードから name の Parameters を取り出して JSON にします。数行程度で実装出来ます。

    types-bridge/src/core.ts

    import * as path from 'path';
    import * as ts from 'typescript';
    import { readFile, writeFile } from 'fs/promises';
    
    type Properties = { [key: string]: string };
    
    let properties: Properties = {};
    const visit = (node: ts.Node | ts.Node[], sourceFile: ts.SourceFile) => {
      if (Array.isArray(node)) {
        node.forEach((n) => {
          visit(n, sourceFile);
        });
      } else {
        if (node.kind === ts.SyntaxKind.Parameter) {
          const texts = node.getText(sourceFile).split(': ');
          const key = texts[0];
          const value = texts[1];
          properties[key] = value;
          return;
        } else {
          visit(node.getChildren(sourceFile), sourceFile);
        }
      }
    };
    
    const extract = async (file: string) => {
      const data = await readFile(path.resolve(`../client/src/${file}`));
      const sourceFile = ts.createSourceFile(
        file,
        data.toString(),
        ts.ScriptTarget.ES2015
      );
      visit(sourceFile.getChildren(sourceFile), sourceFile);
    
      await writeFile(
        process.cwd() + '/json/formdata.json',
        JSON.stringify(properties, null, '  ')
      );
    
      properties = {};
    };
    
    extract(process.argv[2]);
    

    visit関数によって型定義の TypeScript AST を Traverse し、該当する SyntaxKind を取り出してオブジェクトに格納しています。オブジェクトは後に PHP側 の実装と共有するため JSON として書き出しています。別に JSON じゃなくても PHP 側でも読み込めるファイルであればここは何でもいいです。

    PHP側でJSONのデータをPHPDoc Typesに変換

    POST された FormData は PHP 側で $_FILES から受け取ります。定義済み変数の型を拡張する方法がわからなかったので以下のようなクラスを定義しました。(こうすれば拡張できていいよ!とかあれば教えて下さい)

    server/src/shared/http_variables.php

    
    <?php
    
    declare (strict_types=1);
    error_reporting(E_ALL);
    
    class HttpVariables
    {
        // ~~
    
        public function files($key)
        {
            return $_FILES[$key];
        }
    }
    
    

    この状態で PHPStan を実行すると落ちます。(PHPStan の level は  max で実行しています)

    > ./vendor/bin/phpstan analyze -c phpstan.neon
     9/9 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
    
     ------ ------------------------------------------------------------------------------
      Line   shared/http_variables.php
     ------ ------------------------------------------------------------------------------
     # ~~
    
      18     Method HttpVariables::files() has no return typehint specified.
      18     Method HttpVariables::files() has parameter $key with no typehint specified.
     ------ ------------------------------------------------------------------------------
    
     [ERROR] Found 6 errors
    
    Script ./vendor/bin/phpstan analyze -c phpstan.neon handling the phpstan event returned with error code 1
    

    この PHPStan による型チェックが通ることをゴールに進めます。

    files メソッドの @return の型はとりあえず以下のように設定しておきます。

    /**
     * @phpstan-type Files array{tmp_name: string, name: string}
     */
    class HttpVariables
    {
        // ~~
    
        /**
         * @return Files
         */
        public function files($key)
        {
            return $_FILES[$key];
        }
    }
    

    @param の方は先程用意した JSON を元に作成します。以下が実際に実装したコードです。

    server/scripts/types-bridge-client.php

    <?php
      declare(strict_types=1);
    
      error_reporting(E_ALL);
    
      require(dirname(__FILE__) . "/../vendor/autoload.php");
    
      use PhpParser\\Comment\\Doc;
      use PhpParser\\Error;
      use PhpParser\\ParserFactory;
      use PhpParser\\Node;
      use PhpParser\\Node\\Stmt\\ClassMethod;
      use PhpParser\\NodeTraverser;
      use PhpParser\\NodeVisitorAbstract;
      use PhpParser\\PrettyPrinter;
    
      $target_php_file = 'http_variables.php';
      $target_types_file = 'formdata.json';
    
      $target_php_file_path = __DIR__ . "/../src/shared/" . $target_php_file;
      $target_types_file_path = __DIR__ . "/../../types-bridge/json/" . $target_types_file;
    
      $php_code = file_get_contents($target_php_file_path);
      $formdata_types_json = json_decode(file_get_contents($target_types_file_path));
    
      $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
      try {
          $ast = $parser->parse($php_code);
      } catch (Error $error) {
          echo "Parse error: {$error->getMessage()}\\n";
          return;
      }
    
      $traverser = new NodeTraverser();
    
      class NodeVisitor extends NodeVisitorAbstract
      {
          private $formdata_types_json;
    
          function __construct($formdata_types_json)
          {
              $this->formdata_types_json = $formdata_types_json;
          }
    
          public function enterNode(Node $node)
          {
              if ($node instanceof ClassMethod && $node->name->name === 'files') {
                  $prev_doc_comment = $node->getDocComment()->getText();
                  $prev_doc_comment_lines = preg_split('/\\n/', $prev_doc_comment);
                  $formatted_comment_lines = array_map(function ($line) {
                      return trim(preg_replace('/\\*/', "", $line));
                  }, $prev_doc_comment_lines);
                  foreach ($formatted_comment_lines as $line) {
                      if (preg_match('/@param/', $line)) {
                          $words = preg_split('/\\s/', $line);
                          $param_name = $words[array_key_last($words)];
                          $param_types = $this->formdata_types_json->name;
                          $new_doc_comments = "/**
                           * @param $param_types $param_name
                           * @return Files
                           */";
                          $node->setDocComment(new Doc($new_doc_comments));
                      }
                  }
              }
          }
      }
    
      $traverser->addVisitor(new NodeVisitor($formdata_types_json));
    
      $ast = $traverser->traverse($ast);
    
      if ($ast) {
          $pretty_printer = new PrettyPrinter\\Standard();
          file_put_contents($target_php_file_path, $pretty_printer->prettyPrintFile($ast));
      } else {
          throw new \\Exception('Cannot get ast.');
      }
    
    

    PHP-Parser の PhpParser\\NodeTraverser を利用して、対象の PHP ファイルから files メソッドの PHPDoc Comment を探し出し、getDocComment で取り出しています。取り出したコメントは整形した後、@param の型に相当する部分を読み込んだ JSON を元に setDocComment で置き換えています。

    試しにこの files メソッドの戻り値から目当ての値を呼び出してみます。

    <?php
      // ~~
    
      $http_variables = new HttpVariables();
    
      (function () use ($mkdir, $http_variables) {
          $files = $http_variables->files('result_data');
          $input = $files['tmp_name'];
          $output = $files['name'] . ".json";
    
          if (move_uploaded_file($input, "./" . $mkdir->full_data_dir_name . "/" . $output)) {
              echo json_encode(['message' => 'Created listen result data.']);
          } else {
              http_response_code(405);
          }
      })();
    
    

    PHPStan による型チェックは通ります。

    $ composer --working-dir=workspaces/server phpstan
    > ./vendor/bin/phpstan analyze -c phpstan.neon
     9/9 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
    
     [OK] No errors
    
    ✨  Done in 0.69s.
    

    🎉

    ちなみに呼び出す際の key を先程生成した PHPDoc Types に存在しないものにするとちゃんとチェックは落ちます。

    <?php
      // ~~
    
      $http_variables = new HttpVariables();
    
      $mkdir = new Mkdir($http_variables);
      $mkdir->set();
    
      (function () use ($mkdir, $http_variables) {
          $files = $http_variables->files('something');
          $input = $files['tmp_name'];
          $output = $files['name'] . ".json";
    
          if (move_uploaded_file($input, "./" . $mkdir->full_data_dir_name . "/" . $output)) {
              echo json_encode(['message' => 'Created listen result data.']);
          } else {
              http_response_code(405);
          }
      })();
    
    
    $ composer --working-dir=workspaces/server phpstan
    > ./vendor/bin/phpstan analyze -c phpstan.neon
     9/9 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
    
     ------ --------------------------------------------------------------------------------------------------------------------------------------
      Line   listen/set_result_data.php
     ------ --------------------------------------------------------------------------------------------------------------------------------------
      18     Parameter #1 $key of method HttpVariables::files() expects 'audio_data'|'data_map'|'result_data'|'user_dir_name', 'something' given.
     ------ --------------------------------------------------------------------------------------------------------------------------------------
    
     [ERROR] Found 1 error
    
    Script ./vendor/bin/phpstan analyze -c phpstan.neon handling the phpstan event returned with error code 1
    
    

    おわりに

    サーバーサイドが TypeScript でない状況でもどうにか AST の力を借りて型を共有してみました。

    PHP の $_FILES 以外に $_GETや $_POST でも同様に拡張した型定義があれば FormData 以外でも PHP 側に型を流すことが出来ると思います。

    実際に実装したリポジトリなどは公開していないのでお見せ出来ないんですが、何か質問やまさかりなどあれば shuta までお願いします。

    この記事を共有する