のんラボ

自作ストレージサーバー用のための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... 実装内容 エラーハンドリングは行っていないので、雑な作りですが... copied.#! /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 メソッドの処理が実際にコマンドを実行するときの処理になります。 copied.const image = fs.readFileSync(path) で、画像の読み込みをします。 copied.const base64strings = base64js.fromByteArray(image) で、読み込んだ内容をBase64エンコードします。 copied.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 にコマンド名を登録しておくことで copied."bin": { "image-base64": "index.js" } copied.npm i @nonz250/image-base64 したときに bin ディレクトリにシンボリックリンクを貼ってくれます。 つまり、インストールしたときに copied.image-base64 --help のように利用できるようになるということです。 Node Package として公開 copied.npm publish 普通に公開するだけです。 ちょっと間違えて 1.0.0 を unpublish してしまったので 1.0.1 から始まっていますw 初めて使う人は copied.npm adduser をしたり、 copied.npm login をして、ユーザー情報をコマンドに紐付けておきます。 作成したCLIツールの使い方 これも README.md に書いてありますが、 Install package. copied.npm i --location=global @nonz250/image-base64 Restart terminal. copied.image-base64 --help image-base64 encode <path> image-base64 encode ~/foo/bar.png image-base64 encode ./baz.png Npx command. copied.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個紹介します。 この辺でしょうか。 メリット マークダウン記法なので、コードが書きやすい!! これに尽きると思います。 他のプレゼンテーションツールだったらコードを画像化して貼り付ける...とかでしょうか?まぁ面倒臭いですよね。(もしかしたら便利な方法があるかもしれませんが。) しかしマークダウンなので、普通に copied.<?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 でのコードですが、 copied.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 なので、 copied.sudo apt install fonts-noto を実行するだけです。 fonts-noto にはマルチバイト文字が多数含まれているので問題ないとは思いますが、、他に必要な文字があれば適宜インストールすれば大丈夫だと思います。 結果 こうすることでSlackなどに貼り付けたときにキチンと画像が表示され、その生成された画像に絵文字が表示されていますね。 MarpでOGPについては書いていないので公式HPを見てください。 あとは私のリポジトリも見ていただければ少しは参考になるかも知れません。 https://github.com/nonz250/slides 最後に 今回はライトに記事を書いてみました。 皆さんはスライドを作成するときにどのようなツールを使っていますか? Google Slide? Slide Share? Scrapbox? 他にも無料ツールで良いものがたくさんありますが、私は自分の資産としてリポジトリに残しておきたかったので、管理しやすいMarpを選択しました! 結構同じことをやってる人もたくさんいるのでは? 今回はツーリングについて一つ書こうと思っていましたが、時間がなくて急遽この記事にしましたw 次回こそはバイクの写真を掲載します! そのときはよしなに。 .

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 を登録しておきます。 copied.$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 クラスを渡すようにしておくとログをローテーションしてくれます。 copied.(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方式で登録できるようにしておきます。 後々、分解したり解析したりしやすくなるでしょう。 copied.->setFormatter(new Monolog\Formatter\JsonFormatter()), JsonFormatter はMonologに標準で実装されていますね。フィールドなどをカスタマイズしたい場合は継承したりして自作しましょう。 Monologをコンテナに登録できたので、実際のコードを見てみる 実際にログに残しているところはこちら。 ファイルアップロードする処理の中でファイルのサイズをデバッグログとして記録します。 copied.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; } この関数はアップロードされたファイルの原寸大のファイルをそのまま保存します。 この時にどれくらいのイメージサイズなのかをログに残すのですが、 copied.$this->logger->debug(sprintf('%s is %s bytes.', $file->fileNameWithOriginExtension(), $byte)); で記録しています。 その結果がこちら copied.{"message":"hoge.jpeg is 4006827 bytes.","context":{},"level":100,"level_name":"DEBUG","channel":"storage","datetime":"2022-04-23T06:37:25.545894+09:00","extra":{}} ログなので余計な改行なく記録されています。 これをフォーマッターに通すと、 copied.{ "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 を利用します。 copied.$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 メソッドの中身はこんな感じ copied.<?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 クラスを定義しておく 現状では拡張するような要素がないので、ただのラッパークラスになってしまいますが、作成しておきましょう。 copied.<?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 メソッドのこちらのコード copied.if ($bindValues && !$bindValues->isEmpty()) { foreach ($bindValues as $bindKey => $bindValue) { $statement->bindValue($bindKey, $bindValue); } } この条件式キモすぎてなんとかしたい... Iteratable な $bindValues で $bindValues->isEmpty() の確認が必要かどうかというのは議論の余地がありますが、可読性を高めるという意味ではあってもいいのでしょうか? そして BindValues クラスはこんな感じ。 copied.<?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しておきます。 copied.$container->add(Nonz250\Storage\App\Foundation\Model\Model::class) ->addArgument(PDO::class); いや、便利ですね。 次にRepositoryの実装です。 とはいってもガッツリビジネスロジックなので詳細なコードはGitHubを見て欲しいです。 一例として、 CreateClient ユースケースを見てみましょう。 このコードについての解説は記事にしていないので、後日記事にし直すかもしれません。 簡単に言うと、このAPIエンドポイントを利用するためには認証が必要なのですが、その認証に利用するためのクライアントを作成するためのエンドポイントです。ちなみに、このサービスにOAuth2を実装するつもりは無いので認可の仕組みは実装されていません。 copied.<?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されています。 copied.<?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 にあるサービスコンテナ機能です。 話を戻します。 copied.try { $this->clientRepository->beginTransaction(); $this->clientRepository->create($client); $this->clientRepository->commit(); } catch (Throwable $e) { $this->clientRepository->rollback(); throw new CreateClientException('Failed to create client.'); } メインになるロジックはここですね。 copied.$client = $this->clientFactory->newClient($inputPort->appName(), $inputPort->clientEmail()); は Client エンティティを作成する処理をFactoryパターンで実装しているだけなので割愛します。(もしかしたら別の話で記事にするかも) create メソッドの中身を見てみます。 より具体的に言うと ClientRepository の中身です。 copied.<?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 という親クラスがあります。ここは苦渋の決断的クラスになっています。 copied.<?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 を保存する処理が書かれています。 copied.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 にも書いてあるようにこのような感じ。 copied.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 から copied.<?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 の基盤例外クラスがこちら。 copied.<?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レスポンスを返します。 copied.$responseFactory = new ResponseFactory(); $converter = new HttpConverter($responseFactory, true); ResponseInterface を実装した HttpResponse を作成するために必要です。 最終的には toJsonResponse でjson形式のレスポンスを返す関数を利用するために使います。 copied.$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応答: copied.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レスポンスに変えます。 copied.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 を継承したクラスに詰め直して、スローするだけです。 copied.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 の実装はこちら。ただのラッパーであることがわかるはずです。 copied.<?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 に封じ込めることができています。 実際の動作 実際に返ってくるレスポンスはこちら。 copied.{ "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以降のクライアントから送信された入力情報を検証する処理のみを実装する。 ざっくりとした実装と解説 copied.<?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 コメントが多いのは実装途中だからですね。ロギングをしなければエラーを拾うことができなくなってしまいます。未実装なのでメモを残しています。 それ以外のところを上から順に追っていきます。 copied.try { $digests = $request->getHeader('Authorization') ?? []; if (count($digests) === 0) { throw new HttpUnauthorizedException('Please set `Authorization` header.'); } } catch (HttpUnauthorizedException $e) { // TODO: ログ記録 return $e->getApiProblemResponse(); } 特に難しいことはしていません。読めばわかると思います。 Authorization ヘッダがない場合にエラーレスポンスを返しています。 copied.return $e->getApiProblemResponse(); の部分はまだ未解説の部分で、(たぶん)次回に解説するのでその時に。 Authorization ヘッダがない場合というのは クライアントは認証が必要なページをリクエストする。しかし、通常ここではユーザ名とパスワードを送っていない。なぜならばクライアントはそのページが認証を必要とするか否かを知らないためである。 に反しているのでエラーを返しています。 つまり、「このAPIは認証が必須ですよ。」ということをお知らせしています。 次に実際にDigest認証をしているところを見てみます。 下記のように、検証をしている箇所はUseCase層に封じ込めています。 copied.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(); } ほとんどがエラーハンドリングのためのコードですが、実際に検証処理を呼び出している箇所は下記ですね。 copied.$input = new DigestAuthInput($digests[0], $request->getMethod(), App::env('DIGEST_NONCE')); $this->digestAuth->process($input); Digest認証に関するコードはこちら。 copied.Auth ├ ClientRepositoryInterface.php └ Command └─ DigestAuth ├─ DigestAuth.php ├─ DigestAuthInput.php ├─ DigestAuthInputPort.php └─ DigestAuthInterface.php DigestAuthInput クラスに Authorization ヘッダの値 HTTPリクエストメソッド nonce の値 の認証に必要な3つの値を渡し、このInputクラスを利用してDigest認証の検証プロセスを発火します。 ちなみに、 DigestAuthInput の実装はこちら。 copied.<?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点のみです。 copied.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 が実装された入力内容を利用した検証プロセスはこちら。 同様に上から追っていきます。 copied.<?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(); } } } 以外とシンプルにまとまっています。 copied.$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 からユーザー情報を取得できたので、リクエストで入手した情報とサーバーにある情報を比べて比較していきます。 copied.$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: copied.A1 = unq(username) ":" unq(realm) ":" passwd where copied.passwd = < user's password > と実装されるようです。 上記を参考にして、まず A1 から copied.$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: copied.A2 = Method ":" request-uri If the qop value is "auth-int", then A2 is: copied.A2 = Method ":" request-uri ":" H(entity-body) auth を想定していますので、 A2 = Method ":" request-uri ですね。 copied.$A2 = hash(self::SHA_256, "{$inputPort->method()}:{$inputPort->uri()}"); Httpリクエストメソッド と リクエストUri を : で結合したものを MD5 や SHA256 でハッシュ化します。 最後に A1 と A2 からレスポンス値の生成します。 このレスポンス値がクライアントで生成されたと同じであれば認証成功となります。 If the qop value is "auth" or "auth-int": copied.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 でハッシュ化します。 copied.$validResponse = hash(self::SHA_256, "$A1:" . $nonce . ":{$inputPort->nc()}:{$inputPort->cnonce()}:{$inputPort->qop()}:$A2"); こうすることで、クライアント側で生成された response値 と同じものが作成できたはずです。 それを実際に検証し、成功すればOK。失敗すれば例外をスローします。 copied.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. copied.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. copied.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 がこちら。 copied.<?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 を設定します。 copied.$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で取得してします。 copied.<?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 はこちら copied.<?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); } } 一つずつ説明していきます。 copied.$contentTypes = $request->getHeader('Content-Type'); if (count($contentTypes) === 0) { throw new EmptyContentTypeException(); } このコードでRequestHeaderから Content-Type の値を取得します。 取得できない場合は Content-Type が無い旨を示す例外をスローします。 独自に作成した HttpException と HttpExceptionInterface を継承した例外です。 copied.return $e->getApiProblemResponse(); にある RFC 7807 に従ったエラーレスポンスを返す関数を用意しています。 実は league/route にも HttpException は実装されていますが、上記のような独自処理を実装したかったし、 league に依存しすぎるのもどうかと思ったので自前で用意したという流れです。 この件についてもいずれ記事にしたいと思います。 次のコードです。 copied.$contentType = $contentTypes[0]; if ($contentType !== 'application/json') { throw new InvalidContentTypeException(); } このコードは Content-Type の内容が取得できたらその値が application/json かどうかを確認します。 違ったら不適切な Content-Type であることを示す例外をスローします。 copied.$contents = $request->getBody()->getContents(); $parsedBody = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); バリデーションが終了したら実際にRequestBodyを取得します。 json_encode を利用して連想配列へ変換します。このとき、Parseでエラーが発生したら JsonException をスローするように設定しているので、それを catch したらその旨を HttpBadRequestException として投げ直しています。 無事、 $parsedBody を取得できたら、 withParsedBody でRequestクラスに保存しておきます。 copied.$request = $request->withParsedBody($parsedBody); こうすることで、 copied.$contents = $request->getParsedBody(); で連想配列形式で取得できるようになります。 このMiddlewareをRouterに登録しておきます。 copied.$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 について記事にする予定です。 そのときはよしなに。 .

S3みたいなストレージサーバーっぽいものを自前で用意する①【ルーティング初期設定】

2022/03/13 2022/03/13

S3みたいなストレージサーバーっぽいものを自前で用意する こんにちは。のんです。 実はちょっと前からのんラボのリプレースを考えていまして、ブログに載せる画像のアップロード先について考えていました。 これまではLaravelのデフォルト設定であるプロジェクト内のStorageディレクトリ内に保存していました。 ただ、リプレースする際にこれがかなりのネックになっていて、頭を抱えています。 新しいプロジェクトではキチンとストレージサーバーを用意してそこにアップロードするようにしようと思いました。 S3を検討していたのですが、S3使うだけじゃ何のインプットもアウトプットもできないので自前でストレージサーバーを用意しようと思い立ったのがこの記事を書くキッカケです。 今回からそのためのプロジェクトについて記事にしていきます。 一応、連載記事にしようと思っていますが、他にも書きたい内容(Rustとかチームビルディングとか)があるので、とびとびになるかも。 GitHubプロジェクトはこちら GitHub - nonz250/storage Contribute to nonz250/storage development by creating an account on GitHub. できるだけフルスタックなフレームワークは使わない Laravel CakePHP Symfony Slim などは利用しません。 しかし、自前で用意した生のPHPだけで書くという意味ではなく、laminasのコンポーネントや、leagueのコンポーネントを利用していきます。 https://getlaminas.org/ https://getlaminas.org/を見る The League of Extraordinary Packages The League of Extraordinary Packagesを見る ここでの狙いは、 PSR7やPSR15、RFC 7807やRFC 7807などの仕様、動作の勉強 となります。 何を組み合わせればいいのか。どのような実装になっているのか。 LaravelやCakePHPのドキュメントやAPIリファレンスを読んでいるだけでは得ることができない習慣を習得するため です。 では、早速始めましょう。(今回は触りだけですが...) まずはディレクトリ構成から storage/backendが前提です。 copied.. ├── app │   ├── Adapter │   ├── Domain │   ├── Http │   ├── Provider │   └── Strategy ├── bin ├── tests └── vendor app/Adapter 主にRepository層の具象クラスをまとめる。 他のユースケース層で書きたくないものを逃がす場としても利用するかも。 ちょっと考え方ガバいけども、今の考え方で大丈夫そう。 app/Domain 主にUseCase層をまとめる。他にはEntities/ValueObjectをまとめます。つまり、レイヤードアーキテクチャでいうDomain層も含んでいるということ。 app/Http 主にHttpControllerをまとめる。MVCモデルのCです。 bin 独自コマンドを実行できるようにする予定。 リッチなMigration機能は求めていないので、単純なSQLを実行するためのコマンドや、定期的にファイルを調整するコマンドを用意するつもり。 と、だらだら書いたけども... 兎にも角にもRoutingとHttp Requestを捌きたい 自前のストレージサーバーと言ってもアップロード用のAPIを公開して受け付けるくらいの機能しかないです。この辺がFW(フレームワーク)を利用しない理由でもあります。 league/route https://route.thephpleague.com/ https://route.thephpleague.com/を見る Route is a fast PSR-7 routing/dispatcher package including PSR-15 middleware implementation that enables you to build well designed performant web apps. です。この仕様にあるライブラリを探すことになるのですが、このページで書いてある2つのライブラリを利用します。 laminas/laminas-diactoros GitHub - laminas/laminas-diactoros: PSR HTTP Message implementations PSR HTTP Message implementations. Contribute to laminas/laminas-diactoros development by creating an... If you use Laminas Diactoros project you will also need copied.composer require laminas/laminas-httphandlerrunner とあるので、 laminas-httphandlerrunner GitHub - laminas/laminas-httphandlerrunner: Execute PSR-15 RequestHandlerInterface instances and emit responses they generate. Execute PSR-15 RequestHandlerInterface instances and emit responses they generate. - GitHub - lamina... も入れます。 これらを利用してテスト用のアクションを用意する copied.<?php declare(strict_types=1); include_once 'vendor/autoload.php'; /** * Load dotenv. */ $dotenv = Dotenv\Dotenv::createImmutable(__DIR__); $dotenv->load(); /** * Load environment. */ $env = new Nonz250\Storage\App\Shared\ValueObject\Environment($_ENV['APP_ENV']); /** * Create request. */ $request = Laminas\Diactoros\ServerRequestFactory::fromGlobals( $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES, ); /** * Setting router. */ $responseFactory = new Laminas\Diactoros\ResponseFactory(); $strategy = new League\Route\Strategy\JsonStrategy($responseFactory); $router = new League\Route\Router(); if (Nonz250\Storage\App\Foundation\App::environment(Nonz250\Storage\App\Shared\ValueObject\Environment::LOCAL)) { $router ->group('test', static function (League\Route\RouteGroup $router) use ($strategy) { $router->get('/', static function (): array { return [ 'message' => 'test', ]; })->setStrategy($strategy); $router->get('/hello', static function (): Psr\Http\Message\ResponseInterface { $response = new Laminas\Diactoros\Response(); $response->getBody()->write('<h1>Hello, World!</h1>'); return $response; }); $router->get('/action', Nonz250\Storage\App\Http\Test\TestAction::class); }); } $response = $router->dispatch($request); (new Laminas\HttpHandlerRunner\Emitter\SapiEmitter)->emit($response); 最後に 次回はPSR15を参照して、Middlewareを実装した内容を記事にします。 自分のためのAPIなので、認証が必要です。 Basic / Digestなどが一般的ですが、今回はDigest認証をAPI認証として利用します。 OAuth2の実装までやってみようと思いましたが、流石に時間かかりすぎるなと思ったのでやめました。 AuthMiddlewareについてはまた記事にしたいと思います。 そのときはよしなに。 .

Next.jsのGetServerSidePropsの挙動について

2022/02/11 2022/02/11

Next.jsのGetServerSidePropsの挙動について こんにちは。のんです。 Reactは書いたことあるのですが、Next.jsは触ったこと無かったので入門しました。 今回はそこで発見したわかりにくい仕様について備忘録ついでに記事にしたいと思います。 メモ程度で終わってしまいますがご了承ください。 Next.jsのSSGとSSR。前置き。 Next.jsはPreRenderingとServerSideRenderingの2つをサポートしています。 クローラーBotのことなどを考えると、SSR一択かもしれんな。と公式からの回答もあるようにSSR推奨気味ではあるものの、それをどのように動作確認すれば良いものやら試行錯誤していました。 自分的には右クリックからでる「ページのソースを確認」でHTMLの内容が表示されていればSSRできていると考えています。 デフォルトの設定だとSSRできていないようで、どこで切り替えられるのかと調査していました。 Nuxt.jsとは違い設定にSSRモードのフラグ管理も無いようなので、発見時にはなるほど〜といった感想です。 GetServerSidePropsの挙動について 結論としてはGetServerSidePropsの関数をexportしてやる必要がありました。 SSRの処理がない場合でも宣言することでSSRできるようになります。 ここが重要なようで、別段SSR時に特別なfetch処理がない場合でもこいつを宣言することでSSRが可能になるようです。 こんな感じ。(省略とかしてない本当のコード) copied.import {GetServerSideProps, NextPage} from "next"; import {useRouter} from "next/router"; const Article: NextPage = () => { const router = useRouter(); const { id } = router.query; return ( <div> <p>articles</p> <p>{id}</p> </div> ); }; export const getServerSideProps: GetServerSideProps = async (context) => { return { props: {}, }; }; export default Article; このgetServerSidePropsの部分。 処理もしていないし、propsも渡していませんが、このページをSSRするために宣言しています。 いや、そんなことする必要あるの?という話ですが、jsでレンダリングされる以上クローラーに検知され易くするためには必要な処置だと考えています。 具体的な例を書くとこのようになります。 copied.import {GetServerSideProps, NextPage} from "next"; import {useRouter} from "next/router"; const Article: NextPage = () => { const router = useRouter(); const { id } = router.query; return ( <div> <p>articles</p> <p>{id}</p> </div> ); }; // SSRされないようにコメントアウト // export const getServerSideProps: GetServerSideProps = async (context) => { // return { // props: {}, // }; // }; export default Article; とすると、 copied.const router = useRouter(); const { id } = router.query; で取得した copied.<p>{id}</p> の部分がSSRされません。 このコメントアウトをもとに戻すと......SSRされます。 これは copied.const router = useRouter(); const { id } = router.query; がjsの処理であり、PreRenderingされているためソースレベルではhtmlとして出力されていないからだと考えます。 なので、全てをしっかりクローラーに判断してもらうためには、何も処理がなくてもいいので copied.export const getServerSideProps: GetServerSideProps = async (context) => { return { props: {}, }; }; を書いておこうということを発見しました。 最後に 正直、普段触っている方からしたら「何を今更。」という話かもしれませんが、Nuxt.jsユーザーからすると結構珍しいことだったのでメモ程度に記事にさせていただきました。 この「のんラボ」もそろそろ一新するタイミングかなぁ〜と考えています。そのときにはNext.jsを使って一新したいです。 あと、技術記事にするときに「わからなかったこと」や「わかりにくいこと」を中心に記事にしてきましたが、ブログのネタに困ることが多くなってきたので、垂れ流し形式変更しようかなと思い始めています。 まぁこのへんはゆるく考えます。 また記事を書きますので、その時はよしなに。 .

RustでMVC・クリーンアーキテクチャっぽく書いてみた

2022/01/29 2022/01/29

RustでMVC & クリーンアーキテクチャっぽく書いてみた こんにちは。のんです。 久しぶりにRustの記事更新です。 何もしていなかったわけではなく、ブログにできるレベルの成果物ができていなかっただけですw。 成果物 ※masterブランチで作業しているので、既に色々更新されている可能性があることにご注意ください。 GitHub - nonz250/rust-sample Contribute to nonz250/rust-sample development by creating an account on GitHub. 個人的悩みポイント mod.rsについて Rustはmoduleの考え方あので、modを定義する必要があるのですが、クリーンアーキテクチャを適用するとどうしてもファイル数が多くなります。 その際、 mod.rs というファイルをたくさん作ることになるのですが、これをどうにかスマートに書く方法がないものかなやんでしまいました。 結局、 mod.rs を作りまくるこで対応しましたが、もう少しいい感じに書く方法はありそう。。 今後に期待。 エラーについて Rustをやるにあたって一番最初に躓くところだそうです。 特に私はPHPerなので、例外ガンガン書くタイプのプログラマーでした。 Golangなども普段業務で触っているわけではないので、例外がないプログラミング言語をどのように書けばいいのかめっちゃ困りました。 DBアクセスができなかった...とか、データが存在しないとか、ランタイム系のエラーはWEBアプリには付き物なので、通る前提でコーディングできないのが辛いところです。 例外を投げるというより、エラー型?( Result 型)を返すという方がニュアンスとして合っていそうなのでそのように実装していますが、エラーのバケツリレーが始まった感出ていてありゃ〜という感じ この問題もどうにかしてスマートに書く方法はありそう。 現在は泥臭くエラー返しています。。。がこの方法も悪くはないのでは?とブログ書きながら考えていたりもします。 PostRepository の実装部分であるこことか copied. fn find_by_id(id: PostIdentifier) -> Result<Post, String> { let connection = establish_connection(); let post = match posts_schema::dsl::posts .find(id.to_string()) .first::<PostModel>(&connection) { Ok(ret) => ret, Err(_err) => return Err("Post not found".to_string()) }; return Ok(Post::new( PostIdentifier { identifier: Ulid::from_string(&post.id).unwrap() }, PostTitle::new(post.title).unwrap() )) } それを呼び出すユースケース、こことかの話。 copied.impl UpdatePost { pub fn process(input: UpdatePostInput) -> Result<(), String> { let mut post = match PostMySqlRepository::find_by_id(input.identifier) { Ok(post) => post, Err(err) => return Err(err) }; post.change_title(input.title); PostMySqlRepository::save(post); return Ok(()); } } ぜーんぶResult返すようになっちゃいました。 ここまででRustいいなって思ったところ 強力な安全性 これはRustに限ったことではありませんが、やっぱり強力な型安全、処理安全なところですね。 ValueObject は採用していますが、これ書く意味ある?(いや、当然書く意味はあるのですが。)と感じてしまうくらい、Rustの仕様を信じてコーディングできるのはすごいなと思いました。 あと、エラー周りも最初は戸惑いましたが、エラーを投げるということすらも 「これくらいのエラーは想定しておけよ」 と言われているようで、 ストイックな言語である ということがヒシヒシと伝わってきます。私は好感持てました。 CQRSでReadModelを実装したときはほぼほぼRustの仕様に乗っからせてもらっています。 copied.#[derive(Serialize)] pub struct PostReadModel { identifier: String, title: String } そのほかの実装部分 copied.pub struct GetPostMySqlQuery {} pub struct GetPostMockQuery {} pub trait GetPostQuery { fn get_post() -> Vec<PostReadModel>; } impl GetPostQuery for GetPostMySqlQuery { fn get_post() -> Vec<PostReadModel> { let connection = establish_connection(); let results = posts_schema::dsl::posts .load::<Post>(&connection) .expect("Error loading posts"); let mut posts = Vec::new(); for result in results { posts.push(PostReadModel { identifier: result.id, title: result.title }) } return posts; } } impl GetPostQuery for GetPostMockQuery { fn get_post() -> Vec<PostReadModel> { return Vec::from([PostReadModel { identifier: Ulid::new().to_string(), title: "mock".to_string() }]); } } copied.serde = { version = "1.0", features = ["derive"] } ただの構造体をReadModelとして定義して、jsonにシリアライズできるようにしています。 これだけストイックなら、コードを短く書こうと書いている側も頑張るはず これは個人の感想ですが、ここまでストイックな言語ですと、書けば書くほどエラーに直面します。 冗長なコードがだらだら書くとエラーに当たりやすくなるので、短く簡潔に必要なものだけを書くように方向性を矯正させられるとも考えました。 とりあえず動けば良かろうをすると、大変な目に合いますねw 最後に とはいえ、まだまだ基礎中の基礎しか実装てきていないので、このリポジトリで色々勉強していきたいと思います。 そのほかにUlidのライブラリを導入したりしていましたが、この辺は書くまでもないことなので割愛しています。 多分ここまでくればシンプルなWEBアプリは書けるようになっているはずですが、メールの送信とか、 curl の実行くらいは勉強しておきたい。 「rocket使え」ってのはなしでw FW使うと勉強の意味無くなってしまいますから。 また進捗あれば記事にします。 そのときはよしなに。 .