PLEX Product Team Blog

【JavaScript】もちろん「0.1 + 0.2 ≠ 0.3」をちゃんと説明できますよね??

はじめに

こんにちは、サクミル開発チームの栃川です。

どうも世の中には、「0.1 + 0.2 ≠ 0.3」ということを知ってはいるけど なんでそうなるのかよくわかっていない不届き者がおるようです(はい、私のことです笑)。

ということで、今日はなぜJavaScriptにおいて「0.1 + 0.2 ≠ 0.3」となるのか説明してみたいと思います。

前提

  • 2進数について理解があることを前提にしています
  • JavaScriptの範囲に限定して記事を記載しています

0.1 + 0.2の答えは何になるの

実際にコンソール画面で「0.1 + 0.2 」を計算してみます。 答えはもちろん0.3!!と思いきや、なんと答えは「0.30000000000000004」となります。

一体なぜこのようなことが起きてしまうのでしょうか。

これを理解するには「2進数と浮動小数点数」について理解する必要があります。

なお、2進数についての説明は割愛いたします。

もしよくわかってないという方がいれば下記の記事がとてもわかりやすく説明しているので参考にしてみてください。 gmo-miyazaki-creators.com

浮動小数点数とは

浮動小数点数とは、実数をコンピュータで処理するために有限桁の小数で近似値として扱う方式のことです。 今日、最も広く使われている規格は、 IEEE(Institute of Electrical and Electronics Engineers)「米国電気電子技術者協会」が制定している IEEE754というものです。

IEEE754では、全ての数値(N)を、符号部(s)・固定長の指数部(e)・固定長の仮数部(m)の3つの部分を組み合わせにより表現します。

 N = -1^{s}×m×2^{e}

そして、上記の数式で表された値はコンピュータ上では下記のようなビットの並びとして表現されます。

JavaScriptにおいては、数値を表現する際はIEEE754の規格のうち、符号部 1 ビット ・ 指数部 11 ビット ・ 仮数部 52 ビットで表現する倍精度 64ビットバイナリー形式を利用します

例えば、10進数の「10.75」という数値をIEEE754の精度 64ビットバイナリー形式で表現してみます。

「10.75」は2進数で表現すると「1010.11」となります。

2進数に変換した「1010.11」を上述したIEEE754の精度 64ビットバイナリー形式の形に直すと下記のようになることがわかります。

 1010.11_{(2)} = -1^{0}×1.01011_{(2)}×2^{3}

倍精度 64ビットバイナリー形式では、仮数部(m)は小数点部分である「01011」となります。 また指数部は、指数にバイアス値1023を足した値を2進数で表現する決まりとなっています。これは、マイナスの数値を符号なしの2進数で表すために、所定の数(=バイアス値)を加えて表現するためです。よって、指数部は3 + 1023 = 1026を2進数変換して「10000000010」となります。

最後に余ったビットには0を埋めるので最終的には、

  • 符号ビット (s): 0
  • 指数部 (e): 10000000010
  • 仮数部 (m): 0101100000000000000000000000000000000000000000000000

となり、10進数「10.75」はコンピュータ上では「0 10000000010 0101100000000000000000000000000000000000000000000000」と表現されます。

なぜ計算を間違えるのか

浮動小数点数については理解できたと思います。

ここで今回のテーマである「0.1 + 0.2 」について考えてみます。

実は「0.1」 と「0.2」は2進数に変換すると循環小数になります。

  • 0.1 => 0.000110011....
  • 0.2 => 0.00110011....

つまり、0.1と0.2を浮動小数点数になおすと下記のようになります。

 0.1_{(10)} = -1^{0}×1.10011001100....×2^{-4}

 0.2_{(10)} = -1^{0}×1.10011001100....×2^{-3}

これでは仮数部が循環小数になってしまい、52ビットで表現できなくなってしまいます。

こうした場合、IEEE754規格では仮数部の52ビットよりも後の部分は切り捨てられます。切り捨てる際に「偶数丸め(round to even)」のルールが適用されます。つまり、52ビット目が奇数(1)であれば、上に丸めて偶数にします。一方で、52ビット目がすでに偶数(0)であれば、そのまま切り捨てます。

「0.1」も「0.2」もいずれの場合も52ビット目は 1 で、53ビット目も 1となるので繰り上げ処理をしますので、結果として仮数部は「10011001100....10」となります。

つまり、「0.1 + 0.2」をする際には0.1 と0.2は内部的には厳密には表現できないので、それぞれの近似値で加算が行われます。結果として「0.1 + 0.2 ≠ 0.3」となるというわけです。

正しく計算するためには?

では、JavaScriptを使う以上は正しい計算はできないのでしょうか?? もちろんそんなことはありません。

とても便利なライブラリがあるので代表的なものを3つ紹介いたします。

これら3つのライブラリは、いずれもJavaScript の標準メソッドである toExponential, toFixed, toPrecision をサポートしていて、使い慣れた APIで数値演算の正確性を実現してくれる優れものです。 また、ドキュメントも充実して、すぐに使い始めることができます。

それぞれの違いについては作者が丁寧に説明してくれていますので、用途に応じて使い分けると良さそうです。 github.com

お困りの方は、ぜひ選択肢の1つとして検討してみてください。

まとめ

今回は、JavaScriptで「0.1 + 0.2 ≠ 0.3」となる理由について簡単に説明しました。

私自身が小数点の扱いで苦労した経験がありますので、読者の皆さんにはぜひ同じ失敗を避けるために、浮動小数点数の仕組みについて詳しく理解することをおすすめします(笑)きっと計算誤差のトラブルを未然に防ぐことができるはずです!

最後にプレックスではエンジニアとプロダクトデザイナーを募集しております。この記事を読んで、一緒に働いてみたいと思った方がいましたら是非ご連絡をお待ちしています!

【Kaigi on Rails】2日目の参加レポート

こんにちは、Plex Job 開発チームの高岡です。

Kaigi on Railsの2日目に参加してきたので、栃川さんの1日目のレポートに続き、2日目の参加レポートを書きます 📝 product.plex.co.jp

会場にて

会場では、池川さん、栃川さんと一緒に参戦しました🔥

会場の雰囲気に関しては、1日目に会場で参加した栃川さんが臨場感のある参加レポートを上げてくださったので、ぜひそちらをご覧ください!
補足としては、発表する会場は2つあり、どちらとも大きなスクリーンでどの位置からでもはっきり見えてよかったです。

