のんラボ

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エンコードした状態の文字列を送信するようにしました。他にも必要な情報として 画像データ ファイル名 マイムタイプ を入力値として用意します。 copied.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. copied.$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の内容も割愛します。 注目するのはこちら copied.$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割くらい軽いようです。 実際のファイル操作について 入力値を設定できたので、ユースケース層に値を渡して実際に処理をしていきます。 copied.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に登録します copied.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を起動させるのは負荷的に嫌いました。 なので、ファイルそのものは文字列としてはではなく、ファイルとしてサーバーに設置します。 オリジナル画像の設置 copied.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 の内容も見てみます。 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; } ファイル名は入力されたファイル名ではなく、内部で生成されたDBのULIDを使いまわします。 copied.$file->uniqueFileNameWithOriginExtension(); ファイルの設置はPHPのビルトインメソッドである file_put_contents を利用します。 copied.$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を見る サムネイル画像の生成 copied.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 の内容も見てみます。 copied.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; } です。 copied.$source = imagecreatefromstring((string)$file->fileString()); if ($source === false) { throw new UploadFileException('Failed to upload file.'); } まずは imagecreatefromstring でbase64エンコードした画像文字列から画像リソースを取得します。 次に copied.$ratio = $originWidth >= $resizeWidth ? $resizeWidth / $originWidth : 1; $newWidth = (int)($originWidth * $ratio); $newHeight = (int)($originHeight * $ratio); このメソッドに渡した $resizeWidth に応じて比率を導く計算です。デフォルトはFULL HD画像の半分の大きさにしています。 サムネイル用の新しい幅と高さが計算できたら、その結果を利用してリサイズ処理を行います。 copied.$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を見る リサイズのために生成したリソースに画像形式を設定する リサイズしたソースに画像形式を設定します。 copied.$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); リサイズしたソースと設置するパスをマイムタイプによって利用するメソッドを使い分けて設置します。 最後に処理中に確保しているメモリを開放します。 copied.imagedestroy($thumbnail); 実際に出力・生成する内容はこちら copied.{ "message": "Successfully created file.", "id": "ULID", "originFileName": "入力したファイル名", "fileName": "ULIDのファイル名", "originPath": "オリジナル画像のURL", "thumbnailPath": "サムネイル画像のURL" } が返ってきます。 最後に これで、ストレージサーバーとして最低限の機能を実装することができました。 あとは画像の削除や、定期的なクリーンアップ、全拡張子の対応や、画像ファイル以外の対応などやろうと思えば色々案が出てきますね。 ひとまず、現状自分が欲しい機能は実装できたので、これで連載は終了です。 強いて希望を挙げればこのブログのファイルアップロード先を変更して記事にしたかったというのがありますね。 次回は何を書こうかな。久しぶりにバイク関連の記事でも書くかもしれません。 また記事にします。 そのときはよしなに。 .

Reactを触ってみてVueとの違いをまとめてみた

2020/12/13 2020/12/13

