Upgrade to Pro — share decks privately, control downloads, hide ads and more …

PHPの最高機能、配列を捨てよう!! / Throw away all PHP array ...

uzulla
March 25, 2023

PHPの最高機能、配列を捨てよう!! / Throw away all PHP array now!!!

At: PHPerKaigi 2023 ( https://phperkaigi.jp/2023/ ) Track A
DateTime: 2023/3/25 10:20 (40min)
Speaker: uzulla

uzulla

March 25, 2023
Tweet

More Decks by uzulla

Other Decks in Technology

Transcript

  1. 最高機能、「PHPの配列」のすばらしさ あらゆるデータ構造がつくれる 他の言語の言葉を借りれば Vector, Map, Hash, Array, List, Iterable などを備える

    数値や文字列をキーとしたListあるいはMapだが、順序が維持されカーソルを持つ 値は一種に制限されておらず、変数とできるものは何でも入る Push、Pop、Insert、Delete、Map、Filter、Grep等の言語組み込み関数がある つまり、PHPの配列一つでなんでもできる 他の言語より「PHPが簡単」といわれるゆえんの一つ (独自解釈)
  2. 普通の配列 // Array $array[] = "a"; $array[] = "b"; $array[]

    = "c"; foreach($array as $i){ echo $i; } // => abc echo $array[1]; // => b 「ふつうとは???」「普通がわからん」
  3. ハッシュ的な探索 // 初期化は不要 $array['SOME'] = true; $array['THING'] = false; $array['flag']

    = true; // HERE // ... if($array[$key_name_to_find]){ // $key_name_to_find = flag echo 'flag is enabled!'; } // => flag is enabled!
  4. Queue, Stack. $array_queue = []; // 空の配列を作る array_push($array_queue, 1); //

    スタック( 最後に追加) array_push($array_queue, 3); // スタック( 最後に追加) array_splice($array_queue, 1, 0, 2); // 途中にインサート array_unshift($array_queue, 0); // 先頭にインサート echo array_shift($array_queue); // 先頭取り出し echo array_pop($array_queue); // 末尾とりだし array_push($array_queue, 4); // スタック( 最後に追加) echo array_shift($array_queue); // 先頭とりだし // => 0,3,1 // (2,4 is still in the queue)
  5. 柔軟な多次元操作、JSON等不定系データからのデコード (以下、真面目に読む必要はないです) $data1 = ["A"=>["B"=>[1], "C"=>"thing"], "D"=>"data"]; $data2 = ["α"=>["β"=>[new

    DateTime()], "γ"=>new StdClass()], "π"=>NULL]; $data3 = array_merge($data1, $data2); // Array を混合 $data3[0][1][2][3] = 'woo'; // 突然、深い配列要素を追加 unset($data3['α']['γ']); // 突然要素を削除( したら、その子もちゃんと消えます) $json_string = json_encode($data3); // JSON にコンバート( 自動キャスト込み) echo $json_string; // => {"A":{"B":[1],"C":"thing"},"D":"data","\u03b1": // {"\u03b2":[{"date":"2023-02-19 08:40:51.828780", // "timezone_type":3,"timezone":"UTC"}]},"\u03c0":null, // "0":{"1":{"2":{"3":"woo"}}}} $result = json_decode($json_string, true);
  6. var_dump($result); array(5) { ["A"]=> array(2) { ["B"]=> array(1) { [0]=>

    int(1) } ["C"]=> string(5) "thing" } ["D"]=> string(4) "data" ["α"]=> array(1) { ["β"]=> array(1) { [0]=> array(3) { ["date"]=> string(26) "2023-02-19 08:39:14.925685" ["timezone_type"]=> int(3) ["timezone"]=> string(3) "UTC" }}} ["π"]=> NULL [0]=> array(1) { [1]=> array(1) { [2]=> array(1) { [3]=> string(3) "woo" }}}}
  7. ルーター $routes = [ // キーを正規表現、値でアクションのクロージャー '#\A/\z#u' => function($p){echo "top

    page";}, '#/add/([0-9]+)/([0-9]+)#u' => function($p){ echo "{$p[1]} + {$p[2]} = " . $p[1] + $p[2]; }, '#.*#' => function($p){echo "Notfound";} ]; foreach($routes as $route => $action){ if($result = preg_match($route, $_SERVER['REQUEST_URI'], $m)){ $action($m); break; } } /add/1/2 のとき、 1 + 2 = 3 と表示
  8. 「生きている」DB接続などを保持できるコンテナ function getContainer(){ static $container = null; if(is_null($container)){ // 初期化,

    二度目はスキップ $container = ['db' => new PDO('sqlite::memory:', null, null)]; } return $container; } // 〜〜〜 $stmt = getContainer()['db']->prepare('select 1 as a'); $stmt->execute(); $stmt->fetchAll(PDO::FETCH_ASSOC);
  9. 突然、書き換えてみる enum UserType: string{ case UNCONFIRMED = 'u'; case CONFIRMED

    = 'c'; } class User{ public UserType $type; public function __construct(string $type){ // 失敗したらちゃんと例外が上がります $this->type = UserType::from($type); } } $user = new User($_GET['type']); echo $user->type->value;
  10. $user = getUser($_GET); // ここでPHP の配列ちゃんが生まれた if(!is_array($user)){ // かっこよく例外にしよう! throw

    new OutOfBoundsException("user not found"); } $user['lastLoginAt'] = time(); // 最終ログイン時刻更新! saveUser($user); // データ加工したい!でも1 メソッドが長いのは悪いって聞いた!関数化だ! $user = decorateUser($user); // うーん、なんかcreated_at がない事がある…isset いれたらエラー消えた! if(isset($user['created_at']) && (int)$user['created_at'] < 946684800){ $user = oldUser($user); // 古いユーザーは特別な対応が必要なのだ } unset($user['hashed_password']); // セキュリティの都合で消せって言われたから書いた renderProfilePage($user);// ページをレンダリングします!
  11. とりま、getUser()を見てみる function getUser(array $get): array{ $pdo = new PDO('sqlite::memory:'); //

    サンプルなんで… $stmt = $pdo->prepare("SELECT * FROM users WHERE id=:id"); $stmt->bindValue("id", $get['id']); return $stmt->fetch(PDO::FETCH_ASSOC); } 「なるほど、users テーブルから引いてくるんだね!  …情報ゼロやんけ!DBスキーマをみないといけないのか…」 開発と本番でズレてたりして死ぬなどもありますね
  12. decolateUser()をみてみる function decorateUser(array $user): array { if( // 久々のログインならバナーをだしたい isset($user['lastLoginAtEpoch'])

    && $user['lastLoginAtEpoch'] >= time() - 604800 ){ $user['isLongTimeMissed'] = true; } // 開発中は管理者ってことにしておこう! if(TEST_MODE){ $user['is_admin'] = true; } // よくわからないけど、昔からあったのでコピペしてきた foreach($_SESSION['before_user'] as $k => $v){ $user["before_user_" . $k] = $v; // キーを文字列操作で作るのは最強にヤバい } return $user; } // コメントする気もおきないが、しばしば見るコード
  13. 余談: 本当の配列こと、list<T> とは? 0個以上の要素を持ち キー(添え字)が数値の 0 からはじまり、連番で途切れない 例: [1,2,3] ,

    ["a","b","c"] , [new A(1), new A(2)] 順序が数字の通り 値の型が一意(stringならstringしかない) 値が配列の場合は、その配列も同様の条件を満たす であるようなものを指す (実はPHPでもクラスで自作できるが、 ジェネリクスがないので型毎につくる必要がある)
  14. fetchしたら即DTOにする function getUser(int $id): UserDto{ $pdo = new PDO('sqlite::memory:'); $stmt

    = $pdo->prepare("SELECT id, name, UNIX_TIMESTAMP(last_login_at) as lastLoginAtEpoch FROM users WHERE id = :id"); $stmt->bindValue("id", $id, PDO::PARAM_INT); $stmt->execute(); return new UserDto(...$stmt->fetch(PDO::FETCH_ASSOC)); } 引数にPHPの配列はやめる、 getUser($_GET['id']) など明示する 返値が UserDto であることを明示する 上は0件だと例外が上がる
  15. 余談: fetchAllの例 // foreach 例 $list = []; while(false !==

    $row = $stmt->fetch(PDO::FETCH_ASSOC)){ $list[] = new UserDto(...$row); } return $list; // array_map 例 return array_map( fn(array $row) => new UserDto(...$row), $stmt->fetchAll(PDO::FETCH_ASSOC) );
  16. 余談: そもそも、私ならこの場合 // 1 つ return $stmt->fetch(PDO::FETCH_CLASS, UserDto::class); // 全部

    return $stmt->fetchAll(PDO::FETCH_CLASS, UserDto::class); と書くのだが、世間では FETCH_CLASS はあまりなじみがないらしいので…。
  17. 「私のフレームワークではそう書けないんだけど?」 function getUser(int $id): UserDto{ $user = App\Models\User::findOrFail($id) return new

    UserDto( (int)$user->id, (string)$user->name, (int)$user->lastLoginAtEpoch(), ); } splat演算子( ...$var )を使わない、PHP<8の配列の場合も同様 上の $user->id が $user['id'] という感じ
  18. こういうコードを書くと聞かれるよくある余談 「 UserDto のプロパティはテーブルのカラムと一致していなくていいの?」 良い 一つの「データソース」が、複数の異なる形状のDTOにマップされて良い テーブルでも、JSONでも、 $_POST 等も 「ソースとDTOが1:1!」というのは、Active

    Record の呪いである(独自考察) ARの発想をすててみましょう 結果として全部が必要な場合もあるし、手抜きでそうすることもある。 実務では UserPublicDto とか UserInternalDto など、派生を作る事が多い Internal は機密情報を持っているが、 Public は無い等が表現できる
  19. 話をもどして、重要なことは getUser のスコープ内部で、配列(や stdClass )が役割を終えていること $array = $stmt->fetch(PDO::FETCH_ASSOC) return new

    UserDto($array['id'], $array['name']); もし必要なら、この箇所だけPHPの配列を解きほぐせば良い これくらいなら読める
  20. DTOになっていてうれしい事 // 返値がかならずUser であることが保証されるので、isset などが不要 // ( 別途、例外処理は必要だが) $user =

    getUser((int)$_GET['id']); $user = decorateUser($user); $user は、必ず UserDto である どんなプロパティがあって、それらの型がコードでわかる falseやnullではない(無論、0件時のエラーハンドリングは必要) PHPの配列は消えましたね!ヤッター! その後の decorateUser は、必ず UserDto がくることを信頼できる decorateUser 内部でのバリデーション(?)を減らせる (まあ、DTOを加工していく上のコードは微妙…)
  21. 余談: 返値にT|null パターンもある function getUser(int $id): UserDto|null{ //... $array =

    $stmt->fetch(PDO::FETCH_ASSOC); if(!is_array($array)){return null;} return new UserDto(...$array); 例外処理が面倒な場合にはこのパターンもある、私も結構好き ただ、できれば「0件だった」例外を定義した方が良いと思う 例外なら、雑に書いたときエラーになるので安全側に倒れている 「if文をへらす設計」に自然となるのでギプスとしても便利 fetchAll なら空配列だろうから問題にならないかと思う
  22. 加工の様子も見てみましょう (「そもそも加工するな」「ごもっともです」) if( isset($user['lastLoginAtEpoch']) && (int)$user['lastLoginAtEpoch'] >= time() - 604800

    ){ $user['isLongTimeMissed'] = 'true'; } if($user->lastLoginAtEpoch >= time() - 604800){ $user->isLongTimeMissed = 'true'; } issetが不要になった(存在が保障されるので) 型キャストが不要になった(intと定義したので)
  23. そもそも、必須処理ならHydrate処理をコンストラクタに持つ事も可能 class UserDto{ public bool $isLongTimeMissed; const DELTA_SEC = 604800;

    public function __construct( public int $id, public string $name, public int $lastLoginAtEpoch, ){ $this->isLongTimeMissed = $this->lastLoginAtEpoch >= time()-self::DELTA_SEC; } }
  24. 加工時にうれしい事 UserDto 以外がわたされたら、TypeErrorになる decorateUser 内部でプロパティ名をTypoしたらIDEが指摘してくれる 例として、 $user['is_aaadmin']=true は警告されないが、 $user->is_aaadmin =

    true はプロパティが無いと指摘される $user->lastLoginAtEpoch が存在しintであることが保障されるので、 isset チェッ クやキャストが不要になる
  25. 余談: 静的解析による不要なプロパティの探索 // 謎の定数からの要素セット if(TEST_MODE){ $user->isAdmin = true; } 配列の時は

    isAdmin の参照があるかわからなかったのでそっと放置していた… それが、オブジェクトのプロパティならPhpStormのfind usage(静的解析)が使える 結果参照箇所がなければ削除できてHAPPY!!!
  26. 余談: 動的な箇所があると見落とす /** @var UserDto */ // <== このアノテーションがないと`$user` が何か不明

    $user = GodContainer::get("UserDto", 1); if($user->isAdmin){ /*...*/ } 注:コンテナ、サービスロケータ、ファサード等、(明示な)型がないオブジェクト生成器 では見落とす 人がみれば解るが、静的解析ができない。PhpDocは必須 簡単な判断方法:「実装」にコードジャンプができるか? 親クラスやInterfaceにジャンプしたら、静的解析は追跡しきれていない 例「Modelってやつがでてきた」「ヴァーー」
  27. function getUserOrException(array $id): User{ $pdo = new PDO('sqlite::memory:'); $stmt =

    $pdo->prepare("SELECT * FROM users WHERE id=:id"); $stmt->bindValue("id", $get['id']); // 引けないとTypeError になる、独自例外にWrap しなおすことも多い return $stmt->fetch(PDO::FETCH_CLASS, User::class); }
  28. 重要なこと(あくまで指標です) 「不完全なインスタンス」を作らない リクエストやレスポンスオブジェクトが「生まれる」場所を集約する とりあえず new して、配列のようにいじっていくのはバグの温床 「この先で必要なパラメタが生まれるんだ、見逃してくれ」 作れる状態になるまで引数で連れ回しましょう、 トランザクショナルな制作はバグの温床 namespaceをつかう

    「良い名前」をより先に、「違う名前」をつける意識 現代はツールの進化でリネームリファクタリングは簡単にできる (呼び出し側などで動的にしていないかぎり!) 名前は「特性・役割」でなく、「実現する機能」で切る Request\UserPointDto でなく、 UserPoint\RequestDto が良い
  29. ダメな例 class SomeResponse { public string $userId; public string $path;

    } // --- $data = getPointData($userId); // 配列が返るものとする $res = new SomeResponse() $res->userId = $res{'id']; $res->point = $res['point']; new した後にプロパティに代入していくのは悪い手法 中途半端な状態が存在しうると配列と大差がなくなる (どういうプロパティがあるかわかるだけ良いが)
  30. レスポンスのDTO例 namespace My\SomeApi\Point; class UserPointStatusResponseStruct { public function __construct( readonly

    public int $point, readonly public string $expire_at, ){ } } 同様に「一発で完成する」ように作る レスポンスは取り出ししかなく、加工できないようにする 加工したいなら、変数をつくればいいじゃない readonlyをつければ、作成された後に修正ができない (が、セーフガード・補助輪・フールプルーフであり必須ではない)
  31. 利用例、どうやって new するか デコードしたその場で解体して使う $json_string = json_encode([ // 一旦Stub 'point'=>10,

    'expire_at'=>'2024-01-01' ]); $array = json_decode($json_string, true); // ここでValidation をおこなうケースもあるでしょう $userPointStatus = new UserPointStatusResponseStruct( $array['point'], $array['expire_at'] ); // もうarray の役目は終わり PHPの配列はもうすてる、この先は $userPointStatus を信用できる
  32. 余談: レスポンスのDTO(?)例 その2 namespace My\SomeApi\Point; class UserPointStatusResponseStruct { readonly public

    DateTimeImmutable $expireAt; public function __construct( readonly public int $point, readonly public string $expire_at, ){ $this->expireAt = new DateTimeImmutable( $expire_at, new DateTimeZone("Asia/Tokyo") ); } } こういうHyderateする仕組みをいれることもある
  33. 余談: 直接JsonStringを取り込むべきか? class UserPointStatusResponseStruct { readonly public int $point; public

    function __construct( string $json_string, ){ $data = json_decode($json_string); $this->point = $data['point']; } } // ... $userPointStatus = new UserPointStatusResponseStruct( json_decode($json_string, true) ); たまーに聞かれるが、私は悪いと思う テストがしにくい 「ParseできるJsonか?」の責務はHttpClientが背負った方が良い
  34. リクエストのDTO(?)をつくる namespace My\SomeApi\Point; class getUserPointRequestStruct { readonly public string $method

    = 'GET'; readonly public string $path; public function construct( readonly public int $userId, ){ $this->uri = "http://localhost/point/{$userId}"; } } (上は「良い設計」とは到底いえませんが、短く書きたかったので許せ) リクエストも手続き型的に作らず、中途半端なインスタンスが生まれないように 「使っているHttpClient」の仕様にあわせない方が良い
  35. 使い方 たとえばGuzzle class ClientWrapper { static public function getUserPointStatus( getUserPointRequestStruct

    $req ) :UserPointStatusResponseStruct { $client = new GuzzleHttp\Client(); $array = $client->request( $req->method, $req->uri, )->json(); return new UserPointStatusResponseStruct( $array['point'], $array['expired_at'], ); // Array はここで終了 } }
  36. 使い方2 「Guzzleは嫌だなあ」 class ClientWrapper { static public function getUserPointStatus( getUserPointRequestStruct

    $req ) :UserPointStatusResponseStruct { $json_str = file_get_contents($req->uri); $array = json_decode($json_str, true); return new UserPointStatusResponseStruct( $array['point'], $array['expired_at'], ); // Array はここで終了 } } ※ 本当はエラーハンドリングしてくれよな
  37. I am NOT smarter than static analytics! PHPの配列を捨てると静的解析という味方がつく 静的解析は、人間より間違いにめざとい 何度解析させても即座に、文句なく、間違いを淡々と指摘してくれる

    静的解析で見つかる程度の間違い探しに、同僚の時間を奪う必要は無い そのために、静的解析に寄り添ったコードにするのは理にかなっている