TSとPHPでFormDataの型を共有する
#その他目次
背景
PHP を書いていてこういうことがあったのでやりました。
TSとPHP間のFormDataの型めっちゃつらいやつ毎回なってる
— Shuta (@did0es) June 6, 2021
サーバーサイドで 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 までお願いします。