Reactを触ってみてVueとの違いをまとめてみた こんにちは。Nonです。 私は、Laravelに付属しているという理由からVueを採用してフロントをいじってきた経緯があります。 Vueしか触ったことのない私が、フロントをしっかりと勉強する機会が最近増えてきました。 React勉強会なるものを社内で実施した結果、VueとReactの違いに気づいてきたので、そろそろまとめようと思い記事にしました。 とはいえ、何番煎じやねん。という感じなので、感想は他のサイトで上がっている内容と重複する部分もあるかもしれません。(できるだけ私個人の感覚も取り入れて記事にしたいと思います。) 参考記事はページ最下部に貼っておきます。 ということで、下記から「ある一点」に注目して比較してみます。 なので、一概にこっちが...というわけではありません。 それぞれの特徴を考慮してプロダクトを進めていくべきであり、〇〇さんが言ってたからこっちとか言うわけではありません。 採用する背景で重要なのは 経験者の有無、または今後のスキルセット ビジネスサイドを考えたアーキテクト など、包括的な開発背景の裏付けを基に採用を進めるべきというのが持論となります。(両方わかって両方触れる人がいれば完璧なんですが...w) 特徴 特徴については、他のサイトでもいっぱい語られていますね。 なので、私の価値観でサクッと済ませます。 Vue 技術の分離 Vueのテンプレート技術の採用はほぼ必須 HTML、CSS、jsの技術を明確に分けやすい。(Scoped CSSとか) UI / UX的。 画面の内容がメイン HTML / CSS がわかるだけでなんとかなる。(長所であり短所) 冗長ではないため、簡潔。 React 関心事の分離 Class / Entity / Component的。 各コンポーネントはClassでありEntity。 HTML(つまり各コンポーネント)をEntityクラスと受け止めるとすんなり開発が進む javascriptのパワーを最大限発揮できる。 jsxはjsの拡張(これがいいって感じる人は多いはず。) js(jsx)を中心にしているため冗長な分、設計がしっかりしている お手軽さ お手軽さという点に注目して比較してみます。後述する開発効率と重複してしまいそうですが、ここでは学習コスト、設計コストという面に注目します。(初速とも捉えることができます。) Vue > React Vueの特徴の一つに技術の分離があります。WEB / CSS さえ知っていればある程度のコンポーネントを作成できますので、学習コストはVueに軍配が上がるでしょう。 Reactはjsxに設計構想 / 方針についての深い理解を特に求められます。いい意味でも悪い意味でも作業者に高い期待を求めることになります。 フロントでガッツリとした処理を書かない 実装へのお手軽さを重視 フロント技術の網羅的な知識を持っていない人が参画し、作業者となっている こういう時はVueの方がお手軽です。 特にマークアップエンジニアやデザイナーが担当者の場合顕著です。 Vue WEB / CSS 部分を技術的に分離できるので、コーダー、デザイナーはこれまで通りの作業で作業を終えることができ、あとはjsをある程度知っている人にバトンタッチすることができます。 また、画面の動きを実装するという面でも、簡単なjavascriptの動作を勉強するだけで実装することができます。非同期処理や設計に関しては他のエンジニアに任せるという開発運用方法ですね。 例えば、styleやclass要素の変更を加えるときstringで十分扱えるので、特定の関数がフラグを判定しstringを返すというだけならjs入門したばかりの人でも十分しっかりと書けることでしょう。 React Reactのjsxにstyleやclass要素の変更を加えるときにはjsxの構文を知っている必要があります。 基本的にpropsは関数、オブジェクトで有るべきなのがReactです。マークアップエンジニアやデザイナーにはここが最初の障壁となるでしょう。 (もちろんプログラマ的にはClassと受け止めることができるのでメリットです。今は他の方を巻き込む時を想定しています。) (adsbygoogle = window.adsbygoogle || []).push({}); 更に詳しく 技術の分離が明確。HTML / CSS / jsの技術を明確に分けやすい。(特徴の一つ) 私の関わっているプロダクトは新機能追加の重要度が高く、開発スピードも求められます。フロントでの重大な処理もありません。今思うとVueを採用してよかったなと思います。 また、 コーディングができないが、UI / UXに特化したデザイナー javascriptをあまり知らないが、HTML / CSSを熟知しているコーダー がプロダクトに参画しています。 この時、デザイナーが作成した画面デザインをコーダーが再現することになるのですが、コンポーネント設計がされているフロントにコーダーが手を入れるとき、 javascriptにHTMLが実装されているReact templateとjavascriptを分離されているVue とではVueのほうが学習コストが低く、コーダーレベルでも手を出しやすいように感じました。 (もちろん、HTML / CSS / javascriptをきっちり分離しているプロダクトではこの対象ではありませんが、私のプロダクトは前述の通り実装スピードを重視しており、引き継ぎ時点でこのような設計はされていませんでした。) 特に。 Atomic Design by Brad Frost Learn how to create and maintain digital design systems, allowing your team to roll out higher quali... を採用しているとき、Reactはjavascript、jsxの知識をコーダーに求めることになりますが、Vueの場合はtemplateに書かれたpropertyに注意するだけでHTMLをそのまま書く感覚に近い気がします。 設計 設計という点に注目して比較してみます。 先に書いてしまうと、この章をうまく言語化できていません...申し訳ない。 Vue < React 少し具体例を上げるとするなら下記のような感じ。 私はPHPをよく使うので、Smartyをいじる機会も多いのですが、 Vue Vueは予め存在するテンプレートに対して処理を実行するイメージ 処理技術と描画技術を分けて書くため、Smarty(テンプレートエンジン)に処理を書きまくる感覚 Smartyに処理が書かれまくっているのとても嫌。 私が関わっているプロジェクトではそれを嫌って処理部分と描画部分を切り分けるような構造にしていますが、どうしても描画部分に引っ張られる部分がまだある。 React PHPでHTMLをどう描画するか記述しているイメージ 処理オブジェクトと描画オブジェクトを定義して関心事に分けて書くため、処理部分でHTMLを書きまくる感覚 上記から 設計を厳密にしたい 堅牢性を高めたい ビジネスロジックをフロントにバリバリ書く この辺を考慮するならReactを採用するべきでしょう。 更に詳しく フロントでバリバリ処理を実装する。(ここで言う処理とはデザインの動的変更や、アニメーションの実装ではなく、文字通りフロントでビジネスロジックを実装するパターンを指します。) jsxを採用しているReactは関心事の分離に特化している印象を受けます。 jsxについての説明は JSX の導入 – React ユーザインターフェース構築のための JavaScript ライブラリ マークアップとロジックを別々のファイルに書いて人為的に技術を分離するのではなく、React はマークアップとロジックを両方含む疎結合の「コンポーネント」という単位を用いて関心を分離します。後のセクションでコンポーネントについては改めて詳しく紹介しますが、現時点で JavaScript にマークアップを書くことが気にくわない場合、こちらの議論で考えが改まるかもしれません。 Vueはどちらかといえば技術の分離。お手軽さの章で述べたように、チームメンバーの技術の守備領域が明確になっている場合はこの恩恵を受けることができる。 対して、Reactは関心事の分離。プログラマはHTMLのことを熟知していなければなりませんが、ビジネスロジック処理と描画処理(あくまで処理であることが重要と思います。)を切り分けて考えることができるので、アーキテクトに恩恵を受けることでしょう。 これのどこかいいか、何が言いたいかと言うと、技術の分離ではないので全てをjs(jsx)でまとめる事ができます。 js(ts)のみでコンポーネントを設計したい jsコードの中でコンポーネント呼び出しを行い、htmlを変更したい などの実装がすべてjs(jsx)で完結するのがReact。とも感じています。 例えば、jsの中でコンポーネントを実装しようとするとき、 Vueの場合 copied. export default { props: { label: { type: String, required: true, } }, data() { return { // データ定義 } }, template: '<button>{{ label }}</button>', // or render(createElement) {} } となるでしょう。ここのtemplateの部分がstringで書かれることになります。これがとても気持ち悪く感じます。 それなら<template></template>を採用しろ、という話になるのですが、その場合、jsのみで完結することができません(それマークアップじゃんと捉えられてしまいます)。 どうしても文字列を書く部分で出てきてしまい、設計がきれいという話にならなくなってします。 ではrenderではどうか? という話になりますが、jsxではないVueではcreateElementを利用して一つ一つDOMを作成しなければなりません。これが非常に面倒臭いです。プログラマチックといえばそのとおりですが、可読性が下がるのは間違い無いと思います。 Reactの場合 いつもの感じで書けばいいです。 copied.import React from 'react'; class MyButton extend React.Component { construtor(props) { super(props); this.state = { // データ定義 } } // 関心事の分離的。 render() { const button = <button>{this.props.label}</button> return button; } // 技術の分離的。 render() { return ( <button>{this.props.label}</button> ); } } renderの部分でHTMLを書くのが基本となるので、設計上とてもわかり易い上に、jsxでHTMLを直接書く感覚にとても近いです。 何より、突然このコードを書いてもjsなら起動します。これがtsと相性のいい理由でもあります。フレームワーク間で結合のための設定を(比較的)しなくても済むのですから。 jsxについての説明は JSX の導入 – React ユーザインターフェース構築のための JavaScript ライブラリ このおかしなタグ構文は文字列でも HTML でもありません。 これは JSX と呼ばれる JavaScript の構文の拡張です。UI がどのような見た目かを記述するために、React とともに JSX を使用することを私たちはお勧めしています。JSX はテンプレート言語を連想させるでしょうが、JavaScript の機能を全て備えたものです。 とあるように、一つのObjectと捉えることができるもの大きな違いです。 (adsbygoogle = window.adsbygoogle || []).push({}); typescript(堅牢) 堅牢性に注目してみました。 Vue < React 堅牢性、特にtypescriptを採用する場合が顕著です。 このあたりはそれぞれのフレームワークにtypescriptを導入してみたらわかると思います。 導入については他の有用な記事にお任せしたいと思っていますが、Reactはtsとの相性が抜群です。 この時点でプログラマ視点からみた場合、堅牢さで軍配が上がるでしょう。 TypeScriptの導入障壁としてならVue ≒ React 結局は両フレームワークともに状態管理をメインに行うフレームワークなので、この部分さえ型厳密にしてしまえばいいのです。なので、導入障壁は同じ位と勝手に見てます。 しかし、導入後恩恵を受けやすいのはReactなので、このようにしました。 (adsbygoogle = window.adsbygoogle || []).push({}); 理由 render部分に注目するとVueはstringで、Reactはjsxで書かれていることにも注目できます。 tsで型厳密したときもReactの方がrenderへの恩恵を受けやすく、マークアップ部分が書かれたところも型厳密にしやすいことがわかります。(jsxはtsxとして型厳密にできますので。) 処理部分でも同じです。jsxはjavascriptの拡張なのでtsに置き換えやすく、関心事の分離が容易な上に型厳密までできるという堅牢さを持っています。 VueのtemplateはjsxではなくstringかcreateElementなので、この恩恵を受けづらいです。 Vueにjsxを導入する方法はありますが、それならReactを採用するでしょう。(そもそもそこまでしてVueの便利なテンプレート技術を捨てる理由がわからないです。私がそこまでするならReact移行を考えます。) また、Vueではpropsのバリデーションを実装できるのですが、typescriptの型厳密とVueのバリデーションでtsでNGでもVueでOKとなる場合があります。(設定のせいかもですが、この設定がそもそもめんどい) あと、Vueにts導入しようとすると、正味わけわからんくなる。 完全に独断と偏見だけどReact vs Vue してみた - Qiita 比較内容大まかに以下の点で比較検討をしました。私個人はVueよりもReactに慣れているので、偏った意見かもしれないけど参考になれば。ReactVue手軽さ△◎開発効率○○TypeS...... VueのTSサポートについて TSのサポートは微妙。Vueは手軽さとアバウトさが売りなのでしょうがないのかも。 確かに。 型付けがやりにくい(子Componentの場合いちいちデコレータを書かなくてはいけない) 型定義が間違っていてもコンパイルが通ってしまう(スキーマ !== 型定義 の場合でもとりあえずコンパイルは通ってしまう??) 上述した、Vueのprops問題。 子コンポーネントにpropsを渡す際に必須であれば required: true と定義するが、これは型定義で解決するべきことのはず。 そのとおりだ... 開発効率 開発効率という点に注目してみたいと思います。お手軽さと重複してしまいそうですが、ここではどちらかというと 実装時間 という観点に注目したいと思います。 Vue >= React 実装スピードは慣れという部分も大きいので、最終的には同様の効率になるかと思います。設計がしっかりできる分Reactのほうが効率いいように見えますが、そのために冗長な記述を大量に記載するのが特徴なので、結局トントンになるかもしれません。 Vue いい意味でアバウト 完全に独断と偏見だけどReact vs Vue してみた - Qiita 比較内容大まかに以下の点で比較検討をしました。私個人はVueよりもReactに慣れているので、偏った意見かもしれないけど参考になれば。ReactVue手軽さ△◎開発効率○○TypeS...... より引用 役割分担に非常に相性がいい 画面に必要な機能をVueテンプレートがたくさん用意してくれている React 堅牢なのはいいが、その分厄介(面倒)な部分もある。(〇〇したいだけなのに、ここまでしなきゃいけないの!?ってのは慣れてきた今でも感じることが多い) 役割分担がされておらず強力なフロントエンジニアがいる場合は個人のパワーに任せやすい。(↔エンジニアの力量に左右されやすいかも) 設計手法が明確にチーム内で共有されている場合はそこまで遅くならない(はず) 結局どっちを採用すればいいの? 冒頭にも書きましたが、 Vue デザイナーがフロントのメイン担当 役割分担がしっかりしていて、同時にその作業者である。 デザイナーもコーディングすることがあるとか コーダーがフロントではなくマークアップエンジニア的とか フロントにそこまで興味がない人が多い フロントでビジネスロジックを書くことが少ない 特定のページにのみ導入したい ちょっと試しにやってみたい。 部分的導入 速さを求めたい 学習コストをかけたくない このような場合はVueでしょう。 React プログラマがフロントのメイン担当 フロントエンジニアがたくさんいる 強烈なフロントエンジニアがいる 作業者がHTML / CSSに精通しているエンジニア 役割分担というよりチーム開発 デザイナーは画面デザインのみで作業者ではない コーダーのjsの理解が深い(エンジニアと呼べるレベル) 堅牢さを重視 重要なビジネスロジックが存在する 画面でもEntity的な考えを要求する フロントはこのようにあるべきという方針が存在する フロントとバックの明確な分離 デザインとフロントの明確な分離 このような場合はReactでしょう。 最後に ちょっと文章多めですみません。書き出したらこれもあるな。あっ、あれも。といった感じで追記していったので、まとまりが悪いかもしれません。 私はVueのほうが、Reactの方が...と言及するつもりはありません。 やはりチーム内、会社内で事情知るエンジニアに選択をせまり、答えを出した人に従うべきかと思っています。 結局、私の担当するプロダクトでは デザイナーが技術的背景を持たない HTML / CSSに特化したコーダーがいる 納期がすぐそこ といった、止むに止まれぬ事情があったのでVueを採用して書かれています。 しかし、弊社にもフロントを重視する考えが浸透してきて、フロントに対して前向きに取り組むような方が増えてきました。 この背景から中長期的にはReactの方がよかったかもと考えるようになってきて、少し違いをまとめてみようと記事にしました。 Reactへの移行も考えていますが、Vueが堅牢ではないとは明確に言えないので、Vueのts対応を進めて過去の頑張りをブラッシュアップする方針でもいいかもと思っています。(優柔不断) React勉強会については記事にできていませんが、PWA周りや、React Nativeについては今後も投稿していきたいと思います。 そのときはよしなに。 参考 完全に独断と偏見だけどReact vs Vue してみた - Qiita 比較内容大まかに以下の点で比較検討をしました。私個人はVueよりもReactに慣れているので、偏った意見かもしれないけど参考になれば。ReactVue手軽さ△◎開発効率○○TypeS...... 概ね私と同じ考え方でした。引用させていただいた部分も多いかも。 ありがとうございます。 あと、パフォーマンス面にも触れていていいと思った記事です。GraphQLについて言及しているものいいなと思いました。 ReactとVueのどちらを選ぶか - Qiita 主に非Web系のバックエンド開発者(C/C++, Java, Python等を使用)がReactとVueをそれぞれ簡単に触れて、感じたメリット、思ったことなどをまとめています。色々と書いてますが、どち... 私はこの記事に続いて、中長期的な戦略としてVue→React移行がしやすい設計をVueの段階でしておくことも重要だと考えています。

PHPカンファレンス関西2024に行ってきた話

2024/03/20 2024/03/20

こんにちは。 のんです。 実は大阪のエンジニア仲間と一緒にPHPカンファレンス関西に行っておりました。 詳しいことは伏せますが、お付き合いが長いある企業(バレバレかw)の仲間を誘ってみたら快諾していただけましたのでウキウキでグランフロントまで。 懲りずにまた誘おうと思いました。 このときのレポートでも書こうかなと思います。 レガシーシステムへのPHPStan導入から半年での課題と効果 by don | トーク | PHPカンファレンス関西2024 #phpkansai - fortee.jp PHPStan の導入話でした。 初っ端からレベル8で始めるのは素直にすごいと思います。 配列の中身の片付けについても言及されてましたが、個人的には array<string, mixed> 使いがちでちょっとした負債(?)になりがちなイメージ。 私はレベル6が多い印象ですね。レベル7から急に厳しくなる感じ。 とはいえPHPStanを入れて何かバグるようなこともないので、いきなりレベル max で入れて、徐々に直していく方針なんだろうと思いながら見てました。 令和最新版 PHP メモリ管理術 by めもり〜☆ | トーク | PHPカンファレンス関西2024 #phpkansai - fortee.jp めもりーさんはこういうの本当に強い。 普通に勉強になったので、令和最新版 PHP メモリ管理術 - Speaker Deckを見てほしいみはあります。 業務で出会うとしたらファイル操作機能とかになるかな。最近はフロントでそのあたりを捌いたあと、バックに送信する仕組みを作ることが多いので別の問題として切り出してたかもです。 CSV 処理とかでも結構関連ありそう。ただループ回すことが多いので1ファイルごとというより1行ごとって考え方になりそうなので同じく相当な量のデータを捌く場合に限るかな。 1ファイル全部をメモリにぶっこんで捌くような処理は変えたいみがありますね。 アプリケーションエンジニアこそ「監視」だよね!と私が考える訳 by きんじょうひでき | トーク | PHPカンファレンス関西2024 #phpkansai - fortee.jp アプリケーションの監視。大事ですよね。 監視システムはこの世の中に沢山ありますが、導入しても日々のタスクに忙殺されてなかなか見ることが少ない。 ここのお話はそういうマインドについての話が中心でした。 私も新機能の開発ばかりで、 どんなアクセスがあったのか どんなエラーが出ているのか どのくらいの量のアクセスがあるのか など把握しないまま機能追加していた自覚があるので、監視という分野には結構興味あります。 監視=データ分析 なので、最近のAIトレンドにもマッチしてそう。 コードを自在に操るためのPHP文法入門 by うさみけんた | トーク | PHPカンファレンス関西2024 #phpkansai - fortee.jp PHPStan の仕組みと Rector の説明が中心でした。 静的解析ツールなどのためにある Attributes の話も出てきましたが、正直 PHP の Attributes はまだよくわからないんですよね。 アノテーションで頑張るのとはちがうのかな?って思いつつ、 Reflection で操作できるので、プログラミングに関連するのでガッツリ機能として実装もできそう。 具体的な事例を知りたいけど、今すぐ手を出さないと商品のコードがアレになるってことはなさそう。 追々はやってみたいですね。 PHP8.2にバージョンアップしたら文字化けが発生して道頓堀に飛び込みたくなった話 by 藤掛治 | トーク | PHPカンファレンス関西2024 #phpkansai - fortee.jp お話が面白かったですw 冗談まじりに運用の注意点にも話を伸ばして、現場目線のいい話だったと思います。 中身は PHP バージョンアップでちょっとアレなことになったのでみんな気をつけような!って話でした。 Mutation Testingとはなにか? 〜Laravel(Pest)でInfectionを利用したライブデモ〜 by Kanon | トーク | PHPカンファレンス関西2024 #phpkansai - fortee.jp Mutation Testing っていうのがなにかわからなかったので、聞いてみました。 仲間とはモックとは違うのか?とか、本番のコードを利用して(変えて)テストってどゆこと?ってなっててちょっと懐疑的な意見が多かったですね。 ただ、大きい組織が採用していて、それなりに実績があるってことは品質の担保に効果はありそう。 こちらも知らない知識だったので、このセッションきっかけに勉強しようかなと思いました。 擬人化で完全に理解するクリーンアーキテクチャ by しまぶ | トーク | PHPカンファレンス関西2024 #phpkansai - fortee.jp 最初にネタバレすると 擬人化なんてできねーよ! ってことですね! 最初はどんな美少女が出てくるんだ?と期待していましたが、ちゃんと真面目な話しててよかったです。 クリーンアーキテクチャなんて設計手法はない 円の中心でビジネスを叫びたい クリーンアーキテクチャのクリーンはシン・エヴァ、シン・ゴジラの「シン」って話 このあたりは結構有名ですね。 強いて擬人化するなら Role だよねっていうのも好感持てました。 本当の意味で外部の人間のクリーンアーキテクチャに関する考えを生の声で聞いたのは初めてだったので、自分の考えが正しそうで安心できました。復習と再確認にちょうどいい資料だと思います。 ひさしぶりの関西でのカンファレンスいいな〜 関西で PHP カンファレンスが オフライン で行われるのは結構久しぶりなんですよね。 こういうのがないと東京のエンジニアにも負けちゃうし、人も集まらないので頑張ってほしい。応援してる。 向こうのカンファレンスと違ってお祭り感があるというより、学会みたいな雰囲気のほうが強いのも、みんなカンファレンスが初めて。みたいな人が多いってのもありそう。 こちらでももっともっとやってほしい。新幹線つかって東京いくのめんどいし。 最後に ということで、長い事書こうと思って書けてなかったレポートを書きました。 技術的なことは既知のものが多かったのでただの感想文になっちゃいましたけど、カンファレンス楽しいですよ! 久しぶりに大阪のエンジニア仲間にあえて色々話せるのも大きいです。 言語で集まれるし、知見がある方がたくさんいるのであるある話も楽しい。 今後もこういうのには積極的に参加していきたいし、誘っていきたいです。 またこんな感じで記事にします。 そのときはよしなに。 .

UIデザインについての記事が Freelance Hub で紹介されました。

2023/11/07 2023/11/07

UIデザインについて少し調べてみた。の記事が Freelance Hub で紹介されました。 フリーランスエンジニア・クリエイター向けの案件情報を発信している「Freelance hub(運営会社:レバレジーズ株式会社)」様のサイトで、当ブログ『UIデザインについて少し調べてみた。』が『 UI/UXデザイナーとして振り返りや業務に活かせる情報まとめ』の記事でご紹介いただきました。 UIデザインについて少し調べてみた。 | のんラボ 個人開発したアプリでめちゃくちゃダメ出しされました。こんにちは。のんです。今回はデザインについて書いていこうと思います。デザインと言っても、美的なデザインや、商品紹介などの画像配置のデザインではなく、...

Vue Fes Japan 2023 に行ってきました

2023/10/28 2023/10/31

こんにちは。 のんです。 2023年10月28日に行われた Vue Fes Japan 2023 に行ってきました。 https://vuefes.jp/2023/ https://vuefes.jp/2023/を見る その感想記事というか、内容まとめ記事的な感じになるかなと思います。 良かったな〜と思ったセッションには ⭐️ マークでもつけときますね。 走りながらエンジンを交換する 〜 大規模プロダクトを成長させつつVue 3にするには 〜 https://vuefes.jp/2023/sponsor-sessions/cloudsign Vue 2.6 → Vue 2.7 → Vue 3 への入れ替えをするための層を追加する。という趣旨の発表でした。 Composition API のプラグインを利用していたので、 Auto Import 周りがどうしてもきつい。 この import 部分全てを変更する MR を作成するのもいいが、プロダクトの性質上ビックバンリリースは避けたい。という流れでした。 import する部分を新しく層にして、徐々に適応範囲を広げてリリースしていく...という手法を取ったようです。 このタスクで変更したファイルは たったの 4 ファイル だったそうで、中々スマートに着地させたな。という印象でした。 現状、全てが Vue 3 になっているわけではないらしいですが、 Vue 2.6 → Vue 2.7 が完了すればもうゴールは近いのかな。と個人的には思います。 ⭐️ 社内UIコンポーネントライブラリがエンジニアチームにもたらした本当の価値 https://vuefes.jp/2023/sponsor-sessions/unique-vision 社内 UI コンポーネント...結構前に私もやりました。これがあるだけで全然違うんですよね。 質問したかったんですが、その後会えず...もったいないことをした。 セッションの内容は一般的によく言われる UI を統一して管理しようぜ。というデザインシステムの話ですね。 私が以前勉強したときは SmartHR さんのデザインシステムを参考にさせていただいた。 https://story.smarthr-ui.dev/ あと、こちらは自分の勉強兼、個人開発用に作成したもの。 https://storybook.nozomi.bike/ この UI コンポーネントをモジュラモノリス的に作るか、外部ライブラリとして作成し、都度 import or install するか。が質問したいところでした。 私の場合、外部ライブラリとして作成(リポジトリレベルで新しく作成)し、それを install するという形で利用していました。 一部のコンポーネントは複数のプロダクトを横断する形で Vue React などフレームワークを問わず利用できるようにしたり、色々工夫しましたが、このセッションのデザインシステムではどのようになっていたのか気になります。 写真が無いのでアレですが、特にコードレビュー前に UI みて議論が始まることが良い作用になった。 というのはわかりみが深いです。 このあたりの話は以前ブログの記事にもしたことがあります。 スマレジ・デベロッパーズのstorybookを作成してみて | のんラボ スマレジ・デベロッパーズのstorybookを作成してみて7月半ばくらいから、スマレジ・デベロッパーズでstorybookを作成し始めています。ver.1.2.0くらいからstorybookで作成した... 現在参加しているプロダクトではこのような UI コンポーネントライブラリやデザインカタログ、デザインシステムみたいなものは無いので、今後作成できたらなと思います。 ⭐️ Nuxt 2から3へマイグレーションする方法考えてたら、マイクロフロントエンドのフレームワークができた話 https://vuefes.jp/2023/sessions/mew-ton Nuxt 2 で仕上げたものを Nuxt 3 で仕上げるために iframe を利用して共存してやったという話。 時間が短く、結構端折ってた感はあったので具体的な技術については書けませんが、Nuxt 3 側に iframe を設置し、 iframe のなかに Nuxt 2 側のページを表示させ、 postMessage で両方の機能の同期を図る...的なお話だったと解釈しています。 iframe と正面向き合って格闘するのはかなり気合と根性が入ってるな。と感じました。 個人的には登場の仕方が面白くて、そちらが気になったまである。 VRChatの方が気になるまである #vuefes— のん🏍ジョウトのすがた (@nonz250) October 28, 2023 A New Nuxt https://vuefes.jp/2023/sessions/daniel-roe TOEIC 200 点の男には同時翻訳聞きながらでも、話の3割くらいしかわからなかった。 だけど、Live で投票させたり、発表の仕方がかっこよくて印象に残っている。 Nuxt 3 の今後の展望を話していたんだけども、そもそも言いたいことを解釈できているかわからなかったので、詳しいことは別のブログに任せます。 ⭐️ パネルディスカッション https://vuefes.jp/2023/#events-panel めちゃくちゃ面白かった。Vue.js 日本ユーザーグループ代表のkazupon氏を始めとするVue.jsへ造形の深いメンバーでのパネルディスカッションでした。 話している内容も地に足の付いた内容で、私もうんうんと頷きながら聞いていました。 特に、 なぜ Vue.js を使うのか? という議題は同意しかなかった。 以下、メモレベルのまとめ。(一言一句そのまま言っていたわけじゃない。かなり主観入っているので注意) 今新規に採用するなら React.js だけど、古いものを乗り換えるほどではない。使えるから使う。 結局宗教。デザイナーと協業しやすいし、走り始めは速い。だから Vue.js を選んだ。 流行りというのもある。採用しやすいから React.js を選ぶという文脈もありますよね。 ツールにこだわりすぎてプロダクトが前に進まなければ何の意味もない。あくまでツールなのでそこまでのこだわりがなかった。Vue.js がダメ。というわけではないし、たまたまとっつきやすいのが Vue.js だった。その流れ。 ここまで、自分の価値観と合ってるな〜と思いながら聞いていました。 まだこのブログで PV がいい記事のうちの一つです。 記事は古いので、内容が稚拙な部分はありますが、基本骨子は似たような文脈でうれしい。 Reactを触ってみてVueとの違いをまとめてみた | のんラボ Reactを触ってみてVueとの違いをまとめてみたこんにちは。Nonです。私は、Laravelに付属しているという理由からVueを採用してフロントをいじってきた経緯があります。Vueしか触ったことのな... STUDIOの作り方 2023 ver. https://vuefes.jp/2023/sessions/keima 今所属している会社で使ってみようか。となっているプロダクトなので、勉強がてらどのように動いているのか聞いてみました。 こちらも地に足の付いた良いお話でした。 かなり具体的なコードに言及するセッションだったので、これについて書こうとすると一本の記事になってしまいそう。 STUDIO を実現するために HTML DOM をゴリゴリ操作するわけなので、思いもよらないウルトラ C をしているわけではないです。結構イメージはしやすかった。 ChatGPT にページ構造を投げて指示通りに変更差分を反映する...という機能があるらしいのですが、個人的にはそこをどのように差分を埋めているのか気になりました。 アフターパーティー 名刺配りおじさんになると豪語して言ったけど、やはり久しぶりのオンラインイベントでいきなり話しかけるのは緊張する... 名刺を渡すという文化もあまり無い業界なので、あまり配れなかった。 古巣の元同僚と遭遇したり、美味しいごはんを食べたり良い時間でした。 最後に この規模のカンファレンスというかフェスにオフラインで参加するのは 4 年ぶりくらいでとても懐かしい気持ちでした。 コロナでなくなっていたオフラインイベントが復活しているのは良いですね。 ただ、後で見返したり、遠方でも参加したいのでオンライン開催も同時やってくれると嬉しい。 Vue Fes については後日動画が公開されるらしいので、そのときに記事の更新はするかもしれません。 ブログの更新もここ一年でかなり疎らになってしまったので、どこかでペースを戻したい。 気が向いたら書くスタンスは変えないつもりですが、早いうちにもう一個だけでも... そのときはよしなに。 .

GoPro Hero 12とB+COM 6Xを接続してマイクの無線通信の検証をしてみた!

2023/09/18 2023/09/18

動画解説はこちら 以降はテキストで説明します。(動画内画像で申し訳ない) 使ってるバージョン デバイスバージョン ゴープロGoPro Hero 12 ビーコムB+COM 6X タイトルにもあるように GoPro Hero 12 です。 ビーコムは B+COM 6X ちょっと古めなので、今の最新機種でも対応してるはず(たぶん)。 GoProを起動 マイクとペアリング設定していない場合は GoPro 画面の右上にマイクマークが 出ていない ことに注目。 画面上からメニューを呼び出して設定画面にいきます 白い文字が見えにくくて申し訳ないです。画面構成はわかるはずなのでご容赦ください。 (adsbygoogle = window.adsbygoogle || []).push({}); ペアリングボタンを押してデバイスを探します 先の画面の左上に 「ペアリング」 ボタンがあるので、それを押すとペアリングデバイスを検索し始めます。 GoPro の電源を入れます GoPro でペアリングデバイスを探します 私の GoPro はすでにスマホとペアリングしておりまして、デバイス 1 の枠はすでに埋まっています。 そこで今回はデバイス 2 の枠に登録します。 多分あまり差はないと思いますが、不安な方は B+COM の説明書を見てください。 ということで、デバイス 2 のボタンを長押しでデバイスを検索します。 ビーコムのランプがオレンジ色になっていれば検索中です。ちなみに成功するとランプは青色になります。 見にくいけど、 「BTオーディオ」 という項目が表示されているはず。 + マーク を押してペアリングします。 (adsbygoogle = window.adsbygoogle || []).push({}); 接続確認 Bluetooth マークが付き文字が青色になってれば成功。 他の画面にも同様に Bluetooth マークがついてます。 画面右上にマイクマークが出ていればデバイスから音声を拾うようになっているはずです。 どんなふうに聞こえるか 実際に動画にしてアップロードしてます。気になる方は百聞は一見に如かずです。 この機能に関する感想 正味、めちゃくちゃよくないか?? 今回の GoPro の目玉機能なのでは?と思うレベルで欲しかった機能ですね。 今までは別で有線マイクを購入してヘルメットに装着。その後 GoPro もヘルメットにつけてケーブルを GoPro 本体に挿す......という形でした。 ......いや、ケーブルだらけでめちゃくちゃ面倒くさいわ!! という鬱陶しさをすべて取っ払ってくれるのがこの新機能ですね。 できることがかなり増えそう。 メリット コ ー ド が 要 ら な い ( 圧 倒 的 正 義 ) 声を入れるためにコードが要らないので、ヘルメット意外にマウントしている GoPro に声が入れられる エンジンガードにマウントして、その動画に声を入れながら撮影する(ホイールを映しながら PV 風にとか) ハンドルバーにマウントして、その動画に声を入れながら撮影する(自撮りしながらとか) バイクだけでなく Bluetooth の電波が届く範囲ならドローンでも良いかも デメリット マスツーリングに向かないかも? 自分の B+COM とペアリングしているので友達の声は入らない。 → 入れたい場合はこれまで通り直撮り? → もしかしたら複数ペアリングとかでも大丈夫?(たぶん無理だと思うけど) 最後に とても久しぶりにブログを更新しました。 ここ一年色々あって忙しくて何もできず。 これからは気が向いたときに自分の好きな内容を書いていこうかなと思います。 そのときはよしなに。 .

Android 版 Chromium で Notification API が実行できない

2021/12/21 2023/02/19

こんにちは。 のんです。 iOS で PWA 対応がついに来ましたね! 🚨Today is Apple's PWA Day!🚨Safari on iOS and iPadOS 16.4 b1 adds support for:💌Web Push!-⚠️for installed PWAs only🔢Badging🆔Manifest ID with a twist⬇️PWA installation from Third-Party browsers👁️Screen Wake Lock🌄Screen Orientation🧑‍🦰User Activation🎥Web Codecs— Maximiliano Firtman (@firt) February 16, 2023 自分も長い間ウォッチしてきましたが、これは大きなニュースでした! ということで、これまでものんラボでは PWA について記事を書いてきましたが、復習を兼ねて改めてサンプルコードやらアプリやら記事やらを書いている最中でございます。 ということで、今回は PWA を作成している最中にハマったポイントについて記事にしていきます。 結論 Notification() - Web APIs | MDN The Notification() constructor creates a new Notification object instance, which represents a user ... Chrome for Android will throw a TypeError when calling the Notification constructor. It only supports creating notifications from a service worker. See the Chromium issue tracker for more details. Chrome 49 以降では、 incognito モードでは通知が動作しません。 Android 版 Chrome は Notification コンストラクターを呼び出すと TypeError を発生させます。サービスワーカーからの通知の作成にのみ対応しています。 詳しくは Chromium issue tracker をご覧ください。 Android の Chromium では Notification API は利用できません。 ※2023/02/19 現在 まぁ、ServiceWorker 経由しろって話なんですかね? iOS でも PWA (ServiceWorker)でしか通知できないようなことが書いてあった気がします(多分)。 これは Web プッシュ通知 Evil に利用してきた Web 界隈(特にブログ)に責任があるように思います。 いや、だって特に必要ない通知が多過ぎでしょ。皆さんも心当たりがあるかと思います。 対応方法 実際に動作するデモはこちら。 (※ 現在進行形で更新されています。当時のコードとは違う場合があります。) PWA Sample Page - nonz250 PWA Sample Page - nonz250 ServiceWorker では通知は利用できるので ServiceWorker で Notification API を利用します。 そのためにはフロントのコードから ServiceWorker に通知をする必要がありますね。 フロント → ServiceWorker 発火 → 通知 という感じです。そのために ServiceWorker.postMessage() を利用します。 index.tscopied.navigator.serviceWorker.controller?.postMessage(message) とか index.tscopied.navigator.serviceWorker.ready.then((registration) => { registration.active.postMessage("Hi service worker"); }); とか。 詳しくはこちら。 ServiceWorkerGlobalScope: message event - Web APIs | MDN The message event of the ServiceWorkerGlobalScope interface occurs when incoming messages are receiv... このようにした上で、ServiceWorker 上のスクリプトで Notification API を利用します。 sw.tscopied.self.addEventListener('message', event => { if (event.data !== null) { void self.registration.showNotification(event.data, { body: 'PWA Sample notification.', icon: '/labo-round-icon-192x192.png' }) } }) ServiceWorker 上で通知を発火するので、TypeError を吐かずに通知が実行されます。 Firefox などブラウザでは通常通り機能する 当然ですが、Chromium ベース以外のブラウザでは正常に動作します。 copied.const notification = new Notification(message, options); いやでもアプリが起動している間の通知は画面上でも拾いたくないか? 通常のアプリでは、アプリがアクティブであれば通知をOS上(?)ではなくアプリ上に表示したりしたいですよね。 そういう意味では ServiceWorker → フロントへの通知も実装したい。 ということで実装してみました。 sw.tscopied.self.addEventListener('message', event => { if (event.data !== null) { // このコードがあるので通知は送信されますが、 // ここに渡す内容を制御し条件分岐すれば思い通り自由度高く実装できそう。 void self.registration.showNotification(event.data, { body: 'PWA Sample notification.', icon: '/labo-round-icon-192x192.png' }) // 重要なのはこのコード。フロント側にメッセージを通知する。 event.ports[0].postMessage(event.data) } }) index.tscopied.const sendMessage = (message: string): void => { const channel = new MessageChannel() channel.port1.onmessage = (event) => { const result = document.getElementById('message-result') if (result !== null) { result.innerText = event.data } } navigator.serviceWorker.controller?.postMessage(message, [channel.port2]) } 上記のように MessageChannel を利用します。 フロント → ServiceWorker ServiceWorker → フロント の 2 方向に通信を行う必要があるので、port1 port2 を間違えないように注意してください。 port1 では ServiceWorker からの通信を待ち受けます。 この例では ServiceWorker からメッセージ内容を HTML へ反映させています。 ここのコードは自分で好きに変更できるので、色々使い道がありそうです。 index.tscopied.channel.port1.onmessage = (event) => { const result = document.getElementById('message-result') if (result !== null) { result.innerText = event.data } } postMessage の第 2 引数に port2 を渡しておきます。 index.tscopied.navigator.serviceWorker.controller?.postMessage(message, [channel.port2]) ServiceWorker からこのポートにアクセスしてメッセージを渡す。という仕組みです。 sw.tscopied.event.ports[0].postMessage(event.data) これで先程の channel.port1.onmessage が発火する。ということですね。 最後に FCM ( Firebase Cloud Messaging ) を利用して、サーバーからの通知は実装したことあったのですが、オフライン対応するために、フロントのみ実装してました。 この勉強をしてた最中にめちゃくちゃハマったので記事にしました。 もう一度デモ環境を貼っておきますが、このデモはオフラインでも動作するので、是非試してみてください。 PWA Sample Page - nonz250 PWA Sample Page - nonz250 最後に GitHub のリンクもどうぞ。 GitHub - nonz250/pwa: PWA Sample PWA Sample. Contribute to nonz250/pwa development by creating an account on GitHub. オフライン対応、つまりキャッシュ戦略についてはまた別の記事にしようと思っているのでお楽しみに。 とはいえ、結構いい記事やデモサイトはたくさんあるのでオリジナリティを出すのには苦労しそう。 がんばります。 そのときはよしなに。 .

Canvas で画像と文字を合成する【縦書き横書き改行対応】

2023/01/20 2023/01/24

こんにちは。のんです。 今回は Canvas に画像と文字を合成する処理について、備忘録を兼ねて書いていこうと思います。 書こうと思ったキッカケは 今年の抱負メーカー です。 結構ユーザーがいるので、いい加減この使いにくい UI をなんとか使いやすくしようという魂胆です。 他のブログたちの差別化として、クラスベースでまとまった処理を書いています。よくネットに転がっている内容は上から順に流すだけのプログラムや関数が公開されているだけでした。 それをライブラリチックに流用しやすいように書いてますので、一部分だけでも参考にできれば幸いです。 今年の抱負メーカー | Twitterで今年の抱負をつぶやこう!筆書き風の画像をダウンロードして背景に設定したりしてください! 今年の抱負メーカー | Twitterで今年の抱負をつぶやこう!筆書き風の画像をダウンロードして背景に設定したりしてください! 実際にどんな感じで動くかは↑を実際に使ってみてね(宣伝) アプリコードを公開するか、デモ用のページを公開するかは気が向いたらやります。 では早速処理を見ていきましょう。 ✍ 結論(書いたコード) いきなり結論ですが、 自動改行 縦書き 横書き に対応したコードがこちらです。 解説がみたい方はもっと下へどうぞ。 ※ TypeScript です。JavaScript に直接貼り付けても動作しませんのであしからず。 WriteTextOnCanvas.tscopied.import sliceByNumber from './sliceByNumber'; type CanvasWriteProps = { canvas: HTMLCanvasElement image: HTMLImageElement font: string fontSize: number fontColor: string } export default class WriteTextOnCanvas { private props: CanvasWriteProps; private context: CanvasRenderingContext2D; private vertical = true; private maxCharsPerLine: number; private value: string; constructor(props: CanvasWriteProps) { this.props = props; this.context = this.props.canvas.getContext('2d'); this.initialize(); } initialize() { this.props.canvas.width = this.props.image.width; this.props.canvas.height = this.props.image.height; this.maxCharsPerLine = this.vertical ? Math.ceil((this.props.image.height - this.props.fontSize * 2) / this.props.fontSize) : Math.ceil((this.props.image.width - this.props.fontSize) / this.props.fontSize); this.context.font = this.props.fontSize + 'px ' + this.props.font; this.context.fillStyle = this.props.fontColor; this.context.drawImage(this.props.image, 0, 0); this.vertical ? this.context.translate(this.props.fontSize * (this.maxCharsPerLine - 1), this.props.fontSize * 1.5) : this.context.translate(this.props.fontSize * 0.5, 0); } changeImage(image: HTMLImageElement) { this.props.image = image; this.write(this.value); } changeFontSize(fontSize: number) { this.props.fontSize = fontSize; this.write(this.value); } changeMode(mode: string) { this.vertical = mode === 'vertical'; this.write(this.value); } write(value: string) { this.value = value; this.initialize(); const ambitions = value .split('\n') .map(value => [...value]) .map(ambition => { if (ambition.length > this.maxCharsPerLine) { return sliceByNumber(ambition, this.maxCharsPerLine); } return [ambition]; }) .flat(1); ambitions.forEach(rows => { this.vertical ? this.context.translate(-this.props.fontSize, 0) : this.context.translate(0, this.props.fontSize); this.context.save(); rows.forEach((value: string) => { if (this.vertical) { if (value.search(new RegExp(/[ー〜\-~「」[\]()()<><>{}{};:;:||==//__]/, 'g')) !== -1) { this.context.rotate((90 * Math.PI / 180)); this.context.translate(-this.props.fontSize, 0); this.context.fillText(value, 0, 0); this.context.rotate(-1 * (90 * Math.PI / 180)); this.context.translate(0, this.props.fontSize); } else if (value.search(new RegExp(/[。、.,]/, 'g')) !== -1) { this.context.fillText(value, this.props.fontSize * 0.7, -this.props.fontSize * 0.7); } else { this.context.fillText(value, 0, 0); } } else { this.context.fillText(value, 0, 0); } this.vertical ? this.context.translate(0, this.props.fontSize) : this.context.translate(this.props.fontSize, 0); }); this.context.restore(); }); } toDataURL(): string { return this.props.canvas.toDataURL('image/png'); } toBlob(callback: BlobCallback): void { return this.props.canvas.toBlob(callback); } text(): string { return this.value; } } sliceByNumber.ts 配列を渡した数値分ずつに分割する関数。 sliceByNumber.tscopied.export default function sliceByNumber(array, number) { const length = Math.ceil(array.length / number); return new Array(length) .fill(0) .map((_, i) => array.slice(i * number, (i + 1) * number)); } (adsbygoogle = window.adsbygoogle || []).push({}); 🤠 使い方を簡単に(詳細解説は後述) 表示される HTML 。 copied.@php use App\Http\TwitterAuthRedirect\TwitterAuthRedirectAction; @endphp <div class="ambition-form-container"> <div class="ambition-canvas-container"> <div> <label><input id="mode-toggle" type="checkbox">横書き</label> </div> <canvas id="ambition-canvas"></canvas> </div> <div> <form id="tweet-ambition" action="{{ route(TwitterAuthRedirectAction::class) }}" method="post" target="_blank" rel="noopener noreferrer" > @csrf <div> <div><small class="text-xs"><strong>連携したTwitterの情報は「抱負画像のツイート」、「ユーザー名の取得」にのみ使用</strong>し、アカウント情報の保持を一切行いません。</small></div> <div><small class="text-xs">※環境依存文字・絵文字などの入力で予期しない動作をする場合があります。</small></div> </div> <div class="mb-1"> <textarea id="ambition" name="ambitionText" rows="10" class="ambition-textarea" > 改行とスペースを 上手に使うと 綺麗に見えます😃</textarea> </div> <div class="ambition-form-control-panel"> <div class="mb-1"> <input type="hidden" name="ambitionImage"/> <button type="submit" class="button button__twitter-blue">Twitter でつぶやく</button> </div> <div class="mb-1 text-right"> <button type="button" id="ambition-canvas-download" class="button">ダウンロード</button> </div> </div> </form> <div class="mb-1 text-right"> <div> <button id="ambition-share" class="button">Twitter でシェアする</button> </div> <p><small class="text-xs">このボタンを使うときは縦書きだと見づらいかも。横書き推奨↑</small></p> <p><small class="text-xs">Twitter連携せずにつぶやけるようにしています。</small></p> <p><small class="text-xs">DLも活用して上手につぶやいてね!</small></p> </div> </div> </div> HTML の UI のイベントを検知して Canvas を操作する処理。 copied.import WriteTextOnCanvas from './utils/WriteTextOnCanvas'; import loadImage from './utils/loadImage'; type CreateAmbitionResponse = { ambitionUrl: string } document.body.onload = async () => { const fude = new FontFace('fude', 'url(../fonts/fude.ttf)'); await fude.load(); document.fonts.add(fude); const KouzanMouhituFontOTF = new FontFace('KouzanMouhituFontOTF', 'url(../fonts/KouzanMouhituFontOTF.otf)'); await KouzanMouhituFontOTF.load(); document.fonts.add(KouzanMouhituFontOTF); const canvas = document.getElementById('ambition-canvas') as HTMLCanvasElement; const ambitionTextarea = document.getElementById('ambition') as HTMLTextAreaElement; const ambitionCanvasDownloadButton = document.getElementById('ambition-canvas-download') as HTMLButtonElement; const modeToggle = document.getElementById('mode-toggle') as HTMLInputElement; const shareButton = document.getElementById('ambition-share') as HTMLButtonElement; const tweetAmbitionForm = document.getElementById('tweet-ambition') as HTMLFormElement; const vertical = await loadImage('/images/vertical.jpg') as HTMLImageElement; const verticalFontSize = 16 * 13; // 13rem const horizon = await loadImage('/images/horizon.jpg') as HTMLImageElement; const horizonFontSize = 16 * 6; // 6rem const writeTextOnCanvas = new WriteTextOnCanvas({ canvas: canvas, image: vertical, font: 'KouzanMouhituFontOTF', fontSize: verticalFontSize, fontColor: '#404040', }); writeTextOnCanvas.write(ambitionTextarea.value); ambitionTextarea.addEventListener('input', (e) => { const target = e.target as HTMLTextAreaElement; writeTextOnCanvas.write(target.value); }); ambitionCanvasDownloadButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); writeTextOnCanvas.toBlob((blob) => { const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = '今年の抱負'; link.click(); URL.revokeObjectURL(link.href); }); }); modeToggle.addEventListener('input', (e) => { const target = e.target as HTMLInputElement; writeTextOnCanvas.changeMode(target.checked ? 'horizon' : 'vertical'); writeTextOnCanvas.changeImage(target.checked ? horizon : vertical); writeTextOnCanvas.changeFontSize(target.checked ? horizonFontSize : verticalFontSize); }); shareButton.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const response = await fetch('/api/ambitions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, body: JSON.stringify({ 'ambitionImage': writeTextOnCanvas.toDataURL(), 'ambitionText': writeTextOnCanvas.text(), }) }); const data = await response.json() as CreateAmbitionResponse; const ambitionUrl = data.ambitionUrl; const url = new URL('https://twitter.com/intent/tweet'); const urlSearchParams = new URLSearchParams(url.search); urlSearchParams.append('url', ambitionUrl + '\n'); urlSearchParams.append('text', writeTextOnCanvas.text() + '\n\n'); urlSearchParams.append('hashtags', ['今年の抱負\n', '書き初め\n', '今年の抱負メーカー'].join(',')); const shareUrl = new URL(`${url.origin}${url.pathname}?${urlSearchParams}`); const link = document.createElement('a'); link.href = shareUrl.href; link.click(); }); tweetAmbitionForm.addEventListener('submit', () => { const input = document.querySelector('form input[name="ambitionImage"]') as HTMLInputElement; input.value = writeTextOnCanvas.toDataURL(); }); }; とまぁ、こんな感じ。 ここから各部品、処理について詳しい解説を書いていきます。 (adsbygoogle = window.adsbygoogle || []).push({}); ✍ 流用しやすいようにクラスベースで書いてます。 冒頭にも書きましたがクラスで書いてます。 関数でも良いかな?とは思いましたが、オブジェクトを操作している感を出すためにある程度処理をまとめてます。 この例で利用している WEB フォントは 衡山毛筆フォント です。 初期化に必要な引数 copied.type CanvasWriteProps = { // document.getElementById('canvas の id') canvas: HTMLCanvasElement // document.getElementById('img の id') ただしここでは <img> 以外の方法で取得している。後述。 image: HTMLImageElement font: string fontSize: number fontColor: string } 引数内容 canvasCanvas の HTMLElement を指定します。 imageImage の HTMLElement を指定します。 font描画する font-family を指定します。 fontSize描画するフォントサイズを指定します。 fontColor描画するフォントカラーをしてします。 この引数をクラスのコンストラクタに渡します。 初期化 copied.// フォントの読み込み const KouzanMouhituFontOTF = new FontFace('KouzanMouhituFontOTF', 'url(../fonts/KouzanMouhituFontOTF.otf)'); await KouzanMouhituFontOTF.load(); document.fonts.add(KouzanMouhituFontOTF); // 引数に必要な値の取得 const canvas = document.getElementById('ambition-canvas') as HTMLCanvasElement; const vertical = await loadImage('/images/vertical.jpg') as HTMLImageElement; const verticalFontSize = 16 * 13; // 13rem // 初期化 const writeTextOnCanvas = new WriteTextOnCanvas({ canvas: canvas, image: vertical, font: 'KouzanMouhituFontOTF', fontSize: verticalFontSize, fontColor: '#404040', }); コンストラクタでプロパティに必要な変数をセット。 copied.export default class WriteTextOnCanvas { private props: CanvasWriteProps; private context: CanvasRenderingContext2D; private vertical = true; private maxCharsPerLine: number; private value: string; constructor(props: CanvasWriteProps) { this.props = props; this.context = this.props.canvas.getContext('2d'); this.initialize(); } 渡した引数から context を取得しプロパティに維持。 copied.this.context = this.props.canvas.getContext('2d'); 🧑‍🔧 Canvas の設定をしたい 一番はじめに行いたい設定です。 copied.export default class WriteTextOnCanvas { // 省略 initialize() { this.props.canvas.width = this.props.image.width; this.props.canvas.height = this.props.image.height; // 省略 } // 省略 } Canvas の縦横長を決めます。 この値はいわゆる解像度でもあるので、しっかり設定しましょう。 ここでは画像の大きさをそのまま解像度にしています。画像が大きければ大きいほど Canvas も大きくなるということです。 ちなみに、このままでは大きすぎて画面を埋めてしまうので、画面に描画する大きさ(見栄え、スタイル)は CSS で操作します。 e.g.)copied.<!-- width, height は解像度--> <!-- style="width: 200px; height: 200px;" は見栄え、スタイル(どう見えるか) --> <canvas width="100" height="100" style="width: 200px; height: 200px;"></canvas> 🌅 画像ファイルをロードしたい 画像ファイルをロードし、Canvas に描画します。そのために「部品」で書いたこのコードを利用します。 loadImage.ts 画像をファイルパスからロードする関数。 copied.export default function loadImage (src: string) { return new Promise((resolve, reject) => { const image = new Image(); image.src = src; image.onload = () => resolve(image); image.onerror = (e) => reject(e); }); } Image API を利用して img を作成します。 Image() - Web API | MDN Image() コンストラクターは、新しい HTMLImageElement インスタンスを作成します。機能的には document.createElement('img') と同等です。 画像を読み込むのは非同期処理になっているので、async / await に対応できるように Promise でラップしています。 copied.const vertical = await loadImage('/images/vertical.jpg') as HTMLImageElement; const horizon = await loadImage('/images/horizon.jpg') as HTMLImageElement; 普通に同期的に使いたい場合はこのようにすればOK。 copied.export default class WriteTextOnCanvas { // 省略 initialize() { // 省略 this.context.drawImage(this.props.image, 0, 0); // 省略 } // 省略 } これで読み込んだ画像を Canvas の背景に描画し、その上から文字を乗っけることで合成します。 (adsbygoogle = window.adsbygoogle || []).push({}); 🔤 文字の設定をしたい まず基本的なフォントの設定から フォントは CSS でロードするか、JavaScript でロードするかしておけばそのまま font-family が利用できます。 ただ、 CSS でロードするとロード時間中 Canvas に反映されないので、 JavaScript でロードすることをおすすめします。 JavaScript でフォントの読み込み copied.// フォントの読み込み const KouzanMouhituFontOTF = new FontFace('KouzanMouhituFontOTF', 'url(../fonts/KouzanMouhituFontOTF.otf)'); await KouzanMouhituFontOTF.load(); document.fonts.add(KouzanMouhituFontOTF); フォントを読み込んだら、諸々設定します。 フォント、色、サイズ設定 copied.export default class WriteTextOnCanvas { // 省略 initialize() { // 省略 this.context.font = this.props.fontSize + 'px ' + this.props.font; this.context.fillStyle = this.props.fontColor; // 省略 } // 省略 } フォントの設定は以上。 ✍ 文字を書きたい では、肝心の文字を描画する処理です。 Canvas は文字を描画するとき、改行や横文字、縦文字などをよしなに処理してくれませんので自作します。 現在のモードを格納するプロパティを用意する 現在の描画モードが縦横どちらなのかを格納するプロパティを用意します。これは単純に bool で表現してあげます。 copied.export default class WriteTextOnCanvas { private vertical = true; // 省略 changeMode(mode: string) { this.vertical = mode === 'vertical'; this.write(this.value); } // 省略 } 引数が文字列なのは縦横以外にもなにかモードがあるかもしれないな。と思ったからです。 まぁ、普通に bool を渡すように修正してもいいでしょう。直すのが面倒なのでこのまま進めます。大事なのは vertical には bool が入るということ。 デフォルトは true 、つまり縦書きモードです。 changeMode で横書きと切り替えられるよう、API を用意しています。 vertical を変更したら initialize で設定を初期化していますね。 横文字と縦文字を表現するために 文字列を縦に向けたり横に向けたりする処理は API として用意されていません。 自作するにしても、縦のときの描画と、横のときの描画の差をできるだけ大きくしたくありません。 そのために必要なことは 縦横問わず、一行に何文字入るかを格納する変数と計算する処理を用意する 一行に何文字入るかを元に、文字列を配列で表現する ことです。 縦横問わず、一行に何文字入るかを格納する変数と計算する処理を用意する まずは簡単な方。 copied.export default class WriteTextOnCanvas { // 省略 initialize() { // 省略 this.maxCharsPerLine = this.vertical ? Math.ceil((this.props.image.height - this.props.fontSize * 2) / this.props.fontSize) : Math.ceil((this.props.image.width - this.props.fontSize) / this.props.fontSize); // 省略 } // 省略 } maxCharsPerLine が「一行に何文字入るかを格納する変数」です。 計算する処理は vertical のモードによって変わっています。 文字を描画する対象の行幅 / 文字サイズ = 一行に何文字入るか です。 縦横の違いは分子を height にするかwidth にするかだけですが、 Canvas の仕様で 縦の場合は 2 文字分 横の場合は 1 文字分 引いてから計算しています。 これは好みの問題ではなく、ほとんど必須です。やってみればわかる。 一行に何文字入るかを元に、文字列を配列で表現する このあたりは write に記載されている処理を追ってみましょう。 copied.export default class WriteTextOnCanvas { // 省略 write(value: string) { this.value = value; this.initialize(); const ambitions = value // 行ごとに文字列を分割 .split('\n') // 文字列を文字の配列に分割 .map(value => [...value]) // 最大文字数分で更に配列を分割 .map(ambition => { if (ambition.length > this.maxCharsPerLine) { return sliceByNumber(ambition, this.maxCharsPerLine); } return [ambition]; }) // 二層目の配列をフラットに修正。 .flat(1); // 省略 } } 一つずつ追っていきます。 e.g.)copied.このような文字列が あったと しましょう 仮に maxCharsPerLine は 5 文字とします。 まず、 copied.const ambitions = value .split('\n') で、こうなります。 e.g.)copied.[ 'このような文字列が', 'あったと', 'しましょう', ] それが、 copied.const ambitions = value .split('\n') .map(value => [...value]) で、こうなります。 e.g.)copied.[ 'こ', 'の', 'よ', 'う', 'な', '文', '字', '列', 'が', 'あ', 'っ', 'た', 'と', 'し', 'ま', 'し', 'ょ', 'う', ] 要素それぞれの配列長が maxCharsPerLine を超えていたら sliceByNumber を利用して、分割します。 copied.// `sliceByNumber.ts` 配列を渡した数値分ずつに分割する関数。 export default function sliceByNumber(array, number) { const length = Math.ceil(array.length / number); return new Array(length) .fill(0) .map((_, i) => array.slice(i * number, (i + 1) * number)); } const ambitions = value .split('\n') .map(value => [...value]) .map(ambition => { if (ambition.length > this.maxCharsPerLine) { return sliceByNumber(ambition, this.maxCharsPerLine); } // 次の処理のために、最大文字数を超えてなくても一つの配列として用意しておく。 return [ambition]; }) で、こうなります。 e.g.)copied.[ [ ['こ', 'の', 'よ', 'う', 'な'], ['文', '字', '列', 'が'], ], [ ['あ', 'っ', 'た', 'と'], ], [ ['し', 'ま', 'し', 'ょ', 'う'], ] ] これではループで回しにくいので、第二階層を flat します。 copied.const ambitions = value .split('\n') .map(value => [...value]) .map(ambition => { if (ambition.length > this.maxCharsPerLine) { return sliceByNumber(ambition, this.maxCharsPerLine); } return [ambition]; }) .flat(1); で、結果がこちら。 e.g.)copied.[ ['こ', 'の', 'よ', 'う', 'な'], ['文', '字', '列', 'が'], ['あ', 'っ', 'た', 'と'], ['し', 'ま', 'し', 'ょ', 'う'], ] 縦横問わず ループが回しやすくなりました。 こうすることで、ここからは縦横それぞれでこの配列をどのように描画するかを考えれば良さそうです。 文字描画の開始位置の指定する ちょっと順番が前後しますが、細かい重要なところです。 文字の開始位置は 縦の場合は右上から 横の場合は左上から スタートです。 copied.export default class WriteTextOnCanvas { // 省略 initialize() { // 省略 this.vertical ? this.context.translate(this.props.fontSize * (this.maxCharsPerLine - 1), this.props.fontSize * 1.5) : this.context.translate(this.props.fontSize * 0.5, 0); } // 省略 } この設定は縦横それぞれ 1.5 文字分ずらしている処理です。 横の場合は簡単に表現できるんですが、縦の場合はちょっとややこしい。 この辺は好みの問題なので各自調整すればOK。 (adsbygoogle = window.adsbygoogle || []).push({}); 文字を描画する 最後にこれまで用意された設定と配列を利用して文字を描画していきます。 copied.export default class WriteTextOnCanvas { write(value: string) { // 値を保持 this.value = value; // 現在の設定で初期化 this.initialize(); // 行ごとにループ ambitions.forEach(rows => { this.vertical // 縦書きの場合は行が進むごとに文字サイズ分だけ`左`(マイナス方向)にずらす。 ? this.context.translate(-this.props.fontSize, 0) // 横書きの場合は行が進むごとに文字サイズ分だけ`下`(プラス方向)にずらす。 : this.context.translate(0, this.props.fontSize); // 一気に行頭に戻るために、現状のポジション(行頭位置)を保存 this.context.save(); // 文字ごとにループ rows.forEach((value: string) => { if (this.vertical) { // 縦書きモードの場合、 if (value.search(new RegExp(/[ー〜\-~「」[\]()()<><>{}{};:;:||==//__]/, 'g')) !== -1) { //特定の文字は 90 度傾ける。 this.context.rotate((90 * Math.PI / 180)); this.context.translate(-this.props.fontSize, 0); this.context.fillText(value, 0, 0); // 設定を戻す。 this.context.rotate(-1 * (90 * Math.PI / 180)); this.context.translate(0, this.props.fontSize); } else if (value.search(new RegExp(/[。、.,]/, 'g')) !== -1) { // 傾ける必要のない文字は座標移動で対応する。 this.context.fillText(value, this.props.fontSize * 0.7, -this.props.fontSize * 0.7); } else { // それ以外はそのまま。 this.context.fillText(value, 0, 0); } } else { // 横書きはそのまま。 this.context.fillText(value, 0, 0); } this.vertical // 縦書きの場合は文字が進むごとに文字サイズ分だけ`下`にずらす。 ? this.context.translate(0, this.props.fontSize) // 横書きの場合は文字が進むごとに文字サイズ分だけ`左`にずらす。 : this.context.translate(this.props.fontSize, 0); }); // 保持していたポジション(行頭位置)に戻る。 this.context.restore(); }); } } 解説はコード内にコメントで書きました。 工夫したのは copied.// 一気に行頭に戻るために、現状のポジション(行頭位置)を保存 this.context.save(); // 省略 // 保持していたポジション(行頭位置)に戻る。 this.context.restore(); この辺。この関数を挟むことで、考え方がスッキリしているはずです。 しかし、 特定文字の縦書き対応copied.if (value.search(new RegExp(/[ー〜\-~「」[\]()()<><>{}{};:;:||==//__]/, 'g')) !== -1) { // 省略 } 特定の文字を縦書きに対応する方法は苦難しました。 CSS の縦書きモード copied.writing-mode: vertical-rl; が使えると良いのですが、 Canvas にはありませんでした。まぁ、 HTML なので当然といえば当然。 🛠 詳しい使い方 コードの詳しい解説ができたので最後に詳しい使い方。 文字列を書き込みたい copied.// 初期化 const writeTextOnCanvas = new WriteTextOnCanvas({ // 省略 }); // 適当な `id="ambition"` を持つ HTML を用意しておく。ここではテキストエリアです。 const ambitionTextarea = document.getElementById('ambition') as HTMLTextAreaElement; // デフォルト値があるときとか。 writeTextOnCanvas.write('デフォルト値'); // テキストフォームやテキストエリアの変更を検知して書き込むとか。 ambitionTextarea.addEventListener('input', (e) => { const target = e.target as HTMLTextAreaElement; writeTextOnCanvas.write(target.value); }); 上記はあくまでも例です。 copied.writeTextOnCanvas.write('デフォルト値'); 普通になにかのイベントを検知するなりして、 write を呼び出してください。 背景画像を切り替えたい copied.// 初期化 const writeTextOnCanvas = new WriteTextOnCanvas({ // 省略 }); // 縦書き用の画像に変えるとか const vertical = await loadImage('/images/vertical.jpg') as HTMLImageElement; writeTextOnCanvas.changeImage(target.checked ? horizon : vertical); // 横書き用の画像に変えるとか const horizon = await loadImage('/images/horizon.jpg') as HTMLImageElement; writeTextOnCanvas.changeImage(target.checked ? horizon : vertical); // 適当な `id="mode-toggle"` を持つ HTML を用意しておく。ここではチェックボックスです。 const modeToggle = document.getElementById('mode-toggle') as HTMLInputElement; // 変更検知してモードを変えるとか。 modeToggle.addEventListener('input', (e) => { const target = e.target as HTMLInputElement; writeTextOnCanvas.changeImage(target.checked ? horizon : vertical); }); 上記はあくまでも例です。 copied.writeTextOnCanvas.changeImage(target.checked ? horizon : vertical); 普通になにかのイベントを検知するなりして、 changeImage を変更してください。 渡すのは HTMLImageElement であることに注意してください。 loadimage で取得できるやつです。 (adsbygoogle = window.adsbygoogle || []).push({}); フォントサイズを変えたい copied.// 初期化 const writeTextOnCanvas = new WriteTextOnCanvas({ // 省略 }); writeTextOnCanvas.changeFontSize(32); 縦書きモード、横書きモードを切り替えたい copied.// 初期化 const writeTextOnCanvas = new WriteTextOnCanvas({ // 省略 }); // 縦書きモードに変えるとか writeTextOnCanvas.changeMode('vertical'); // 横書きモードに変えるとか writeTextOnCanvas.changeMode('horizon'); // 適当な `id="mode-toggle"` を持つ HTML を用意しておく。ここではチェックボックスです。 const modeToggle = document.getElementById('mode-toggle') as HTMLInputElement; // 変更検知してモードを変えるとか色々。 modeToggle.addEventListener('input', (e) => { const target = e.target as HTMLInputElement; writeTextOnCanvas.changeMode(target.checked ? 'horizon' : 'vertical'); }); ✍ 最後に 今回は久しぶりにガッツリとした記事を書いてみました。 内容としてはネット上に載っているものをまとめただけだけど、自分なりの設計思想を付加して書いてみたので差別化はできているはず。 ライブラリ化も考えたけど流石に機能が少なすぎた。GitHub に public で置くくらいのことは考えてみようかな。 冒頭にも書いたけど、どんな感じで動くかのデモは 今年の抱負メーカー | Twitterで今年の抱負をつぶやこう!筆書き風の画像をダウンロードして背景に設定したりしてください! 今年の抱負メーカー | Twitterで今年の抱負をつぶやこう!筆書き風の画像をダウンロードして背景に設定したりしてください! 実際にアプリ使ってみてね(宣伝)。 🗒️ 参考 衡山毛筆フォント

僕が考えた最強のエラーハンドリング

2019/12/22 2022/08/03

エラーハンドリング・例外に対する考え方(Laravelとか含む) こんにちは。のんです。 今回の記事は社内の勉強会で利用した記事のリライト記事になります。 内容はエラーハンドリングについてです。 例外に対する考え方について 言語にもよりますが、PHPer の自分にとって例外は goto 構文と同じです。一度例外をスローするとどこでキャッチされるかわからない不安があります。 そのあたり Rust や Go のようにエラー型を返す方式は「なるほど」と感じました。PHP でもこれらの言語のように例外に対して厳しく接するとこのような書き味になるかなと考えました。 e.g.)copied.<?php declare(strict_types=1); $example = new Example(); if (!$example->isValid()) { // どこに飛んでいくかわからない。 throw new RuntimeException(); } 例外のキャッチの仕方について 基本的に1階層ごとに必ずキャッチする方式がいいと思います。 採用するアーキテクチャにもよりますが、大抵は層ごとに関心事が別れていたり、役割が別れていたりします。 その層に必要な例外を独自に作成してスローするということです。 e.g.) Infrastructure 層の Repository では。 e.g.)copied.<?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.)copied.<?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.)copied.<?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 に記載するでしょう。 copied.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 の中身もスッキリするでしょう。これもまた一つのメリットですね。 copied.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 で返すべきなのか、例外をスローすべきなのか。 copied.<?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 で返すべきか、例外をスローすべきなのか。 copied.<?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ということもあって鯉のぼりがたくさんありました! 宇和島フェリー バイクでフェリーに乗るのは初めて!とてもワクワクしました! コロナで客室が個室になっていたのもポイント高かったです! 沈堕の滝 / 沈堕発電所跡 鍋ヶ滝 スマレジ福岡天神ショールーム 展海峰 日本本土最西端(神埼鼻公園) 平尾台カルスト 火の山公園展望台 本州最西端(毘沙ノ鼻) 角島展望台 元乃隅神社 青山剛昌ふるさと館 魚見台公園