のんラボ

【自己流】エラーハンドリング【リライト記事】

2019/12/22 2022/08/03

【自分流】エラーハンドリング・例外に対する考え方【Laravelとか含む】 こんにちは。のんです。 今回の記事は社内の勉強会で利用した記事のリライト記事になります。 内容はエラーハンドリングについてです。 例外に対する考え方について 言語にもよりますが、PHPer の自分にとって例外は goto 構文と同じです。一度例外をスローするとどこでキャッチされるかわからない不安があります。 そのあたり Rust や Go のようにエラー型を返す方式は「なるほど」と感じました。PHP でもこれらの言語のように例外に対して厳しく接するとこのような書き味になるかなと考えました。 e.g.)<?php declare(strict_types=1); $example = new Example(); if (!$example->isValid()) { // どこに飛んでいくかわからない。 throw new RuntimeException(); } 例外のキャッチの仕方について 基本的に1階層ごとに必ずキャッチする方式がいいと思います。 採用するアーキテクチャにもよりますが、大抵は層ごとに関心事が別れていたり、役割が別れていたりします。 その層に必要な例外を独自に作成してスローするということです。 e.g.) Infrastructure 層の Repository では。 e.g.)<?php declare(strict_types=1); final class SampleRepository { public function save(Sample $sample): void { $sampleModel = $this->sampleModel->newQuery()->find((string)$sample->id()); if ($sampleModel instanceof SampleModel) { // データが存在しない場合は独自の例外をスローする。 throw new SampleNotExistsException('Failed to save sample.'); } // データ更新など if (!$sampleModel->save()) { // データの保存に失敗したときも独自の例外をスローする。 throw new SaveSampleException('Failed to save sample.'); } } } Sample のデータが無いときの例外 SampleNotExistsException 、データの保存に失敗したときの例外 SaveSampleException をそれぞれスローするようにします。 この例外は上の階層についての知識は特に持たず、この層独自の例外として宣言しておきます。 e.g.) Application 層の ApplicationService では。 e.g.)<?php declare(strict_types=1); final class UpdateSample { private LoggerInterface $logger; private SampleRepository $sampleRepository; public function __construct( LoggerInterface $logger, SampleRepository $sampleRepository ) { $this->logger = $logger; $this->sampleRepository = $sampleRepository; } public function handle(UpdateSampleInputPort $input): void { // 何かしらの処理 // 権限を調べるとか。 if (!$sample->isOwner()) { // Application 層のユースケースに合った例外をスローする。 // ここでは所有者ではない例外をスローする。 throw new NotOwnerException('Not owned by Sample.'); } // 何かしらの処理 try { $this->sampleRepository->save($sample); } catch (SampleNotExistsException | SaveSampleException $e) { $this->logger->error($e); // Application 層のユースケースに合った例外をスローする。 // 下の階層のメッセージをそのまま採用する。 throw new UpdateSampleException($e->getMessage()); } catch (Throwable $e) { $this->logger->error($e); // Application 層のユースケースに合った例外をスローする。 // Fatal なエラーなので、ログのみに残してメッセージは固定とか。 throw new UpdateSampleException('Fatal error.'); } } } Repository 特有の例外をしっかりキャッチして、この層特有の例外をスローし直すということが重要だと思います。 バケツリレー的になってコードを書くのは面倒ですが、層ごとに関心事が別れた例外ができて、何が飛んでくるのかが良くわかるようになるので見通しが良くなります。 SaveSampleException ≠ UpdateSampleException ということが重要です。 SaveSampleException は Repository で「データの保存に失敗する」という関心事についての例外。 UpdateSampleException は「 Sample を保存する」というユースケースが失敗したという関心事の例外。 です。 なので、文面は似ていますが、それぞれ関心事が違うので役割のレイヤーが違う。というイメージです。 こうすることで、層ごとに分離できるようになり他のユースケースなどでリポジトリを再利用しやすくなったり、違うコントローラーでユースケースを再利用することがしやすくなります。 e.g.) Adapter 層の Controller では。 e.g.)<?php declare(strict_types=1); final class UpdateSampleAction { private LoggerInterface $logger; public function __construct( LoggerInterface $logger, UpdateSample $updateSample ) { $this->logger = $logger; $this->updateSample = $updateSample; } public function __invoke(UpdateSampleRequest $request) { try { try { // ユースケースに入力するための Input を作成する。 // ここでは SampleValueObject が InvalidArgumentException をスローするとする。 $input = UpdateSampleInput(new SampleValueObject($request->input('sample', ''))); } catch (InvalidArgumentException $e) { $this->logger->error($e); // ValueObject のエラーなので、400 BadRequest を返す例外をスローする。 throw new BadRequestHttpException($e->getMessage()); } try { $this->updateSample->handle($input); } catch (NotOwnerException $e) { $this->logger->error($e); // 権限 のエラーなので、404 NotFound を返す例外をスローする。 // (403 でもいいが、ここではデータがることを悟られたくないため404とする。要件にも寄る。) throw new NotFoundHttpException($e->getMessage()); } catch (UpdateSampleException $e) { $this->logger->error($e); // 更新できなかったエラーなので、500 InternalServerError を返す例外をスローする。 throw new InternalServerErrorHttpException($e->getMessage()); } } catch (BadRequestHttpException | NotFoundHttpException $e) { $this->logger->error($e); // 「ユーザー起因のエラー」なので、そのままエラーレスポンスを返す。 return $e->getApiProblemResponse(); // エラーレスポンスを返すためのメソッド。 } catch (InternalServerErrorHttpException $e) { $this->logger->error($e); // 「システム起因のエラー」なので、レポートに残す目的で上位層のエラーハンドラにスローする。 // Laravel を想定するなら Handler クラス。 throw $e; } catch (Throwable $e) { $this->logger->error($e); // 「それ以外の想定外のエラー」なので、レポートに残す目的で上位層のエラーハンドラにスローする。 // Laravel を想定するなら Handler クラス。 throw $e; } } } Controller は Http Request や Http Response を捌く層なので、 Application 層からキャッチした例外や、 ValueObject を生成するときにスローされる例外を捌きます。 特に Controller では4xx系と5xx系で分けるべきと考えているので、キャッチした例外の種類(後述)でスローする HttpException に詰め替えてスローしています。 さらに、4xx系はレポートに残す必要の無い例外なので Adapter の Contoller の中でエラーレスポンスを返すようにします。対して、5xx系はレポートに残したい例外なので、上位のハンドラにスローしてレポーティングしてもらいます。 (adsbygoogle = window.adsbygoogle || []).push({}); 例外の種類 / 階層について これまでに少し出てきた例外の種類と階層について言及したいと思います。 まずは階層について 階層については前章の例がわかりやすいです。 プログラム上の例外 引数が違う DB接続に失敗した 仕様上の例外 データにアクセスする権限がない 更新に失敗した プログラム上の例外はいわゆる低レイヤの例外なのでシステム的なエラーを保持します。 前章の例で説明すると Infrastructure 層の Repository の SampleNotExistsException や、SaveSampleException がこれに当たります。 仕様上の例外はいわゆるビジネスロジックの例外なので、仕様的なエラーを保持します。 前章の例で説明すると Application 層の ApplicationService の NotOwnerException や、 UpdateSampleException に当たります。 ここまででバケツリレーした例外を最終的に Controller でキャッチして更に上位のハンドラにスローするかエラーレスポンスを返すか決めます。このときはエラーの種類(後述)が 重要になってきます。 次に種類について 例外の種類というのは、どのような性質を持つかということだと考えています。 重要な例外 想定できないシステムエラー 実行中に発生してしまうランタイムエラー 軽微な例外 入力値が起因のエラー 権限が起因のエラー 重要な例外は開発者起因のエラー。つまり開発者がレポーティングしてきっちり監視しておきたいです。Laravel だと Handler などで収集外部サービスなど経由で監視したい。(後述) 前章では UpdateSampleException がこれに当たります。 軽微な例外はユーザー起因のエラー。つまり画面でエラーメッセージを出せるようなエラーですし、頻発して起こることが想定できます。なので管理する必要は薄いです。ロギングだけしておけば、レポーティングの必要はないでしょう。そのまま Controller で Response を返してしまいます。 前章では InvalidArgumentException や NotOwnerException がこれに当たります。 Laravel の Handler クラスについて Laravel を利用しているプロジェクトでは、一般的に Handler.php が便利なので、そこにスローする形をとりがち。 Laravel というシステムに何も考えずに乗っているのはとても楽ちんではあるんですが、大規模なシステムのエラーを追おうとすると複雑になってしまいます。関心事の分離という観点でもしっかり Handler を利用すべき時とそうでない時を使い分けるべきです。 結論としてはこれまでにも述べているように、Handler には重要なエラーのみをスローするで良いかと思います。 その理由については書いていきます。 一般的に Application Performance Monitoring & Error Tracking Software Self-hosted and cloud-based application performance monitoring & error tracking that helps software ... などを Laravel で作成されているプロジェクトに導入するときは Handler に記載するでしょう。 public function report(Throwable $e): void { if ($this->shouldReport($e) && app()->bound('sentry')) { app('sentry')->captureException($e); } parent::report($e); } ということは4xx系のエラーも Handler にスローしてしまうとこれらも Sentry に記録され、本当にチェックしたい例外が埋もれてしまいます。 そこで Controller でエラーレスポンスを返してしまうもの(軽微な例外)と、 Handler にスローするもの(重要な例外)で種類分けするという流れです。このとき、Handler にスローされる例外の種類が少ないので render の中身もスッキリするでしょう。これもまた一つのメリットですね。 public function render($request, Throwable $e) { // 1. Laravel が実装している例外について if ($e instanceof NotFoundHttpException) { // Laravel の NotFoundHttpException をどのように返すか書く。 } if ($e instanceof ValidationException) { // Laravel の ValidationException をどのように返すか書く。 } // 2. Adapter 層の Controller で利用している HttpException の基底例外クラスについて if ($e instanceof HttpException) { // 独自に定義した例外の HttpException をどのように返すか書く。 // API サーバーだと過程するなら RFC7807 に沿ったレスポンスを返すとか。 return $e->getApiProblemResponse(); } // 3. それにも対応していない想定外のエラーについて if ($e instanceof Throwable) { // どのように返すか書く。 } } Laravel の4xx系エラー(軽微なエラー)をレポーティングをしたくないとすれば $dontReport に登録しておくのも手。 しかし、自分で決めた独自例外をハンドリングせず何もしないまま Handler へスローして、 $dontReport で制御しようとするのは安直で管理が煩雑になりやすい。 よって、 $dontReport に登録するものは Laravel 特有の例外のみで良さそうです。 あるあるなのが、エラー時に Boolean 型で判定するパターン。 Boolean で返すべきなのか、例外をスローすべきなのか。 <?php declare(strict_types=1); final class SampleRepository { public function save(Sample $sample): bool { $sampleModel = $this->sampleModel->newQuery()->find((string)$sample->id()); // データ更新など return $sampleModel->save() } // or public function save(Sample $sample): void { $sampleModel = $this->sampleModel->newQuery()->find((string)$sample->id()); // データ更新など if (!$sampleModel->save()) { throw new SaveSampleException('Failed to save sample.'); } } } 個人的には例外をスローでいいと思う。 理由としては、 保存できなかった。というのはエラーなので例外をスローしたい Boolean は ON / OFF とかに利用したい。成否を Boolean で表現するのは微妙(な気がする) この save がなにか特定のオブジェクトを返す時、返り値が mixed になってしまい、可読性(見通し)が下がる などがあります。 基本的に成否に関しては Boolean ではなく例外をスローしたい気持ち。Rust や Go も同じ考え方(のはず) 考えるべき点:データ取得処理でデータが無いときに NULL を利用する 次にあるあるなのが、DBからデータを探して存在しないときに null を返す場合です。 null で返すべきか、例外をスローすべきなのか。 <?php declare(strict_types=1); final class SampleRepository { public function findById(SampleIdentifier $sampleIdentifier) { // モデルを返すのかnullを返すのかわからない、mixed。 return $this->sampleModel->newQuery()->find((string)$sampleIdentifier); } // or public function findById(SampleIdentifier $sampleIdentifier): SampleModel { $sampleModel = $this->sampleModel->newQuery()->find((string)$sample->id()); if (!$sampleModel instanceof SampleModel) { throw new SampleNotExistsException(); } return $sampleModel; } } 個人的には例外をスローすべきと考えています。 そもそも nullable プログラムが嫌いというのが理由の筆頭です。 nullableを許容したくない 例外をスローすることで「データが存在しない」というエラーを表現できる 上位の層で拡張性が上がる。 が理由でしょうか? 例外を投げるべき。というより、 nullable を避けるべき。のほうがとても強い。 最後に 基本的に例外はガンガンスローすべきというのが私の結論となりました。 Rust や Go を書くと例外を投げる。ではなく、エラー型を返すというコードをよく書くようになり、エラーの流れがとてもわかりやすく感じました。 記事をネットで調べていると、中には例外が無いことに不満がある記事もありましたが、私は逆でした。 また、そもそも例外をスローしないようなコードを書こう。という設計についても理解できます。 ただ、そうすると Boolean や null を返すしかなくなるので、 mixed な値を返す関数を大量精算してしまいます。それを避けるために仕方なく例外を利用する。というニュアンスでしょうか。PHPでもエラー型を返したい。 try - catch 地獄(例外のバケツリレー)にはなりますが、エラー時のデータフローがよくなると感じているのでやるべきかな?とは思います。 今回は初心者にはあまり注目されない(?)エラーハンドリングについて書いてみました。 所属しているチームのメンバーもよく詰まっている部分なので、この機会に自分の考えを言語化してみました。 自分の考えを書く系の記事を少しずつ増やそうかな?と思っています。 そのときはよしなに。 .

バルカンSで西日本をぐるっと一周してきました!

2022/07/28 2022/07/28

バルカンSで西日本をぐるっと一周してきました! こんにちは。のんです。 今回は久しぶりにツーリングについて記事にしたいと思います。 技術記事については他の記事を見てくださいね! 実は結構ツーリング行っていたんですが、ストレージサーバーの件など連載記事を書いていたので書くタイミングがありませんでした... 時間が取れて気が向いたらYoutubeにも動画をアップロードしようと思います! ※このツーリングはGWに行ったものです。 淡路島(道の駅あわじ) 一日目の朝ごはんはいつもの道の駅で🍚 淡路島でツーリングするときはいつもここ。 美味しいのでおすすめです。近いし👍 四国(石鎚スカイライン) ここは二日目に行きました。 四国にはツーリングしに何回か来たことはあるのですが、ここを走るの地味に初めてでした。 CMとかにも利用されるくらいキレイな景色と聞いていたのですが、案の定の霧でした......が!霧の中のツーリングもとても楽しかったです!🏍 霧が晴れて、ギリギリいい感じの景色が撮れました。 それ以外はこんな感じw 全然景色見えないww 内子フレッシュパークからり(からり橋) GWということもあって鯉のぼりがたくさんありました! 宇和島フェリー バイクでフェリーに乗るのは初めて!とてもワクワクしました! コロナで客室が個室になっていたのもポイント高かったです! 沈堕の滝 / 沈堕発電所跡 鍋ヶ滝 スマレジ福岡天神ショールーム 展海峰 日本本土最西端(神埼鼻公園) 平尾台カルスト 火の山公園展望台 本州最西端(毘沙ノ鼻) 角島展望台 元乃隅神社 青山剛昌ふるさと館 魚見台公園

自作ストレージサーバー用のためのCLIツールをNode.jsで作成した話

2022/07/10 2022/07/10

自作ストレージサーバー用のためのCLIツールをNode.jsで作成した話 こんにちは。のんです。 今回はCLIツールを自作してみた話をしようと思います。 前にストレージサーバーを作成したけど... 以前に自作のストレージサーバー作成した話をしました。 S3みたいなストレージサーバーっぽいものを自前で用意する⑦【ファイルアップロード機能実装】 | のんラボ S3みたいなストレージサーバーっぽいものを自前で用意する⑦【ファイルアップロード機能実装】こんにちは。のんです。前回に引き続き自前でストレージサーバーを開発していこうと思います。 ... このストレージサーバーのAPIは画像データをBase64で送信する仕組みです。 このブログのようにサービスからAPIをコールするタイプなら面倒ではないのですが、Postmanなどを利用してちょっとした画像をアップロードするときに、いちいちBase64エンコードするツールWEBサイトを開くのは面倒でした。 そこで、画像をBase64エンコードするツールを作成しようと思いたったという経緯です。 @nonz250/image-base64 GitHub - nonz250/image-base64 Contribute to nonz250/image-base64 development by creating an account on GitHub. こちらが作成したツールです。 @nonz250/image-base64 Convert images to base64 strings.. Latest version: 1.0.2, last published: 6 months ago. Start using ... CLIツールのために利用したパッケージ 詳細はコードを見ていただければわかりますが、そもそも自分で何か特別なロジックを書いているわけではありません。便利なライブラリを導入してそれを組み合わせただけのコードです。 一応少しだけ紹介をしておこうと思います。 base64-js バイナリデータをBase64形式に変換してくれるライブラリです。 GitHub - beatgammit/base64-js: Base64 encoding/decoding in pure JS Base64 encoding/decoding in pure JS. Contribute to beatgammit/base64-js development by creating an a... cac JavaScriptでCLIの基本機能を提供してくれるライブラリです。 GitHub - cacjs/cac: Simple yet powerful framework for building command-line apps. Simple yet powerful framework for building command-line apps. - GitHub - cacjs/cac: Simple yet power... chalk コンソールにメッセージを表示するときに、色付けを行ってくれるライブラリです。 GitHub - chalk/chalk: 🖍 Terminal string styling done right 🖍 Terminal string styling done right. Contribute to chalk/chalk development by creating an account o... clipboardy 環境に関わらず、クリップボードの操作を行ってくれるライブラリです。 GitHub - sindresorhus/clipboardy: Access the system clipboard (copy/paste) Access the system clipboard (copy/paste). Contribute to sindresorhus/clipboardy development by creat... 実装内容 エラーハンドリングは行っていないので、雑な作りですが... #! /usr/bin/env node import base64js from 'base64-js' import cac from 'cac' import chalk from 'chalk' import clipboardy from 'clipboardy' import fs from 'fs' const cli = cac(); cli .command('encode <path>', 'Convert images to base64 strings.') .action((path) => { const image = fs.readFileSync(path) const base64strings = base64js.fromByteArray(image) clipboardy.write(base64strings).then(() => { console.log(chalk.green('✅ Successfully. Copied to clipboard.')) }).catch(() => { console.error(chalk.red('⚠️ Failed. Please check your input.')) }) }) cli.help() cli.parse() command メソッドでCLIで実行するコマンドの名前を決めます。ここでは encode <path> で引数に画像までのパスを指定する作りになっています。 次に、action メソッドの処理が実際にコマンドを実行するときの処理になります。 const image = fs.readFileSync(path) で、画像の読み込みをします。 const base64strings = base64js.fromByteArray(image) で、読み込んだ内容をBase64エンコードします。 clipboardy.write(base64strings).then(() => { console.log(chalk.green('✅ Successfully. Copied to clipboard.')) }).catch(() => { console.error(chalk.red('⚠️ Failed. Please check your input.')) }) で、Base64文字列をクリップボードに貼り付けます。 CLIのコマンドをpackage.jsonに登録 下記のように、 package.json にコマンド名を登録しておくことで "bin": { "image-base64": "index.js" } npm i @nonz250/image-base64 したときに bin ディレクトリにシンボリックリンクを貼ってくれます。 つまり、インストールしたときに image-base64 --help のように利用できるようになるということです。 Node Package として公開 npm publish 普通に公開するだけです。 ちょっと間違えて 1.0.0 を unpublish してしまったので 1.0.1 から始まっていますw 初めて使う人は npm adduser をしたり、 npm login をして、ユーザー情報をコマンドに紐付けておきます。 作成したCLIツールの使い方 これも README.md に書いてありますが、 Install package. npm i --location=global @nonz250/image-base64 Restart terminal. image-base64 --help image-base64 encode <path> image-base64 encode ~/foo/bar.png image-base64 encode ./baz.png Npx command. npx @nonz250/image-base64 --help npx @nonz250/image-base64 encode <path> npx @nonz250/image-base64 encode ~/foo/bar.png npx @nonz250/image-base64 encode ./baz.png です。 最後に 今回は自作のストレージサーバーのための、自作CLIツールを作成してみました。 画像をBase64を変換する機能はあまり需要が無いかもしれませんが、公開もしていますのでよろしければ使ってみてください。 バイクの記事が書けてないので、今度こそ...今度こそ書きたいと思いますw そのときはよしなに。 .

Github ActionsでMarpのOGP画像を作成するときに文字化けする

2022/03/06 2022/06/26

Github ActionsでMarpのOGP画像を作成するときに文字化けする こんにちは。のんです。 今回はMarpについて記事を書きたいと思います。 Marpってなに Marpとはマークダウン記法でスライドを作成できるようにするエコシステムのことですね。 公式HPはこちら。 Marp: Markdown Presentation Ecosystem Marp (also known as the Markdown Presentation Ecosystem) provides an intuitive experience for creati... 利用用途しては、社内でLT会をやっているのですが、そのスライド作成のために利用しているという感じです。 ちなみにそのスライドたちはこちら https://nonz250.github.io/slides/ その中でも面白かったのは この絵文字の印象は?絵文字アンケート結果発表 📣 5つの一般的な絵文字の印象について調査しました。 みんなはどうしてる? チームの取り組み3選 🦾 チームの取り組みについて3個紹介します。 この辺でしょうか。 メリット マークダウン記法なので、コードが書きやすい!! これに尽きると思います。 他のプレゼンテーションツールだったらコードを画像化して貼り付ける...とかでしょうか?まぁ面倒臭いですよね。(もしかしたら便利な方法があるかもしれませんが。) しかしマークダウンなので、普通に <?php // こんな感じで直接コードを書いてしまう echo 'foo'; echo 'bar'; としてしまえばいいのです。 他にはマークダウンなので、HTMLタグが使えるというところでしょうか? もちろんHTMLを書くのは面倒ですが、ちょっとしたスタイルの調整には役立ちます。 プレゼンテーション先がブラウザなので、HTMLベースというところもいいですよね。 PDFなどにも出力できるので、割となんでもできる気がします。 あとはこれもメリットですね。 マークダウンなので、スライドがごちゃつかない スライドあるあるですねw デメリット スライドを生成する必要がある。 まぁこれは強いて挙げれば、というところでした。 マークダウンファイルなので、スライドではありません。 MarpではマークダウンファイルをHTMLファイルに変換し、そのHTMLをブラウザで見たときにスライド風に見えるというツールです。 なのでそのHTMLを出力するコマンドを実行する必要があります。 これは GitHub Actions で自動化しているのですが、そのコマンド実行時に文字化けするというエラーを踏んだ...というのが今回の記事の趣旨になります。 OGP画像生成 このスライドはHTMLなので、リンクをTwitterなどのSNSに掲載したときにOGP画像を出力することができます。 Marpではスライドを画像に変換して出力するコマンドがありますので、デプロイ時にそのコマンドを実行し、 <head> タグで読み込むようにしています。 ...が。 OGP画像生成時に絵文字(マルチバイト文字)が表示されない というエラーにあたってしまいました。 ローカル環境ではキチンと出力されているようなのに、CI経由だと出力されていませんでした。 とは言っても知見のある方ならすでに答えがわかったかと思います。 そうです。文字セットがCI上のコンテナに無いだけですねww。 なので、対処としてこの文字セットをインストールして終了という形になります。 下記のコードは GitHub Actions でのコードですが、 jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install font run: sudo apt install fonts-noto (省略) となります。 slides/main.yml at main · nonz250/slides Contribute to nonz250/slides development by creating an account on GitHub. コンテナは Ubuntu なので、 sudo apt install fonts-noto を実行するだけです。 fonts-noto にはマルチバイト文字が多数含まれているので問題ないとは思いますが、、他に必要な文字があれば適宜インストールすれば大丈夫だと思います。 結果 こうすることでSlackなどに貼り付けたときにキチンと画像が表示され、その生成された画像に絵文字が表示されていますね。 MarpでOGPについては書いていないので公式HPを見てください。 あとは私のリポジトリも見ていただければ少しは参考になるかも知れません。 https://github.com/nonz250/slides 最後に 今回はライトに記事を書いてみました。 皆さんはスライドを作成するときにどのようなツールを使っていますか? Google Slide? Slide Share? Scrapbox? 他にも無料ツールで良いものがたくさんありますが、私は自分の資産としてリポジトリに残しておきたかったので、管理しやすいMarpを選択しました! 結構同じことをやってる人もたくさんいるのでは? 今回はツーリングについて一つ書こうと思っていましたが、時間がなくて急遽この記事にしましたw 次回こそはバイクの写真を掲載します! そのときはよしなに。 .

S3みたいなストレージサーバーっぽいものを自前で用意する⑦【ファイルアップロード機能実装】

2022/03/06 2022/06/12

S3みたいなストレージサーバーっぽいものを自前で用意する⑦【ファイルアップロード機能実装】 こんにちは。のんです。 前回に引き続き自前でストレージサーバーを開発していこうと思います。 S3みたいなストレージサーバーっぽいものを自前で用意する⑥【Logging実装】 | のんラボ S3みたいなストレージサーバーっぽいものを自前で用意する⑥【Logging実装】こんにちは。のんです。前回に引き続き自前でストレージサーバーを開発していこうと思います。 ... 今回はこのアプリのメイン機能であるファイルアップロード機能について書こうと思います。 GitHubプロジェクトはこちら GitHub - nonz250/storage Contribute to nonz250/storage development by creating an account on GitHub. ファイルアップロード時の仕様 基本的にWEBアプリケーションで利用しようと思っています。 オリジナルのファイルも置いておくつもりですが、通信量の削減のためサムネイル画像も生成してどちらを採用するか選べるようにしたいです。また、どの形式の画像を作成するかも決められるように死体と思います。 アップロードした画像のサムネイル画像を生成する アップロードした画像と任意の拡張子の画像を生成する の2つを主な機能として作成していきます。 APIでファイルアップロードできるエンドポイントを用意する APIでファイルアップロードするために、画像をbase64エンコードした状態の文字列を送信するようにしました。他にも必要な情報として 画像データ ファイル名 マイムタイプ を入力値として用意します。 POST http://localhost/files HTTP/1.1 Authorization: Digest token Content-Type: application/json { "fileName": "ファイル名", "mimetype": "image/jpeg", "file": "画像をbase64エンコードした文字列" } このようなエンドポイントを用意します。 入力値について 流石にとても長いコードになってしまうので、詳しくはこちらのコードをご覧ください。 storage/UploadFileAction.php at main · nonz250/storage Contribute to nonz250/storage development by creating an account on GitHub. $requestBody = $request->getParsedBody(); try { try { $fileEncoded = $requestBody['file'] ?? ''; $fileDecoded = base64_decode($fileEncoded, true); if ($fileDecoded === false) { throw new Base64Exception('Failed to base64 decode.'); } $fileString = new FileString($fileDecoded); $fileName = new FileName($requestBody['fileName'] ?? ''); $clientId = new ClientId((string)$requestBody['client_id']); $mimeType = new MimeType($requestBody['mimetype'] ?? MimeType::MIME_TYPE_WEBP); } catch (InvalidArgumentException $e) { $this->logger->error($e); throw new HttpBadRequestException($e->getMessage()); } catch (Base64Exception $e) { $this->logger->error($e); throw new HttpInternalErrorException($e->getMessage()); } } catch (HttpException $e) { return $e->getApiProblemResponse(); } 各ValueObjectの内容も割愛します。 注目するのはこちら $mimeType = new MimeType($requestBody['mimetype'] ?? MimeType::MIME_TYPE_WEBP); マイムタイプのデフォルトは image/webp としています。これはWEBアプリで画像を取り扱う際にSEOや負荷的に有利な画像形式だからです。 An image format for the Web  |  WebP  |  Google Developers An image format for the Web. WebP lossless images are 26% smaller in size compared to PNGs. WebP lossy images are 25-34% smaller than comparable JPEG images at equivalent SSIM quality index. WebPロスレス画像は、PNGと比較して26%サイズが小さくなっています。WebPロッシー画像は、同等のSSIM品質指数で、同等のJPEG画像より25~34%小さい。 by deepl とあるように、PNGやJPEGと比べ大体3割くらい軽いようです。 実際のファイル操作について 入力値を設定できたので、ユースケース層に値を渡して実際に処理をしていきます。 public function process(UploadImageInputPort $inputPort): UploadImageOutputPort { $file = $this->fileFactory->newImageFile($inputPort->clientId(), $inputPort->fileName(), $inputPort->fileString()); // Save Webp extension. $file->changeThumbnailMimeType($inputPort->mimeType()); try { $this->fileRepository->beginTransaction(); $this->fileRepository->create($file); } catch (Throwable $e) { $this->fileRepository->rollback(); $this->logger->error($e); throw new UploadFileException('Failed to register database.'); } try { $originFilePath = $this->fileService->uploadOriginImage($file); $this->logger->debug($originFilePath); } catch (Throwable $e) { $this->fileRepository->rollback(); $this->logger->error($e); throw new UploadFileException('Failed to upload origin file.'); } try { $thumbnailFilePath = $this->fileService->uploadThumbnailImage($file); $this->logger->debug($thumbnailFilePath); } catch (Throwable $e) { $this->fileRepository->rollback(); if (!unlink($originFilePath)) { $this->logger->error(sprintf('Failed to delete origin file. -- %s', $originFilePath)); } $this->logger->error($e); throw new UploadFileException('Failed to upload thumbnail file.'); } $this->fileRepository->commit(); return new UploadImageOutput( $file->identifier(), $file->fileNameWithOriginExtension(), $file->uniqueFileNameWithOriginExtension(), FileService::UPLOAD_ORIGIN_DIRECTORY . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithOriginExtension(), FileService::UPLOAD_THUMBNAIL_DIRECTORY . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithThumbnailExtension(), ); } 初めにDBに登録します try { $this->fileRepository->beginTransaction(); $this->fileRepository->create($file); } catch (Throwable $e) { $this->fileRepository->rollback(); $this->logger->error($e); throw new UploadFileException('Failed to register database.'); } ファイルをアップロード・生成したあとにロールバックするのは面倒くさいので、先にDB処理を行ってしまいます。 DBに登録するのは オリジナルの画像情報 サムネイルの画像情報 のみで、ファイルそのものは登録しません。DBに画像を登録することも考えましたが、その場合画像を出力するときにDBアクセスが必要になってしまいます。 基本的にパーミッションの設定をするつもりはなかったし、画像を返すためにPHPを起動させるのは負荷的に嫌いました。 なので、ファイルそのものは文字列としてはではなく、ファイルとしてサーバーに設置します。 オリジナル画像の設置 try { $originFilePath = $this->fileService->uploadOriginImage($file); $this->logger->debug($originFilePath); } catch (Throwable $e) { $this->fileRepository->rollback(); $this->logger->error($e); throw new UploadFileException('Failed to upload origin file.'); } です。失敗したらDBロールバックをしていますね。 続いて uploadOriginImage の内容も見てみます。 public function uploadOriginImage(File $file): string { $uploadStorageDirectory = getcwd() . self::UPLOAD_ORIGIN_DIRECTORY; $this->createDir($uploadStorageDirectory); $originFilePath = $uploadStorageDirectory . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithOriginExtension(); $byte = file_put_contents($originFilePath, (string)$file->fileString()); if ($byte === false) { throw new UploadFileException('Failed to upload file.'); } $this->logger->debug(sprintf('%s is %s bytes.', $file->fileNameWithOriginExtension(), $byte)); return $originFilePath; } ファイル名は入力されたファイル名ではなく、内部で生成されたDBのULIDを使いまわします。 $file->uniqueFileNameWithOriginExtension(); ファイルの設置はPHPのビルトインメソッドである file_put_contents を利用します。 $byte = file_put_contents($originFilePath, (string)$file->fileString()); file_put_contents の仕様はこちら。第2引数にbase64エンコードした画像文字列を渡しています。 https://www.php.net/manual/ja/function.file-put-contents.php https://www.php.net/manual/ja/function.file-put-contents.phpを見る サムネイル画像の生成 try { $thumbnailFilePath = $this->fileService->uploadThumbnailImage($file); $this->logger->debug($thumbnailFilePath); } catch (Throwable $e) { $this->fileRepository->rollback(); if (!unlink($originFilePath)) { $this->logger->error(sprintf('Failed to delete origin file. -- %s', $originFilePath)); } $this->logger->error($e); throw new UploadFileException('Failed to upload thumbnail file.'); } です。失敗したらDBロールバックをしていますね。 続いて uploadThumbnailImage の内容も見てみます。 public function uploadThumbnailImage(File $file, int $resizeWidth = self::FULL_HD_WIDTH / 2): string { $uploadThumbnailDirectory = getcwd() . self::UPLOAD_THUMBNAIL_DIRECTORY; $this->createDir($uploadThumbnailDirectory); [$originWidth, $originHeight, $type] = getimagesizefromstring((string)$file->fileString()); $this->logger->debug(sprintf('width: %s, height: %s, type: %s -- %s', $originWidth, $originHeight, $type, $file->identifier())); $source = imagecreatefromstring((string)$file->fileString()); if ($source === false) { throw new UploadFileException('Failed to upload file.'); } $ratio = $originWidth >= $resizeWidth ? $resizeWidth / $originWidth : 1; $newWidth = (int)($originWidth * $ratio); $newHeight = (int)($originHeight * $ratio); $thumbnail = imagecreatetruecolor($newWidth, $newHeight); if (!imagecopyresampled($thumbnail, $source, 0, 0, 0, 0, $newWidth, $newHeight, $originWidth, $originHeight)) { throw new UploadFileException('Failed to upload file.'); } $mimeType = $file->thumbnailMimeType(); $thumbnailFilePath = $uploadThumbnailDirectory . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithThumbnailExtension(); if ($mimeType->isBmp()) { $result = imagebmp($thumbnail, $thumbnailFilePath); } elseif ($mimeType->isGif()) { $result = imagegif($thumbnail, $thumbnailFilePath); } elseif ($mimeType->isJpeg()) { $result = imagejpeg($thumbnail, $thumbnailFilePath); } elseif ($mimeType->isPng()) { $result = imagepng($thumbnail, $thumbnailFilePath); } elseif ($mimeType->isWebp()) { $result = imagewebp($thumbnail, $thumbnailFilePath); } else { $this->logger->error(sprintf('Unknown mimetype. [%s]', $mimeType)); throw new LogicException('Unknown mimetype.'); } if (!$result) { throw new UploadFileException('Failed to upload file.'); } imagedestroy($thumbnail); return $thumbnailFilePath; } です。 $source = imagecreatefromstring((string)$file->fileString()); if ($source === false) { throw new UploadFileException('Failed to upload file.'); } まずは imagecreatefromstring でbase64エンコードした画像文字列から画像リソースを取得します。 次に $ratio = $originWidth >= $resizeWidth ? $resizeWidth / $originWidth : 1; $newWidth = (int)($originWidth * $ratio); $newHeight = (int)($originHeight * $ratio); このメソッドに渡した $resizeWidth に応じて比率を導く計算です。デフォルトはFULL HD画像の半分の大きさにしています。 サムネイル用の新しい幅と高さが計算できたら、その結果を利用してリサイズ処理を行います。 $thumbnail = imagecreatetruecolor($newWidth, $newHeight); if (!imagecopyresampled($thumbnail, $source, 0, 0, 0, 0, $newWidth, $newHeight, $originWidth, $originHeight)) { throw new UploadFileException('Failed to upload file.'); } imagecreatetruecolor でベースになる画像を生成します。大きさを設定するために通していますね。 大事なのはここからです。 imagecopyresampled を利用してサムネイル画像を生成します。先に生成したものをベースにサムネイル画像をリサイズするという感じですね。 ここで話に挙がるのは imagecopyresized というメソッドです。PHPのドキュメントを見ると、 イメージは半分サイズで出力されますが、 imagecopyresampled() を使用するとより良い品質になります。 と書いてあります。 速度的には imagecopyresized のほうが早いけど品質的に imagecopyresampled のほうが良いのでこちらを採用しています。 詳しくはこちらをご覧ください。 https://www.php.net/manual/ja/function.imagecopyresampled.php https://www.php.net/manual/ja/function.imagecopyresampled.phpを見る https://www.php.net/manual/ja/function.imagecopyresized.php https://www.php.net/manual/ja/function.imagecopyresized.phpを見る リサイズのために生成したリソースに画像形式を設定する リサイズしたソースに画像形式を設定します。 $mimeType = $file->thumbnailMimeType(); $thumbnailFilePath = $uploadThumbnailDirectory . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithThumbnailExtension(); if ($mimeType->isBmp()) { $result = imagebmp($thumbnail, $thumbnailFilePath); } elseif ($mimeType->isGif()) { $result = imagegif($thumbnail, $thumbnailFilePath); } elseif ($mimeType->isJpeg()) { $result = imagejpeg($thumbnail, $thumbnailFilePath); } elseif ($mimeType->isPng()) { $result = imagepng($thumbnail, $thumbnailFilePath); } elseif ($mimeType->isWebp()) { $result = imagewebp($thumbnail, $thumbnailFilePath); } else { $this->logger->error(sprintf('Unknown mimetype. [%s]', $mimeType)); throw new LogicException('Unknown mimetype.'); } if (!$result) { throw new UploadFileException('Failed to upload file.'); } imagedestroy($thumbnail); リサイズしたソースと設置するパスをマイムタイプによって利用するメソッドを使い分けて設置します。 最後に処理中に確保しているメモリを開放します。 imagedestroy($thumbnail); 実際に出力・生成する内容はこちら { "message": "Successfully created file.", "id": "ULID", "originFileName": "入力したファイル名", "fileName": "ULIDのファイル名", "originPath": "オリジナル画像のURL", "thumbnailPath": "サムネイル画像のURL" } が返ってきます。 最後に これで、ストレージサーバーとして最低限の機能を実装することができました。 あとは画像の削除や、定期的なクリーンアップ、全拡張子の対応や、画像ファイル以外の対応などやろうと思えば色々案が出てきますね。 ひとまず、現状自分が欲しい機能は実装できたので、これで連載は終了です。 強いて希望を挙げればこのブログのファイルアップロード先を変更して記事にしたかったというのがありますね。 次回は何を書こうかな。久しぶりにバイク関連の記事でも書くかもしれません。 また記事にします。 そのときはよしなに。 .

S3みたいなストレージサーバーっぽいものを自前で用意する⑥【Logging実装】

2022/04/11 2022/05/29

S3みたいなストレージサーバーっぽいものを自前で用意する⑥【Logging実装】 こんにちは。のんです。 前回に引き続き自前でストレージサーバーを開発していこうと思います。 S3みたいなストレージサーバーっぽいものを自前で用意する⑤【DI / Repogitory実装】 | のんラボ S3みたいなストレージサーバーっぽいものを自前で用意する⑤【DI / Repogitory実装】こんにちは。のんです。前回に引き続き自前でストレージサーバーを開発していこうと思います。 ... 今回はロギングの実装について話していきたいと思います。 アプリケーション開発の基本中の基本ですね。 GitHubプロジェクトはこちら GitHub - nonz250/storage Contribute to nonz250/storage development by creating an account on GitHub. ログにはMonologを採用します GitHub - Seldaek/monolog: Sends your logs to files, sockets, inboxes, databases and various web services Sends your logs to files, sockets, inboxes, databases and various web services - GitHub - Seldaek/mo... PHPで人気のあるログシステムの一つですね。 とても使いやすく、PSR-3に対応しています。 https://www.php-fig.org/psr/psr-3/ https://www.php-fig.org/psr/psr-3/を見る 実装していきます MonologはPSR-3の LoggerInterface に対応しているので、コンテナに Psr\Log\LoggerInterface として Monolog\Logger を登録しておきます。 $container->add(Psr\Log\LoggerInterface::class, Monolog\Logger::class) ->addArgument('storage') ->addMethodCall('pushHandler', [ (new Monolog\Handler\RotatingFileHandler( sprintf('%s/../logs/application.log', __DIR__), 30, Nonz250\Storage\App\Foundation\App::environment(Nonz250\Storage\App\Shared\ValueObject\Environment::PRODUCTION) ? Monolog\Logger::INFO : Monolog\Logger::DEBUG, ))->setFormatter(new Monolog\Formatter\JsonFormatter()), ]); コンテナに登録するときに addMethodCall で pushHandler メソッドを呼ぶようにしておきます。 pushHandler メソッドはMonologの関数です。 この関数に、 RotatingFileHandler クラスを渡すようにしておくとログをローテーションしてくれます。 (new Monolog\Handler\RotatingFileHandler( sprintf('%s/../logs/application.log', __DIR__), 30, Nonz250\Storage\App\Foundation\App::environment(Nonz250\Storage\App\Shared\ValueObject\Environment::PRODUCTION) ? Monolog\Logger::INFO : Monolog\Logger::DEBUG, 名前は application.log です。ローテーションの間隔は30日間ですね。本番環境ではinfoレベルまで、それ以外ではdebugレベルまでログに残すような仕組みになっています。 また、ログの内容はjson方式で登録できるようにしておきます。 後々、分解したり解析したりしやすくなるでしょう。 ->setFormatter(new Monolog\Formatter\JsonFormatter()), JsonFormatter はMonologに標準で実装されていますね。フィールドなどをカスタマイズしたい場合は継承したりして自作しましょう。 Monologをコンテナに登録できたので、実際のコードを見てみる 実際にログに残しているところはこちら。 ファイルアップロードする処理の中でファイルのサイズをデバッグログとして記録します。 public function uploadOriginImage(File $file): string { $uploadStorageDirectory = getcwd() . self::UPLOAD_ORIGIN_DIRECTORY; $this->createDir($uploadStorageDirectory); $originFilePath = $uploadStorageDirectory . DIRECTORY_SEPARATOR . $file->uniqueFileNameWithOriginExtension(); $byte = file_put_contents($originFilePath, (string)$file->fileString()); if ($byte === false) { throw new UploadFileException('Failed to upload file.'); } $this->logger->debug(sprintf('%s is %s bytes.', $file->fileNameWithOriginExtension(), $byte)); return $originFilePath; } この関数はアップロードされたファイルの原寸大のファイルをそのまま保存します。 この時にどれくらいのイメージサイズなのかをログに残すのですが、 $this->logger->debug(sprintf('%s is %s bytes.', $file->fileNameWithOriginExtension(), $byte)); で記録しています。 その結果がこちら {"message":"hoge.jpeg is 4006827 bytes.","context":{},"level":100,"level_name":"DEBUG","channel":"storage","datetime":"2022-04-23T06:37:25.545894+09:00","extra":{}} ログなので余計な改行なく記録されています。 これをフォーマッターに通すと、 { "message": "hoge.jpeg is 4006827 bytes.", "context": {}, "level": 100, "level_name": "DEBUG", "channel": "storage", "datetime": "2022-04-23T06:37:25.545894+09:00", "extra": {} } という感じになります。 最後に これで、本番運用中の調査も楽ちんになりました。 とは言っても自分で使うサービスなので、ログを確認することはほとんど無いかも知れません。 ログがあると開発中も色々便利なので入れた。という感じですかね。 次回は実際のファイルアップロードについて触れて最終回となるかも知れません。 今回は短かったですが次回は長くなりそう。長くなりすぎる場合は何個かに切り取りましょうかね。 また記事にします。 そのときはよしなに。 .

S3みたいなストレージサーバーっぽいものを自前で用意する⑤【DI / Repogitory実装】

2022/04/11 2022/05/15

S3みたいなストレージサーバーっぽいものを自前で用意する⑤【DI / Repogitory実装】 こんにちは。のんです。 前回に引き続き自前でストレージサーバーを開発していこうと思います。 S3みたいなストレージサーバーっぽいものを自前で用意する④【RFC 7807 エラーレスポンス実装】 | のんラボ S3みたいなストレージサーバーっぽいものを自前で用意する④【RFC 7807 エラーレスポンス実装】こんにちは。のんです。前回に引き続き自前でストレージサーバーを開発していこうと思います。 ... GitHubプロジェクトはこちら GitHub - nonz250/storage Contribute to nonz250/storage development by creating an account on GitHub. RepositoryというかまずはDIの話 Repositoryを実装するのですが、まずはDependency injection.(依存性注入)を簡単にできるようにすることになります。 これはクリーンアーキテクチャにおけるユースケース層を実装するのにも深く関わってくるので早めに解決しておきましょう。ちなみにLaravelでいうサービスプロバイダーですね。 依存性注入の話をここでするつもりは無いです。 他の有用なブログなどたくさんありますので詳しい話はそちらにお任せしようかな?と思います。 league/container を利用する では、早速実装していこうと思います。フレームワークを利用しないというルールを設けましたが、自作するのはなんか無駄な気がするので、ライブラリを利用します。 league/router と同様に league/container を利用します。 https://container.thephpleague.com/ https://container.thephpleague.com/を見る GitHub - thephpleague/container: Small but powerful dependency injection container Small but powerful dependency injection container. Contribute to thephpleague/container development ... このライブラリを利用することでDIを簡単に実装できるようになります。 Repositoryを実装する PDO を設定する WEBアプリなので、MySQLを利用しています。なので主に利用されるデータストアはDBです。 PHPでDBを操作するライブラリはこの世にたくさんあり、どれも便利なのですが、このストレージサーバーは複雑な動作を求めるようなWEBアプリではないのでSQLを直接書いてしまおうと思います。 というわけでPDOをそのまま利用します。 PDOクラスは https://www.php.net/manual/ja/class.pdo.php https://www.php.net/manual/ja/class.pdo.phpを見る にもあるように、接続先情報をコンストラクタに注入する必要があります。しかしこれを毎回やるのは面倒なので league/container を利用します。 $container = new League\Container\Container(); $container->add(PDO::class) ->addArgument(sprintf( 'mysql:dbname=%s;host=%s;port=%s', Nonz250\Storage\App\Foundation\App::env('DB_NAME'), Nonz250\Storage\App\Foundation\App::env('DB_HOST'), Nonz250\Storage\App\Foundation\App::env('DB_PORT') )) ->addArgument(Nonz250\Storage\App\Foundation\App::env('DB_USERNAME')) ->addArgument(Nonz250\Storage\App\Foundation\App::env('DB_PASSWORD')); このようにこのライブラリでは Container クラスに addArgument メソッドが実装されており、クラスをインスタンス化するときに注入する値を宣言することができます。 これをアプリが起動するときに必ず発火するrootファイルに書いておきます。 ちなみに、 Nonz250\Storage\App\Foundation\App::env('DB_NAME') の env メソッドの中身はこんな感じ <?php declare(strict_types=1); namespace Nonz250\Storage\App\Foundation; final class App { // 省略 public static function env(string $key, $default = '') { return $_ENV[$key] ?? $default; } // 省略 } ただ、環境変数に存在する値を取得するだけの関数ですね。なければ default 値を取得します。 将来的に拡張できるように Model クラスを定義しておく 現状では拡張するような要素がないので、ただのラッパークラスになってしまいますが、作成しておきましょう。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Foundation\Model; use PDO; use PDOException; use PDOStatement; class Model { protected PDO $pdo; public function __construct(PDO $pdo) { $this->pdo = $pdo; } public function beginTransaction(): void { if (!$this->pdo->beginTransaction()) { throw new PDOException('Failed begin transaction.'); } } public function commit(): void { if (!$this->pdo->commit()) { throw new PDOException('Failed commit.'); } } public function rollBack(): void { if (!$this->pdo->rollBack()) { throw new PDOException('Failed roll back.'); } } public function execute(string $sql, ?BindValues $bindValues = null): PDOStatement { $statement = $this->pdo->prepare($sql); if ($statement === false) { throw new PDOException('Failed prepared query.'); } if ($bindValues && !$bindValues->isEmpty()) { foreach ($bindValues as $bindKey => $bindValue) { $statement->bindValue($bindKey, $bindValue); } } if (!$statement->execute()) { throw new PDOException('Failed execute statement.'); } return $statement; } public function select(string $sql, ?BindValues $bindValues = null): array { try { $statement = $this->execute($sql, $bindValues); } catch (PDOException $e) { throw new PDOException('Failed execute select.'); } $result = $statement->fetchAll(PDO::FETCH_ASSOC); if ($result === false) { throw new PDOException('Failed fetch all.'); } return $result; } public function insert(string $sql, BindValues $bindValues): void { try { $this->execute($sql, $bindValues); } catch (PDOException $e) { throw new PDOException('Failed execute select.'); } } } コードを読めばわかりますが、前述したように PDO クラスのただのラッパーですね。 あとは頻出する SELECT や INSERT などをやりやすくするために専用メソッドを用意しています。 BindValues が nullable なのでキモくてしょうがないですね。 どうにかしようと頑張ったのですが、いい案が思いつかずにこのまま放置してしまっています。 ちなみに余談ですが、この弊害が execute メソッドのこちらのコード if ($bindValues && !$bindValues->isEmpty()) { foreach ($bindValues as $bindKey => $bindValue) { $statement->bindValue($bindKey, $bindValue); } } この条件式キモすぎてなんとかしたい... Iteratable な $bindValues で $bindValues->isEmpty() の確認が必要かどうかというのは議論の余地がありますが、可読性を高めるという意味ではあってもいいのでしょうか? そして BindValues クラスはこんな感じ。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Foundation\Model; use ArrayIterator; use InvalidArgumentException; use IteratorAggregate; final class BindValues implements IteratorAggregate { private array $values = []; public function bindValue(string $key, $value): self { $this->validate($key); $this->values[$key] = $value; return $this; } private function validate(string $key): void { if ($key === '') { throw new InvalidArgumentException('Key is required.'); } if (array_key_exists($key, $this->values)) { throw new InvalidArgumentException('Key already exists.'); } } public function isEmpty(): bool { return count($this->values) === 0; } public function toArray(): array { return $this->values; } public function getIterator(): ArrayIterator { return new ArrayIterator($this->values); } } このクラスはSQLのパラメータに利用する bindValue や bindParam で利用するパラメータマップを配列ではなくクラス型で表現するために作成しておきました。 地味に便利です。 実装することをおすすめします。 最後に、この Model を利用するときに PDO を利用するので PDO をDIしておきます。 $container->add(Nonz250\Storage\App\Foundation\Model\Model::class) ->addArgument(PDO::class); いや、便利ですね。 次にRepositoryの実装です。 とはいってもガッツリビジネスロジックなので詳細なコードはGitHubを見て欲しいです。 一例として、 CreateClient ユースケースを見てみましょう。 このコードについての解説は記事にしていないので、後日記事にし直すかもしれません。 簡単に言うと、このAPIエンドポイントを利用するためには認証が必要なのですが、その認証に利用するためのクライアントを作成するためのエンドポイントです。ちなみに、このサービスにOAuth2を実装するつもりは無いので認可の仕組みは実装されていません。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Domain\Client\Command\CreateClient; use Nonz250\Storage\App\Domain\Auth\ClientRepositoryInterface; use Nonz250\Storage\App\Domain\Client\ClientFactoryInterface; use Nonz250\Storage\App\Domain\Client\Exceptions\CreateClientException; use Throwable; final class CreateClient implements CreateClientInterface { private ClientFactoryInterface $clientFactory; private ClientRepositoryInterface $clientRepository; public function __construct( ClientFactoryInterface $clientFactory, ClientRepositoryInterface $clientRepository ) { $this->clientFactory = $clientFactory; $this->clientRepository = $clientRepository; } public function process(CreateClientInputPort $inputPort): array { $client = $this->clientFactory->newClient($inputPort->appName(), $inputPort->clientEmail()); try { $this->clientRepository->beginTransaction(); $this->clientRepository->create($client); $this->clientRepository->commit(); } catch (Throwable $e) { $this->clientRepository->rollback(); throw new CreateClientException('Failed to create client.'); } return [ 'clientId' => (string)$client->clientId(), 'clientSecret' => (string)$client->clientSecret(), 'appName' => (string)$client->appName(), 'clientEmail' => (string)$client->clientEmail(), ]; } } このユースケースを利用するために必要なクラスは ClientFactoryInterface と ClientRepositoryInterface を実装したクラスです。 この2つのクラスも league/container を利用して ClientServiceProvider でDIされています。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Provider; use League\Container\ServiceProvider\AbstractServiceProvider; use Nonz250\Storage\App\Adapter\Auth\ClientRepository; use Nonz250\Storage\App\Domain\Auth\ClientRepositoryInterface; use Nonz250\Storage\App\Domain\Auth\Command\DigestAuth\DigestAuth; use Nonz250\Storage\App\Domain\Auth\Command\DigestAuth\DigestAuthInterface; use Nonz250\Storage\App\Domain\Client\ClientFactory; use Nonz250\Storage\App\Domain\Client\ClientFactoryInterface; use Nonz250\Storage\App\Domain\Client\Command\CreateClient\CreateClient; use Nonz250\Storage\App\Domain\Client\Command\CreateClient\CreateClientInterface; use Nonz250\Storage\App\Foundation\Model\Model; use Nonz250\Storage\App\Http\Auth\AuthMiddleware; use Nonz250\Storage\App\Http\CreateClient\CreateClientAction; use Psr\Log\LoggerInterface; class ClientServiceProvider extends AbstractServiceProvider { public function provides(string $id): bool { $services = [ AuthMiddleware::class, CreateClientAction::class, ]; return in_array($id, $services, true); } public function register(): void { $this->getContainer() ->add(ClientRepositoryInterface::class, ClientRepository::class) ->addArgument(Model::class); $this->getContainer() ->add(DigestAuthInterface::class, DigestAuth::class) ->addArgument(ClientRepositoryInterface::class); $this->getContainer() ->add(AuthMiddleware::class) ->addArguments([ LoggerInterface::class, DigestAuthInterface::class, ]); $this->getContainer() ->add(CreateClientAction::class) ->addArguments([ LoggerInterface::class, CreateClientInterface::class, ]); $this->getContainer() ->add(ClientFactoryInterface::class, ClientFactory::class); $this->getContainer() ->add(CreateClientInterface::class, CreateClient::class) ->addArgument(ClientFactoryInterface::class) ->addArgument(ClientRepositoryInterface::class); } } 詳しくは https://container.thephpleague.com/4.x/service-providers/ https://container.thephpleague.com/4.x/service-providers/を見る に書かれています。 league/container にあるサービスコンテナ機能です。 話を戻します。 try { $this->clientRepository->beginTransaction(); $this->clientRepository->create($client); $this->clientRepository->commit(); } catch (Throwable $e) { $this->clientRepository->rollback(); throw new CreateClientException('Failed to create client.'); } メインになるロジックはここですね。 $client = $this->clientFactory->newClient($inputPort->appName(), $inputPort->clientEmail()); は Client エンティティを作成する処理をFactoryパターンで実装しているだけなので割愛します。(もしかしたら別の話で記事にするかも) create メソッドの中身を見てみます。 より具体的に言うと ClientRepository の中身です。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Adapter\Auth; use Nonz250\Storage\App\Domain\Auth\ClientRepositoryInterface; use Nonz250\Storage\App\Domain\Client\Client; use Nonz250\Storage\App\Domain\Client\ValueObject\AppName; use Nonz250\Storage\App\Domain\Client\ValueObject\ClientEmail; use Nonz250\Storage\App\Domain\Client\ValueObject\ClientSecret; use Nonz250\Storage\App\Foundation\Exceptions\DataNotFoundException; use Nonz250\Storage\App\Foundation\Model\BindValues; use Nonz250\Storage\App\Foundation\Repository; use Nonz250\Storage\App\Shared\ValueObject\ClientId; final class ClientRepository extends Repository implements ClientRepositoryInterface { public function findById(ClientId $clientId): Client { $sql = 'SELECT * FROM `clients` WHERE id = :client_id'; $bindValues = new BindValues(); $bindValues->bindValue(':client_id', (string)$clientId); $clients = $this->model->select($sql, $bindValues); if (count($clients) === 0) { throw new DataNotFoundException(sprintf('%s is not found.', ClientId::NAME)); } $client = $clients[0]; return new Client( new ClientId($client['id']), new ClientSecret($client['secret']), new AppName($client['app_name']), new ClientEmail($client['email']), ); } public function create(Client $client): void { $sql = 'INSERT INTO `clients` (`id`, `secret`, `app_name`, `email`) VALUE (:client_id, :client_secret, :app_name, :email)'; $bindValues = new BindValues(); $bindValues->bindValue(':client_id', (string)$client->clientId()); $bindValues->bindValue(':client_secret', (string)$client->clientSecret()); $bindValues->bindValue(':app_name', (string)$client->appName()); $bindValues->bindValue(':email', (string)$client->clientEmail()); $this->model->insert($sql, $bindValues); } } この ClientRepository は Repository という親クラスがあります。ここは苦渋の決断的クラスになっています。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Foundation; use Nonz250\Storage\App\Foundation\Model\Model; class Repository implements RepositoryInterface { protected Model $model; public function __construct(Model $model) { $this->model = $model; } public function beginTransaction(): void { $this->model->beginTransaction(); } public function commit(): void { $this->model->commit(); } public function rollback(): void { $this->model->rollBack(); } } うーん...微妙w トランザクション管理をModel経由ではなくRepository経由で行いたくてこのような実装をしました。 まぁ、 Model と Repository はアプリケーション層とユースケース層で別なので、きっちり分けて書かれているという言い訳でギリギリセーフと言えなくもないかもしれませんw ユースケースで利用されている create メソッドでは clients テーブルに渡された Client を保存する処理が書かれています。 public function create(Client $client): void { $sql = 'INSERT INTO `clients` (`id`, `secret`, `app_name`, `email`) VALUE (:client_id, :client_secret, :app_name, :email)'; $bindValues = new BindValues(); $bindValues->bindValue(':client_id', (string)$client->clientId()); $bindValues->bindValue(':client_secret', (string)$client->clientSecret()); $bindValues->bindValue(':app_name', (string)$client->appName()); $bindValues->bindValue(':email', (string)$client->clientEmail()); $this->model->insert($sql, $bindValues); } クライアントを作成する処理だけがRepositoryではないので、他にもたくさんのRepositoryが存在しますが、その一例として紹介しました。詳しくはGitHubを見てください。 最後に こんな感じでRepositoryを実装してみました。 GitHubをみればわかるとは思いますが、実は基本機能の部分は全て実装し終えています。 この記事で出てきたFactoryについても解説する必要はあるかもしれません。気が向いたら書こうかな。 それか、実際の機能の解説するときのついでに書くかもしれません。 次回は Logging について書こうと思っています。 そのときはよしなに。 .

S3みたいなストレージサーバーっぽいものを自前で用意する④【RFC 7807 エラーレスポンス実装】

2022/03/26 2022/04/24

S3みたいなストレージサーバーっぽいものを自前で用意する④【RFC 7807 エラーレスポンス実装】 こんにちは。のんです。 前回に引き続き自前でストレージサーバーを開発していこうと思います。 S3みたいなストレージサーバーっぽいものを自前で用意する③【Digest認証実装】 | のんラボ S3みたいなストレージサーバーっぽいものを自前で用意する③【Digest認証実装】こんにちは。のんです。前回に引き続き自前でストレージサーバーを開発していこうと思います。 ... GitHubプロジェクトはこちら GitHub - nonz250/storage Contribute to nonz250/storage development by creating an account on GitHub. APIのエラーレスポンス標準化について APIのエラーレスポンスってサービスによって独自に決められたりしていてとても不便ですよね。 そこでHTTP APIのエラーレスポンスにも規約が定められたりしています。 それが RFC 7807 です。 RFC ft-ietf-appsawg-http-problem: Problem Details for HTTP APIs This document defines a "problem detail" as a way to carry machine- readable details of errors in a ... 日本語版はこちら https://tex2e.github.io/rfc-translater/html/rfc7807.html https://tex2e.github.io/rfc-translater/html/rfc7807.htmlを見る RFC 7807 の実装 では、早速この規約に従って実装しよう。と思いましたが、まぁそれも面倒です。 こういう標準化規約されているものは世界の優秀な方々がすでに作成していたりするものなのでこれに乗っかる形で実装してしまいましょう。 俗に言う standing on the shoulders of Giants ですね。 さくっと調べた結果、 Crell / ApiProblem GitHub - Crell/ApiProblem: A simple implementation of the api-problem specification. Includes PSR-15 support. A simple implementation of the api-problem specification. Includes PSR-15 support. - GitHub - Crell/... というものがありそうです。私も伝聞で知ったものなので信頼性などの調査は完璧ではありませんが、タダ乗りなので文句は言えないでしょう。こちらを採用します。 書き味は Example にも書いてあるようにこのような感じ。 use Crell\ApiProblem\ApiProblem; $problem = new ApiProblem("You do not have enough credit.", "http://example.com/probs/out-of-credit"); // Defined properties in the API have their own setter methods. $problem ->setDetail("Your current balance is 30, but that costs 50.") ->setInstance("http://example.net/account/12345/msgs/abc"); // But you can also support any arbitrary extended properties! $problem['balance'] = 30; $problem['accounts'] = [ "http://example.net/account/12345", "http://example.net/account/67890" ]; $json_string = $problem->asJson(); // Now send that JSON string as a response along with the appropriate HTTP error // code and content type which is available via ApiProblem::CONTENT_TYPE_JSON. // Also check out asXml() and ApiProblem::CONTENT_TYPE_XML for the angle-bracket fans in the room. しかし、いくら便利でもこの実装を至るところに書くのは面倒ですし、設計上よくありません。 そこで、 S3みたいなストレージサーバーっぽいものを自前で用意する②【ミドルウェア実装】 | のんラボ S3みたいなストレージサーバーっぽいものを自前で用意する②【ミドルウェア実装】こんにちは。のんです。前回に引き続き自前でストレージサーバーを開発していこうと思います。 ... で少し触れた、独自 HttpException が活きてきます。もちろん、設計の是非はあるとは思います。私なりの解釈を次の章で説明します。 HttpException に RFC 7807 のエラーレスポンスを実装する このサービスでは HttpException にエラー時のHttpレスポンスを乗せることを想定しました。 何かしらのエラーが発生したときは最終的に HttpException をスローし、それを Controller ないしは Middleware がレスポンスとして返す。という流れです。 Exception に実装されている code と Http Status Code は別物なので、レスポンスの情報を乗せられるように実装します。 まずは HttpExceptionInterface から <?php declare(strict_types=1); namespace Nonz250\Storage\App\Foundation\Exceptions; use Psr\Http\Message\ResponseInterface; interface HttpExceptionInterface { public function getStatusCode(): int; public function getApiProblemResponse(): ResponseInterface; } HttpExceptionInterface を実装する HttpException は必ず HttpStatusCode を取得できるようにしていなければなりません。なので、 getStatusCode を実装するように定義します。 getApiProblemResponse でエラーレスポンスを作成し取得します。 そして肝心の HttpException の基盤例外クラスがこちら。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Foundation\Exceptions; use Crell\ApiProblem\ApiProblem; use Crell\ApiProblem\HttpConverter; use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\ResponseFactory; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Throwable; class HttpException extends RuntimeException implements HttpExceptionInterface { private int $statusCode; private string $description; public function __construct( int $statusCode = StatusCodeInterface::STATUS_OK, $description = '', $message = '', $code = 0, Throwable $previous = null ) { parent::__construct($message, $code, $previous); $this->statusCode = $statusCode; $this->description = $description; } public function getStatusCode(): int { return $this->statusCode; } public function getApiProblemResponse(): ResponseInterface { $responseFactory = new ResponseFactory(); $converter = new HttpConverter($responseFactory, true); $problem = new ApiProblem($this->getMessage()); $problem ->setStatus($this->getStatusCode()) ->setDetail($this->description); return $converter->toJsonResponse($problem); } } この例外クラスは 例外の主なメッセージ Http Status Code (4XX や 5XX を想定) 例外の詳細文 を必要とし、これらの情報と Crell / ApiProblem を利用して ResponseInterface を実装した Httpレスポンスを返します。 $responseFactory = new ResponseFactory(); $converter = new HttpConverter($responseFactory, true); ResponseInterface を実装した HttpResponse を作成するために必要です。 最終的には toJsonResponse でjson形式のレスポンスを返す関数を利用するために使います。 $problem = new ApiProblem($this->getMessage()); $problem ->setStatus($this->getStatusCode()) ->setDetail($this->description); 肝心の部分、 type は必須のため コンストラクタに 例外の主なメッセージ を渡しておきます。 その後、必要に応じて status や detail を埋めるために setter で Http Status Code (4XX や 5XX を想定) や 例外の詳細文 を設定します。 このあたりの詳細は RFC 7807 を参照していただければと思います。 わかりやすいように引用しておくと、 o "type"(文字列)-問題のタイプを識別するURI参照 (RFC3986)。この仕様では、逆参照すると、問題の種類について人間が読める形式のドキュメントが提供されるようになります(たとえば、HTML (W3C.REC-html5-20141028)を使用)。このメンバーが存在しない場合、その値は "about:blank"であると見なされます。 o "title"(文字列)-人間が読める形式の問題タイプの概要。ローカリゼーションの目的を除いて、問題の発生ごとに変更するべきではありません(たとえば、事前対応型のコンテンツネゴシエーションを使用します。(RFC7231)、セクション3.4を参照)。 o "status"(number)-この問題の発生に対してオリジンサーバーによって生成されたHTTPステータスコード((RFC7231)、セクション6)。 o "detail"(文字列)-この問題の発生に固有の、人間が読める説明。 o 「instance」(文字列)-問題の特定の発生を識別するURI参照。逆参照すると、詳細情報が得られる場合と得られない場合があります。 エラーレスポンスの例としては For example, an HTTP response carrying JSON problem details: たとえば、JSON問題の詳細を含むHTTP応答: HTTP/1.1 403 Forbidden Content-Type: application/problem+json Content-Language: en { "type": "https://example.com/probs/out-of-credit", "title": "You do not have enough credit.", "detail": "Your current balance is 30, but that costs 50.", "instance": "/account/12345/msgs/abc", "balance": 30, "accounts": ["/account/12345", "/account/67890"] } と書いてありますね。 最後に、この ApiProblem クラスを toJsonResponse メソッドを使ってHttpレスポンスに変えます。 return $converter->toJsonResponse($problem); これが getApiProblemResponse の全容です。 ちなみに、 HttpStatusCode の番号をマジックナンバーにしたくないため、 GitHub - php-fig/http-message-util Contribute to php-fig/http-message-util development by creating an account on GitHub. のライブラリを利用しています。 PSR-7 のHTTPに関する番号や数字をまとめて定義してくれている便利なライブラリです。 独自 HttpException クラスの使い方 何かしらの例外やエラーをキャッチしたときに、この HttpException を継承したクラスに詰め直して、スローするだけです。 try { $digests = $request->getHeader('Authorization') ?? []; if (count($digests) === 0) { throw new HttpUnauthorizedException('Please set `Authorization` header.'); } } catch (HttpUnauthorizedException $e) { $this->logger->error($e); return $e->getApiProblemResponse(); } 上記の用に HttpUnauthorizedException などを用意しておけば更に使いやすいはずです。 HttpUnauthorizedException の実装はこちら。ただのラッパーであることがわかるはずです。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Foundation\Exceptions; use Fig\Http\Message\StatusCodeInterface; use Throwable; class HttpUnauthorizedException extends HttpException { public function __construct($description = '', $message = 'Unauthorized.', $code = 0, Throwable $previous = null) { parent::__construct(StatusCodeInterface::STATUS_UNAUTHORIZED, $description, $message, $code, $previous); } } こうすることでとりあえずエラーが発生したときは HttpException を実装した例外クラスをスローしておけばなんとかなる。 最終的にキャッチしたところで、 return $e->getApiProblemResponse() をすれば RFC 7807 を実装したHttpレスポンスとして返すことができるという便利な設計になりました。エラーレスポンスに関する責務も HttpException に封じ込めることができています。 実際の動作 実際に返ってくるレスポンスはこちら。 { "title": "BadRequest.", "type": "about:blank", "status": 400, "detail": "appName is required." } 最後に このサービスは自分だけが利用することを想定しているので、APIエラー時に参照すべきドキュメントはありません。 そのため type は全て about:blank ですが、外部に公開しているサービスならこの部分がドキュメントへのURLになるでしょう。とても親切な作りですね。 ドキュメントを読む限り invalid-params を利用すれば複数のバリデーションエラーも含めることができそうなので、汎用性はとても高そう。 PHPフレームワークにも標準で実装しておいていただければ助かるものですが、まぁ、そう思うなら自分でプルリク出せという話ですね。どちらかというとプラグインとして用意してあげるのが良さそう?でもそれなら、この記事で紹介したライブラリで十分というものでしょう。 という感じで RFC 7870 を実装してみました。 次回は Repository の実装になりそうです。 このときにDIについても話になるかもしれません。 また記事にします。 そのときはよしなに。 .

S3みたいなストレージサーバーっぽいものを自前で用意する③【Digest認証実装】

2022/03/26 2022/04/10

S3みたいなストレージサーバーっぽいものを自前で用意する③【Digest認証実装】 こんにちは。のんです。 前回に引き続き自前でストレージサーバーを開発していこうと思います。 S3みたいなストレージサーバーっぽいものを自前で用意する②【ミドルウェア実装】 | のんラボ S3みたいなストレージサーバーっぽいものを自前で用意する②【ミドルウェア実装】こんにちは。のんです。前回に引き続き自前でストレージサーバーを開発していこうと思います。 ... GitHubプロジェクトはこちら GitHub - nonz250/storage Contribute to nonz250/storage development by creating an account on GitHub. 前回作成したAuthMiddleware にDigest認証を実装する Digest認証について Basic認証はBase64でエンコードされているとはいえ、パスワードを送ってしまうのでデコードされたらバレちゃう→ダメじゃん って流れからパスワードを含めた情報を md5 や sha256 でハッシュ化して送ろうと考案された。この辺は散々他のWEBサイトで解説されているので簡単に説明しました。 API認証でこのDigest認証を採用する可能性があった(仕事で使うかもしれない)から勉強するために採用しました。 Digest認証 - Wikipedia Digest認証 - Wikipediaを見る wikiによれば クライアントは認証が必要なページをリクエストする。しかし、通常ここではユーザ名とパスワードを送っていない。なぜならばクライアントはそのページが認証を必要とするか否かを知らないためである。 サーバは401レスポンスコードを返し、認証領域 (realm) や認証方式(Digest)に関する情報をクライアントに返す。このとき、ランダムな文字列(nonce)とサーバーがサポートしている qop (quality of protection) を示す引用符で囲まれた1つまたは複数のトークンも返される。 それを受けたクライアントは、認証領域(通常は、アクセスしているサーバやシステムなどの簡単な説明)をユーザに提示して、ユーザ名とパスワードの入力を求める。ユーザはここでキャンセルすることもできる。 ユーザによりユーザ名とパスワードが入力されると、クライアントはnonceとは別のランダムな文字列(cnonce)を生成する。そして、ユーザ名とパスワードとこれら2つのランダムな文字列などを使ってハッシュ文字列(response)を生成する。 クライアントはサーバから送られた認証に関する情報(ユーザ名, realm, nc(nonce count), nonce, cnonce, qop)とともに、responseをサーバに送信する。 サーバ側では、クライアントから送られてきたランダムな文字列(nonce、cnonce)などとサーバに格納されているハッシュ化されたパスワードから、正解のハッシュを計算する。 この計算値とクライアントから送られてきたresponseとが一致する場合は、認証が成功し、サーバはコンテンツを返す。不一致の場合は再び401レスポンスコードが返され、それによりクライアントは再びユーザにユーザ名とパスワードの入力を求める。 とある。 APIには ページが無い 認証が必要であることが明示的にわかっている ため、1, 2, 3の手順は必要ない。 このため、 AuthMiddleware には4以降のクライアントから送信された入力情報を検証する処理のみを実装する。 ざっくりとした実装と解説 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Http\Auth; // 省略 class AuthMiddleware implements MiddlewareInterface { private DigestAuthInterface $digestAuth; public function __construct(DigestAuthInterface $digestAuth) { $this->digestAuth = $digestAuth; } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { try { $digests = $request->getHeader('Authorization') ?? []; if (count($digests) === 0) { throw new HttpUnauthorizedException('Please set `Authorization` header.'); } } catch (HttpUnauthorizedException $e) { // TODO: ログ記録 return $e->getApiProblemResponse(); } try { try { $input = new DigestAuthInput($digests[0], $request->getMethod(), App::env('DIGEST_NONCE')); $this->digestAuth->process($input); } catch (InvalidArgumentException | DataNotFoundException $e) { // TODO: ログ記録 throw new HttpBadRequestException($e->getMessage()); } catch (InvalidResponseException $e) { // TODO: ログ記録 throw new HttpUnauthorizedException('Please check user.'); } catch (Throwable $e) { // TODO: ログ記録 throw new HttpInternalErrorException($e->getMessage()); } } catch (HttpException $e) { // TODO: ログ記録 return $e->getApiProblemResponse(); } return $handler->handle($request); } } 以上が大まかな実装です。 TODO コメントが多いのは実装途中だからですね。ロギングをしなければエラーを拾うことができなくなってしまいます。未実装なのでメモを残しています。 それ以外のところを上から順に追っていきます。 try { $digests = $request->getHeader('Authorization') ?? []; if (count($digests) === 0) { throw new HttpUnauthorizedException('Please set `Authorization` header.'); } } catch (HttpUnauthorizedException $e) { // TODO: ログ記録 return $e->getApiProblemResponse(); } 特に難しいことはしていません。読めばわかると思います。 Authorization ヘッダがない場合にエラーレスポンスを返しています。 return $e->getApiProblemResponse(); の部分はまだ未解説の部分で、(たぶん)次回に解説するのでその時に。 Authorization ヘッダがない場合というのは クライアントは認証が必要なページをリクエストする。しかし、通常ここではユーザ名とパスワードを送っていない。なぜならばクライアントはそのページが認証を必要とするか否かを知らないためである。 に反しているのでエラーを返しています。 つまり、「このAPIは認証が必須ですよ。」ということをお知らせしています。 次に実際にDigest認証をしているところを見てみます。 下記のように、検証をしている箇所はUseCase層に封じ込めています。 try { try { $input = new DigestAuthInput($digests[0], $request->getMethod(), App::env('DIGEST_NONCE')); $this->digestAuth->process($input); } catch (InvalidArgumentException | DataNotFoundException $e) { // TODO: ログ記録 throw new HttpBadRequestException($e->getMessage()); } catch (InvalidResponseException $e) { // TODO: ログ記録 throw new HttpUnauthorizedException('Please check user.'); } catch (Throwable $e) { // TODO: ログ記録 throw new HttpInternalErrorException($e->getMessage()); } } catch (HttpException $e) { // TODO: ログ記録 return $e->getApiProblemResponse(); } ほとんどがエラーハンドリングのためのコードですが、実際に検証処理を呼び出している箇所は下記ですね。 $input = new DigestAuthInput($digests[0], $request->getMethod(), App::env('DIGEST_NONCE')); $this->digestAuth->process($input); Digest認証に関するコードはこちら。 Auth ├ ClientRepositoryInterface.php └ Command └─ DigestAuth ├─ DigestAuth.php ├─ DigestAuthInput.php ├─ DigestAuthInputPort.php └─ DigestAuthInterface.php DigestAuthInput クラスに Authorization ヘッダの値 HTTPリクエストメソッド nonce の値 の認証に必要な3つの値を渡し、このInputクラスを利用してDigest認証の検証プロセスを発火します。 ちなみに、 DigestAuthInput の実装はこちら。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Domain\Auth\Command\DigestAuth; class DigestAuthInput implements DigestAuthInputPort { private array $data = []; private string $method; private string $nonce; public function __construct(string $value, string $method, string $nonce) { $this->parse($value); $this->method = $method; $this->nonce = $nonce; } public function userName(): string { return $this->data['username'] ?? ''; } public function uri(): string { return $this->data['uri'] ?? ''; } public function qop(): string { return $this->data['qop'] ?? ''; } public function nc(): string { return $this->data['nc'] ?? ''; } public function cnonce(): string { return $this->data['cnonce'] ?? ''; } public function response(): string { return $this->data['response'] ?? ''; } public function method(): string { return $this->method; } private function parse(string $value): void { preg_match_all( '@(cnonce|nc|qop|response|username|uri)=(?:([\'"])([^\2]+?)\2|([^\s,]+))@', $value, $matches, PREG_SET_ORDER ); foreach ($matches as $match) { $this->data[$match[1]] = $match[3] ?: $match[4]; } } public function nonce(): string { return $this->nonce; } } 解説するべき点は1点のみです。 preg_match_all( '@(cnonce|nc|qop|response|username|uri)=(?:([\'"])([^\2]+?)\2|([^\s,]+))@', $value, $matches, PREG_SET_ORDER ); foreach ($matches as $match) { $this->data[$match[1]] = $match[3] ?: $match[4]; } Authorization ヘッダの中身(つまりクライアントから送信された入力内容)から、cnonce値, nc値 , qop値 , response値 , username値 , uri値 を取得します。 具体的な検証プロセスとその解説 DigestAuthInputPort が実装された入力内容を利用した検証プロセスはこちら。 同様に上から追っていきます。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Domain\Auth\Command\DigestAuth; // 省略 class DigestAuth implements DigestAuthInterface { private const REALM = 'Secret Zone'; private const SHA_256 = 'sha256'; private ClientRepositoryInterface $clientRepository; public function __construct(ClientRepositoryInterface $clientRepository) { $this->clientRepository = $clientRepository; } public function process(DigestAuthInputPort $inputPort): void { $client = $this->clientRepository->findById(new ClientId($inputPort->userName())); $userName = (string)$client->clientId(); $password = (string)$client->clientSecret(); $nonce = $inputPort->nonce(); // @see https://tex2e.github.io/rfc-translater/html/rfc7616.html $A1 = hash(self::SHA_256, "$userName:" . self::REALM . ":$password"); $A2 = hash(self::SHA_256, "{$inputPort->method()}:{$inputPort->uri()}"); $validResponse = hash(self::SHA_256, "$A1:" . $nonce . ":{$inputPort->nc()}:{$inputPort->cnonce()}:{$inputPort->qop()}:$A2"); if ($validResponse !== $inputPort->response()) { throw new InvalidResponseException(); } } } 以外とシンプルにまとまっています。 $client = $this->clientRepository->findById(new ClientId($inputPort->userName())); まず、入力されたユーザー(クライアント)情報を取得します。このときにユーザーの存在チェックも行っています。 ClientRepogitory の実装については実際にGithubのコードを見てみてください。 storage/ClientRepository.php at main · nonz250/storage Contribute to nonz250/storage development by creating an account on GitHub. とは言ってもただのSQL実行機ですが...複雑なクエリを必要としないので、ORMやクエリビルダなどのツールは用意していません。(これもそのうち記事にしたいと思います。) ともかく、 Repogitory からユーザー情報を取得できたので、リクエストで入手した情報とサーバーにある情報を比べて比較していきます。 $userName = (string)$client->clientId(); $password = (string)$client->clientSecret(); $nonce = $inputPort->nonce(); 見てわかるように、今後の処理が追いやすいように変数に格納しているだけです。 次のコードに行きます。 @see でも書かれているように、Digest認証は RFC 7616 で決められています。 下のサイトは日本語訳されているサイトなので比較的読みやすいと思います。 https://tex2e.github.io/rfc-translater/html/rfc7616.html https://tex2e.github.io/rfc-translater/html/rfc7616.htmlを見る ただ、もちろん RFC ft-ietf-httpauth-digest: HTTP Digest Access Authentication The Hypertext Transfer Protocol (HTTP) provides a simple challenge- response authentication mechanis... こちらのサイトの確認も推奨します。 さて、具体的な実装については 3.4.1章 に書かれていそうです。 前提として、このAPIのDigest認証は下記の仕様に従うものとします。 qop 値が auth であること ハッシュ化アルゴリズムは SHA256 であること とすると、 If the algorithm parameter's value is "", e.g., "SHA-256", then A1 is: A1 = unq(username) ":" unq(realm) ":" passwd where passwd = < user's password > と実装されるようです。 上記を参考にして、まず A1 から $A1 = hash(self::SHA_256, "$userName:" . self::REALM . ":$password"); ユーザー名 、 realm値 、 パスワード を : で結合したものを MD5 や SHA256 でハッシュ化します。 次に A2 です。 If the qop parameter's value is "auth" or is unspecified, then A2 is: A2 = Method ":" request-uri If the qop value is "auth-int", then A2 is: A2 = Method ":" request-uri ":" H(entity-body) auth を想定していますので、 A2 = Method ":" request-uri ですね。 $A2 = hash(self::SHA_256, "{$inputPort->method()}:{$inputPort->uri()}"); Httpリクエストメソッド と リクエストUri を : で結合したものを MD5 や SHA256 でハッシュ化します。 最後に A1 と A2 からレスポンス値の生成します。 このレスポンス値がクライアントで生成されたと同じであれば認証成功となります。 If the qop value is "auth" or "auth-int": response = <"> < KD ( H(A1), unq(nonce) ":" nc ":" unq(cnonce) ":" unq(qop) ":" H(A2) ) <"> See below for the definitions for A1 and A2. 日本語の文法的に最後になってしまいました。これまで作成した A1 と A2 からレスポンス値の生成方法は、 A1 、 nonce値 、 nc値 、 cnonce値 、 qop値(ここではauth) と A2 を : で結合して MD5 や SHA256 でハッシュ化します。 $validResponse = hash(self::SHA_256, "$A1:" . $nonce . ":{$inputPort->nc()}:{$inputPort->cnonce()}:{$inputPort->qop()}:$A2"); こうすることで、クライアント側で生成された response値 と同じものが作成できたはずです。 それを実際に検証し、成功すればOK。失敗すれば例外をスローします。 if ($validResponse !== $inputPort->response()) { throw new InvalidResponseException(); } これで検証プロセスの実装が完了しました。 まとめ クライアント側でユーザー情報など各種値と、決められた手順でハッシュ化された response値 を Authorization ヘッダにセット サーバーに送信。 サーバー側でユーザー情報の有無確認 クライアントとは違うリソース (重要: クライアントから取得した情報を利用しても意味がない。DBなどで保存されているクライアントとは別のリソース)を使い、同じ手順で response値 を生成 response値 の比較 という手順。 おまけ: エラーハンドリングについて ValueObjectなど引数のエラー BadRequest ユーザー情報の有無や認証エラー Unauthorized その他 InternalServerError という感じ。 最後に ぶっちゃけこのコードもまだまだ改善点はありますね。 qop は auth のみを前提にしていますし、ハッシュ化のアルゴリズムも <algorithm> -sess の考慮がされていません。 想定されていないことなら、それをエラーレスポンスとしてユーザーに教えてあげる必要がありますよね。でもそれがない。というのが今後の課題になりそうです。 とはいえ、結局自分しか使わないサービスになりそうなのでその辺は...実装するかな?w 次回は予定通り RFC 7807 について記事にしたいと思います。 APIを実装する上でエラーレスポンスをどう返すかというのをルール化したものですね。 この話はどちらかというと採用したライブラリや、独自例外クラスの記事になりそうです。 賛否あるかと思いますが、温かい目で見ていただければ幸いです。 また記事にします。 その時はよしなに。 .

S3みたいなストレージサーバーっぽいものを自前で用意する②【ミドルウェア実装】

2022/03/26 2022/03/26

S3みたいなストレージサーバーっぽいものを自前で用意する②【ミドルウェア実装】 こんにちは。のんです。 前回に引き続き自前でストレージサーバーを開発していこうと思います。 S3みたいなストレージサーバーっぽいものを自前で用意する①【ルーティング初期設定】 | のんラボ S3みたいなストレージサーバーっぽいものを自前で用意するこんにちは。のんです。実はちょっと前からのんラボのリプレースを考えていまして、ブログに載せる画像のアップロード先について考えていました。これまで... GitHubプロジェクトはこちら GitHub - nonz250/storage Contribute to nonz250/storage development by creating an account on GitHub. ミドルウェアを実装する league/route の MiddlewareInterface https://route.thephpleague.com/ https://route.thephpleague.com/を見る league/route は PSR-15 準拠のミドルウェア実装に対応しているので、 https://www.php-fig.org/psr/psr-15/#22-psrhttpservermiddlewareinterface https://www.php-fig.org/psr/psr-15/#22-psrhttpservermiddlewareinterfaceを見る にあるように、 The following interface MUST be implemented by compatible middleware components. namespace Psr\Http\Server; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; /** * Participant in processing a server request and response. * * An HTTP middleware component participates in processing an HTTP message: * by acting on the request, generating the response, or forwarding the * request to a subsequent middleware and possibly acting on its response. */ interface MiddlewareInterface { /** * Process an incoming server request. * * Processes an incoming server request in order to produce a response. * If unable to produce the response itself, it may delegate to the provided * request handler to do so. */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface; } を実装するようにします。 process の引数である ServerRequestInterface は PSR-7 、RequestHandlerInterface は PSR-15 準拠です。 ServerRequestInterface (※ 長いので省略) https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterface https://www.php-fig.org/psr/psr-7/#321-psrhttpmessageserverrequestinterfaceを見る RequestHandlerInterface https://www.php-fig.org/psr/psr-15/#22-psrhttpservermiddlewareinterface https://www.php-fig.org/psr/psr-15/#22-psrhttpservermiddlewareinterfaceを見る The following interface MUST be implemented by request handlers. namespace Psr\Http\Server; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; /** * Handles a server request and produces a response. * * An HTTP request handler process an HTTP request in order to produce an * HTTP response. */ interface RequestHandlerInterface { /** * Handles a request and produces a response. * * May call other collaborating code to generate the response. */ public function handle(ServerRequestInterface $request): ResponseInterface; } ミドルウェアはよくドーナツ型の図で表現されます 前処理や後処理では、実際にControllersやActionに行く前にしておきたい共通処理を書くことが多いですね。 ログの書き込み 認証チェック 共通データの取得 などなど。 もちろん上図のように、ドーナツは二重三重にすることができます。 実際に作成した AuthMiddleware がこちら。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Http\Auth; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; class AuthMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { // ミドルウェアでしたい処理。ここではDigest認証を行います。 return $handler->handle($request); } } と言っても、まだDigest認証の実装はしていません。この実装については次回の記事で書こうと思います。 前回作成したルートにこの AuthMiddleware を設定します。 $router = new League\Route\Router(); $router ->group('/', static function (League\Route\RouteGroup $router) { // 認証が必要なアクションをここに登録します。 }) ->middleware(new Nonz250\Storage\App\Http\Auth\AuthMiddleware); これで $router->group() で登録したアクションを実行するときに必ず AuthMiddleware が発火します。 認証エラーの場合は例外をスローする実装にしておけばアクションまでは実行されないという寸法です。 ついでにRequestBodyをParseするMiddlewareを作成する フルスタックなフレームワークを利用していないので、 Content-Type が application/json の状態でアクションからRequestBodyを取得するとStringで取得してします。 <?php declare(strict_types=1); namespace Nonz250\Storage\App\Http\Test; use Laminas\Diactoros\Response; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; class TestAction { /** * @param ServerRequestInterface $request * @return ResponseInterface */ public function __invoke(ServerRequestInterface $request): ResponseInterface { $response = new Response(); // ここで取得できる$contentsがjson。つまりStringのまま。 $contents = $request->getBody()->getContents(); $response->getBody()->write($contents); return $response; } } Laravelなどのフレームワークなら自動的に配列に変換されていたり、プロパティとして取得できるのですが、それができていません。これだと非常に不便なので、同じようにMiddlewareを用意してその中でRequestBodyをjsonから配列に変換する処理を行います。 ServerRequestInterface には withParsedBody と getParsedBody という関数が実装されていますので、これを利用します。 実際に作成した ParseRequestMiddleware はこちら <?php declare(strict_types=1); namespace Nonz250\Storage\App\Http\ParseRequest; use JsonException; use Nonz250\Storage\App\Foundation\Exceptions\HttpBadRequestException; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; class ParseRequestMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { try { try { $contentTypes = $request->getHeader('Content-Type'); if (count($contentTypes) === 0) { throw new EmptyContentTypeException(); } $contentType = $contentTypes[0]; if ($contentType !== 'application/json') { throw new InvalidContentTypeException(); } $contents = $request->getBody()->getContents(); $parsedBody = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); } catch (EmptyContentTypeException|InvalidContentTypeException $e) { // TODO: ログ記録 throw new HttpBadRequestException($e->getMessage()); } catch (JsonException $e) { // TODO: ログ記録 throw new HttpBadRequestException('Json syntax error.'); } } catch (HttpBadRequestException $e) { return $e->getApiProblemResponse(); } $request = $request->withParsedBody($parsedBody); return $handler->handle($request); } } 一つずつ説明していきます。 $contentTypes = $request->getHeader('Content-Type'); if (count($contentTypes) === 0) { throw new EmptyContentTypeException(); } このコードでRequestHeaderから Content-Type の値を取得します。 取得できない場合は Content-Type が無い旨を示す例外をスローします。 独自に作成した HttpException と HttpExceptionInterface を継承した例外です。 return $e->getApiProblemResponse(); にある RFC 7807 に従ったエラーレスポンスを返す関数を用意しています。 実は league/route にも HttpException は実装されていますが、上記のような独自処理を実装したかったし、 league に依存しすぎるのもどうかと思ったので自前で用意したという流れです。 この件についてもいずれ記事にしたいと思います。 次のコードです。 $contentType = $contentTypes[0]; if ($contentType !== 'application/json') { throw new InvalidContentTypeException(); } このコードは Content-Type の内容が取得できたらその値が application/json かどうかを確認します。 違ったら不適切な Content-Type であることを示す例外をスローします。 $contents = $request->getBody()->getContents(); $parsedBody = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); バリデーションが終了したら実際にRequestBodyを取得します。 json_encode を利用して連想配列へ変換します。このとき、Parseでエラーが発生したら JsonException をスローするように設定しているので、それを catch したらその旨を HttpBadRequestException として投げ直しています。 無事、 $parsedBody を取得できたら、 withParsedBody でRequestクラスに保存しておきます。 $request = $request->withParsedBody($parsedBody); こうすることで、 $contents = $request->getParsedBody(); で連想配列形式で取得できるようになります。 このMiddlewareをRouterに登録しておきます。 $router = new League\Route\Router(); // AuthMiddlewareと違い、全てのルートでONにしておく $router->middleware(new Nonz250\Storage\App\Http\ParseRequest\ParseRequestMiddleware); $router ->group('/', static function (League\Route\RouteGroup $router) { // 認証が必要なアクションをここに登録します。 }) ->middleware(new Nonz250\Storage\App\Http\Auth\AuthMiddleware); 最後に 今回はMiddleware、特に前処理を利用して、認証やRequestBodyの加工の実装を行いました。 連想配列を返すっていうのもまた微妙に使いづらいですが、ひとまずここで良しとしておきましょう。 必要がある場合はまた自前で拡張してプロパティから取得できるように実装すればいいでしょう。...が、その場合は素直にフレームワークを利用したほうが安心できるかもしれませんね。 次回はDigest認証の実装について記事を書こうと思います。 その後に RFC 7807 について記事にする予定です。 そのときはよしなに。 .