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

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

Avatar for uzulla 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

Avatar for 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の配列を捨てると静的解析という味方がつく 静的解析は、人間より間違いにめざとい 何度解析させても即座に、文句なく、間違いを淡々と指摘してくれる

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