UIデザインについての記事が Freelance Hub で紹介されました。
2023/11/07 2023/11/07UIデザインについて少し調べてみた。の記事が 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ということもあって鯉のぼりがたくさんありました! 宇和島フェリー バイクでフェリーに乗るのは初めて!とてもワクワクしました! コロナで客室が個室になっていたのもポイント高かったです! 沈堕の滝 / 沈堕発電所跡 鍋ヶ滝 スマレジ福岡天神ショールーム 展海峰 日本本土最西端(神埼鼻公園) 平尾台カルスト 火の山公園展望台 本州最西端(毘沙ノ鼻) 角島展望台 元乃隅神社 青山剛昌ふるさと館 魚見台公園
自作ストレージサーバー用のための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/26Github 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みたいなストレージサーバーっぽいものを自前で用意する⑦【ファイルアップロード機能実装】
2022/03/06 2022/06/12S3みたいなストレージサーバーっぽいものを自前で用意する⑦【ファイルアップロード機能実装】 こんにちは。のんです。 前回に引き続き自前でストレージサーバーを開発していこうと思います。 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" } が返ってきます。 最後に これで、ストレージサーバーとして最低限の機能を実装することができました。 あとは画像の削除や、定期的なクリーンアップ、全拡張子の対応や、画像ファイル以外の対応などやろうと思えば色々案が出てきますね。 ひとまず、現状自分が欲しい機能は実装できたので、これで連載は終了です。 強いて希望を挙げればこのブログのファイルアップロード先を変更して記事にしたかったというのがありますね。 次回は何を書こうかな。久しぶりにバイク関連の記事でも書くかもしれません。 また記事にします。 そのときはよしなに。 .