発表を行なっている会場

お弁当やコーヒーもいただけて、休憩室や作業室などもあり、会場内はとても満足して過ごすことができました!
スポンサー企業のノベルティがたくさんあり、どれもデザインが良かったです👏
栃川さんはX(旧Twitter)のアイコンで缶バッジを作成していただいていました、、すごい 👀

印象に残ったセッション

約9000個の自動テストの時間を50分から10分に短縮、偽陽性率(Flakyテスト)を1%以下に抑えるまでの道のり

参加したセッションはどれも興味深いものでしたが、特にhatsuさんのセッションが印象的でした! このセッションでは、自動テストの数が8871個、CIにかかる時間が50分、偽陽性(Flaky)によってCIが失敗する確率が15%という状況から、CIの時間を10分、失敗率は1%程度まで大幅な改善を達成した道のりについて、振り返りを交えつつ、詳しく説明されていました。

特にテスト時間の削減は、改善に寄与した施策を

  • 手軽さ
  • 効果

の2つの観点から紹介していてとても分かりやすかったです。

施策の中では、Capybaraやtest-profなどのgemを使用して、ボトルネックとなっている箇所を解消していくアプローチがありました。 以下、具体的なアプローチになります。

Capybaraの機能に任せてsleepを削除
APIレスポンスを待ってテキスト表示を確認するテストのために、sleepが使用されている箇所が複数存在していて、そのsleepでテスト時間が増加している課題があったようです。
その対応として、Capybaraの「特定のテキストを確認するまで指定した秒数の間、監視し続けてくれる」have_textメソッドを使用して、sleepしている箇所を削除して解決されていました。

test-profのbefore_allメソッドを使ってデータ作成を省略
テストデータをテストケース分だけ再生成を行なっていて、テスト時間の中でテストデータ生成時間が大きな割合を占めている課題があったようです。
こちらは、test-profのbefore_allメソッドを使用して、1度生成したテストデータを共通するテストケースで再利用することで、データ生成時間の課題を解決されていました。

Flakyテストの対応は、1つずつ地道に発見して潰していく中の苦労や工夫したことを説明されていました。
特に気になったのは、Allure Reportというテスト結果をダッシュボード化するツールで、Failedしたテストのログやスクリーンショットの可視化を行い、原因が分かりづらいFlakyテストを解消していました。

私のチームでは使用していないgemやツールも多く、いいアプローチだと思ったのでぜひ積極的に取り入れていきたいと思いました 🙆‍♂️

▼ 発表スライド  speakerdeck.com

一緒に向かった池川さんにも気になったセッションの感想をお聞きしてみました 🙌

▼池川さん
推し活のハイトラフィックに立ち向かうRailsとアーキテクチャ

株式会社TwoGate取締役CTOのHayato OKUMOTOさんの発表がとても印象的でした!(まずタイトルに惹かれますね)

前半では、ハイトラフィックに対してどのような対策をとっているのか、在庫テーブルの例を用いて説明されていました。
注文時に在庫数を更新する処理でDBのデッドロックを回避するために、商品単位で在庫数を管理するのではなく、在庫一つひとつをレコードで持たせる形でテーブル設計を採用されるなど私にはない観点だったので参考になりました!
また、後半は決済×パフォーマンスの話で、話の中で出てきた「正しく諦める」という言葉が印象的でした。
外部の決済システムを使っている場合、そのシステムの限界というものがあります。
サービスを作るにあたって、解決できない問題にこだわるのではなく正しく諦めたうえでUXを損なわない実装をすることが大切だと感じました。

▼ 発表スライド  speakerdeck.com

さいごに

このようなオフラインの技術イベントは初めて参加したので、とても刺激になる体験でした!
技術的な学びはもちろん、スライドの構成や発表スタイルなども私自身が登壇者になった際に参考になる部分が多くありました!
来年はプレックスジョブのエンジニアメンバーもプロポーサルを提出して、登壇に挑戦したいと思います 💪

最後に現在プレックスではソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーを募集しています。
この記事を読んで来年の Kaigi on Rails に一緒に参加したいと思った方は、是非気軽にご連絡をください! dev.plex.co.jp

【Kaigi on Rails】1日目の参加レポート

はじめに

こんにちは、サクミル開発チームの栃川です。

10月25日・26日で開催のオフラインイベント、Kaigi on Railsの1日目に参加してきたので いち早く感想をお届けしたいと思います!!

※ 2日目のレポートはこちらに記載しております product.plex.co.jp

きっかけ

実は私自身「Kaigi on Rails」に参加したことはありませんでした。

過去の登壇資料や動画などを閲欄することは今まで何度もあったので認知はしていましたが、オフラインで参加することはありませんでした。

弊社には、技術カンファレンスへの参加費用の補助制度があるため、それを活用して初めて「Kaigi on Rails」に参加してみました!!

会場について

会場への行き方

今回の会場は、「@有明セントラルタワーホール & カンファレンス」という場所です。 私は、りんかい線国際展示場駅から向かいました。

人の流れに合わせてまっすぐ歩くこと5分、ついに会場に到着!とても立派なビルです。

エスカレータで受付フロアに向かうとこんな感じでお出迎えしてくれます。 テンションがあがりますね〜!

会場の雰囲気

受付を終えて、まずは出展フロアへ。

平日なので、人の入りは比較的落ち着いていると思いきや たくさんの人で大盛り上がりでした。

普段なかなか話せない他業界のエンジニアと「これはどんな技術スタックなの?」とか「どうやって実装しているの?」なんて会話ができるのは オフラインイベントならではの楽しさでした!

ちなみにグッズもたくさんあったのでたくさんもらって帰らせていただきました(笑)

講演やブース見学で疲れた方は、「ワイワイ部屋」では、なんとコーヒーが無料で飲めるようです。 珈琲店を営んだ経験があるエンジニア「荻野さん」が入れたコーヒーはとてもおいしかったです!ありがとうございました!

(※ 本人に掲載許可をとっております)

セッションについて

「初学者から上級者までが楽しめるWeb系の技術カンファレンス」と謳っているだけあって、「知る人ぞ知るニッチな知識」というよりは、「業務の中で得た知識」に関するものが多かった印象です。 そのため、どの発表も単にわかりやすいだけでなく、自分ごととして捉えることができるので大変学びが多い時間となりました。

