- 投稿日:2020-12-07T23:58:03+09:00
クエリを見直してチューニングしてみよう!【変なSQL見える化作戦】
本記事は、サムザップ #2 AdventCalendar 2020の12/16の記事です。
この記事では、開発中のフェーズで、リリース直前のスマートフォン向けゲームを開発しているプロジェクトで実際に行ったMySQLのパフォーマンスチューニングのとある取り組み(作戦)について紹介したいと思います。
はじめに
例えば、このような雑なゲームで考えてみます。
以下のように、ユーザが3枚のカードをデッキに所持しているとします。▼ user_card :ユーザが所持するカードテーブル(card_idはm_cardテーブルのidを指す)
id user_id card_id 123 1 1 124 1 2 125 1 3 ▼ m_card :カードのマスタデータテーブル
id name hp 1 赤たぬき 60 2 青たぬき 42 3 黄たぬき 84 このとき、ユーザが所持しているカードの一覧を表示させようとすると、
- user_cardテーブルから所持しているカードを取得
- m_cardテーブルから所持しているカードのマスタデータを取得
となるかと思います。
1. user_cardテーブルから所持しているカードを取得
まずは、user_id=1のカードを取得します。
select * from user_card where user_id=1;これで、このユーザが所持しているカードのcard_idが1,2,3ということがわかります。
2. cardテーブルから所持しているカードのマスタデータを取得
次に、card_idが1,2,3のマスタデータを取得します。
select * from m_card where id = 1; select * from m_card where id = 2; select * from m_card where id = 3;これで、所持しているカードの情報も取得できます。
気になった
さて、ここで気になりました。
select * from m_card where id = 1; select * from m_card where id = 2; select * from m_card where id = 3;これって、IN句を用いれば1クエリで書けるんじゃないかと。
select * from m_card where id in (1,2,3);ちょっとした工夫だけで、この例だけでもクエリ数が2つ減りました。
1クエリを投げて返ってくるまでのレイテンシも意外と大きく、場合によってはms単位で時間がかかってしまいます。10msだとしても、10クエリを投げるだけで100msもかかります。もし、ユーザが100枚のカードを所持していたら1秒もかかってしまう計算です。このように、例えばN+1問題のような、アプリ全体を重くする原因になりそうなクエリは良くない!ということで、こういうクエリを
変なSQL
と呼ぶこととします。変なSQL見える化作戦
この変なSQLを撲滅させたいと思い、その取り組みを
変なSQL見える化作戦
としました。もちろん見える化がゴールではなく、撲滅がゴールです。今思えば作戦名も変なSQL撲滅作戦の方が良かったかもしれないですね。具体的なソースコード等は省略します。また、この記事で紹介するプログラムやログ等の例は実際に出力されたものではなく、この記事用に書いているので、おかしなところがあるかもしれませんが、気にせず良い感じに汲み取ってください。
変なSQLの見える化
撲滅するためにも、まずはこの変なSQLを把握しないといけません。
まず、上記のユーザの所持しているカードの例だと、この所持カード一覧取得API
を叩いたときに流れるSQLをチェックすれば良いと考えました。つまり、そのゲームで用意されている各APIを叩いて、その時のSQLを解析して、変なSQLの有無を確認すれば良いということになります。前提条件
言語はPHP、データベースにMySQLを利用しています。
APIの叩き方
まずは、APIを叩くところについてです。
今回利用したのはphpunitです。また、このチームでは、API定義を書いていて、その定義ファイルからAPIを叩いてレスポンスが問題ないことのテストコードを自動で生成して書くようにしています。そこに対して、前処理を追加したり、異常系のテストを追記したりしています。もちろん何も書かなくてもAPIを叩くだけのテストは出来上がっています。
(APIの定義は、swaggerでもオリジナルの定義ファイルでも構いません)
例えば、HTTPテスト 8.x Laravel - JSONとの完全一致を検証に書いてあるサンプルコードで例えると、
リクエストパラメータがname
、レスポンスがcreated
とする/user
のAPIの定義を書いておけば、そこに書いてある(以下のコード)テストをコマンド一つで生成されるようになっています。
※チーム内でのAPIのテストの生成はこれとは違った形で生成していますが、json()
を用いているのは同じです。ExampleTest.php<?php class ExampleTest extends TestCase { /** * 基本的な機能テストの例 * * @return void */ public function testBasicExample() { $response = $this->json('POST', '/user', ['name' => 'Sally']); $response ->assertStatus(201) ->assertExactJson([ 'created' => true, ]); } }そのため、全てのAPIの定義ファイルが存在するので、基本的には全てのAPIを叩くテストはそれぞれ最低1つは書かれていることになります。(ここではそのテストによるカバレッジは無視している)
APIを叩いたときに発行されるSQLの取得方法
APIを叩いたときに流れるSQLだけを取得方法についてです。
SQLログは、言語に依存しないように、MySQLのgeneral_logを使用することにしました。「APIを叩いたときに流れるSQLだけ」を取得するために、先程紹介したテストコードの
$this->json()
に注目しました。このメソッドを叩く処理は各APIを叩いていることとほぼ同じかなと思います。(全く同じではないけど、APIが叩かれたときの処理が実行されるという意味ではほぼ同じという表現)
すなわち、json()
の処理の間に出力されるgeneral.logだけを見れば良いということになります。ここでは、複雑なことは考えません。
json()
が呼ばれる直前にgeneral.logの中身を削除するjson()
の処理をさせるjson()
が終わった後にgeneral.logの中身を取得する以上のようにすれば、そのAPIが叩かれるときに発行されるクエリを取得することができます。
元のテストに対して、なるべく手を加えずに
変なSQL見える化作戦
を実施したかったので、以下のようなTestCaseを継承したクラスを作成しました。TestCaseForSqlCheck<?php abstract class TestCaseForSqlCheck extends TestCase { public function json($method, $uri, array $data = [], array $headers = []) { // 1. general.logの中身を削除する // ~~~general.logを削除する処理を書く~~~ // 2. APIを叩く parent::json($method, $uri, $data, $headers); // 3. general.logの中身を取得する //~~~general.logを取得してゴニョゴニョする処理を書く~~~ } }後は、変なSQL見える化作戦でSQLをチェックするときだけ、各APIのテストの継承元をこのクラスに変更するだけです。
$ git diff ExampleTest.php - class ExampleTest extends TestCase + class ExampleTest extends TestCaseForSqlCheck全APIのテストを一括で変更する必要があると思いますが、sedコマンド等を利用すれば、コマンド一つで一括置換ができるかと思います。
変なSQLの抽出
ここまでで、各APIを叩いたときに発行されるクエリログを取得することができるようになりました。(APIを叩いたときに出力されるgeneral.logが取得できた)
さて、ここで変なSQLの定義を改めて考えてみましょう。
- 同じようなクエリが2回以上発行されるもの(複数回投げられていたら疑っても良さそう)
- update文は1クエリにまとめるとめんどくさいので、ここでは無視する(insertは楽でしょ)
同じようなクエリとはどういうことなのか?一番最初の例で見てみましょう。
select * from m_card where id = 1; select * from m_card where id = 2; select * from m_card where id = 3;idが1か2か3の違いですね。ということは、PREPARE構文を利用すれば良さそうに見えます。
上記の例だと、以下のようにクエリログが吐かれるかと思います。
(わかりやすくPrepareだけにフィルタリングしています。grep Prepare
みたいなイメージ。また、以下は実際に出力されたクエリログではなく、手で書いたログなので、general.logの出力フォーマットとは少し異なります)Prepare select * from m_card where id = ? Prepare select * from m_card where id = ? Prepare select * from m_card where id = ?全く同じクエリが発行されていることがわかりますね!これなら判別することができます。
MySQLで例えるなら、このログに対して、select sql,count(*) from general.log group by sql
みたいなことをすれば良さそうですね!ということで、変なSQLの抽出についての処理をまとめると以下になるかと思います。
- general.logのPrepareだけをフィルタリングする(grepするイメージ)
- update文は除外する
- 同じクエリをグルーピングしてカウントする
- カウントした結果が2回以上のものを
変なSQL候補
とするまた、全体のphpunitを実行して各APIに対しての変なSQLを集計した後処理として、ログを集計するために、phpunitのTestRunnerの拡張の
executeAfterLastTest()
を利用することで、集計を楽にすることができました。アウトプットイメージ
上記のような手順を行い、各APIごとの変なSQLとその出現回数をログとして出力させます。
解析結果出力例URL: post /user 2回: select * from user where id = ? URL: get /user 2回: select * from user where id = ? 3回: select * from m_card where id = ? URL: /card 2回: select * from user where id = ? 3回: select * from m_card where id = ? 項目数: 5個変なSQLが見えましたね。これで見える化は完了です!
上記の解析結果出力例は変なSQLは2種類ですが、項目数を5個として数えています。
この項目数
が、サービス全体に与える影響力に比例しそうだったので、この数値を追うことにしました。select * from user where id = ?
このクエリがいろんなところに影響しているということですね。
この例に関しては、この2種類の変なSQLを撲滅させれば撲滅完了ということになります。撲滅フェーズ
さて、ここまでの手順で見える化が完了しました。
撲滅のために、以下について紹介したいと思います。
- 自動実行させる
- 結果を見えるところに出力させる
- 撲滅順序の決定
- チームメンバーを巻き込む
- 実行タイミング・周期
自動実行させる
定期的に上記の見える化の処理を実行し、解析するようにします。
そのためにも、人間が手動で実行していたら、例えばその人が一回忘れたらおそらく一生再実行されることはないと思ってます。また、その人が異動や退職したりすることでも、この作戦はそこで停止してしまう可能性があります。そのためにも、実行者は人間ではなくcron等を用いて自動実行をするようにしました。そこで、今回はGithub Actionsを利用することにしました。サーバサイドだけで環境を用意でき、また、PHPソースをgit管理したときに、同じレポジトリでその実行フローも管理することができたりと、導入がとてもしやすい印象なので、Github Actionsを選択しました。
Github Actions上のフローをざっくりと説明すると、以下のような流れになるかと思います。
- 環境構築(composer install等。docker-composeを使ってローカル開発環境と同じにすることで、デバッグしやすくしている)
- phpunitの設定(TestRunnerのエクステンションを利用するようにphpunit.xmlの編集をしたりする)
- 各TestCaseの継承先を上で紹介したTestCaseForSqlCheckに変更する(sedコマンドで一発)
- phpunitを実行してAPIのテストを行う
- 最後に解析結果を出力する
そして、Github Actionsの実行結果は以下のようになります。(スクショは自動実行ではなく、手動実行の結果ですがほぼ表示は変わらないです。)
このようにして、Github Actions上で実行・確認ができるようになりました。
結果を見えるところに出力させる
解析結果を出力するのですが、それがGithub Actionsのコンソールやどこかのスプレッドシートやファイルに出力するだけではダメです。みんなが嫌でも目に入るところに出力する必要があります。
そこで、今回はSlackに投稿するようにしました。社内でのチャット・連絡手段はSlackを使用しているので、みんなが必ず毎日何度も目にします。見ないと仕事になりませんよね。
Slackでも、それ用のチャンネルを作成して出力するのも良くないと思います。ミュートにされたらおしまいです。そのため、例えばエンジニアが普段連絡をするようなチャンネルにあえて投稿してやります。
あとは投稿されたらできるだけ毎回違うリアクションを速攻でつけると、みんなはそのリアクションが気になって解析結果を見てしまうと思います。そのようなちょっとした工夫を積み重ねてチームにちょっとずつ浸透させていきました。
撲滅順序の決定
時間には制限があります。もしかしたら全ての変なSQLを撲滅することができないかもしれないです。
そこで、それぞれの変なSQLに対して、優先度をつけます。優先度は以下のように分けました。
優先度レベル 内容 対応方針 S 状況・条件によっては3回以上のクエリが投げられる or 固定だけど多い
(n+1問題みたいなもの)リリースまでに直す A 3〜5回だけど、どんな状況・条件でも必ず固定の回数だけ投げられる or 不要なクエリが投げられている リリースまでに直したい B 必ず2回だけ いつかは直したい D 仕様上仕方がない or 作り上仕方がない 放置する。変なSQLではない! 優先度レベルがSには、マスタデータの状態、データベース、ユーザデータによってものすごく増える可能性があるものを設定しています。
一番最初の例のように、ユーザのカードの所持枚数が100枚とかになると100クエリ投げられてしまう、のように無限ほどではないけど、どんどん増えてしまうものを最優先で撲滅しました。select * from m_card where id = 1; select * from m_card where id = 2; select * from m_card where id = 3;また、例えばフレンドになるという処理を考えたときに、フレンド上限の判定をする場合、自分のフレンド数と相手のフレンド数をチェックする必要があると思います。その時のフレンド数を取得するクエリが2回になったとしたら、それは必ず2回以内しか投げられません。3人以上のフレンド数を取得する必要はありません。でも、きっと一回で取得することも可能だと思います。そういった場合は優先度レベルをBとしています。
実際にやってみて、優先度レベルがAになるのは、処理をざっくりみただけじゃわからないけど、でもヤバそう、みたいなざっくりとした感覚で設定していました。
また、優先度をBにしたけど、直そうと思ったら「システムを作り直す」「機能自体を作り変えなきゃいけない」みたいなことが発生した場合は優先度レベルはDに下がります。
この作戦に対しての工数はそこまでかけ過ぎないことが大事だと思っています。チームメンバーを巻き込む
変なSQLの撲滅運動は、1人でやらず、みんなでやりましょう。
みんなで実施することで、チームメンバー全員が変なSQLに対する感度が上がり、これを通してよりパフォーマンスを意識したプログラムを書けるようになると思います。
また、1人だとなかなか終わらないので、みんなで協力しましょう。実行タイミング・周期
さて、優先度が決まったからと言って、片っ端からどんどん片付けていくのはよくありません。最初から全速力で走ると、すぐに疲れてしまいます。
そこで、今回は「1週間に1種類の変なSQLだけ撲滅させる」としました。「1週間に1つだけなら、なんとなくできそうな感じがしませんか?」
また、毎週木曜日にチーム内のサーバ定例MTGを実施しているので、そこでその週の振り返りをするようにしていました。
そのMTGの変なSQL見える化作戦で話すこととしては、以下になります。
- 現状の変なSQL一覧の確認
- この一週間で増えた/減った項目の確認
- 次の一週間でどれを撲滅させるかの決定
現状の変なSQL一覧の確認
変なSQLを一覧で可視化します。今回はこれをスプレッドシート等にまとめて管理します。実際に使用したスプレッドシートを紹介したいと思います。
(ここだけ貼り付けがアナログですね。)スプレッドシートにした大きな理由は特にありません。とりあえず変なSQLの一覧が見えてフィルタとか色つけたりできればいいなって思ったくらいなので、今回はスプレッドシートを使用しました。
こうやってみると、このチームのシステムは変なSQLがたくさんありますが、このスクショはこれでも一部なんです。この一週間で増えた/減った項目の確認
この一週間でどれだけ減ったかを増えたかを確認します。もちろん開発をしていれば増える可能性もあります。また、直したと思ったらいつの間にか別の変なSQLが出現している場合もあります。そういったのを確認して、認識をあわせます。
確認するために、前回の解析結果と今回の解析結果を単純に
diff
を取るだけです。Slackをちょっと遡れば前回の結果が投稿されているので、ファイルは作成する必要はありますが、コマンド一発で簡単に差分の確認ができます。$ diff 今回.txt 前回.txt
また、減っていくと気持ちが良いですよね。「今週は○項目減って、S,A,Bレベルが残り○○項目です!」っていうのが意外と楽しいんですよね。これを発表するとメンバーから拍手が聞こえたりするのでちょっと盛り上がります。
しかも1種類だけの変なSQLを撲滅させたとしても、それが複数のAPIで発行されている可能性があるので、複数項目を撲滅させることができるのです。
なので、例えば変なSQLが項目数が50項目あったとしても、50回撲滅させる必要はなく、もしかしたら実際には20種類の変なSQLしかない可能性もあるのです。次の一週間でどれを撲滅させるかの決定
そして、MTGの最後にはこの一週間で撲滅させる変なSQLをみんなに決めてもらいます。ここがポイントです。「自分はこれをやります」って言ってしまったら、翌週のMTGまでに片付けないといけなくなりますよね。
また、周りの人がちゃんと撲滅させているのに、1人だけ撲滅してなかったら焦りますよね。そういう意味でも、ちゃんと毎週1人1種類ずつではありますが、撲滅させていきます。決定したら、あとはその一週間のうちで気が向いたときに対応してもらっています。もちろん、開発が遅れてはいけないので、そちらを優先しつつも、なるべく撲滅してもらうようにしていました。
バッチの実行タイミング
ということで、毎週実施のサーバ定例MTGまでには、解析してその実行結果をスプレッドシートにまとめておく必要があります。
そこで、木曜日の朝に、バッチの実行設定をしておきます。
出社した頃には解析結果が出てて、それを集計してサーバ定例に挑む。そのような感じで毎週実施してました。そのため、Github Actionsのスケジュールで朝7時に設定していました。
code_check.ymlname: 変なSQL見える化 on: schedule: #毎週木曜日の7(-2+9)時(=水曜日22時)実行するように設定 - cron: '0 22 * * 3'Github Actionsを実行していると、7時にcron実行の設定をしても7時ちょうどには実行されなく、遅いときは8時直前になることもあったりするのです。
そのため、ものすごい余裕をもって早めに実行するように設定しています。そして、撲滅へ
このようにして、毎週撲滅していきます。
実際にやってみて、今回はおよそ2ヶ月半くらいでS,A,Bレベルの変なSQLの撲滅に成功しました。もちろん最初の方に書きましたが、phpunitのAPIテストでカバーしている処理に限りますが、これで把握している変なSQLはなくなったはずです。
最後に
この作戦は、サービス全体のパフォーマンス・チューニングの役立つ情報の1つになると思います。
これにより、1リクエストあたりのクエリが減ることで、DBの負荷も下がり、最終的にコスト削減にも繋がり、結果利益の向上にも貢献できます。また、この作戦を実施する目的はそれだけではなく、実はチームメンバーにもパフォーマンスを気にする癖をつけてもらいたいと思って実施しました。SQLを意識することで、よりパフォーマンスの高いコードを書けるようになったらいいなと思っていました。(これはまだチームメンバーに伝えておりませんwこの記事を通して伝えることになりそうです。。)
おまけ
ここからはおまけです。上で紹介しなかった工夫をここで軽く触れておこうと思います。
どのテストによる変なSQLかの出力
各API毎に変なSQLとその回数を出力させるだけではなく、どのテストを実行したときに発行されたSQLなのかも一緒に出力するようにしました。
これをやることで、どのテストで発行されたかを調査する時間がなくなり、業務効率がちょっぴり上がります。雑実装ですが、以下のようにどこテストから呼ばれているかを
json()
内から知ることができます。json()内の処理$backTrace = debug_backtrace(); $functionName = $backTrace[2]['function']; $className = $backTrace[2]['class'];json()の中身で実際にAPIを叩いちゃう
元の
json()
は擬似的にリクエストを作成してPHPの処理をさせているだけですが、実際にcurlしてしまおうという考えです。
処理するプロセスを分けることで、例えばPHP側で保持しているクエリキャッシュやstatic変数に保存している値を利用しないようにして、よりリアルな変なSQLを知ることができるようになります。雑ではありますが、だいたいこんな感じの処理でjsonメソッドを置き換えることができました。
※nginxのコンテナを利用しているので、リクエスト先がnginxになっています。public function json($method, $uri, array $data = [], array $headers = []) { $content = json_encode($data, JSON_UNESCAPED_UNICODE); $url = 'http://nginx' . $uri; $header = [ "Accept': 'application/json", "Content-Type: Content-Type: application/json", "Content-Length: " . strlen($content), 'RealTime: ' . $realTime, ]; $options = ['http' => [ 'header' => implode("\r\n", $header), 'method' => $method, 'content' => $content, 'ignore_errors' => true, ]]; $response = file_get_contents($url, false, stream_context_create($options)); preg_match('/HTTP\/1\.[0|1|x] ([0-9]{3})/', $http_response_header[0], $matches); $statusCode = $matches[1]; $this->response = new JsonResponse(); $this->response->setData(json_decode($response, true)); $this->response->setStatusCode($statusCode); }カバレッジが気になる
APIのテストを実施したときのテストのカバレッジを計測して可視化することで、どの処理が網羅できてそうかがわかります。
去年のアドベントカレンダーで書いた「テストコードのカバレッジの見える化をして、テストを書く文化を作ろう。」でも紹介しましたが、phpunitで簡単にカバレッジを可視化することができます。
また、APIのテスト(tests/Controller
)とロジックのテスト(tests/Logic)のディレクトリを分けておくことで、APIだけのテストを実施することも可能となります。$ phpunit tests/Controller/
手軽に実行できるようにしておく
GithubのレポジトリのActionsのタブから、簡単に解析をするようにしています。
これは、workflowのトリガーのところに、
workflow_dispatch
イベントを設定することで簡単に実現することができます。
参考:ワークフローの手動実行 - GitHub Docscode_check.ymlname: 変なSQL見える化 on: schedule: #毎週木曜日の7(-2+9)時(=水曜日22時)実行するように設定 - cron: '0 22 * * 3' workflow_dispatch: inputs: post_slack: description: 'post slack' required: false default: 'false'これで、Jenkinsのジョブを実行する感覚で、ブランチを指定して解析処理を開始できるようになりました。
また、定期実行時はslackに投稿されるようになっているので、手動実行時はSlack投稿されないように、入力パラメータを設けてdefault値をfalseで設定しています。
ワークフロー内では、例えば以下のような条件式を書くことで簡単にSlack投稿の処理をさせないようにすることも可能です。
ymlcode_check.ymlif [ "${{ github.event.inputs.post_slack }}" != "false" ]; then # slack投稿処理 fiこれで、変なSQLの撲滅作業時に、ちゃんと撲滅できたかな?というチェックをすることができるようになりますね。
おまけは増え続ける
おまけでいくつか紹介しましたが、この作戦を続けていくと、どんどん工夫は増えていくと思います。
実際に、毎週開催のサーバ定例MTGのときにもチームの皆さんからフィードバックをもらって、例えば「どのテストによる変なSQLかが知りたい!」ってことで、おまけに書いてある機能を追加したりしています。
もしかしたら、この記事を投稿した翌日にも新しい機能が追加されているかもしれません。
- 投稿日:2020-12-07T23:55:08+09:00
業務でチーム開発をしていて思うこと
チームで開発するのって難しい。。。
Webサービス開発を業務でやっていて大変に思うことが色々あります。
特にチーム開発で進めるときには細心の注意を払います。
(上手くいかないとお互いにストレス溜まりますよね)今後のためになんとなく現段階で気をつけようと思う点をメモしておきます。
言い方って大事
⇨ 何かお願いすることの連続
⇨ 教えてもらったり教えたり
⇨ 自分の知っている部分は他の人は知らなかったり知っていたり
↪︎誰が上で誰が下かがあまりないかもしれない
⇨ 開発している人は詳しいが、他の人はそのファイル回りをあまり見てないので詳しくない
⇨ 指示が不明確だとかなり困る
⇨ 横柄な態度はよくない
⇨ 指示なのかお願いなのか何を話したか曖昧になる
⇨ 言った言ってないの問題になる
↪︎メモとるの大事
⇨ 同じイメージをちゃんと共有できているか人によって開発スピードや順序が違う
⇨ 統一するべき?
⇨ ペースを合わせるのが大事だし大変どこまでテキトーにやるか、どこまで厳密にやるか
⇨ クオリティかスピードか
⇨ 質か量か
⇨ 難しい、日頃からすり合わせするべきかもしれない
⇨ 性格が出る、亀裂を生む可能性も今日の名言
お前のためにチームがあるんじゃねぇチームの為にお前がいるんだ!!
-安西先生(湘北高校バスケットボール部顧問)
- 投稿日:2020-12-07T23:49:23+09:00
PHPコンテナのDockerファイルでCMDを書くとnginxコンテナと接続できなくなった
Laravel、cron、nginx、nodeの環境を作ったら詰みかけた。
先に結論
php:7.4-fpm-alpine
をベースイメージにして、CMD
を書くと
php-fpm
が起動されなくなる作ったものはこちら
https://github.com/natsume0718/Docker_Laravelなにがおきたか
cronとphpを同一コンテナにいれたものと、nginxを接続しようとしたらできなかった。
nginx側で
[emerg] host not found in upstream “app:9000”
と出てた。1.alpineでphp-fpmのDockerファイルをこんな感じで作った
Laravelが動くようにしたぐらいで変哲はない
FROM php:7.4-fpm-alpine # 基本的なあれこれとgd,node,npm RUN apk upgrade --update && \ apk --no-cache --update add \ icu-dev autoconf make g++ gcc bash git zip unzip vim\ coreutils \ freetype-dev \ libjpeg-turbo-dev \ libltdl \ libmcrypt-dev \ libpng-dev \ oniguruma-dev \ nodejs npm # php extension RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j$(nproc) gd \ bcmath opcache sockets pdo_mysql # install Composer RUN curl -sS https://getcomposer.org/installer | php && \ mv composer.phar /usr/local/bin/composer && \ chmod +x /usr/local/bin/composer # php ini COPY ./php.ini /usr/local/etc/php/php.ini2.nginxはベースイメージをそのままつかって.
default.conf
だけいじったservices: nginx: image: nginx:stable-alpine container_name: nginx ports: - ${APP_PORT:-80}:80 volumes: - ./src:/var/www/html:cached - ./nginx/default.conf:/etc/nginx/conf.d/default.conf depends_on: - app networks: - laravel app: container_name: app build: context: ./app dockerfile: Dockerfile volumes: - ./src:/var/www/html:cached environment: TZ: "Asia/Tokyo" networks: - laravel下記の様にphpのコンテナのポート指定して接続していた
default.conf# ~省略~ location ~ \.php$ { fastcgi_pass app:9000; # php-fpm側のコンテナ:9000 fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; } # ~省略~この時点まではnginxは問題なくPHPを表示できていました。
cronを入れたとたんうごかなくなるnginx
cronいれるというか最初から入ってるので、cronで動かす設定ファイルコピーして内容書き込み、最後に起動しているだけです。
FROM php:7.4-fpm-alpine # 基本的なあれこれとgd,node,npm,supervisor RUN apk upgrade --update && \ apk --no-cache --update add \ icu-dev autoconf make g++ gcc bash git zip unzip vim\ coreutils \ freetype-dev \ libjpeg-turbo-dev \ libltdl \ libmcrypt-dev \ libpng-dev \ oniguruma-dev \ nodejs npm # php extension RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ && docker-php-ext-install -j$(nproc) gd \ bcmath opcache sockets pdo_mysql # install Composer RUN curl -sS https://getcomposer.org/installer | php && \ mv composer.phar /usr/local/bin/composer && \ chmod +x /usr/local/bin/composer # php ini COPY ./php.ini /usr/local/etc/php/php.ini # cronファイルコピーして、内容を書き込む COPY ./laravel-crontab /var/spool/cron/crontabs/ RUN cat /var/spool/cron/crontabs/laravel-crontab >> /var/spool/cron/crontabs/root CMD ["/usr/sbin/crond"]が、nginxでPHPが動作しているページを開くと
502 Bad Gateway
エラーログを調べると
[emerg] host not found in upstream “app:9000”
原因を探す
ググってると似たような状態にいる人を発見する
cronを追加した途端動かなくなったとのこと。
https://stackoverflow.com/questions/62752220/laravel-docker-cron-nginx-502-bad-gateway-issue-111-connection-refused-while
CMD cron && docker-php-entrypoint php-fpm
で解決しましたとある。
docker-php-entrypoint
コマンドは何者...?という疑問になり調べると下記のようなのが見つかり、もともと知らぬ間に
docker-php-entrypoint php-fpm
というのを実行していたとのこと。
https://qiita.com/shim-hiko/items/653059fab63af962a21fんでこのコマンドはphp-fpmをデーモン化してくれていたらしい...
どう対処したか
supervisor入れて、cronとphpをデーモン化して、CMDではsupervisorの起動をするようにしました
色々対応方法あるっぽいけど、Laravelのキューでsupervisor使うのでまあ良いかって感じですもっと良い対処法あれば知りたいです
[supervisord] nodaemon=true logfile=/var/log/jobschedule/supervisord.log pidfile=/var/log/jobschedule/supervisord.pid [program:php-fmp] command = /usr/local/bin/docker-php-entrypoint php-fpm -D autostart = true [program:laravel-worker] process_name=%(program_name)s_%(process_num)02d command=php /var/www/html/artisan queue:work database --tries=1 --sleep=3 autostart=true autorestart=true user=root numprocs=8 redirect_stderr=true stdout_logfile=/var/log/jobschedule/worker.log stopwaitsecs=3600 [program:crond] command = /usr/sbin/crond user = root autostart = true stdout_logfile=/var/log/jobschedule/cron.log
- 投稿日:2020-12-07T23:15:12+09:00
Laravel Auth 初回ログイン後URLの変更
前提
Laravel6.x
PHP7以上$ composer require laravel/ui "^1.0" --dev $ php artisan ui vue --auth上記コマンドにて認証機能を作成したこと
参考:https://readouble.com/laravel/6.x/ja/authentication.html目的
初回ログイン時のURLを条件分岐させたいという事がありました。
LoginControllerの確認
LoginController.phpには必要最低限のコードしか書かれていません。
そのため、Illuminate\Foundation\Auth\AuthenticatesUsers
を確認します。※単純に
/home
を変更したければRouteServiceProvider.php
のHOMEを変更すれば良いです。RouteServiceProvider.phpclass RouteServiceProvider extends ServiceProvider { protected $namespace = 'App\Http\Controllers'; /** * The path to the "home" route for your application. * * @var string */ public const HOME = '/hoge'; ... ... }AuthenticatesUsers.phpの確認
ここにはログイン関連の処理が書かれているようでした。
AuthenticateUsers.phptrait AuthenticatesUsers { use RedirectsUsers, ThrottlesLogins; ...省略 public function login(Request $request) { $this->validateLogin($request); // If the class is using the ThrottlesLogins trait, we can automatically throttle // the login attempts for this application. We'll key this by the username and // the IP address of the client making these requests into this application. if (method_exists($this, 'hasTooManyLoginAttempts') && $this->hasTooManyLoginAttempts($request)) { $this->fireLockoutEvent($request); return $this->sendLockoutResponse($request); } if ($this->attemptLogin($request)) { return $this->sendLoginResponse($request); } // If the login attempt was unsuccessful we will increment the number of attempts // to login and redirect the user back to the login form. Of course, when this // user surpasses their maximum number of attempts they will get locked out. $this->incrementLoginAttempts($request); return $this->sendFailedLoginResponse($request); } ...省略ここでバリデーションなどを行い、最終的にログインに成功したら
sendLoginResponse
を呼んでるのでそれを見に行きます。AuthenticateUsers.phpprotected function sendLoginResponse(Request $request) { $request->session()->regenerate(); $this->clearLoginAttempts($request); return $this->authenticated($request, $this->guard()->user()) ?: redirect()->intended($this->redirectPath()); }ここでintendedの返り値のURLにリダイレクトするようですね。
引数に入っている$this->redirectPath()
が今回の目的です。redirectPath()の確認
RedirectsUsers
というトレイトの中に記載されていました。RedirectUsers.phptrait RedirectsUsers { /** * Get the post register / login redirect path. * * @return string */ public function redirectPath() { if (method_exists($this, 'redirectTo')) { return $this->redirectTo(); } return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; } }ここでは、
- redirectToという関数があればその返り値を返す
- もしなければredirectToというプロパティを返す
- それもなければ'/home'を返す
という処理が実装されているので、二つのやり方がありそう。
redirectPath()
をオーバーライドするredirectTo()
を定義するのどちらかでしょうか。
(ベストプラクティスはどちらなんでしょう。redirectTo()
を定義する方なのかな。ご教示いただけると幸いです。)ちなみに最初に
HOME
を変えれば良いと書いたのは、redirectTo
というプロパティだったからです。寄り道ですが、
AuthenticateUsers.php
のintended
メソッドは何かというと、Redirector.phppublic function intended($default = '/', $status = 302, $headers = [], $secure = null) { $path = $this->session->pull('url.intended', $default); return $this->to($path, $status, $headers, $secure); }こんな感じ。
簡潔に言うと、ログイン前のURLがセッションに保存されていたらそのURLを返すよという事です。
pullとかtoが何をしているかは見ればわかると思います。Store.phppublic function pull($key, $default = null) { return Arr::pull($this->attributes, $key, $default); }
url.intended
のセッションが無ければ$default
を返すという事です。
どこまで掘り下げればいいかわからなくなったので、気になる方はソースコードを読むと良いと思います。
参考:https://readouble.com/laravel/6.x/ja/helpers.html目的のredirectPath()のオーバーライド
LoginController.phppublic function redirectPath() { if ('foo' === 'foo') { return '/foo'; } else { return '/bar' } }もしくは、こっち。
(個人的にはこっちの方が良いと思います。)LoginController.phppublic function redirectTo() { if ('foo' === 'foo') { return '/foo'; } else { return '/bar' } }これで初回ログイン時の分岐ができると思います。
間違いなどがありましたらご指摘をお願いします!
- 投稿日:2020-12-07T21:57:54+09:00
laravelでフォローしたユーザーの投稿を取得する
フォローしたユーザーの投稿を取得します
以下にテーブル構造やモデルの設定を簡単に示しておきます以前にもフォロー機能作成記事を書いているので詳しくはそっちで
https://qiita.com/mitsu-0720/items/0c2fcdd367e8a6c5999cXXXX_XX_XX_XXXXXX_create_follow_users_table.phpSchema::create('follow_users', function (Blueprint $table) { $table->bigIncrements('id'); $table->unsignedBigInteger('followed_user_id')->index(); $table->unsignedBigInteger('following_user_id')->index(); $table->foreign('followed_user_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('following_user_id')->references('id')->on('users')->onDelete('cascade'); $table->timestamps(); });User.php// フォロワー→フォロー public function followUsers() { return $this->belongsToMany('App\User', 'follow_users', 'followed_user_id', 'following_user_id'); } // フォロー→フォロワー public function follows() { return $this->belongsToMany('App\User', 'follow_users', 'following_user_id', 'followed_user_id'); }PostController.phppublic function timeline() { $posts = Post::query()->whereIn('user_id', Auth::user()->follows()->pluck('followed_user_id'))->latest()->get(); return view('posts.timeline')->with([ 'posts' => $posts, ]); }$posts = Post::query()->whereIn('user_id', Auth::user()->follows()->pluck('followed_user_id'))->latest()->get();
について日本語で解説してみると
Post::query() ポストモデルの中の
whereIn('user_id') user_idが
Auth::user()->follows()->pluck('followed_user_id') 自分がフォローしているユーザーの中でフォロワーが自分であるユーザーを取得して
latest()->get() 最新順に取得するといった感じになります
whereInはまだ自分も理解が浅いのですが第二引数が配列となる場合はこっちを使わないと想定した結果を取得できないイメージ
今回みたいに条件指定が複雑な場合はwhereよりwhereInを使った方がいいと思います
実際このtimeline()関数もwhereだと何も取得できませんでしたpluckメソッドは引数の値だけを取得できるメソッド
今回はフォローしてるユーザーのIDだけを取得したかったのでpluck('followed_user_id')でIDのみを取得していますつまり今回自分がuser_id1、2、3のユーザーをフォローしているとすると上の関数は
Post::query()->whereIn('user_id', [1,2,3])->latest()->get();
といった意味を持ちますハマった点
以前自分が書いたメソッドはこう
PostsController.php$posts = Post::query()->whereIn('user_id', Auth::user()->follows()->pluck('following_user_id'))->latest()->get();フォローしてるユーザーのIDが欲しいんだから取得するのはfollowed_user_idではなくfollowing_user_idや!
と思ってdd($posts)してみたところ取得されたのは自分自身の投稿のみだった当時は、は?????????????なんで??????????????????
ってなってましたが今考えればそれもそのはずフォローユーザーは自分なのだからフォローしているユーザーのidを取得しても自分のIDしか取得できないのだ
まとめ
フォローしているユーザーの投稿を取得したいのだからフォロワーのIDなんか使わないだろうと頭でっかちになって考えていたが
「フォロワーが自分であるユーザー」を取得することにより結果的に「自分がフォローしているユーザー」を取得できるという結論になったのはいい経験になった
プログラミングの面白さをしれたいい経験でしたおまけ
「フォローしているユーザー」+「自分自身」の投稿を取得したい場合
PostsController.php$posts = Post::query()->whereIn('user_id', Auth::user()->follows()->pluck('followed_user_id'))->orWhere('user_id', Auth::user()->id)->latest()->get();
- 投稿日:2020-12-07T20:20:37+09:00
【PHP8】PHPに代数的データ型ほしくない? よし、まずは列挙型だ
PHPの開発者のひとり、Larry GarfieldがMLに列挙型のRFCを投稿しました。
最近Ilija ToviloとふたりでPHPに代数的データ型のサポートを追加する作業を行ってるよ。
このプロジェクトは何段階かあるんだけど、まずは第一段階として列挙型を公開レビューするよ。MLとプルリクで幾つかの突っ込みと修正が入っています。
しかし全体としては多くが賛成となっており、致命的な問題でも見つからないかぎり導入される可能性は高いでしょう。ということで以下はPHP RFC: Enumerationsの日本語訳です。
PHP RFC: Enumerations
Introduction
このRFCは、PHPに列挙型を導入するものです。
このRFCの範囲は列挙型のみに限定されています。
すなわちプリミティブ型の糖衣構文などはなく、列挙型に関連する事項以外の追加情報は含まないようにしています。列挙型の機能により、データモデリング、カスタム型の定義、モナドスタイルのサポートなどが大幅に拡充されるでしょう。
列挙型は、「無効な状態は存在できないようにする」というモデリングを可能とし、不要なテスト工数を減らし、より堅牢なコードを記述できるようになります。多くの言語は、複数種類の列挙型をサポートしています。
複数言語を調査した結果によると、一般的に3つのグループに分類できることがわかりました。
すなわちファンシーな定数、ファンシーなオブジェクト、そして完全な代数的データ型です。このRFCは、完全な代数的データ型を導入するという大きな取り組みの一部です。
従ってこのRFCでは、今後のRFCで完全な代数的データ型に拡張できるように、"ファンシーなオブジェクト"タイプのENUMを実装します。
これはSwift、Rust、Kotlinなどの考え方の影響を受けていますが、それらを直接的にモデル化したわけではありません。列挙型の値のもっとも一般的なケースは真偽型で、取ることのできる値は
true
とfalse
のみです。
このRFCでは、開発者が独自に任意の列挙型を定義することが可能にしています。Proposal
列挙型は、クラスとオブジェクトを下敷きに構築されています。
すなわち、特に断りのない限り「この場合の列挙型の動作はどうなるの?」は、「オブジェクトのインスタンスと同じ」となります。
たとえばオブジェクト型チェックに成功し、大文字小文字を区別しません(オートロード時の区別がファイルシステム・クラスローダ依存なのも同じ)。Unit enumerations
このRFCでは、
enum
という新しい言語機能が導入されます。
Enumはクラスに似ており、クラス・インターフェイス・トレイトと同じ名前空間に存在します。
また同じようにオートロードも可能です。
列挙型は、限られた数の有効な値を持ちます。enum Suit { case Hearts; case Diamonds; case Clubs; case Spades; }この構文では、4つの値を持つ新たな列挙型
Suit
を作成しました。
値とはすなわちSuit::Hearts
・Suit::Diamonds
・Suit::Clubs
・Suit::Spades
です。
これら4値を変数などに代入することができます。
型宣言に列挙型を指定した場合は、その型の値のみを渡すことができます。$val = Suit::Diamonds; function pick_a_card(Suit $suit) { ... } pick_a_card($val); // OK pick_a_card(Suit::Clubs); // OK pick_a_card('Spades'); // TypeError列挙型は0個以上の
case
を持つことができ、上限はありません。
case
が0個の列挙型は構文的には有効ですが、実質的にあまり意味がありません。
case
は具体的なスカラー値に紐付けられているわけではありません。
それぞれのcase
は紐付けられているシングルトンと等しくなります。$a = Suit::Spades; $b = Suit::Spades; $a === $b; // true $a instanceof Suit; // true $a instanceof Suit::Spades; // trueこのタイプの列挙型(
case
のみでできていて、関連データなどは付けられない)はユニット列挙型として知られています。これはEnumインターフェイスを実装するオブジェクトとして実装されており、また内部インターフェイスUnitEnumも実装しています。
他のオブジェクトと列挙型を区別する方法のひとつです。Suit::Hearts instanceof Enum; // true Suit::Hearts instanceof UnitEnum; // trueEnumerated Case Methods
列挙型もクラスなので、メソッドを定義することができます。
またインターフェイスをimplementsすることもできますが、その場合は全ての
case
に対して実装しなければなりません。
個々のcase
にインターフェイスをimplementsすることはできません。interface Colorful { public function color(): string; } enum Suit implements Colorful { case Hearts { public function color(): string { return "Red"; } } case Diamonds { public function color(): string { return "Red"; } } case Clubs { public function color(): string { return "Black"; } } case Spades { public function color(): string { return "Black"; } } public function shape(): string { return "Rectangle"; } } function paint(Colorful $c) { ... } paint(Suit::Clubs); // Worksこの例では、4つの
case
は全てSuite
から継承したメソッドcolor
を持っています。
case
内のメソッドは他のメソッドと同じように動作します。また
case
内では$this
が定義されていて、Case
インスタンスを参照することができます。上記の場合、
Red
とBlack
の値を持つSuitColor
列挙型を定義して、それを返す方がよりよいモデリングになるでしょう。
ただそうすると例が複雑になってしまうため避けました。上記の列挙型は、下記のように書き替えることもできます。
interface Colorful { public function color(): string; } abstract class Suit implements UnitEnum, Colorful { public function shape(): string { return "Rectangle"; } public function cases(): array { // See below. } } class Hearts extends Suit { public function color(): string { return "Red"; } } class Diamonds extends Suit { public function color(): string { return "Red"; } } class Clubs extends Suit { public function color(): string { return "Black"; } } class Spades extends Suit { public function color(): string { return "Black"; } }列挙型に静的メソッドを持たせ、各
case
は静的メソッドを持たないことができます。列挙型に静的メソッドを持たせる理由は、主に代替コンストラクタのためです。
たとえば以下のような記述が可能です。enum Size { case Small; case Medium; case Large; public static function fromLength(int $cm) { return match(true) { $cm < 50 => static::Small, $cm < 100 => static::Medium, default => static::Large, }; } }Comparison to objects
列挙型は内部的にはクラスを使って実装されていて、セマンティクスの多くを共有しています。
ただし幾つかのオブジェクトスタイルの機能は禁止されています。
これらの機能は列挙型においては意味がないか、不明瞭であるか、あるいは議論の余地がある(今後追加される可能性がある)ものです。
- コンストラクタ・デストラクタ:状態を持たせなければ不要です。
- 継承:列挙型は設計上クローズドであり、継承は列挙型を壊します。
- 定数:メソッドを使います。
- プロパティ:状態を持たせません。
- 動的プロパティ:良い設計ではありません。
- マジックメソッド:別途記載する一部を除き使用できません。
- シリアライズ:シリアライズはできません。
これらの機能が必要であれば、既存のクラスの方が優れた選択肢になります。
以下のオブジェクトの機能は利用可能です。
- メソッドのpublic / protected / privateアクセス修飾子
- マジックメソッド
__get
・__call
・__invoke
- 定数
__CLASS__
・__FUNCTIONS__
列挙型へのマジック定数
::class
はオブジェクトと全く同じ、名前空間を含む型名で評価されます。各
case
へのマジック定数::class
は、::
のついた値で評価されます。
たとえばFoo\Bar\Baz\Suit::Spades
です。静的メソッドは
case
には使えず、列挙型自体には使用可能です。また、各
case
はnew
で直接インスタンス化することはできず、newInstanceWithoutConstructor
などのリフレクションでインスタンス化することもできません。Scalar Enums
デフォルトでは、列挙された
case
に等しいスカラ値は存在しません。
単なるシングルトンオブジェクトです。
しかし列挙型の値を他のデータベースなどにストアする可能性は高いので、シリアライズ可能なスカラ値が存在すると便利です。以下の構文で、列挙型にスカラ値を定義することができます。
enum Suit: string { case Hearts = 'H'; case Diamonds = 'D'; case Clubs = 'C'; case Spades = 'S'; }スカラ値の型はintもしくはstringであり、ひとつの列挙型は単一の型のみをサポートします。
つまりint|string
のユニオン型はサポートされません。
列挙型がスカラ値を持つ場合は、全てのcase
は一意でなければならず、同じ値を持つことはできません。
自動インクリメントなどの自動生成スカラ値は存在しません。スカラ列挙型の値をスカラコンテキストで使用する場合、自動的にスカラ値にダウンキャストされます。
たとえばprint Suit::Clubs; // "C" print "I hope I draw a " . Suit::Spades; // "I hope I draw a S".スカラ列挙型は内部インターフェイス
UnitEnum
にくわえてScalarEnum
インターフェイスも実装されており、ScalarEnum
インターフェイスにはfrom()
メソッドが定義されています。
このメソッドはスカラ値から対応するcase
を返します。
一致するcase
が存在しない場合はValueErrorをthrowします。$record = get_stuff_from_database($id); print $record['suit'];// "H" $suit = Suit::from($record['suit']); $suit === Suit::Hearts; // trueスカラ列挙型も、ユニット列挙型同様メソッドを持つことができます。
enum Suit: string { case Hearts = 'H'; case Diamonds = 'D'; case Clubs = 'C'; case Spades = 'S' { public function color(): string { return 'Black'; } } public function color(): string { // ... } }Value listing
UnitEnum
インターフェイスには静的メソッドcases()
が実装されています。
このメソッドは、定義された全てのcase
を順に返します。Suit::cases(); // [Suit::Hearts, Suit::Diamonds, Suit::Clubs, Suit:Spades]スカラ列挙型でない場合は0から順にインデックスがつけられます。
スカラ列挙型の場合は、対応するスカラ値がキーになります。また
ScalarEnum
インターフェイスには、スカラ値を返すvalue()
メソッドが実装されています。'D' == Suit::Diamonds->value(); // trueAttributes
列挙型と各caseには、他の言語要素と同じくアトリビュートを適用可能です。
Attributeクラスにふたつのターゲット定数が追加されます。
TARGET_ENUM
は列挙型自体を対象とし、TARGET_CASE
はcase
を対象とします。言語が定義するアトリビュートはありません。
ユーザ定義のアトリビュートでは何でもできます。Match expressions
列挙値に応じてロジックを分岐させる方法として、
match
式が便利な文法を提供します。$val = Suit::Diamonds; $str = match ($val) { Suit::Spades => "The swords of a soldier", Suit::Clubs => "Weapons of war", Suit::Diamonds => "Money for this art", default => "The shape of my heart", }
match
式の自然な使い方であり、match
の構文に手を加える必要はありません。Reflection
列挙型のリフレクションは、
ReflectionClass
をextendsしたReflectionEnum
クラスを使用します。
無関係なメソッド、getProperty()
などは常に空の値を返します。
また、以下のメソッドが追加されます。
hasCase(string $name): bool
そのcase
を持っていればtrueを返す。$r->hasCase('Hearts')
はtrue。getCases(): array
ReflectionCaseの配列を返す。getCase(string $name): ReflectionCase
その名前のReflectionCaseを返す。hasType(): bool
スカラ列挙型であればtrueを返す。getType(): ReflectionType
スカラ列挙型であればその型を、そうでなければ空のReflectionTypeを返す。
ReflectionCase
は、列挙型の中の個々のcase
を表します。
これもReflectionClass
をextendsしており、以下のメソッドが追加されます。
getEnum(): ReflectionEnum
caseの入っている列挙型のReflectionEnumを返す。getScalar(): ?int|string
スカラ列挙型であればその値を、そうでなければnullを返す。getInstance(): Enum
該当の列挙型インスタンスを返す。Examples
以下に、列挙型の例をいくつか表示します。
Basic limited values
enum SortOrder { case ASC; case DESC; } function query($fields, $filter, SortOrder $order) { ... }
query
関数は、引数$order
がSortOrder::ASC
もしくはSortOrder::DESC
のいずれかであることを保証できるようになりました。
それ以外の値を指定するとTypeErrorが発生するため、これ以上のチェックやテストは必要ありません。Advanced Exclusive values
enum UserStatus: string { case Pending = 'pending' { public function label(): string { return 'Pending'; } } case Active = 'active' { public function label(): string { return 'Active'; } } case Suspended = 'suspended' { public function label(): string { return 'Suspended'; } } case CanceledByUser = 'canceled' { public function label(): string { return 'Canceled by user'; } } }ユーザのステータスは
UserStatus::Pending
・UserStatus::Active
・UserStatus::Suspended
・UserStatus::CanceledByUser
のいずれかであり、他の値を取ることはできません。これら4値はポリモーフィックな
label()
メソッドを持っており、これは人間に読める文字列を返します。
この値はデータベースやHTMLのselectボックスに入れる値とは独立して指定することができます。foreach (UserStatus::cases() as $key => $val) { printf('<option value="%s">%s</option>\n', $key, $val->label()); }
label()
メソッドは個別に実装せず、ひとつのメソッドとして列挙型クラスに実装することもできます。enum UserStatus: string { case Pending = 'pending'; case Active = 'active'; case Suspended = 'suspended'; case CanceledByUser = 'canceled'; public function label(): string { return match($this) { UserStatus::Pending => 'Pending', UserStatus::Active => 'Active', UserStatus::Suspended => 'Suspended', UserStatus::CanceledByUser => 'Canceled by user', }; } }どちらのアプローチが適切であるかは、何をするかの目的によって異なるものであり、開発者の裁量に委ねられます。
State machine
列挙型は、有限ステートマシンを簡単に表現できます。
enum OvenStatus { case Off { public function turnOn() { return OvenStatus::On; } } case On { public function turnOff() { return OvenStatus::Off; } public function idle() { return OvenStatus::Idle; } } case Idle { public function on() { return OvenStatus::On; } } }この例では、オーブンはオン、オフ、アイドルの3状態を持っています。
しかしオフからアイドル、アイドルからオフに移行することはできず、オフにするには必ずオンの状態を経由しなければなりません。
すなわち、オフから直接アイドルに移行するテストやコードを書いたりする必要がないということです。もちろん、実際のケースではもっと追加の実装が必要になることも多いでしょうが。
New interfaces
これまで出てきたように、このRFCでは3つの内部インターフェイスが追加されます。
このインターフェイスは、ユーザの作成したコードが列挙型であるか、列挙型である場合はどのような種類であるかを判断するために利用可能です。
ユーザが直接implementsすることはできません。interface Enum {} interface UnitEnum extends Enum { public function cases(): array; } interface ScalarEnum extends Enum { public function value(): int|string; public static function from(int|string $scalar): static; }Backward Incompatible Changes
enum
がキーワードになります。グローバルスコープにインターフェイス
Enum
・UnitEnum
・ScalarEnum
が追加されます。Open questions
特定の
case
に対してタイプヒントは可能?たとえばpublic function stuff(Suit::Heart|Suit:Diamond $card) { ... }Future Scope
代数的データ型のRFCを参照ください。
Grouped syntax
単純なユニット列挙型であれば、まとめて定義を可能にする。
enum Suit { case Hearts, Diamonds, Clubs, Spades; }メソッドの定義されていない単純なユニット列挙型にのみ可能な文法です。
この列挙型がどのくらい使われるのか不明であり、構文も議論の余地があり、必要に応じて後から追加も可能な構文なので、現時点では対応していません。Enums as array keys
列挙型の
case
はオブジェクトであるため、連想配列のキーとしては使用できません。
将来的には対応するかもしれません。Serialization
Suit::Clubs === unserialize(serialize(Suit::Clubs))
を安全にシリアライズする方法があれば、後から追加される可能性があります。
今のところは非対応です。感想
最終的に代数的データ型の実装を目指す遠大な計画の一端です。
個人的には
const
でどうにもならない場面に遭遇したことがないので、PHPにおいて列挙型がどこまで有用なのかよくわかりません。
しかし、PHPで列挙型を作ろうという試みが昔から山ほど行われていることを鑑みると、けっこうな需要はありそうです。
特に型以上の引数値限定はこれまで基本的にはできませんでしたから、この点についてはかなり便利そうです。このような追加機能については、往々にしてユーザによる勝手拡張の方が便利だったりすることが多いですが、列挙型の場合は
extends enum
を文法で禁止することで値を完全に保証したり、値にメソッドを生やすことができたりと、公式実装の方が便利っぽいですね。
しかし、いちいちcase
を入れないといけないのは面倒ですね。
他言語ではenum Suit{Hearts, Diamonds, Clubs, Spades}
みたいに書けるものも多いのですが、PHPでは構文解析の都合上難しかったのかな?ところでSplEnumってどうなったんだろう。
RFCでもMLでもプルリクでも誰一人話題に上げていない。
- 投稿日:2020-12-07T20:19:21+09:00
LightsaillにMagentoをインストール
はじめに
所属しているロボコンチームでECサイトが必要になったのですが、自動で伝票を作成できる機能などがあるMagentoをインストールすることにしました。
結構苦戦したので、備忘録的な意味も含めて、メモしておきます。
なお、一般的なものとあまり変わらない部分は、簡単に記載するだけになるので、追加でいろいろ調べながらやってみてください。インスタンスの作成
これは、簡単です。
AWSのアカウントを取得して、Ligsaillの管理画面からインスタンスを作成するだけです。
本来は、Magentoがセットになっているインスタンスもありますが、今回は何もセットになっていないインスタンスを作成します。
OSはAmazonLinux2で、メモリが4Gのプランです。
Lightsaillはインスタンスのアップグレードはできても、ダウングレードはできないので大きくしすぎないように気を付けましょう。
メモリは、最低でも2G無いとインストールできないと思うので、それ以上のプランを選ぶようにしましょう。
また、接続用のSSHキーペアを作成する場合は、なくさないようにしましょう。
Apacheのインストール
Apacheのインストールは特段難しくありません。
sudo yum -y update sudo yum install -y httpd起動及び自動起動設定
systemctl start httpd systemctl enable httpd
SSLの設定
今回は、手動インストールしましたが、yumでも大丈夫だと思います。
sudo curl https://dl.eff.org/certbot-auto -o /usr/bin/certbot-auto sudo chmod 700 /usr/bin/certbot-autoなお、そのまま使用するとエラーがでますので、こちらの記事を参考に実行ファイルを少し変更してください。
Amazon Linux2でLet's Encrypt使おうとしたらコケた話
それが終わったら、通常通り証明書を発行すればよいです。
MySQLのインストール
これも特段変わりません。
こちらに記載する必要もないので、リンクのみ。
2.5.1 MySQLYumリポジトリを使用したLinuxへのMySQLのインストールPHPのインストールについて
これで少し詰まりました。
初期状態でAmazonLinux2にPHPを入れると、7.4xがインストールされるのですが、動作はしたものの7.3が推奨されており、拡張機能がうまくインストールできなかったので、PHP7.3xをインストールします。
まずは、現在インストールされているものをアンインストールします。yum remove php-* -yそして、php7.4を無効にし、7.3をインストールします。
sudo amazon-linux-extras disable php7.4 sudo amazon-linux-extras install php7.3=7.3.13終わったら、php-fpmの再起動を忘れずに
systemctl reload php-fpmMagentoのインストール
これも通常通り行えばよいです。
今回は、 /var/www/html にインストールします。composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition /var/www/html/var/www/htmlに移動し、インストールコマンドを実行します。
必要なものは適宜置き換えてください。bin/magento setup:install \ --base-url=http://localhost/magento2ee \ --db-host=localhost \ --db-name=magento \ --db-user=magento \ --db-password=magento \ --admin-firstname=admin \ --admin-lastname=admin \ --admin-email=admin@admin.com \ --admin-user=admin \ --admin-password=admin123 \ --language=en_US \ --currency=USD \ --timezone=America/Chicago \ --use-rewrites=1インストールが完了すると、管理画面のURLが発行されるので、そこにアクセスします。
初期設定
スクショを取り忘れたので、言葉で説明します。
ログイン画面に移動したら、インストール時に設定したアカウント情報でログインします。
そうすると、メールに2段階認証の設定コードを送ったと書かれた画面になりますが、メールを設定していないので、送られてきません。
そこで、少しファイルを編集します。vi vendor/magento/module-two-factor-auth/Model/EmailUserNotifier.phpでファイルを開き、
/** * Send configuration related message to the admin user. * * @param User $user * @param string $token * @param string $emailTemplateId * @param string $url * @return void */ private function sendConfigRequired( User $user, string $token, string $emailTemplateId, string $url ): void { try { $transport = $this->transportBuilder ->setTemplateIdentifier($emailTemplateId) ->setTemplateOptions([ 'area' => 'adminhtml', 'store' => 0 ]) ->setTemplateVars(この記載個所の
try{の前に、
var_dump($url);と入力します。
そして、先ほどの画面に戻ると、メールに送られるはずだったURLが出力されるので、アクセスして2段階認証の設定をします。
設定が終わったら、ファイルは元に戻しておいてください。日本語化
日本語化も簡単です。
以下をインストールすれば完了です。composer require veriteworks/m2-japaneselocale管理画面のアカウント設定で日本語を選択すれば、日本語になります。
メモリ不足について
もし、メモリーが足りないと出たら、Swapファイルを作成します。
1024と書かれているところを変更すれば、容量も変更できます。sudo dd if=/dev/zero of=/swapfile bs=1M count=1024 sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfilefreeコマンドを実行して、認識されていれば、成功です。
free total used free shared buff/cache available Mem: 4037584 2441604 397488 740 1198492 1366768 Swap: 2097148 0 2097148まとめ
ECCubeを使ってきた人としては、結構大変でしたが、やはり越境ECや大規模サイトの場合の運営のしやすさ、他のツールとの連携性はシェア率からしても高いです。
最初は、CDNをかまそうかと思ったのですが、調べてもメディアファイルだけCDNを通す、という記事しか発見できなかったのであきらめました。
もし、間違えなどあったら指摘してください。
- 投稿日:2020-12-07T19:55:17+09:00
Laravel6をさくらレンタルサーバーにデプロイ 備忘録
手順
sshでサーバにログイン
ssh user@xxx.comLaravel プロジェクトをgit経由でダウンロードする
/home/user/mkdir laravel cd laravel git clone ~~composerインストールをする
/home/user/laravel/project_name/curl -sS https://getcomposer.org/installer | phpインストールできたか確認してみる
/home/user/laravel/project_name/php composer.pharVenderのインストール
/home/user/laravel/project_name/php composer.phar install.envファイルの設定をする
/home/user/laravel/project_name/.envAPP_ENV=production APP_DEBUG=false DB_CONNECTION=mysql DB_HOST=mysql000.db.sakura.ne.jp DB_PORT=3306 DB_DATABASE=さくらインターネットのレンタルサーバで作成したデータベース名 DB_USERNAME=アカウント名 DB_PASSWORD=データベースパスワードphp artisan ○○をする
/home/user/laravel/project_name/php artisan key:generate php artisan migrate php artisan storage:link.htaccessを編集する
/home/user/laravel/project_name/public/.htaccess<IfModule mod_rewrite.c> <IfModule mod_negotiation.c> Options -MultiViews -Indexes </IfModule> RewriteEngine On # Handle Authorization Header RewriteCond %{HTTP:Authorization} . RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] # Redirect Trailing Slashes If Not A Folder... RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_URI} (.+)/$ RewriteRule ^ %1 [L,R=301] # Handle Front Controller... RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^ index.php [L] </IfModule>index.phpを編集する
/home/user/laravel/project_name/public/index.phprequire '/home/user/laravel/project_name/vendor/autoload.php'; $app = require_once '/home/user/laravel/project_name/bootstrap/app.php';wwwフォルダー下にシンボリックリンクを作成する
ln -s /home/user/laravel/project_name/public/ /home/user/www/project_nameブラウザからアクセスして確認してみる
https://xxx.com/project_name/以上。
自分が実際にデプロイした際の手順をまとめておきました。
いろいろなサイトを飛び回って探して苦労したので忘れない為に。
- 投稿日:2020-12-07T19:15:40+09:00
WordPressで親テーマの余計なCSSを削除
ワードプレスのテーマtwentytwentyを基盤に、wp_head( )を記述した際にビューが崩れる問題に対し、wp_head( )内で親テーマのCSSを読み込まないようにする実装を行ったので、備忘録的にこの記事を残す。
環境情報
PHP:version 7.3.12
WordPress:version 5.5.3
WPテーマ:twentytwenty作業
子テーマのfunction.phpに以下を記述することで解決しました。
function.php//parent-style-cssの削除 function my_delete_plugin_files() { wp_dequeue_style('parent-style'); } add_action( 'wp_enqueue_scripts', 'my_delete_plugin_files' ); //twentytwenty-style系cssの削除 add_action( 'wp_enqueue_scripts', function() { $styles = wp_styles(); $styles->add_data( 'twentytwenty-style', 'after', array() ); }, 20 );
- 投稿日:2020-12-07T19:13:47+09:00
MAMPでPDO接続の際にUnknown character set,Connection failed,が出た時の対応
人生で初めてPDO接続したので速攻起きたエラーのまとめです。
SQLSTATE[HY000] [2019] Unknown character setへの対応
index.php<?php try{ $db= new PDO('mysql:dbname=my_db;host=127.0.0.0;charset=utf-8', 'root','root'); }catch(PDOException $e){ echo "DB接続エラー".$e->getMessage(); } ?>
SQLSTATE[HY000] [2019] Unknown character set
と出ました。charcterってことは、utf-8
あたりが悪いだろうなと大体察しはつくのですが、
どこが悪いのかよくわかりません。どこがいけないのだろうとググってみると.
charset=utf-8の
utf-8ではなくutf8
で「-」(ハイフン)は不要とのこと!
これは罠ですよね...index.php<?php try{ $db= new PDO('mysql:dbname=my_db;host=127.0.0.0;charset=utf8', 'root','root'); }catch(PDOException $e){ echo "DB接続エラー".$e->getMessage(); } ?>これで書き換えてOKかと思いきや...
次は「Connection failed」が発生しました。全くわからないので、調べてみました。
Connection failed: SQLSTATE[HY000] [2002]への対応
Connection faildは接続が失敗している...
どうやら接続しているhostやportがいけないみたいですね。index.phphost=127.0.0.0ではなく
idenx.phpport=8889に変えると、良いと書いてありました。
そういえばMAMPのportデフォルトのままだった...
index.php<?php try{ $db= new PDO('mysql:dbname=my_db;port=8889;charset=utf8', 'root','root'); }catch(PDOException $e){ echo "DB接続エラー".$e->getMessage(); } ?>これでエラーが解消しました!
参考にした記事
- 投稿日:2020-12-07T19:06:42+09:00
PHPでパッケージを自作する方法
動機
パッケージを自作する一連の流れが分かりやすいものが見つからなかったため、最低限の流れを自分用にメモ。
なんでパッケージをつくるのか
アプリケーション開発において、同じような処理を毎回書くのは時間がもったいない。そのため、パッケージを自分でつくって、再利用できるようにしたい。
つくりかた
Packagistを利用して、パッケージを作成する。以下がその流れ。
雛形
以下を雛形として
git clone
する。このディレクトリ構造が標準となるらしい。
https://github.com/php-pds/skeleton.git$ git clone https://github.com/php-pds/skeletoncomposer.jsonの編集
composer.jsonを自分のパッケージ用に編集する。対話的につくりたいので、元のファイルは削除して新しく作成する。あと、いらないフォルダやファイルは削除しておく。
$ rm composer.json $ composer initすると、以下のように対話的にcomposer.jsonが作成できる。
vendorはGitHubのユーザー名などがいいらしい。Welcome to the Composer config generator This command will guide you through creating your composer.json config. Package name (<vendor>/<name>) [username/directory]: hoge/hoge (-> composer require hoge/hoge になる) Description []: calulate addition (パッケージの説明) Author [***** <***@***.***>, n to skip]: Minimum Stability []: stable Package Type (e.g. library, project, metapackage, composer-plugin) []: library License []: MIT (MITライセンス) Define your dependencies. Would you like to define your dependencies (require) interactively [yes]? n Would you like to define your dev dependencies (require-dev) interactively [yes]? n (必要ならここでdependencies入力しておく) { "name": "hoge/hoge", "type": "library", "license": "MIT", "authors": [ { "name": "*****", "email": "***@***.***" } ], "minimum-stability": "stable", "require": {} } Do you confirm generation [yes]? yesPHPUnitのインストール
パッケージ化するにあたって、テストを実行する必要があるのでPHPUnitをインストールしておく。
$ composer require phpunit/phpunit --devソースコードを書く
srcディレクトリにディレクトリを作成する。今回はMyPackageとする。
作成したディレクトリ内にソースコードを記述する。$ cd src $ mkdir MyPackage && cd MyPackage $ touch MyMath.php $ vim MyMath.phpsrc/MyPackage/MyMath.php<?php namespace ksrnnb\MyPackage; class MyMath { /** * 足し算する * @param int $a * @param int $b * @return int */ public function add(int $a, int $b): int { return $a + $b; } }README.mdを書く
パッケージ利用者のために、README.mdを書いておく。
テストコードを書く
testsディレクトリ以下に書いていく。
$ cd tests $ touch MyMathTest.php $ vim MyMathTest.phptests/MyMathTest.php<?php use PHPUnit\Framework\TestCase; use ksrnnb\MyPackage\MyMath; class MyMathTest extends TestCase { /** * @var MyMath */ protected $obj; public function setUp(): void { parent::setUp(); $this->obj = new MyMath(); } /** * 足し算のテストをする */ public function testAdd(): void { $this->assertEquals(3 + 5, $this->obj->add(3, 5)); } }PHPUnitの設定ファイルを作成する
パッケージのルート直下にphpunit.xmlファイルを作成して、PHPUnitの設定ファイルを作成する。
設定は以下を参照。
https://phpunit.readthedocs.io/ja/latest/configuration.html#appendixes-configurationphpunit.xml<?xml version="1.0" encoding="utf-8" ?> <phpunit colors="true"> <testsuites> <testsuite name="All tests"> <directory>tests/</directory> </testsuite> </testsuites> </phpunit>テストする
以下のコマンドで、設定したディレクトリ下のテストが実行される。
$ vendor/bin/phpunitタグをつけてGitHubにpushする
タグの命名はComposerのサイトを参照。
https://getcomposer.org/doc/articles/versions.md$ git tag v1.0.0 $ git push origin v1.0.0Packagistに登録
このへんは簡単なので省略。
ログイン→Submit→GitHubのURL入力。パッケージを自動更新するようにする
そのままだと自動更新設定してないよってメッセージが出るので対応しておく。
GitHubのリポジトリ→Setting→Webhooks→Packagistの手順を参考に追加する。利用する
以上でパッケージを利用する準備はOKのはず。
下記コマンドで、パッケージをインストールすることができる。
なぜかは分からないが、しばらく時間が経たないとインストールできないこともあった。$ composer require hoge/hoge参考
自作Composerのパッケージの基本的な構成
https://getcomposer.org/doc/articles/versions.md
https://getcomposer.org/doc/04-schema.md
- 投稿日:2020-12-07T18:46:06+09:00
CakePHP CASE文をORMで表現する
こちらの記事はユアマイスターアドベントカレンダー2020の7日目の記事です。
やりたいこと
- SQLのSELECT句におけるCASE文を、ORMで表現したい
TL;DR
- queryオブジェクトを生成
- addCase()を使用してCASE文作成
- 第一引数:条件
- 第二引数:条件に関連した値
- 最後の値(
new IdentifierExpression('SUM(price)')
)は、SQLのELSEの値に当たる- 第三引数:型
- CASE文をORMのSELECT句に指定
$query = $this->Products->find(); $case = $query->newExpr()->addCase( [$query->newExpr()->add(['SUM(price) IS NULL'])], [0, new IdentifierExpression('SUM(price)')], ['integer', 'integer'] ); $query->select(['price' => $case])->all();SQL
- ORMで表現したいSQL
SELECT CASE WHEN SUM(price) IS NULL THEN 0 ELSE SUM(price) END FROM products;ORM
- 上記のSQLをORMで表現
$query = $this->Products->find(); $case = $query->newExpr()->addCase( [$query->newExpr()->add(['SUM(price) IS NULL'])], [0, new IdentifierExpression('SUM(price)')], ['integer', 'integer'] ); $query->select(['price' => $case])->all();解説
- queryオブジェクトを生成
- addCase()を使用してCASE文作成
- 第一引数:条件
- 第二引数:条件に関連した値
- 最後の値(
new IdentifierExpression('SUM(price)')
)は、SQLのELSEの値に当たる- 第三引数:型
- CASE文をORMのSELECT句に指定
- 投稿日:2020-12-07T18:23:49+09:00
【PHP】宇宙船演算子の復習【個人メモ】
はじめに
https://qiita.com/Hiraku/items/62821d7d0e7af1ac211e
こちら様の記事の個人用メモになります。難しい!宇宙船演算子の基本
<=>
と記載する「小なり」「イコール」「大なり」がくっついた演算子のこと。名称がわからず検索に苦労。
三方比較演算子とも表記するみたいです。基本的にusort()
関数と併用します。基本的に
$a <=> $b
とした場合、usortで指定した配列は昇順(0,1,2,...)でソートされます。
対して、$b <=> $a
とした場合は指定した配列が降順(...,2,1,0)でソートされる、という特徴があるようです。サンプル1
$array = array(1,0,3,2); usort ($array, function ($a, $b){ return $a <=> $b; //昇順に並び替えられる 0,1,2,3 }); print_r($array); /* 出力結果 Array ( [0] => 0 [1] => 1 [2] => 2 [3] => 3 ) */$array = array(1,0,3,2); usort ($array, function ($a, $b){ return $b <=> $a; //降順に並び替えられる 3,2,1,0 }); print_r($array); /* 出力結果 Array ( [0] => 3 [1] => 2 [2] => 1 [3] => 0 ) */サンプル2(2次元配列)
$array = [ [2,2], [5,3], [3,5], [3,4], [4,2], [2,3], [2,5], [3,6], [2,4], [4,1] ]; //[値x,値y]として配列に入っている。 //今回はxの値を優先的に昇順(0,1,2,...)でソートされるように決定。 //xの値が、比較先のxと同じである場合はyの値を確認し、yが降順(...,2,1,0)に並ぶようにする。 usort ($array , function ($a, $b) { //$aのxと、$bのxが同じだった場合。 //===は変数の型も同じである場合。(==と記載した場合も、今回のコードは動きます)。 if ($a[0] === $b[0]) { //yの降順で並べる //降順の際は、比べている値を逆さに記載する return $b[1] <=> $a[1]; } //それ以外の場合は、xの値を昇順に並べる return $a[0] <=> $b[0]; }); foreach ($array as $a) { echo implode(' ', $a). "\n"; } /* 出力結果 2 5 2 4 2 3 2 2 3 6 3 5 3 4 4 2 4 1 5 3 */さいごに
自分以外が見ても分かるように書いたつもりですが、自分の理解していない箇所についてはうまく言語化できず・・・
記事にしてアップすると知識が定着すること以外にも、自分の弱い箇所も分かって良いですね。自分で配列の値を弄って、実行して結果を見て様子を確認していくと結構理解が進んだ気がします。
- 投稿日:2020-12-07T18:23:49+09:00
【PHP】宇宙船演算子の復習
はじめに
https://qiita.com/Hiraku/items/62821d7d0e7af1ac211e
こちら様の記事の個人用メモになります。難しい!宇宙船演算子の基本
<=>
と記載する「小なり」「イコール」「大なり」がくっついた演算子のこと。名称がわからず検索に苦労。
三方比較演算子とも表記するみたいです。基本的にusort()
関数と併用します。基本的に
$a <=> $b
とした場合、usortで指定した配列は昇順(0,1,2,...)でソートされます。
対して、$b <=> $a
とした場合は指定した配列が降順(...,2,1,0)でソートされる、という特徴があるようです。サンプル1
$array = array(1,0,3,2); usort ($array, function ($a, $b){ return $a <=> $b; //昇順に並び替えられる 0,1,2,3 }); print_r($array); /* 出力結果 Array ( [0] => 0 [1] => 1 [2] => 2 [3] => 3 ) */$array = array(1,0,3,2); usort ($array, function ($a, $b){ return $b <=> $a; //降順に並び替えられる 3,2,1,0 }); print_r($array); /* 出力結果 Array ( [0] => 3 [1] => 2 [2] => 1 [3] => 0 ) */サンプル2(2次元配列)
$array = [ [2,2], [5,3], [3,5], [3,4], [4,2], [2,3], [2,5], [3,6], [2,4], [4,1] ]; //[値x,値y]として配列に入っている。 //今回はxの値を優先的に昇順(0,1,2,...)でソートされるように決定。 //xの値が、比較先のxと同じである場合はyの値を確認し、yが降順(...,2,1,0)に並ぶようにする。 usort ($array , function ($a, $b) { //$aのxと、$bのxが同じだった場合。 //===は変数の型も同じである場合。(==と記載した場合も、今回のコードは動きます)。 if ($a[0] === $b[0]) { //yの降順で並べる //降順の際は、比べている値を逆さに記載する return $b[1] <=> $a[1]; } //それ以外の場合は、xの値を昇順に並べる return $a[0] <=> $b[0]; }); foreach ($array as $a) { echo implode(' ', $a). "\n"; } /* 出力結果 2 5 2 4 2 3 2 2 3 6 3 5 3 4 4 2 4 1 5 3 */さいごに
自分以外が見ても分かるように書いたつもりですが、自分の理解していない箇所についてはうまく言語化できず・・・
記事にしてアップすると知識が定着すること以外にも、自分の弱い箇所も分かって良いですね。自分で配列の値を弄って、実行して結果を見て様子を確認していくと結構理解が進んだ気がします。
- 投稿日:2020-12-07T18:12:51+09:00
LaravelExcelのバージョンアップでハマったことまとめ
経緯
Laravelをバージョンアップするに伴い、PHPやライブラリであるLaravelExcelやPHPExcelなどもバージョンアップされ、いくつかの変更点でハマった点をまとめてみた
新旧のバージョン情報
Laravel
新:6.18.42
旧:5.1.46PHP
新:7.4.11
旧:5.6.30ハマった点と解決方法
Excel::load()の廃止
元々はこんな感じでExcelを読み込んでいました。
test.phpExcel::load('file.xlsx', function ($file) { // PHPExcelを使った処理 など });→後述のPHPSpreadsheetの機能で読み込む方法に変更しました。
多分、Excelファイルを読み込んで値を返すだけならLaravelExcelの3以降だとExcel::import()を使用するのがいいと思います。
PHPExcelなどを使ってシート、列、行、セルなどを操作している場合はPHPSpreadsheetを使用する形へ変更した方がいいと思います。PHPExcelの廃止→PHPSpreadsheetへ移行
PHPExcel自体が非推奨・廃止となり、PHPSpreadsheetの使用を推奨されています。
使用するクラスなどが変更となっています。ちなみにExcelファイルの読み込みは下記のような形で実装しました。
namespace App\Http\Controllers; use PhpOffice\PhpSpreadsheet\Reader\Xlsx as Reader; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; class ImportExcelController extends Controller { // エクセル読み込み処理 $reader = new Reader(); $sheet = $reader->load('/test.xlsx'); }※前述した通り、色々な操作をする場合のExcel::import()での読み込みが上手く構築出来ず…もし何か良い方法などをご存知の方がいらっしゃいましたらご教示くださいますと嬉しいです。
セルオブジェクトの値取得時、列番号の指定が変更
PHPSpreadsheetへ移行したことでセルオブジェクトにおける列番号の指定が変更になりました。これが地味に混乱させる要因に…
旧処理
test.php$col = 0; // 列 $row = 0; // 行 // A1のセルオブジェクトを取得 $cell = $sheet->getCellByColumnAndRow($col, $row)->getValue();新処理
test.php$col = 1; // 列 $row = 0; // 行 // A1のセルオブジェクトを取得 $cell = $sheet->getCellByColumnAndRow($col, $row)->getValue();旧処理では列はA=0,B=1…と0始まりで指定されますが、新処理ではA=1,B=2…と1始まりに変更になっています。
普段プログラミングからもずれており、ややこしさを感じますね。結論
・Excelを使う処理はシンプルであるべき!
特にテンプレートに可変部分を持たせないようにした方がいいですマジで参考資料
- 投稿日:2020-12-07T17:58:37+09:00
WordPressのAll in One SEOパックで指定されたTDKを変更したい!
前提
WordPress
All in One SEOパックインストール済み背景
今回関わっている案件で顧客からWordPressの記事サイトのTDKをカスタマイズしたいという要望を受けて対応をしたので、そのあたりについてメモがてらまとめておく
All in One SEOパックを利用しているので、ある程度は設定画面からカスタマイズできるのはこの記事に辿りつくかたならご存知だと思いますが、今回特定のカテゴリのみカスタマイズしたいという要望だったため、設定画面からでの対応では難しく、モジュール側に手を入れる方向で対応を行いました対応内容
結論から言うと専用のfilterがあったので、
functions.php
にそのフィルターを追加する形で対応を行いました。ソースコードは以下functions.php/** * 一覧のdescription制御 * @param $description All in One SEOで設定しているdescription */ function archive_description_custom($description) { if (is_archive()) { // 表示中のカテゴリを取得 $cat = get_term(get_query_var('cat'), 'category'); // カテゴリの判定ロジック if ($cat->slug === 'XXXXXXXX') { // ここで変更したい内容のdescriptionを入れる $description = ""; } } // カスタマイズ後のディスクリプションをreturnする return $description; } add_filter('aioseop_description', 'archive_description_custom');aioseop_xxxxの部分をtitleに変更すればそのままtitleのカスタマイズができます
- 投稿日:2020-12-07T16:31:00+09:00
WordPressでのscreenshot.png(テーマ画像)について
ワードプレスのテーマtwentytwentyを基盤に、管理画面「外観」>「テーマ」でサイトのスクリーンショット画面を表示させる実装を行ったので、備忘録的にこの記事を残す。
環境情報
PHP:version 7.3.12
WordPress:version 5.5.3
WPテーマ:twentytwenty作業
管理画面「外観」>「テーマ」でサイトのスクリーンショット画面を表示させるためには、「screenshot.png」が必要です。
スクリーンショット画面は、たとえば次の要領で作成・保存します。
トップページなどのスクリーンショットを取得し、画像サイズを880×660ピクセルにします。画像ファイル名は「screenshot.pmg」とします。
作成したscreenshot.pngをindex.phpやstyle.cssと同じ階層に配置します。
- 投稿日:2020-12-07T12:51:52+09:00
[初心者向け][sqlite3]なぜだ。コマンドでsqlite使えない。
sqliteコマンド使えんやんけ!
題名のとおり、laravelを安定の青本で勉強していた際に、データベースの部分でsqlite3を使う機会がありました。
その際になぜかsqliteコマンドが反映されないことがあったので、メモ程度に残させていただきます。環境
● Windows10
● sqlite(ダウンロード方法は省きます。)
※Mac環境の方は、元から搭載されているらしく関係ないそうです。今回しようとしていたこと
cmd.sqlite3 database.sqliteこれを実行するも
「sqlite3」は使用できません。
よし、ググってみましょう。解決方法
原因
「コマンドラインツールは別アーカイブになっています。sqlite-tools-win32-x86-なんとか.zipをダウンロードして取り出してください。」
とのことでsqlite-tools-win32-x86-なんとかをダウンロードし、コマンドたたいたところ、
cmd.sqlite3 database.sqlite SQLite version ***** Enter ".help" for usage hints.お、できました。
まとめ
とてもしょうもないのですが、上記の通りコマンドは別アーカイブらしいので、コマンドのもダウンロードする必要があるそうです。
あまり記事がなさそうだったので、投稿してみました。少しでも参考になれば幸いです。
- 投稿日:2020-12-07T10:10:15+09:00
ショートコードでポリモーフィズムの恩恵を体感してみる
この記事は PHP Advent Calendar 2020 - Qiita の 8 日目 の記事です
ポリモーフィズムは言葉による説明だけでは恩恵を実感しにくいので、
実際に手を動かして体感してみましょう、という趣旨のエントリです。なお、ある程度の規模を持ったプログラムでないと、ポリモーフィズムの恩恵を実感しにくいところを、敢えて、ショートコードで実装しているので、一部現実的ではない設計になっているところがあります。
タイトルが大げさなわりに中身やコードそのものはしょうもないです。
対象読者 / 留意点
- オブジェクト指向の初歩的なエントリです。
- 主に、Interface, Trait, Abstractにフォーカスを当てています。
- あくまで筆者自身の現時点での捉え方にすぎませんので誤り点ありましたらコメント欄にて教えて頂けると幸いです。
本エントリで扱わないこと
- 「ポリモーフィズムとは何か」という説明は多くの優良記事があるので、 そちらにお任せし、あくまで「実装を通してメリットを体験すること」に重きを置きます。
免責事項
本エントリをご覧頂いている方々へ。
本エントリ内で、触れているポケットモンスター関連の著作権・商標登録を有している権利団体や、メディア媒体Qiita関連の権利団体から、削除/編集要請などがあった際には、速やかに削除対応/編集対応させて頂きますので、ご理解のほどよろしくお願いします。
それでは本題へ移りましょう!
抽象クラス、インターフェース、トレイトを使うと何が嬉しいの?
「具象クラスの継承のみで構成してもいいんじゃないの?」という観点に対して
1. 実装漏れを防ぐことができる。
1匹のポケモンに対して、具象クラスを1つずつ付けるとして、ピチューに逃げるを実装し損ねてしまうと、Fatal Errorを吐いてくれて、抽象メソッドを実装してくださいと怒ってくれる。 => "contains 1 abstract method and must therefore be declared abstract or implement the remaining methods" 。
2. パーツとして切り出せる。
単一継承の制約をしてもらっている中で、「部分的に」複数回記述が被っている部分をパーツとして切り出すことができる。「余分なメソッド」を下位の具象クラスに継承させなくて済む。
3. テストの際にモックしやすくなる
(=> Dependency Injectionが使える)
本エントリ内で取り扱う例を使って触れることのできる話ではないので別エントリに書きます。今から実装するミニゲームの設定
野生のポケモンの行動をテロップとして流す既存のPHPコードに仕様追加していきます。
- 「たたかう」「にげる」のみ実装済
追加実装:1回目
- ポケモンの数を151匹から251匹に増やすことになり、自分はピチューを実装するように頼まれた。
追加実装:2回目
- 捕獲困難な伝説のポケモン・ライコウの実装を頼まれた。
大雑把に実装条件を列挙
- 151匹のポケモンを実装したPHPコードがすでに存在している。(後述のbase.php)
- stringでテロップを返す。
- 野生のポケモンは「たたかう」「にげる」ができる
- 技には使えるポケモンと使えないポケモンの区別がある。
- 10まんボルトを使えるポケモンと使えないポケモンが居る。
- [使える] ピカチュウ、ライチュウ、ピチュー、ライコウ
- [使えない] アーボック
主人公エンジニアが本コードを設計時に考えたこと。
Interface (インターフェース)
- 野生のポケモンとプレイヤーのポケモンで共通するものを抽象メソッドで宣言。
- 継承先にて、そのインターフェイスを実装。
- 例えば、プレイヤーのポケモンには「やせいのXXXがあらわれた」という実装は不要。
Trait (トレイト)
- 技(10まんボルト, なみのり)は、ポケモンによって使える使えないが異なるので、共通部品として切り出してあげたい。
Abstract Class (抽象クラス)
- 野生のポケモンのみに共通する挙動は、この階層にて抽象メソッドで制約を加え、その他の共通処理はメソッド内部の実装まで行ってしまい、具象メソッドとして書く。
自分の好きな関係図 (abstract, trait, interface)
全体像を俯瞰するにはとても見やすい関係図とその掲載元のリンクを貼っておきます。
実装前に、一度ご覧ください。
)
出典元:https://coinbaby8.com/php-class-abstract-interface-trait-di.html
長い長い前置きが終わり、ようやく手を動かせます!
体験方法
(手順1/2) まずベースとなるコードをbase.phpとしてコピー&ペーストして保存します。
編集前のコードは以下です。
base.php<?php // ポケモン (Interface) interface PokemonInterface { // [Interface内で抽象メソッド] public function chooseAction($rate): string; // [Interface内で抽象メソッド] public function fight(): string; // [Interface内で抽象メソッド] public function run(): string; } // 10まんボルト (トレイト) trait ThunderboltTrait { protected static $power = 100; protected static $type = 'でんき'; protected static $name = '10まんボルト'; public function fight(): string { return $this->useThunderbolt(); } public function useThunderbolt(): string { return 'てきの ' . static::NAME . ' の ' . self::$name; } } // 野生のポケモン (抽象クラス) abstract class WildPokemonAbstract { // [抽象クラス内で抽象メソッド] abstract public function appear(): string; // [抽象クラス内で具象メソッド] public function chooseAction($rate = 99): string { if (mt_rand(1, 100) <= $rate) { return $this->fight(); } return $this->run(); } // [抽象クラス内で具象メソッド] public function fight(): string { // 遅延静的束縛 (Late Static Binding) return 'てきの ' . static::NAME . ' は ' . 'わざ' . ' を くりだした'; } // [具象クラス内で具象メソッド] public function run(): string { // 遅延静的束縛 (Late Static Binding) return 'てきの ' . static::NAME . ' は にげだした'; } } // ポケモン (具象クラス) class WildPokemon extends WildPokemonAbstract implements PokemonInterface { // ポケモン図鑑の番号 protected const INDEX = 'none'; // ポケモンの日本語名 protected const NAME = 'ポケモン'; // ポケモンの逃走率 protected const FLEERATE = 2; // [具象クラス内で具象メソッド] public function appear(): string { return 'やせいの ' . '[' . static::INDEX . '] ' . static::NAME . ' が あらわれた'; } } /* |-------------------------------------------------------------------------- | 具体的なポケモンの省略 |-------------------------------------------------------------------------- | | ポケモン図鑑001-023は省略 | ポケモン図鑑026-151は省略 | */ // アーボック class WildArbok extends WildPokemon { // ポケモン図鑑の番号 protected const INDEX = '024'; // ポケモン日本語名 protected const NAME = 'アーボック'; } // ピカチュウ class WildPikachu extends WildPokemon { use ThunderboltTrait; // ポケモン図鑑の番号 protected const INDEX = '025'; // ポケモン日本語名 protected const NAME = 'ピカチュウ'; } // ライチュウ class WildRaichu extends WildPokemon { // ポケモン図鑑の番号 protected const INDEX = '026'; // ポケモン日本語名 protected const NAME = 'ライチュウ'; } /* |-------------------------------------------------------------------------- | 具体的なポケモンの省略 |-------------------------------------------------------------------------- | | ポケモン図鑑001-023は省略 | ポケモン図鑑026-151は省略 | */ // インスタンス生成 $wildPikachu = new WildPikachu(); // 草むらにて野生のピカチュウとエンカウント echo $wildPikachu->appear() . PHP_EOL; // やせいの [025] ピカチュウ が あらわれた // 野生のピカチュウが確率で行動を選択 echo $wildPikachu->chooseAction() . PHP_EOL; /* |-------------------------------------------------------------------------- | (99%の確率で) てきの ピカチュウ の 10まんボルト | (1%の確率で) てきの ピカチュウ は にげだした |-------------------------------------------------------------------------- */ // 野生のピカチュウがたたかうメソッド echo $wildPikachu->fight() . PHP_EOL; // てきの ピカチュウ の 10まんボルト // 野生のピカチュウが逃げるメソッド echo $wildPikachu->run() . PHP_EOL; // てきの ピカチュウ は にげだした(手順2/2) ターミナルアプリで、下記コマンドを実行すると出力されます。
$ php base.phpいかがでしょう?
4行の文字出力は正常に出ましたか?やせいの [025] ピカチュウ が あらわれた てきの ピカチュウ の 10まんボルト てきの ピカチュウ の 10まんボルト てきの ピカチュウ は にげだした
それでは追加実装に、移って行きます。
追加実装 : 1回目
251匹に増えたので、ピチューを担当することになりました。
- ピチューにも10まんボルトが使えるように実装したいです。
base.phpに下記追記をします。
base.php<?php // 既存コードの一番下に追記 /* |-------------------------------------------------------------------------- | // ピチュー (No.172) 追加 |-------------------------------------------------------------------------- */ // ピチュー (172) 追加 class WildPichu extends WildPokemon { use ThunderboltTrait; // ポケモン図鑑の番号 protected const INDEX = '172'; // ポケモン日本語名 protected const NAME = 'ピチュー'; } $wildPichu = new WildPichu(); // 草むらにて野生のピチューとエンカウント echo $wildPichu->appear() . PHP_EOL; // やせいの [172] ピチュー が あらわれた // 野生のピチューが確率で行動を選択 echo $wildPichu->chooseAction() . PHP_EOL; /* |-------------------------------------------------------------------------- | (99%の確率で) てきの ピチュー の 10まんボルト | (1%の確率で) てきの ピチュー は にげだした |-------------------------------------------------------------------------- */ // 野生のピチューがたたかうメソッド echo $wildPichu->fight() . PHP_EOL; // てきの ピチュー の 10まんボルト // 野生のピチューが逃げるメソッド echo $wildPichu->run() . PHP_EOL; // てきの ピチュー は にげだした下記コマンドの実行
$ php base.phpいかがでしょう?
8行の文字出力は正常に出ましたか?やせいの [025] ピカチュウ が あらわれた てきの ピカチュウ の 10まんボルト てきの ピカチュウ の 10まんボルト てきの ピカチュウ は にげだした やせいの [172] ピチュー が あらわれた てきの ピチュー の 10まんボルト てきの ピチュー の 10まんボルト てきの ピチュー は にげだした
追加実装 : 2回目
伝説のポケモン・ライコウの実装
- ライコウは、1ターン目ですぐに逃げてしまうので、それをchooseActionの中で反映させてあげたいです。
base.phpに下記追記をします。
base.php<?php // 既存コードの一番下に追記 /* |-------------------------------------------------------------------------- | ライコウ (No.243) 追加 |-------------------------------------------------------------------------- */ // ライコウ (243) 追加 class WildRaikou extends WildPokemon { use ThunderboltTrait; // ポケモン図鑑の番号 protected const INDEX = '243'; // ポケモン日本語名 protected const NAME = 'ライコウ'; // ポケモンの逃走率 protected const FLEERATE = 99; // [抽象クラス内で定義した具象メソッドのオーバーライド] public function chooseAction($rate = self::FLEERATE): string { if (mt_rand(1, 100) > $rate) { return $this->fight(); } return $this->run(); } } $wildRaikou = new WildRaikou(); // 草むらにて野生のライコウとエンカウント echo $wildRaikou->appear() . PHP_EOL; // やせいの [243] ライコウ が あらわれた // 野生のライコウが確率で行動を選択 echo $wildRaikou->chooseAction() . PHP_EOL; /* |-------------------------------------------------------------------------- | (1%の確率で) てきの ライコウ の 10まんボルト | (99%の確率で) やせいの ライコウ は にげだした |-------------------------------------------------------------------------- */ // 野生のライコウのたたかうメソッド echo $wildRaikou->fight() . PHP_EOL; // てきの ライコウ の 10まんボルト // 野生のライコウの逃げるメソッド echo $wildRaikou->run() . PHP_EOL; // てきの ライコウ は にげだした下記コマンドの実行
$ php base.phpいかがでしょう?
12行の文字出力は正常に出ましたか?やせいの [025] ピカチュウ が あらわれた てきの ピカチュウ の 10まんボルト てきの ピカチュウ の 10まんボルト てきの ピカチュウ は にげだした やせいの [172] ピチュー が あらわれた てきの ピチュー の 10まんボルト てきの ピチュー の 10まんボルト てきの ピチュー は にげだした やせいの [243] ライコウ が あらわれた てきの ライコウ は にげだした てきの ライコウ の 10まんボルト てきの ライコウ は にげだした
いかがでしたでしょうか?
インターフェースやトレイトを耳にしたことはあるけれど、
実際にコードに入れる機会がなかった、という方が
イメージするのにお役に立てたら嬉しいです。
おっと...読者の方に、次の実装依頼がきたようです
実装追加 : 3 (体験: トレイト)
実装要件
- アーボック(Arbok)が、どくばり(PoisonSting)を標準出力できるように実装
実装する際の1つの提案
- テロップでどくばりを流すために、10まんボルトの時と同じく、PoisonStingをtraitとして実装してみるのは1つの実装方法かと思います。
実装追加 : 4 (体験: トレイト + 具象クラス追加)
実装要件
- 第3世代のNo.258 ミズゴロウ(Mizugorou)をキャラクタとして実装
- なみのりをテロップとして流してあげる実装。
実装する際の1つの提案
- 具象クラスとしてミズゴロウを実装して
- なみのり(Surf)をtraitとして実装するのは1つの実装方法かと思います。
実装追加 : 5 (体験: インターフェース + 抽象クラス)
実装要件
- ライチュウがまひ状態に陥る実装
- 「ライチュウ は からだが しびれて うごけない」をテロップとして流してあげる実装。
実装する際の1つの提案
- 全てのポケモン(トレーナーのポケモンも野生のポケモンも)まひ状態に陥る可能性があるので、InterfaceにParalysisを抽象メソッドとして加えてあげる。
- 宣言した抽象メソッドは必ず「インターフェースの実装」として中身を書いてあげないといけないので、2つの抽象クラス(WildPokemonAbstract と TrainerPokemonAbstract)の中で実装してあげる。
- 実際に、実装済みのポケモンクラスをインスタンス化し、まひ状態のテロップを流すメソッドを呼び出すのは1つの実装方法かと思います。
あとがき
ご覧頂きありがとうございました。
今年もお互いに実りあるクリスマスを過ごせますように
参考資料
- 投稿日:2020-12-07T02:41:42+09:00
Laravelプロジェクト作成時に"Could not find package laravel/laravel with stability stable."と出る
筆者の環境
- macOS Catalina バージョン10.15.6
- PHP 7.4.13
- Composer 2.0.8
発生問題
久しぶりに新規Laravelプロジェクトをローカルに作成しようと思い、いつも通りターミナルから以下のコマンドを打つと…
$ composer create-project laravel/laravel laravel_sample --prefer-dist以下のエラーが。
[InvalidArgumentException] Could not find package laravel/laravel with stability stable.仕方がないのでlaravel/installerを使おうと思い、以下のようにlaravel/installerをインストールするコマンドを打つと…
$ composer global require "laravel/installer"以下のエラーが。
[InvalidArgumentException] Could not find package laravel/installer. Did you mean one of these? laravel/installer codemyviews/vanilla-installer解決策
結論、Composer自体を完全にアンインストールし、インストールし直したら無事エラーも起きずLaravelプロジェクトが作成でき、laravel/installerもインストールできました…(詳細な原因は分からず…)
アンインストール手順については以下の記事を参考させて頂きました。
(アンインストール時に必要となる.composerとcomposer.pharのパスはMacでは、「/Users/ユーザ名/.composer」、「/usr/local/bin/composer/composer.phar」となります。)[PHP]Composer自体を完全にアンインストールする方法 | akamist blog
問題の原因は特定できませんでしたが、ひとまずアンインストールすることで解決できました。
「Could not find package laravel/laravel with stability stable」に遭遇時の対処法はネット上ではあまり見つからなかったため、今回投稿致しました。
同じような問題に遭遇した方の参考になれば幸いです。
- 投稿日:2020-12-07T00:04:11+09:00
多言語化の対応を、PHP-Parserでコード置換&静的解析でサポートする話
こんにちわ。 OPENLOGI AdventCalendar 6日目です。
オープンロジでは昨年インドネシアで実証実験 を行いました。それにあたってシステムの多言語化行ったのですが、その際に利用したPHP-Parserの活用について少し紹介したいと思います。
前提
Laravelの多言語の仕組み
lang/en/messages.php<?php return [ 'welcome' => 'Welcome to our application', ];のような言語ファイルを用意し、
echo __('messages.welcome');と記述することで、多言語の対応を行うことができます。詳しくは https://readouble.com/laravel/7.x/en/localization.html あたりを参照。
やりたいこと
- その1、翻訳対象の文字列を全て
__()
で囲いたい。- その2、翻訳文言が全て言語ファイルに定義されていることを担保したい。
と言う感じです。
翻訳対象の文字列を全て
__()
で囲いたいさて本題。
ざっくり言うと、Laravel内で定義した全ての日本語(っぽい文字列)を、__()
で囲いたい。と言うのが要件です。まぁこれだけだとPHPの言語仕様上許されないケースもありますし、多言語化をする必要のないケースなどあります。
が、そういったイレギュラーへの対応は一旦無視し、分かりやすさ重視ということで細かいところは端折って書いきますのでご了承ください。簡単な具体例をだしてみます。まずこれが変更したいソースコード。
before.php<?php class Sample { public function test() { echo 'aiueo'; echo 'あいえうお'; echo __('かきくけこ'); } }そしてこっちが期待するコードです。
after.php<?php class Sample { public function test() { echo 'aiueo'; echo __('あいえうお'); echo __('かきくけこ'); } }多言語のキーは敢えて元の日本語文字列としてます。元の仕様通り
messages.あいうえお
ってキーに変換してあげてもい良い。また、一括でSample.1
のようなキーに変換してあげても良いかもしれませんが、分かりやすさ重視、ということで。デフォルト文言をキーにしてます。
さて、ではまずは上側ファイルをASTでどのような構造となるかを確認しましょう。function dumpAST($filePath) { $lexer = new Emulative([ 'usedAttributes' => [ 'comments', 'startLine', 'endLine', 'startTokenPos', 'endTokenPos', ], ]); $node = (new Php7($lexer))->parse(file_get_contents(new SplFileInfo($filePath))); echo (new NodeDumper())->dump($node) . PHP_EOL; }するとこんな感じになります。(読まなくて良いよ!)
Stmt_Class( flags: 0 name: Identifier( name: Sample ) extends: null implements: array( ) stmts: array( 0: Stmt_ClassMethod( flags: MODIFIER_PUBLIC (1) byRef: false name: Identifier( name: test ) params: array( ) returnType: null stmts: array( 0: Stmt_Echo( exprs: array( 0: Scalar_String( value: aiueo ) ) ) 1: Stmt_Echo( exprs: array( 0: Scalar_String( value: あいえうお ) ) ) 2: Stmt_Echo( exprs: array( 0: Expr_FuncCall( name: Name( parts: array( 0: __ ) ) args: array( 0: Arg( value: Scalar_String( value: かきくけこ ) byRef: false unpack: false ) ) ) ) ) ) ) ) )なるほどなるほど。ってことで、
0: Scalar_String( value: あいえうお )を、
Expr_FuncCall( name: Name( parts: array( 0: __ ) ) args: array( 0: Arg( value: Scalar_String( value: あいうえお ) ) ) )とできたら勝ちですね。
さて、PHP-Parserでは、ASTの木構造について各ノードを処理するVisitorを実装します。今回上記を実現するためのVisitorはこんな感じで書いてみました。
ポイントとしては
- A ) 文字列(
Node\Scalar\String_
) がASCIIでなければ、関数(Node\Expr\FuncCall
)で囲った関数ノードに置き換える- B ) すでに該当の関数で囲われている場合は
NodeTraverser::DONT_TRAVERSE_CHILDREN
を利用してそのノードは対象外とする- C ) Aで囲った関数ノードの内部も同様に対象外とする
WrapNonAsciiTextVisitor.php<?php use PhpParser\Node; use PhpParser\Node\Name; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use SplFileInfo; final class WrapNonAsciiTextVisitor extends NodeVisitorAbstract { /** @var SplFileInfo */ private $splFileInfo; /** @var string */ private $functionName; /** @var array */ private $modifications = []; // ログ用。変換したやつをあとで一覧にしたいから。 private $wrapNode; function __construct(SplFileInfo $splFileInfo, string $functionName = '__') { $this->splFileInfo = $splFileInfo; $this->functionName = $functionName; } public function enterNode(Node $node) { // C) wrapNodeの内部はなにもしない if ($this->wrapNode) return NodeTraverser::DONT_TRAVERSE_CHILDREN; } // B) すでに関数で囲われているものも対象外 if ($node instanceof Node\Expr\FuncCall) { if (isset($node->name->parts) && $node->name->parts === [$this->functionName]) { return NodeTraverser::DONT_TRAVERSE_CHILDREN; } } // A) 文字列でASCIIでない場合は、関数に置き換える if ($node instanceof Node\Scalar\String_) { if (!$this->isAscii($node->value)) { $this->modifications[] = [ 'start_line' => $node->getStartLine(), 'value' => $node->value, ]; $this->wrapNode = new Node\Expr\FuncCall(new Name($this->functionName), [ new Node\Arg(new Node\Scalar\String_($node->value)) ]); // C) 置き換えたノードがわかるように一時的に保持しておく return $this->wrapNode; } } return $node; } public function getModifications() { return $this->modifications; } public function leaveNode(Node $node) { // C) 置き換えたノードを出る時に初期化 if ($this->wrapNode === $node) { $this->wrapNode = null; } return parent::leaveNode($node); } private function isAscii($text) { // とりあえずASCII以外とする。 return mb_check_encoding($text, 'ASCII'); } }wrapNodeをわざわざ保持しているのは、
Scalar_String
=>Expr_FuncCall(Scalar_String)
と置換しているため。無限にラップし続けるのを防ぐための機構。これで、AST上の表現として、コードの書き換えの準備がととのいました。
あとはASTをソースコードに変換してファイルを上がいて行けばよいのですが、上のASTの表現見ると、空白行のようなもの、ないですよね。一度ASTに変換したものをコードに戻す場合、空白行が削られたりするのですが、元のフォーマットを維持するように出力しています。また、変更した箇所をVisitorで保持しておいたので、CSVっぽくログを出すようにしてます。
public function transform($splFileInfo) { $lexer = new Emulative([ 'usedAttributes' => [ 'comments', 'startLine', 'endLine', 'startTokenPos', 'endTokenPos', ], ]); $parser = new Php7($lexer); // 元のフォーマットを保持する $statementPreservingTraverser = new NodeTraverser(); $statementPreservingTraverser->addVisitor(new CloningVisitor()); $oldStatements = $parser->parse(file_get_contents($splFileInfo)) $newStatements = $statementPreservingTraverser->traverse($oldStatements); // 実際の変換処理 $visitor = new WrapNonAsciiTextVisitor($splFileInfo); $nodeTraverser = new NodeTraverser(); $nodeTraverser->addVisitor($visitor); $nodeTraverser->traverse($newStatements); // 変換したやつの一覧を出しておく。ただのログ foreach($visitor->getModifications() as $modification) { fputcsv(STDOUT, array( $this->splInfo->getPathname(), $modification['start_line'] ?? '', $modification['value'] ?? '', )); } // 変換後のコードでファイルを更新する $newCode = (new Standard)->printFormatPreserving($newStatements, $oldStatements, $lexer->getTokens()); file_put_contents($this->splInfo->getRealPath(), $newCode); }あとは、特定のディレクトリを全てなめて実行すればOK!
function wrapNonAsciiText(string $dir) { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::SKIP_DOTS ) ); /** @var SplFileInfo $fileinfo */ foreach ($iterator as $fileinfo) { transform($fileInfo); } }ここまでの実装は、 https://github.com/haradakunihiko/php-code-mod にあげているので参考にしてください。
上述しましたが、実際にこの処理だけではうまくいかないケースが多々あります。
例えばメンバ変数の初期値に関数を利用してしまう、とか。まぁその辺りは地道にVisitorを拡張していきましょう。翻訳文言が言語ファイルに存在することをチェックしたい
はい。
てなわけで一旦__
を全部にかませることができました。
あとは今後の開発者がやるべきことを忘れないようにチェックですね。例えば、__()
を利用するの忘れた とか、__()
は使ったんだけど、言語ファイルに追記するの忘れた ってやつですね。多言語化の一番の課題って結局この辺なのかなと思ってます。どちらも上の応用でできるとおもいますが、前者はイレギュラーパターンを考えると単純にアサートするのは難しそう。後者は要件明確です。言語ファイルに全ての文言が存在するかをユニットテストで担保しましょう。
ということで、__('文字列')
となっている文字列を抽出するためのVisitor。use PhpParser\Node; use PhpParser\NodeVisitorAbstract; class TranslationMessageVisitor extends NodeVisitorAbstract { private $translateFunction; private $keys; public function __construct(string $translateFunction) { $this->translateFunction = $translateFunction; $this->keys = collect(); } public function enterNode(Node $node) { if ($node instanceof Node\Expr\FuncCall) { if (isset($node->name->parts) && ( $node->name->parts === [$this->translateFunction] )) { /** @var Node\Arg $key */ $keyArg = array_get($node->args, '0'); if ($keyArg instanceof Node\Arg) { /** @var Node\Scalar\String_ $key */ $key = $keyArg->value; if ($key instanceof Node\Scalar\String_) { $this->keys->push($key->value); } } } } return $node; } public function getKeys() { return $this->keys; } }これで、全ての
__()
に渡しているキーが取得できます。あとはこれらのキーに対して、言語の翻訳があるかどうかをチェックすればOK。
Laravelの仕組みであればこんな感じ。$this->assertTrue(app('translator')->hasForLocale("messages.{$key}", $lang));という風にユニットテストを書いてます。
最後に
最初から多言語化を考慮してあればよいのですが、そうも限らないですからね・・
ということで、PHP-Parserを利用して、PHPコードの一括置換や、静的解析をしてユニットテストへ組み込む例を紹介しました。
実際にはもう少し工夫 & 拡張して
- (上に書いたように)定数やフィールドの初期化は除外するとか
- 例外に利用されている部分だけを抜き出して特別な関数に置き換えるとか
__()
で囲われたキーを、各翻訳の言語ファイルに出力するところまでを半自動化するとかってことを追加でやってます。
IDEの機能を利用したり正規表現を使うのも手頃で良いですが、意外とASTを利用した置換や静的解析もやってみると簡単にできるので、おすすめです。リンク