SmartHR Tech Blog

SmartHR 開発者ブログ

RubyKaigi2022スケジュールアプリ、フロントをNext.jsに移行してみてわかったこと

こんにちは、開発者のkinoppydです。こんにちは。

SmartHRでは、去年から引き続きRubyKaigiにスケジュールアプリを提供しています。事前にRubyKaigiスケジュールから「拙者のセットリスト」を作成してもらい、SNSで他の参加者とシェアして楽しんでもらうことを目的にしています。

去年のソースコードを利用しつつ、今年は新しいチャレンジとしてフロントの環境をNext.jsに移行してみようと考えました。去年の時点で、フロントはほぼReactで書かれており、helperという名の実質APIも書かれていたので、そんなに大きな手間にはならないだろうと思いましたが、とはいえ色々と起こったのでその様子をお伝えしたいと思います。

tech.smarthr.jp

どうしてフロントをNext.jsに?

前述のとおり、すでにフロントのコードはほぼReactだったことと、あとは単純にやってみたかったからです。Reactを使うフレームワークをいくつか眺めてみて、Next.jsが最もStatic Exportに強そうだったということが決め手でした。なぜStatic Exportを重視したかというと、インフラを必要以上に複雑にしたくないという理由で、BFFを設けずにRailsからの静的ファイル配信のみで完結したかったという理由があります。

フロントをNext.jsにしたことで何が起こったの?

良かったこと

フロントとバックのコードが分離できた

開発を始めた当時はRails6の頃でWebpackerを使用していたため、WebpackerをRailsから完全にアンインストールした上で新しくnextコマンドを使って管理できるようになったのは、便利になったかなと感じました。もともとほぼReactで書かれていたという事もあって、全体的にスッキリしたし、Rails側のコードもAPIとして振る舞うことに専念できて、とても良かったと思っています。

UIとしての体験が向上した

2021年版では、当初の設計ミスによってデータの変更を行うリクエストがページ遷移を挟んだりFetchAPIで処理されたりと、ユーザーから見たときにアプリケーションの挙動に統一感がない箇所がありました。Next.js化することにより、ページ内での更新系リクエストを全てFetchAPIに寄せることができ、結果としてユーザーの体験の向上に繋がりました。

Rails側でViewを制御していると、うまく作らないとどうしてもページ全体の情報を更新したいときなどにリロードなどの作業を挟むしかなくなってしまうのですが、APIを提供するだけになるとレンダリングや情報更新の難しさから開放されます。Reactはそれはそれで別の意味で難しいですが、Railsと同時に悩むよりはマシかなと思います。

情報をhelperではなくAPIとして提供することで、情報の構造が改善した

react-railsでReact側にPropsを渡す際のhelperとして、react_helperというものを用意しており、それをほぼそのままAPI化することで今回のNext.js置き換え作戦を低コストで進めようと思っていました。しかし、やはりページごとに特化したhelperが書かれているため、情報の構造として本来嬉しくない形をしているものも多々あり、それらを再度見直した上でデータのあり方を考え直すという事ができました。実際には、RESTFULできれいなAPIというふうには行かなかったですが、Viewの関係で分断されていたhelperや、i18nをサーバーサイドで実施していたために付随していた大量のメタ情報などを全て剥がしきることができました。

タイムゾーンの推定をやりやすくなった

これは単純に、JavaScriptがブラウザの情報を使ってユーザーのタイムゾーンを推定しやすいという話です。day.jsでguessしました。Rails側でi18nやTimeZoneをコントロールしていた頃は、基本的にユーザーからの申告を真に受ける以外の方法が無いので、2021年版では最初は全ユーザーUTCで表示していました。これを、セッション取得時にブラウザから推定したタイムゾーン情報を受け取ることで、ユーザーの実際のタイムゾーン表示に最初から合わせることができるようになりました。Railsで制御していたころも似たことは出来なくはなかったと思いますが、フロントを分離した結果よりやりやすくなったという形です。

あまり良くなかったこと

i18n

RailsをAPIに専念させるために、i18n関連はNext.js側で完結させようと当初考えていました。これは、Rails側でi18nを処理すると、APIのなかにViewの表示を定義する情報が入ってしまうので、必然的にそうなりました。Next.jsには Internationalized Routing という機能があり、パスベースでi18nへの対応を行えます。ユーザーが任意で言語を選択できる余地を残したまま、ユーザーの言語ベースのリダイレクトなどができるので、最近のWebサイトではよく見る造りです。例えば、 https://example.com/ にアクセスすると、ブラウザの言語設定を見た上で https://example.com/enhttps://example.com/ja などにリダイレクトを挟むという仕組みです。

