背景
- あるリソースの一覧をランダム順に返却する API でページネーションを行う必要があった
- 単純に順序をランダム化するだけだとリクエストの度にランダム順に返ってしまう
- 1ページ目をリクエストした時と2ページ目以降をリクエストした時で同じ順番に並んでいてほしい
環境
- Ruby 2.7.3
- Rails 6.1.3.1
- PostgreSQL 12.5-alpine
- React 17.0.2
- TypeScript 3.9.9
設計
- 要素をランダムに並べる時に seed という数値を指定すれば、 seed が同じである限り同じ順番にランダム化してくれることを利用する
- フロント側で順序を維持したい範囲に seed を保持して、リクエストの度にパラメータとして送信する
- サーバー側では seed が同じであれば同じ順番でランダム化された一覧を返すように実装する
サーバー側
実装
- 今回は諸事情により Ruby でランダムに並べ替えていたので、下記のように実装した。
- SQL でランダム化したい場合の実装は後述。
class StaffsController < ApplicationController
def index
page = params[:page].to_i
per_page = params[:per_page].to_i
seed = params[:randomize_seed].to_i
staffs = Staff.all.shuffle(random: Random.new(seed)).slice((page - 1) * per_page, per_page) || []
render json: { staffs: staffs }
end
end
解説
shuffle(random: Random.new(seed))
のところが肝だと思うので解説していく。
- Array#shuffle で配列をランダムに並び替える時に、引数として Random オブジェクトを渡すことができる。
- Random::new で Random オブジェクトを初期化する際に seed として数値を渡すことができる。
-
params[:randomize_seed]
でフロントから受け取った seed を渡すことで順番を維持できる。
フロント側
実装
const StaffListPage: React.FC = () => {
const [staffs, setStaffs] = useState<Staff[]>([]);
const [page, setPage] = useState(1);
const seed = useMemo(() => Math.floor(Math.random() * 100), []);
useEffect(() => {
const fetchStaffs = async () => {
const response = await fetch(`/api/staffs?page=${page}&per_page=10&randomize_seed=${seed}`, {
method: 'GET',
});
const data = await response.json();
setStaffs(data.staffs);
};
}, [setStaffs, page, seed]);
return <StaffList staffs={staffs} onChangePage={setPage} />;
};
解説
- Math.random() と Math.floor() を用いて0から100までのランダムな数値を取得している
- ランダムな数値がレンダリングの度に変わらないように useMemo でキャッシュしている
- ページが切り替わっても同一の randomize_seed がサーバーに送信されるので順番が保持される
余談
- 普通は SQL 側でランダム化・ページネーションを行いたいと思うので、その時の実装も検討。
MySQL
- RAND() が利用できそう。
class StaffsController < ApplicationController
def index
page = params[:page].to_i
per_page = params[:per_page].to_i
seed = params[:randomize_seed].to_i
staffs = Staff.order("RAND(#{seed})").page(page).per(per_page)
render json: { staffs: staffs }
end
end
PostgreSQL
- setseed が利用できそう。
class StaffsController < ApplicationController
def index
page = params[:page].to_i
per_page = params[:per_page].to_i
seed = params[:randomize_seed].to_i
Staff.connection.execute("SELECT setseed(#{seed});")
staffs = Staff.order("RANDOM()").page(page).per(per_page)
render json: { staffs: staffs }
end
end
備考
- 要点を絞って伝えるために本番で動作しているコードの一部を簡略化して掲載したので、実際に実行してみて動かなかったらごめんなさい。