どのセッションも本当に素晴らしかったのですが、特に印象に残ったものについて感想を述べていきたいと思います。

Railsの仕組みを理解してモデルを上手に育てる

1つ目は、「Railsの練習帳」の著者でもあるIgarashiさんの発表です。

kaigionrails.org

発表の中で「機能の実装方法をチームメンバー全員に問うた時に、全員が同じ実装方法を答えられる状態を作る」ことの重要性を「PORO(Plain Old Ruby Object)」や「Service層」の話に触れつつ主張されていた点がとても印象的でした。

たしかに、普段の業務では、「読みやすいコード」や「シンプルな設計」を追求しますが、「そもそも、なぜそれをやる必要あるんだっけ」という問いを忘れがちなので、改めて考えさせられました。

技術カンファレンスというと、手法だったりテクニックにフォーカスが当たるものだと思っていましたが、こうした本質的な問いを投げかけてくれる発表は非常に貴重だなと思いました。

カラム追加で増えるActiveRecordのメモリサイズ、イメージできますか?

2つ目は、GMOビューティー株式会社でCTOのasayamaさんの発表です。

kaigionrails.org

発表内容の難易度が非常に高く、ついていくのに必死でした(笑)。

それでも、asayamaさんが「業務の中で疑問に思ったことを追求した結果」と何度も語っていたのが印象的で、知識以上にその「好奇心の深さ」に驚かされました。

私も業務中に「なぜこうなるのだろう?」と感じることはありますが、「カラムを追加するとどのくらいバイト数が増えるのか?」や「そもそもなぜ増えるのか?」といった深いレベルで考えることがまだできていませんでした。

asayamaさんの「技術への向き合い方」は、自分にとって不足している部分だと痛感し、知識だけでなくマインド面でも多くを学ばせていただきました。

Sidekiqで実現する長時間非同期処理の中断と再開

3つ目は、株式会社SmartHRのhypermktさん の発表です。

kaigionrails.org

この発表では、非同期処理の中断・再開に関する実践的な手法が解説されており、SmartHRの実例も紹介されていました。

具体的には、処理の進捗を保存しながらのジョブ実行や、Redisを用いた「ID番号方式」や「行番号方式」による進捗管理が紹介されていて、長時間のジョブを安定して中断・再開させるためのノウハウが凝縮されていて、とても参考になりました。

また、新たにSidekiq Iterationの機能が登場し、進捗管理の自動化と中断・再開が容易に実装できるようになっているとの情報は全く知らなかったので、とても有益な情報でした!

まとめ

まだ1日目なのにもかかわらず、「技術的な知識」だけでなく「自分のマインドセットや技術への向き合い方」にも大きな変化起こすための有意義な時間となりました。

明日の2日目もとても楽しみです!!

※ 2日目のレポートはこちらに記載しております product.plex.co.jp

さいごに

最後に現在プレックスではソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーを募集しています。

とても働きやすい環境なので一緒に働いてみたいと思った方がいましたら、是非ご連絡をお待ちしています!

dev.plex.co.jp

新卒エンジニアが複数社合同で輪読会を開催している話

はじめに

こんにちは、2024年4月に株式会社プレックスに新卒入社した佐藤祐飛です。現在は建設業界向けSaaSプロダクト「サクミル」の開発に携わっています。

2024年5月から約2ヶ月間、日本CTO協会主催の「新卒エンジニア向けの合同研修」に参加し、こちらの研修で知り合った10社の24卒新卒エンジニアと月に1度、各メンバーのオフィスにて合同で輪読会を開催しています。本ブログではその内容についてご紹介します。

目的

輪読会を開催する目的は大きく2つあります。

知見の共有

1つ目は、複数社の知見を共有して技術書の理解を深めることです。新卒エンジニアは実務経験がまだ浅く、技術書を読んでもその内容を完全に理解するのは容易ではありません。特に、抽象的な概念については、経験がなければ理解が深まらないことが多いです。そこで、異なる企業から集まったエンジニアたちが、それぞれの経験を持ち寄って議論することで、個々の経験不足を補いながら理解を深めることができます。

第一回、第二回は「A Philosophy of Software Design(以下APoSD)」を対象の書籍にしました。APoSDはソフトウェア設計の書籍なのですが抽象的な内容が多いです。それでも、10社のエンジニアの視点が集まることで、書籍の理解が深まりました。

継続的な交流の機会

2つ目は、継続的に交流の場を設けることです。24卒の新卒社員は、パンデミックの影響で学生時代にリモート授業を余儀なくされ、対面での交流の機会が非常に少なかった世代です。そういった背景から、一つ一つの出会いを大切にしたいという思いが私には強くあります。

輪読会の後には毎回、懇親会を開催し、参加者同士で近況を報告し合っています。お互いに良い刺激を得ることができ、非常に有意義な時間となっています。

輪読会の様子

輪読会の開催方法はオンラインとオフラインのハイブリッドで、それぞれのメンバーのオフィスを持ち回りで開催しています。前提として、企業間の交流に積極的な日本CTO協会に加盟した企業間で開催しているので、オフィス開催のハードルが低くなっています。私はメンバーのオフィス見学をすることが楽しみの一つになっております笑。

スマートキャンプ株式会社様のオフィスで開催

参加者は気になった点や知見があれば、Slackにコメントを残し、必要に応じて議論を行います。活発な議論が起こるのでとても良い学びの場になります。

Slack上での議論の様子

輪読会を継続させる工夫

一名が発表する形式

輪読会のたびに書籍を読む必要があると、参加のハードルが高くなってしまいます。そのため、担当者一人が資料をまとめて発表する形式をとっています。この形式は発表者の負担が多くなるというデメリットがありますが、発表自体が良いアウトプットになるので高い学習効果を得られるというメリットもあります。

早めに日程を抑える

こうした催しは予定を先に抑えておけば継続しやすくなるので、先4ヶ月分の予定を押さえています。また、他社様のオフィスを抑えてしまっているという事実も輪読会参加を促進していると思います。

notionで輪読会を管理

懇親会を実施

目的でも書きましたが、輪読会後は毎回、懇親会を実施しており、こちらが実質のメインです笑。研修中に日本CTO協会に所属されているCTOとお話しする中で、皆様口を揃えて仰るのは「縁」が大事であるということでした。私自身、縁を広げて深めることに大きなモチベーションを持っているので懇親会は必ず実施していきたいと思います。