nextjs.org

しかし、開発を始めてからしばらくして、Next.jsの Internationalized Routing はStatic Exportにおいてサポートされていないことに気づきました。

nextjs.org

こうしてみると、たしかにStatic Exportで利用できない機能は、動的要素を含んでいてそりゃそうだなという感じの機能が多くある程度納得感はありますが、一方で Internationalized Routing はStatic Exportでも対応可能なのでは? と思ったのでかなり落ち込みました。

この問題の解決策として、2つの方法がありました。1つは、Internationalized Routingとほぼ同じ機能を自分で書く方法です。

dev.to

blog.utgw.net

かなりコードを記述しなくてはいけませんが、ほぼ Internationalized Routing らしい機能を用意することが出来ます。問題点としては、書くコード量がかなり多くなってしまうという点です。たった3ページしか無いWebアプリに対して、このアプローチはかなりハイカロリーな選択になってしまいます。

もう1つの方法は、next-export-i18nというライブラリを利用する方法です。

github.com

こちらはほぼライブラリ任せで、ユーザーのブラウザ言語を見て表示を動的に切り替える方法を提供してくれます。問題点としては、パスベースの言語切替が出来ないという点です。

最終的には、書くコード量を減らしたいというモチベーションを最大に考えて、next-export-i18nを選択しました。

少しでも動的な要素がある場合は、BFFは使うべき

最初にセクション名で結論を書いていますが、BFFは使えるのであれば使うべきです。Static Export したファイルに対して、動的に変更を加えることはとても難しいですし、その場合は基本的にCSRが必要となるので、速度や体験の観点でBFFを挟んだほうが圧倒的に有利です。今回は、配信インフラとして既存のRails用Heroku以外を用意したくなかったことと、デプロイの手間を考えてStatic Exportを選択しましたが、それらのデメリットを鑑みても今後はStatic ExportではなくBFF + Railsの構成にするべきだと感じました。

さて、動的な要素にはいくつか種類があると思いますが、今回のアプリに関しては「IDごとの個別の予定ページ」と「IDごとの個別の予定ページに動的生成されるOGP用情報」の2種類がありました。

まずIDごとの個別の予定ページですが、これに関しては最初の目論見では問題なく対応できるのではないかと思っていました。ですが、Static Exportをすると、getStaticPathとgetStaticPropsが動かず、まあそりゃ使えないよねということにすぐ気づきました。

nextjs.org

nextjs.org

とはいえ、Static Exportしたテンプレートに対して、Rails側から配信する際にIDの有無を確認し、実際に値を埋めるのにはuseEffectを使いCSRするという方法が使えるので、そこまで大きな問題ではありませんでしたし、本来はそのやり方を考えていたためスイッチも難しくありませんでした。

本当に問題だったのは、IDごとの個別の予定ページに動的生成されるOGP用情報でした。これは当初の計画ではすっかり頭から抜け落ちており、必要になった段階で気づき大変に頭を抱えることになりました。OGPは、予定ページに使われているユーザー登録の文言などによって動的に変わるtitleやdescription、imageなどを使用しているため、Static Exportした時点ではリソースを確定することが出来ませんでした。そのため、Railsで静的ファイルを配信するときに、何かしらのフックによって動的にそれらの情報を差し込まなくてはなりませんでした。

いくつか解決策はあると思いますが、今回は最も汚く原始的な方法で解決を試みました。Static Exportしたファイルに対して、sed<%= @ogp_tags %> のようなERB用テンプレートを挿入することにしました。実際のコードは次のようになっています。

yarn build 
cp out/2022.html app/views/static/root2022.html.erb
cp out/2022/schedules.html app/views/schedules/page.html.erb
cp out/2022/plans/\[id\].html app/views/plans/page.html.erb
cp out/404.html app/views/errors/not_found.html.erb
cp out/500.html app/views/errors/server_error.html.erb
ruby lib/sedlike.rb app/views/plans/page.html.erb \<\/head\> \<%=\ raw\ @ogp_tags\ %\>\<\/head\>

lib/sedlike.rb は、MacとLinuxで挙動が違うsedを吸収するために書いた小さいスクリプトです。