最後に

弊社の新卒エンジニアは私だけなので、こうした機会に恵まれていることを嬉しく思います。こうした複数社合同の取り組みは業界的にも珍しい試みであると思いますので、今後も継続させていきたいと思います!

また、こちらの輪読会に参加したい24卒のエンジニアの方がいらっしゃれば、私のX(旧Twitter)アカウントにDM送っていただけると嬉しいです。

最後にプレックスではエンジニアとプロダクトデザイナーを募集しております。この記事を読んで、一緒に働いてみたいと思った方がいましたら是非ご連絡をお待ちしています!

dev.plex.co.jp

【Rails】ワンタイムトークンが作れる generates_token_for の内部実装を追ってみた

こんにちは、Plex Job 開発チームの池川です。

今回の記事では、Railsアプリでワンタイムトークンを使うにあたって ActiveRecord::Base.generates_token_forActiveRecord::Base.find_by_token_for ついて調べた内容をまとめていきます。

検証環境

どのようなメソッドか

generates_token_for は Rails7.1 から使えるようになったメソッドで、 Rails ガイド では下記のように説明されています。

ActiveRecord::Base.generates_token_forは特定の目的で利用するトークンの生成を定義します(#44189)。生成されたトークンは失効させることも、レコードデータを埋め込むこともできます。トークンを用いてレコードを取得すると、トークンのデータと現在のレコードのデータが比較されます。両者が一致しない場合、トークンは無効とみなされ、期限切れとして扱われます。

次に Rails ガイド に掲載されているサンプルコードを見ていきます。

class User < ActiveRecord::Base
  has_secure_password

  generates_token_for :password_reset, expires_in: 15.minutes do
    # `password_salt`(`has_secure_password`で定義される)は、
    # そのパスワードのsaltを返す。パスワードが変更されるとsaltも変更されるので、
    # パスワードが変更されるとこのトークンは無効になる。
    password_salt&.last(10)
  end
end

user = User.first
token = user.generate_token_for(:password_reset)
# => BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU--f67d5f27c3ee0b8483cebf2103757455e947493b

User.find_by_token_for(:password_reset, token)
# => #<User id: 1, .....>

user.update!(password: "new password")
User.find_by_token_for(:password_reset, token)
# => nil

generates_token_for では、次の3項目を設定します。

  • purpose
    • 何でもOKで、トークンが紐つく属性のようなもの
    • find_by_token_for の引数に指定して、トークンの検証時に使用する
  • expires_in
    • 有効期限
    • 有効期限が2時間後なら 2.hours 、2日後なら 2.days のように書く
  • block
    • トークンに含めるオプションを指定する

なお、expires_in と block は指定しなくても動作します。

expires_in を指定しない場合、有効期限がないため時間経過によるトークンの失効は行われません。block に指定したオプション、サンプルコードだとパスワードが変更されて password_salt が更新された場合にトークンが失効されます。

class User < ApplicationRecord
  generates_token_for :password_reset do
    password_salt&.last(10)
  end
end

block にオプションを指定しない場合、expires_in で定義された期間が終了した場合のみ、トークンが失効します。

class User < ApplicationRecord
  generates_token_for :password_reset, expires_in: 15.minutes
end

expires_in と block を両方指定しない場合、失効しないトークンが発行されます。

class User < ApplicationRecord
  generates_token_for :password_reset
end

find_by_token_for には、トークン生成時に指定した purpose と生成されたトークンをセットで渡します。 トークンが有効であれば該当のモデルオブジェクトが返りますが、トークンが無効の場合は nil が返ってきます。 上記のように、簡単にワンタイムトークンを生成、検証することが可能です。

次にサンプルコードを動かしていて気になった以下の3点について Rails の内部実装を見ながら順番に確認していきます。

  1. トークンはどのように生成されているか
  2. トークンは生成の都度変わるか
  3. トークンの検証時に、実際に比較されている値は何か

1. トークンはどのように生成されているか

generates_token_for の内部実装をみてみます。

generates_token_for の内部実装

github.com

generates_token_forActiveRecord::TokenFor に定義されています。 ActiveRecord::TokenFor には generates_token_for メソッドがクラスメソッドとインスタンスメソッドの2つ定義されています。 トークンの定義で使われるのはクラスメソッドの方です。 purpose に紐つける形で、purpose、expires_in、block をもとに TokenDefinition オブジェクトを作成しています。

def generates_token_for(purpose, expires_in: nil, &block)
  self.token_definitions = token_definitions.merge(purpose => TokenDefinition.new(self, purpose, expires_in, block))
end

トークンの定義をもとに、実際にトークンを生成するのはインスタンスメソッドの方です。 generate_token_for の中では generate_token が呼び出されており、その中でActiveSupport::MessageVerifier を使ってトークンを生成しています。

TokenDefinition = Struct.new(:defining_class, :purpose, :expires_in, :block) do # :nodoc:
  # 省略

    def generate_token(model)
      message_verifier.generate(payload_for(model), expires_in: expires_in, purpose: full_purpose)
    end
end

def generate_token_for(purpose)
  self.class.token_definitions.fetch(purpose).generate_token(self)
end

ActiveSupport::MessageVerifier

api.rubyonrails.org

message_verifier.generate の内部実装も覗いてみます。

# lib/acctive_support/message_verifier.rb
   def generate(value, **options)
      create_message(value, **options)
    end

    def create_message(value, **options) # :nodoc:
      sign_encoded(encode(serialize_with_metadata(value, **options)))
    end

    # 省略

    private
      def sign_encoded(encoded)
        digest = generate_digest(encoded)
        encoded << SEPARATOR << digest
      end

      # 省略

      def generate_digest(data)
        OpenSSL::HMAC.hexdigest(@digest, @secret, data)
      end

generate では、

の流れでトークンを生成します。

generate については message_vefirier.rb 内のコメントで下記のように説明されています。

Generates a signed message for the provided value. The message is signed with the +MessageVerifier+'s secret. Returns Base64-encoded message joined with the generated signature.

"指定された値に対して署名されたメッセージを生成します。 メッセージは +MessageVerifier+ の秘密鍵で署名されます。 生成された署名とともに、Base64エンコードされたメッセージを返します。"

署名時に使用される秘密鍵は、secret_key_base に指定した値が使われます。secret_key_basecredentials.enc.yml環境変数で指定していると思います。

verifier = ActiveSupport::MessageVerifier.new("secret")
verifier.generate("signed message")
# => "BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU--f67d5f27c3ee0b8483cebf2103757455e947493b"

トークンは下記の2つの値から構成されています。

  • BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU(エンコードされたメッセージ部分)
  • f67d5f27c3ee0b8483cebf2103757455e947493b(HMAC 署名部分)

エンコードされたメッセージ部分

purpose、expires_in、block からシリアライズされたハッシュを元に、Base64エンコードされた値が使われています。

# シリアライズされたハッシュ
{
  "_rails": {
    "data": [
      1, # Userのid
      "3gAd4RMK5" # ブロックで指定した値
    ],
    "exp": "2024-09-30T05:58:44.104Z",
    "pur": "User\\npassword_reset\\n900"
  }
}

# Base64エンコードされた値
BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU

HMAC署名

OpenSSL::HMAC.hexdigestm を使っています。

docs.ruby-lang.org

2. トークンは生成の都度変わるか

有効期限経過後に、トークンが変わるか試してみます。

user = User.first
token = user.generate_token_for(:password_reset)

# => eyJfcmFpbHMiOnsiZGF0YSI6WzEsIjNnQWQ0Uk1LNS4iXSwiZXhwIjoiMjAyNC0wOS0zMFQwNTo1NDowMS45NDlaIiwicHVyIjoiQWRtaW5cbnBhc3N3b3JkX3Jlc2V0XG45MDAifX0=--499c53d3345bf65e80eb843e99f55ba60d4592ba

# 有効期限15分経過後(expires_in: 15.minutes)
user = User.first
token = user.generate_token_for(:password_reset)

# => eyJfcmFpbHMiOnsiZGF0YSI6WzEsIjNnQWQ0Uk1LNS4iXSwiZXhwIjoiMjAyNC0wOS0zMFQwNjoyNzoyMC4yODlaIiwicHVyIjoiQWRtaW5cbnBhc3N3b3JkX3Jlc2V0XG45MDAifX0=--e9da25d31735b34179663371436f0588e286ea41

上記のように、有効期限後にトークンを再度生成したところ、変更されていました。 トークンの前半部分も似ていますがよく見ると違います💦 1で内部実装をみて確認したように、トークン生成には expires_in(有効期限)が使われています。 トークン生成時に使われる有効期限は下記のようにタイムスタンプ形式なので、generates_token_forトークンを生成する都度、有効期限の日時が毎回変わり、結果異なるトークンが生成されます。

"exp": "2024-09-30T05:58:44.104Z"

3. トークンの検証時に、実際に比較されている値は何か

github.com

find_by_token_for の内部実装を見ていきます。 find_by_token_forgenerates_token_for と同様に ActiveRecord::TokenFor に定義されています。 実際に比較しているのは resolve_tokenmodel && payload_for(model) == payload の部分です。

def resolve_token(token)
  payload = message_verifier.verified(token, purpose: full_purpose)
  model = yield(payload[0]) if payload
  model if model && payload_for(model) == payload # 👈ここ
end

payload_for(model) と payload には下記の値が入ります。

  • payload_for(model)
    • 現在のレコードのデータ
    • モデルのIDと、ブロックにオプションの指定があればその値を検証のタイミングで算出して配列に入れる
def payload_for(model)
  block ? [model.id, model.instance_eval(&block).as_json] : [model.id]
end

ActiveSupport::MessageVerifier

api.rubyonrails.org

message_verifier.verified の内部実装も覗いてみます。

# lib/acctive_support/message_verifier.rb
def verified(message, **options)
  catch_and_ignore :invalid_message_format do
    catch_and_raise :invalid_message_serialization do
      catch_and_ignore :invalid_message_content do
        read_message(message, **options)
      end
    end
  end
end

def read_message(message, **options) # :nodoc:
  deserialize_with_metadata(decode(extract_encoded(message)), **options)
end
# lib/acctive_support/message_verifier.rb
def extract_encoded(signed)
  if signed.nil? || !signed.valid_encoding?
    throw :invalid_message_format, "invalid message string"
  end

  if separator_index = separator_index_for(signed)
    encoded = signed[0, separator_index]
    digest = signed[separator_index + SEPARATOR_LENGTH, digest_length_in_hex]
  end

  unless digest_matches_data?(digest, encoded)
    throw :invalid_message_format, "mismatched digest"
  end

  encoded
end

def digest_matches_data?(digest, data)
  data.present? && digest.present? && ActiveSupport::SecurityUtils.secure_compare(digest, generate_digest(data))
end

extract_encoded では、

チェックしています。

Rubyvalid_encoding? を使ってエンコーディングが問題ないか判定し、問題があれば例外を投げます。

docs.ruby-lang.org

エンコーディングが問題なければ、トークンを前半のメッセージ部分と後半の署名部分に分けた上で、digest_matches_data? の中で、

  • メッセージ部分と署名部分がそれぞれ存在しているか?
  • メッセージ部分を元に再度署名を生成し、それがトークンに付与された署名と一致するか

の確認を行っています。

# lib/acctive_support/messages/metadata.rb
def deserialize_with_metadata(message, **expected_metadata)
  if dual_serialized_metadata_envelope_json?(message)
    envelope = deserialize_from_json(message)
    extracted = extract_from_metadata_envelope(envelope, **expected_metadata)
    deserialize_from_json_safe_string(extracted["message"])
  else
    deserialized = deserialize(message)
    if metadata_envelope?(deserialized)
      extract_from_metadata_envelope(deserialized, **expected_metadata)["data"]
    elsif expected_metadata.none? { |k, v| v }
      deserialized
    else
      throw :invalid_message_content, "missing metadata"
    end
  end
end

def extract_from_metadata_envelope(envelope, purpose: nil)
  hash = envelope["_rails"]

  if hash["exp"] && Time.now.utc >= parse_expiry(hash["exp"])
    throw :invalid_message_content, "expired"
  end

  if hash["pur"].to_s != purpose.to_s
    throw :invalid_message_content, "mismatched purpose"
  end

  hash
end

さらに deserialize_with_metadata の中で呼ばれている extract_from_metadata_envelope では、

  • 有効期限内かどうか
  • トークンの purpose が定義済みの値と一致しているか

を検証しています。

どのような値が入っているか確認してみる

1で動かしてみた Rails ガイドのサンプルコードを実行して、どのような値が入るか確認してみます。

payload_for(model)
# => [1, "3gAd4RMK5"]

payload
# => [1, "3gAd4RMK5"]

返ってきた値はトークン生成時に出てきたハッシュの中の data の部分です。

{
  "_rails": {
    "data": [
      1, # Userのid
      "3gAd4RMK5" # ブロックで指定した値
    ], 👈ここ
    "exp": "2024-09-30T05:58:44.104Z",
    "pur": "User\\npassword_reset\\n900"
  }
}

ということで実際に比較しているのは、「モデルオブジェクトのIDとブロックで指定したオプションの値が入った配列」でした。

利用にあたっての注意点

利用にあたっては以下のような注意点があります。

  1. ブロックに指定するオプションに機密情報を含まない
  2. モデルの属性値や属性値を構成する要素を指定する

1. ブロックに指定するオプションに機密情報を含まない

lib/active_record/token_for.rb 内のコメントでも下記ように記載されています。

Note that the value returned by the block should not contain sensitive information because it will be embedded in the token as human-readable plaintext JSON.

"なお、ブロックによって返される値は機密情報を含んではいけません。なぜならその値は、人間が読み取れるプレーンテキスト JSONとしてトークンに埋め込まれるためです。"

内部実装を見て確認した通り、トークンの前半部分はBase64エンコードしているため複合化できます。 試しに下記でブロックのオプションに名前と電話番号を指定してみました。

class User < ActiveRecord::Base
  has_secure_password

  generates_token_for :password_reset, expires_in: 15.minutes do
    # 氏名と電話番号をオプションに指定
    "氏名:#{name}-電話番号:#{phone_number}"
  end
end

user = User.first
token = user.generate_token_for(:password_reset)

payload = JSON.parse(Base64.decode64(token))
p JSON.pretty_generate(payload)

# =>
{
  "_rails": {
    "data": [
      1,
      "氏名:運送太郎-電話番号:99012345678" 👈ここ
    ], 
    "exp": "2024-09-30T05:58:44.104Z",
    "pur": "User\\npassword_reset\\n900"
  }
}

生成されたトークンを複合化したところ、名前と電話番号が表示されてしまっています。

オプションには機密情報などを指定しないようにしましょう!

2. モデルの属性値や属性値を構成する要素を指定する

class User < ActiveRecord::Base
  has_secure_password

  generates_token_for :password_reset, expires_in: 15.minutes do
    # 乱数を生成
    SecureRandom.hex(10)
  end
end

user = User.first
token = user.generate_token_for(:password_reset)
# => eyJfcmFpbHMiOnsiZGF0YSI6WzEsIjNnQWQ0Uk1LNS4iXSwiZXhwIjoiMjAyNC0wOS0zMFQwNTo1NDowMS45NDlaIiwicHVyIjoiQWRtaW5cbnBhc3N3b3JkX3Jlc2V0XG45MDAifX0=--499c53d3345bf65e80eb843e99f55ba60d4592ba

User.find_by_token_for(:password_reset, token)
# => nil

たとえば上記のように generates_token_for のブロックに乱数を指定した場合、find_by_token_fornil が返ります。 トークン生成時と検証時で、SecureRandom.hex(10) の結果が異なるためです。 (find_by_token_for では現在のレコードのデータとトークンのデータが同一か確認している)

User.find_by_token_for(:password_reset, token)

"payload_for(model): [1, \\"b8822aac56ac979f66bd\\"]" # 検証時のデータ
"payload: [1, \\"c9cff7d532f29cff6940\\"]" # トークン生成時のデータ

まとめ

今回の記事では、ActiveRecord::Base.generates_token_forActiveRecord::Base.find_by_token_for について、内部実装を確認しながら疑問点を解消していきました。 使うにあたっては一定の注意が必要ですが、数行書くだけでワンタイムトークンが生成できる便利なメソッドだと思います。

さいごに

最後に現在プレックスではソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーを募集しています。 この記事を読んで、一緒に働いてみたいと思った方がいましたら是非ご連絡をお待ちしています!

dev.plex.co.jp

【CTO協会研修記録】 未経験エンジニアがISUCONで圧倒優勝するまでの話

はじめに

こんにちは、2024年4月に株式会社プレックスに新卒入社した佐藤祐飛です。現在は建設業界向けSaaSプロダクト「サクミル」の開発に携わっています。

2024年7月31日に、日本CTO協会主催の新卒合同研修でISUCON研修が開催され、50万点を超えるスコアで優勝することができました。

ISUCONは若手エンジニアにとってハードルが高いコンテストです。私自身、エンジニアとしての経験がまだ1年に満たず、初めはISUCON関連の書籍やブログを読んでも理解が難しかったです。しかし、約2ヶ月間の準備を経て、なんとかISUCONに挑戦できるレベルに達することができました。

本ブログでは、私の体験を通じて「ISUCONに取り組むべき理由」「ISUCONに向けた対策」を共有したいと思います。

ISUCONとは?

ISUCON*1とは「いい感じにスピードアップコンテスト」の略で、LINEヤフー株式会社が運営するWebサービスのチューニングコンテストです。

ISUCONとはLINEヤフー株式会社が運営窓口となって開催している、お題となるWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトルです。 引用: ISUCON公式Blog

3人以下のチームで8時間以内にチューニングを行い、スコアを競います。基本的にWebサービスがより多くのリクエストを処理するほどスコアが上がります。禁止事項でない限り、DBのスキーマ変更やミドルウェア導入によるキャッシュの利用など、自由にチューニングを行えます。実際のWebサービスと同様に様々なアプローチを取ることができるのがISUCONの魅力となっております。

ISUCONに取り組むべき理由

ISUCONはハードルが高いですが、若手エンジニアこそISUCONに挑戦するべきだと考えています。その理由は大きく3つあります。

ISUCONは意思決定の連続

ソフトウェア開発において、意思決定は大きな責任が伴い、最も成長に直結するプロセスです。ISUCONでは、限られた時間内で優先順位を決め、多くの選択肢から最適なものを選ぶ意思決定を何度も行います。このプロセスがスコアとしてフィードバックされるため、迅速な意思決定とフィードバックのサイクルを経験できます。意思決定とフィードバックのサイクルがここまで短いソフトウェア開発は、ISUCON以外ではなかなか経験できません。また、ISUCONは参加者のブログが数多く投稿されているので強いエンジニアの意志決定をトレースすることも容易です。

研修中、PR TIMESの金子さん(@catatsuy)やLayerXの松本さん(@y_matsuwitter)は「ISUCONと障害対応を通じてエンジニアとして成長した」と語っていました。障害対応もISUCONと同様に、プレッシャーの中で重要な意思決定を繰り返す必要があります。若いエンジニアが早いうちに意思決定の経験を積むことは、非常に重要だと思います。

普段の業務では味わえない経験

ISUCONは若手エンジニアが普段経験できないことに挑戦することができます。具体例として以下のような技術的課題に取り組むことができます。

  • 枯渇するCPUリソース・メモリへの対応
  • CPUプロファイリング
  • スロークエリログの分析
  • DBスキーマの変更
  • 空間インデックスの利用
  • SQLiteからMySQLへの移行
  • 複数インスタンスを利用したアーキテクチャへの移行
  • 並行処理による最適化
  • N+1の解消
  • Redisやmemcachedなどのキャッシュ利用
  • NginxやVarnishなどのリバースプロキシ設定
  • HTTPキャッシュ設定

実践を積まなければ身につかないスキルや、クラウド環境が主流となった現代では見過ごされがちな技術が多くあります。ISUCONでは、多くの選択肢を持って意思決定を行うことが重要であり、そのために日々の技術研鑽がまだまだ必要だと痛感させられます。

面白いアプリケーション

ISUCONで出題されるアプリケーションは毎年工夫が凝らされており、学習教材としても非常に有用です。特に普段業務で触れない技術やコードに触れる良い機会となります。私自身もGoの学習教材として利用しています。

以下にISUCONのイントロ動画を貼ってみます。めっちゃ面白いです。

youtu.be

youtu.be

ISUCONに向けた対策

ここからは私がISUCON研修に向けて行った具体的な準備を紹介します。

OS・CPU

OS・CPUの基礎知識がないと計測結果やMySQL・Nginxのパラメータなどの意味を理解することができません。「[試して理解]Linuxのしくみ」ではこれらの基礎知識をわかりやすく図解しています。スレッド、スケジューリング、メモリ、ファイルディスクリプタ、ディスクI/Oなどに不安がある方は一読してみることをお勧めします。

gihyo.jp

DB

ISUCONではスコアを上げるためにDBの理解が必須です。特に役に立った記事と書籍を紹介します。

MySQLのパフォーマンスについて解説されています。トランプの話はとてもわかりやすかったです。 www.slideshare.net

InnoDBのインデックスを詳しく図解しており、Bツリーインデックスやインデックスを張った際の振る舞いをイメージすることができるようになります。 techlife.cookpad.com

分散システムにおけるレプリケーション、パーティショニングなどについて詳しく書かれています。ISUCONでは複数台アーキテクチャを利用できるため、DB分割なども打ち手として持てるようになります。 www.oreilly.co.jp

Go

普段の実務ではRuby及びTypeScriptを扱っているのですが、ISUCONにおいてはGoの使用率が高いため、Goについて一からキャッチアップしました。

基本文法はお馴染みの「A Tour Of Go」で学習しました。 go-tour-jp.appspot.com

メモリを中心としたGoの低レイヤーについて詳しく書かれています。正直、ISUCONで低レイヤーまでチューニングをほとんどしませんが、読んで損はないと思います。 www.oreilly.co.jp

並行処理を扱ったことがあまりなく、ピンと来なかったので読んでみました。 www.oreilly.co.jp

リバースプロキシ・HTTPキャッシュ

Nginxを詳しく学びたい方におすすめです。 tatsu-zine.com

HTTPキャッシュはヘッダーやキャッシュ条件が複雑で奥が深いので一読してみることをお勧めします。 gihyo.jp

ISUCON本

ISUCON関連の本の中では一番有名な本で、private-isuという問題を題材にチューニングの基礎が解説されています。エンジニアになりたての昨年は、難しく読み進めるのが大変でしたが、上記のインプットを入れた後だとスッと頭に入ってきました。

私はprivate-isu関連のブログを読みこみ、最終的にprivate-isuの点数を約91万点まで伸ばすことができました。

gihyo.jp

giarrium.hatenablog.com

Makefile

ISUCONでは計測と改善のサイクルを高速に回すことが大切です。そのために、Makefileスクリプトを事前に作り込んでおくことが重要です。私は東京工業大学デジタル創作同好会traPの@_oribe1115さんのMakefileを参考にさせていただきました。

また、計測結果をチームメンバーと素早く共有するため、notify_slackを利用してSlackに通知を送るようにしました。

github.com

ISUCON過去問

ISUCON11予選は出題の意図にもある通り「教科書的な問題でありつつも、解きごたえのある問題」でprivate-isuの次に解く問題として丁度良い難易度だと思いました。 isucon.net

本番を想定した練習

時間制限がある中で解くISUCONは無制限で解くISUCONと全くの別物です。可能であれば、多くの人を巻き込み、本番と同じ条件で練習することを強くお勧めします。私はISUCON研修の週に上司に頼んで社内ISUCONを開催していただきました。また、社内ISUCONの翌日にはCTO協会の研修メンバー5人でチーム対抗戦を行いました。

それぞれ、ISUCON13とISUCON9予選の問題を解きました。

isucon.net

isucon.net

ISUCON研修本番

研修本番は二人一組でチームを組み、全31チームの対抗戦でした。ここまでは初見の問題を解く想定で準備をしていましたが、本番の問題はまさかのprivate-isuでした。private-isuは既に4回解いていたので、さくさく計測と改善を回して、15:30頃には目立ったボトルネックを潰すことができていました。

15:30頃のスコアボード

最後はhtmlファイルのExecuteが一番のボトルネックとなっていたのでhtmlを全てバイト列で持ち、レスポンスに直接書き込むという黒魔術を使って50万点越えを達成しました。

github.com

最後に

このような素晴らしい研修を開催していただいたCTO協会とその関係者の皆様、本当にありがとうございました。

私は弊社で初めての新卒エンジニアとして入社し、同じ立場で話せる同期エンジニアがいないことに寂しさを感じていました。そんな時に、松本さんの新卒研修を開催するというXの投稿を目にし、弊社のCTOに参加をお願いして実現に至りました。研修後の懇親会も毎回楽しみで、絶対に良い仲間をたくさん作ろうという気持ちで臨んでいました。

新卒研修終了後も継続的に交流するために勉強会のグループを立ち上げました。月一ペースで各メンバーのオフィスで輪読会を開催する予定です。僕の今の夢は10年経ってもこのメンバーで組織を語り合いながらお酒を飲むことです笑。24卒で参加したい方がいらっしゃればXのDMで是非連絡ください!

最後にプレックスではエンジニアとプロダクトデザイナーを募集しております。ご興味ある方はご連絡していただけると嬉しいです! dev.plex.co.jp

plex.co.jp

*1:「ISUCON」は、LINEヤフー株式会社の商標または登録商標です。

【入社エントリ】フロントエンドからバックエンドへ、さらなる高みを目指すために入社しました!🔥

入社エントリーアイキャッチ画像

はじめまして、プレックスの高岡と申します。

2024年4月に株式会社プレックス(以下、プレックス)にエンジニアとして入社しました。

今回、入社して4ヶ月程経ちましたので、入社経緯や入社してからの感じていることをまとめておきたいと思います。
一人でも多くの方にプレックスを知ってもらえると嬉しいです。

自己紹介

現在25歳で社会人4年目になります。

新卒では、地元の受託開発の会社に就職しました。
大学時代に海外留学をしていて、新卒で就職するつもりはなく2,3年は海外で暮らそうと考えていました。 ただコロナ禍の影響もあり帰国することを決めたので、地元で就活を行い、その会社に新卒入社いたしました。

主に、フロントエンドチームに所属して、Webアプリケーションの開発・運用を行っていました。海外拠点への出張やチームリーダーなどの経験を積むことができ、とても刺激のある会社で働くことができたと実感しています。
約3年間お世話になった後に、プレックスへの転職をする運びになりました。

プレックスではドライバー向けの求人サービスであるプレックスジョブの開発にフロントエンド・バックエンドの両面で携わっています。

プレックスへ入社した理由

エンジニアとして成長できる環境が一番整っていると感じたからです。
それと前職の先輩であり、今は同じチームで働く、師匠かつ飲み友の池川さんよりお誘いいただいたことがきっかけです🙏

まだ経験の浅いバックエンドの開発に携われること、主体的に動ける(良い意味で組織・チームの環境が整いすぎていない)ことなどが成長できる環境と定義していました。
前職でマネジメントを行う中で、エンジニアとしての技術力が足りないことを痛感していましたので、技術力を上げることを最優先にしていました。
幸い、池川さんより社内の様子やエンジニアチームの雰囲気などを聞けて、イメージが湧いていたのがよかったなと思っています。

ただイメージは湧いていたものの、経験が浅いのでついていけるか・ドメイン知識がないが大丈夫かなど不安は多くありました...

入社して感じたところ

そんな不安を抱えつつも、入社してからはあっという間に4ヶ月が経過しました。
転びながらも前に進めているのではと思っています。この機会に改めて振り返りたいと思います 🏃‍♂️

やっぱり求められる基準が高い

長く運用できるプロダクトを開発することを意識して、全メンバーのコードの質技術負債の解消に対する積極性などの基準が高いです。

コードレビューでは一切の妥協なくコードの質をあげるためのフィードバックを常にいただいています。
プロダクトも成長していく中で、データの複雑化やビジネスサイドのオペレーションの変化もあります。なので要件の背景を正確に理解して、なぜそのコードがベストなのかを常に考える必要があります。

技術負債に関しても、開発のサイクル内で継続的に取り組むよう、洗い出しを行なった上で優先度をつけて対応をしています。

上記の基準で常にパフォーマンス高く開発を行っているメンバーが多く在籍しています。
私も上記の基準が当たり前になった時にどのような世界が見えるのか楽しみです!

他のエンジニアチーム・メンバーの色や個性が多様でおもしろい

現在のエンジニア組織は、プレックスジョブ、サクミル、コーポレートの3つの事業部に分かれています。
入社後すぐにそれぞれの事業部に色の違いを感じることができるぐらい、雰囲気の違いがあります。 詳細を書くと長くなりますので、ぜひ入社後に感じていただければと思っています!

個々のメンバーでも強みやこだわりを持っているメンバーが多いです。
例えば、バックエンドが好きでデータベースに関わる技術を極めようとするメンバーやとにかく熱量が高く何でも取り組むメンバーなどがいます。

私自身はフロントエンド出身ということもあり、UIに少しこだわりがあります。 別のメンバーからUI厨と呼ばれるくらいに、使用するアプリやガジェットにこだわっています...笑

ビジネスサイドにも魅力的なメンバーが多い

ビジネスサイドにもパワーあふれるメンバーが多いので、エンジニア同士とはまた違った良い刺激を受けます。

特に毎週金曜日に事業部で自慢大会があるのですが、そこでよく目標売上を達成して拍手喝采が起こるのを目の当たりにしています。 直接的な売上ではないのですが、プロダクトが成長することで売り上げにつながるので、私も貢献するぞという気持ちでいっぱいです。

それと他のメンバーのブログでも記載がある通り、事業の売り上げが右肩上がりで、従業員数もすごい勢いで増えています。
事業が伸びているからこそ、良いメンバーが集まり、良い結果を出せるという好循環が生まれています。

最後に

バックエンドの経験がまだ少なく学ぶことがたくさんあり、パフォーマンスが良くないと落ち込む日も少なくないです。
ただ着実に前へ進んでいる実感があるので、すぐに立ち上がり走り出すことができています。
現在は周りのメンバーに支えられていることが多いですが、これからは自分自身がチームの基準を上げていきたいと思っています 💪

最後にはなりますが、現在プレックスではソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーの募集もあります。
もしこの記事を読んで、一緒に熱く働いてみたいと思った方がいましたら是非ご連絡をお待ちしています!

dev.plex.co.jp

次回予告

先月 Reactの公式ドキュメントを読み込む会を朝活のテーマとして行なっていました 📚
そちらでキャッチアップした内容を活かして、「Reactにおけるパフォーマンスの改善」をテーマに既存コードをリファクタリングしてみる内容のブログを公開予定です。

もちろんバックエンドの開発も楽しいのですが、やはりフロントエンドがもっと好きだということに最近気づきました!
ぜひ次回もお楽しみにいただければ 🤲