def sed(file, pattern, replacement)
  File.open(file, "r") do |f_in|
    buf = f_in.read
    buf.gsub!(pattern, replacement)
    File.open(file, "w") do |f_out|
      f_out.write(buf)
    end
  end
end

sed(*ARGV)%

これによって、ERBをレンダリングするときにOGP用の情報を差し込むことができるようになりました。しかし、そもそも静的出力したファイルをERBとして再度レンダリングしたり、sedでHTMLを書き換えていたりと、実装としてあまりにも良くないため、自らに対する戒めとしてここに残します。

styled-componentsを使ったUIライブラリとの相性の悪さ

使用しているUIライブラリであるSmartHR UIは、styled-componentsを使ってスタイリングされています。そのため、全体的にstyled-componentsを使うことは避けられないのですが、Next.jsでstyled-componentsを使うには追加で設定が必要です。

nextjs.org

それ自体は問題ないのですが、なぜかSmartHR UIを使用している箇所で、レンダリング時と実行時で違うクラス名が当てられたりしており、コンパイラの設定やbabelでの変換も試みたのですがうまくいきませんでした。これは未だに解決できておらず、時間を掛けて原因を探っていこうと思います。

Next.js + Rails するときのTips

CORS

Next.jsのdevサーバーとRailsサーバーを同時に動かすと、どうしても異なるポートで起動せざるを得ず、devモードで立ち上がっているNext.jsのフロントからRailsのサーバーに対するアクセスがCORS制約で拒否されてしまいます。なので、devモードで開発するときのみ、Next.jsサーバー側にexpressのミドルウェアを噛ませて、APIへの通信をリバースプロキシするようにします。

yarn add -D http-proxy-middleware
const express = require('express');
const next = require('next');
const { createProxyMiddleware } = require('http-proxy-middleware');

const port = parseInt(process.env.PORT, 10) || 4000
const dev = process.env.NODE_ENV !== 'production'
const API_URL = process.env.API_URL || 'http://localhost:3000'

const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare().then(() => {
  const server = express();

  server.use(
    '/2022/api',
    createProxyMiddleware({
      target: API_URL,
      changeOrigin: true
    })
  );

  server.all('*', (req, res) => {
    return handle(req, res)
  });

  server.listen(port, err => {
    if (err) throw err
    console.log(`> Ready on http://localhost:${port}`)
  });
});

その後、devサーバーの起動を yarn dev ではなく、 yarn node server.js とすれば、Railsサーバーへの通信をProxyしてくれるNext.jsのサーバーが立ち上がります。package.jsonの scripts で、 "dev": "next dev" となっている部分を "dev": "node server.js" と書き換えると楽になると思います。

単数形リソース

単数形リソース、つまり /api/me など、リソースIDではなくセッションに依存するリソースでは、BFF側で getStaticProps などで取得する際にセッションがうまく伝播せず、セッションに紐付いた情報を取得できません。結果としてページのレンダリングが期待したものにならないので、注意する必要があります。

railsguides.jp

今回は useEffect 内でセッションに関わるAPI呼び出しを行うように書き換え、BFFを介さないようにしました。ですが、BFF側にcookieを伝播させる方法などもないことはない様なので、単数形リソースのAPIにはそれらの手段を用いるなどしてセッション情報を伝えるようにする必要があります。

Next.jsとは全然関係ない課題

2021年のデータは?

2022年版では、これまで書いてきたようにアプリケーションの構造が大きく変わっているが、DBは全く変わっていません。そのため、2021年のデータと、2022年のデータをどのようにして共存させるかという問題が生まれました。2021年のデータは、2021年のデザインで表示したい。しかし、デザイン類はすでに2022のものに置き換わってしまっている。困りました。

そこで、ひとまず今回は2021年のDBをエクスポートして、Next.jsのStatic Export機能をつかい、静的ファイルとしてすべて書き出すことにしました。移行作業時にまずNext.js化してからデザインを更新していたため、デザインを変更する直前の状態のコミットでNext.jsのgetStaticPath専用のRails側APIを作成し、一時的に全ページのStatic Exportを行い、その結果をpublicディレクトリに配置して、2022のコードに更新するという手段をとりました。あるべき姿としては、複数の年のスケジュールを一つのアプリで配信できるべきだと思うので、今後の課題にしていきます。

We are 仲間を求めている

SmartHRでは、RubyKaigiを盛り上げたい仲間も求めています。RubyKaigi2022の会場にブースもあるので、是非お話をさせてください!

hello-world.smarthr.co.jp