はじめに
不特定多数のユーザがアクセスするWebアプリケーションでは、リソースの連番IDが露出することは好ましくありません。
しかし、UUIDなどはかなり長いので、DBでリソースを管理するテーブルの主キーを決める際に、とりあえずUUIDにしようというのはちょっと尻込みします。
先日、個人開発アプリを作成していた際に、DB設計の時点であまり何も考えずに全てのリソースのIDを連番にしてしまい、URLを決める段になってかなり困りました。
例えば、次のようなURLです。
https://example.com/tasks/253
これでは、タスクのIDが253であることがそのまま分かってしまいます。その直後に新規作成したタスクのIDが254であればいよいよ「アプリ全体でいまタスクは254あるのだな」と推測することは容易です。
いくらきちんと認可処理を実装して他のユーザのリソースを見ることができないようになっていたとしてもイケてないですよね。
とはいえ、そこからDBごとUUIDに変更するのはかなり面倒だし、時間的な余裕もなく、そこまでの手間をかけなければいけない理由もなかったため、なんとかURLだけ素直な連番を露出しないようにできないか調べました。
すると、Sqidsというライブラリが有用そうだとわかりました。
Sqidsを利用すると、例えば253という数値を次のような短い文字列として扱えます。
253
↓
C1MpO1qW
実際にはUUIDのようなランダム値を生成しているわけではなく、数値から「ランダムに見える非連番の文字列」へ決定的に変換しています。
今回は、Java + SpringBootプロジェクトにSqidsを導入し、連番IDを短めの文字列にエンコードしてURLに使う手順をまとめます。
前提
- Java 25
- SpringBoot 4.1.1 (Jackson 3 を使用)
Sqids導入前の構成
シーケンス図で簡易的に示すように、ユーザが画面を操作すると、フロントエンドがバックエンドのREST APIを叩いてリクエストを行い、レスポンスを受け取ったら画面を描画する構成を想定しています。
Sqidsを使わない状態であれば、以下のようにフロントエンド⇔バックエンドおよびバックエンド⇔DBの全てで連番IDを用いてデータをやり取りしており、ユーザには連番IDが丸見えです。
この状態から、ユーザから見える範囲のIDを連番とわからないように隠していきます。
Sqids導入後の構成
今回、Sqidsによるエンコード・デコードはバックエンド側だけで行います。
フロントエンドにSqidsを導入する必要はありません。
また、DBのスキーマは変えずにこれまで通り連番IDを使用します。
tasks
+-----+------------------+
| id | name |
+-----+------------------+
| 253 | Sample Task |
+-----+------------------+
DBの253をSqidsでエンコードした文字列が仮にC1MpO1qWになった場合、フロントエンドからは次のようなURLでAPIへアクセスします。
GET /api/tasks/C1MpO1qW
バックエンドは受け取ったC1MpO1qWをSqidsでデコードして253へ戻し、その値を使用してDBを検索します。
フロントエンドの内部の処理及びバックエンドとフロントエンドの間は、Sqidsでエンコードされた後の文字列だけを用います。バックエンドでエンコード/デコードを行い、バックエンドとDBの間はこれまで通り連番IDを使用します。
これで、DBの設計は変えずしてユーザに見える部分だけを非連番の文字列にすることができます。
Sqids導入のために次のようなpublic_idカラムを追加することはしません。
tasks
+-----+-----------+----------------+
| id | public_id | name |
+-----+-----------+----------------+
| 253 | C1MpO1qW | Sample Task |
+-----+-----------+----------------+
Sqidsは同じ条件設定と同じ数値に対して決定的にIDを生成するため、必要なときにエンコードするものとします。
この記事で触れないこと
- 認証・認可の実装方法とHTTPヘッダの中身
Sqidsは暗号化やセキュリティ対策としては使えないので、ユーザが所有するリソース以外にアクセスできないようにするにはSqidsとは別に認証・認可機能を実装する必要があります。
ただし、この記事では実装している前提とし、意識しないものとします。
導入方法
1. build.gradleに導入
Java版SqidsはMaven Centralから取得できます。
build.gradleのdependenciesに次の依存関係を追加します。
dependencies {
implementation 'org.sqids:sqids:0.1.0'
}
これでJavaから次のクラスを利用できるようになります。
import org.sqids.Sqids;
2. まずは単純にエンコード・デコードしてみる
Sqidsの基本的な使い方は非常に単純です。
import org.sqids.Sqids;
import java.util.List;
public class SqidsExample {
public static void main(String[] args) {
Sqids sqids = Sqids.builder().build();
String encoded = sqids.encode(List.of(253L));
System.out.println(encoded);
List<Long> decoded = sqids.decode(encoded);
System.out.println(decoded.getFirst());
}
}
encode()には数値のリストを渡します。
sqids.encode(List.of(253L));
戻り値はSqidsによって生成された文字列です。
反対にdecode()へSqids文字列を渡すと、元の数値をList<Long>として取得できます。
List<Long> decoded = sqids.decode(encoded);
Sqidsは複数の数値を1つのIDへまとめてエンコードする機能を持っているため、デコード結果も単一のLong
ではなくList<Long>になります。
今回はDBの主キー1つだけを扱うため、常に要素数1のリストとして扱います。
なお、上記のメソッドを実行するとコンソールに以下のように出力されます。
ujG
253
これは、誰が・いつ・どのPCで実行しても同じ結果になるはずです(Sqidsのバージョンが変わらなければ)。
上記メソッド内の記述はSqidsのデフォルト条件のまま実行しています。
Sqidsは乱数を使わず、ただ次の条件に依存して計算しているだけなので、それらの条件が同一なら必ず常に同じ結果を返します。
- alphabet(エンコードに使用する文字とその並び順を定義する文字列)
- minLength(最低文字数)
- blocklist(出力を避ける文字列)
ですから、プロジェクトにとって望ましい文字列を生成させるために、次節で必要な条件設定を行っていきます。
実装方法
1. Sqidsの設定とエンコード・デコード処理をまとめる
まず、Sqidsの設定とエンコード・デコード処理を1つのクラスにまとめます。
今回はSqidsIdCodecというユーティリティクラスを作成します。
package com.example.demo.sqids;
import org.sqids.Sqids;
import java.util.List;
import java.util.Set;
public final class SqidsIdCodec {
private static final String ALPHABET =
"FxnXM1kBN6cuhsAvjW3Co7l2RePyY8DwaU04Tzt9fHQrqSVKdpimLGIJOgb5ZE";
private static final int MIN_LENGTH = 8;
// 今回はSqidsが用意しているデフォルトのblocklistをそのまま使用
private static final Set<String> BLOCK_LIST =
Sqids.Builder.DEFAULT_BLOCK_LIST;
private static final Sqids SQIDS = Sqids.builder()
.alphabet(ALPHABET)
.minLength(MIN_LENGTH)
.blockList(BLOCK_LIST)
.build();
// プライベートコンストラクタ
private SqidsIdCodec() {
}
// 連番ID -> Sqids文字列
public static String encode(long id) {
if (id < 0) {
throw new IllegalArgumentException("ID must be non-negative.");
}
return SQIDS.encode(List.of(id));
}
// Sqids文字列 -> 連番ID
public static long decode(String encodedId) {
List<Long> decoded = SQIDS.decode(encodedId);
// 不正なSqidsが渡され、数値に変換できなかった場合に例外を投げる
if (decoded.size() != 1) {
throw new IllegalArgumentException("Invalid Sqids ID.");
}
return decoded.getFirst();
}
}
Sqidsの設定として、
alphabetminLengthblockList
をこのユーティリティクラスに集約しています。
アプリケーション内からSqidsを直接操作するのではなく、
SqidsIdCodec.encode(id);
SqidsIdCodec.decode(encodedId);
を通して変換するようにします。
今回alphabetは説明を分かりやすくするためJavaファイル内に直接記述していますが、ソースコードをGitHubで公開しているような場合なら、alphabetが知られるのは好ましくないので環境変数として管理してもよいでしょう。alphabetを知られると文字列から連番IDをデコードするのが容易になってしまうので、せっかくSqidsを導入した意味が薄れてしまいます。
ただしalphabetを変更すると同じ数値から生成されるSqidsも変化します。公開済みURLとの互換性を保つため、運用開始後は不用意に変更しないようにします。
blockListについては、今回はSqidsがデフォルトで持っているものをそのまま指定しています。
Java版Sqidsでは、独自のblockListを.blockList()に渡すとそのSetが使用されるため、デフォルトの禁止語を維持したまま独自の禁止語を追加したい場合は、
Sqids.Builder.DEFAULT_BLOCK_LISTと独自のSetを結合してから渡します。
2. API上で使用するTaskIdを作成する
ここから実際のSpring Boot側へ組み込んでいきます。
DBやEntityではこれまで通りlong型の連番IDを使用しますが、Web APIとの境界ではSqidsを表すための
TaskIdを使用することにします。
package com.example.demo.task;
import com.example.demo.sqids.TaskIdDeserializer;
import com.example.demo.sqids.TaskIdSerializer;
import tools.jackson.databind.annotation.JsonDeserialize;
import tools.jackson.databind.annotation.JsonSerialize;
@JsonSerialize(using = TaskIdSerializer.class)
@JsonDeserialize(using = TaskIdDeserializer.class)
public record TaskId(long value) {
}
このクラス自体は内部にlongを1つ持っているだけです。
ただし、JacksonによってJSONへ変換するときにはTaskIdSerializer、JSONからJavaへ変換するときにはTaskIdDeserializerを使用するように指定しています。
これによって、
new TaskId(253L)
というJava上の値をJSONへ返す際には、
"C1MpO1qW"
のようなSqids文字列として扱えるようにします。
3. Jackson Serializerを作成する
まず、Javaの連番IDからSqidsへ変換するSerializerを作成します。
package com.example.demo.sqids;
import com.example.demo.task.TaskId;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ValueSerializer;
public class TaskIdSerializer extends ValueSerializer<TaskId> {
@Override
public void serialize(
TaskId taskId,
JsonGenerator generator,
SerializationContext context
) {
generator.writeString(
SqidsIdCodec.encode(taskId.value())
);
}
}
TaskIdの中では、
253L
という通常の連番IDを保持しています。
それをJSONとして出力するときだけ、
SqidsIdCodec.encode(taskId.value())
によってSqidsへ変換します。
例えばJava上で次のようなレスポンスオブジェクトがあったとします。
new TaskResponse(new TaskId(253L), "Sample Task");
Jacksonによるシリアライズ後は次のようになります。
{
"id": "C1MpO1qW",
"name": "Sample Task"
}
ControllerやServiceはSqidsの文字列を生成する必要がありません。
4. Jackson Deserializerを作成する
逆に、JSONとして受け取ったSqidsを連番IDへ戻すDeserializerも作成します。
package com.example.demo.sqids;
import com.example.demo.task.TaskId;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.ValueDeserializer;
public class TaskIdDeserializer extends ValueDeserializer<TaskId> {
@Override
public TaskId deserialize(
JsonParser parser,
DeserializationContext context
) {
String encodedId = parser.getValueAsString();
try {
return new TaskId(
SqidsIdCodec.decode(encodedId)
);
} catch (IllegalArgumentException e) {
// 不正なSqidsを数値にデコードできなかった際に投げられた例外をキャッチして
// デシリアライズエラーとして送出
throw context.weirdStringException(
encodedId,
TaskId.class,
"Invalid Sqids ID."
);
}
}
}
例えば次のJSONを受け取った場合、
{
"taskId": "C1MpO1qW"
}
DTO側でTaskIdとして定義しておけば、
public record TaskRequest(
TaskId taskId
) {
}
Jacksonによるデシリアライズ後には、
taskId.value()
からDB上の連番IDを取得できます。
C1MpO1qW
↓
253
このようにJSONとJavaオブジェクトの境界でSqidsを変換する処理をJacksonへ寄せることで、Controller自身が「このIDをSqidsへ変換する」「この文字列を連番IDへ戻す」といった処理を持つ必要がなくなります。
一応紹介しましたが、今回の「IDをPathVariableで指定してタスクの詳細を受け取るGETリクエスト」だけのAPIにはTaskIdDeserializerの出番はありません。
また、設計次第ですが、例えば更新用リクエストDTOを作成する場合、IDの指定はPathVariableに任せてリクエストDTOには持たせないケースもあると思います。その場合もデシリアライザーは呼び出されません。
今回は例示したTaskオブジェクトの作りが簡単すぎるせいでそうなりますが、Taskが自身のフィールドで他のリソースを参照する(そのタスクを担当するユーザをユーザIDで持つなど)作りの場合はデシリアライザーも使うので、必要に応じて判断してください。
5. レスポンスDTOで使用する
タスク詳細APIのレスポンスDTOを次のようにします。
package com.example.demo.task;
public record TaskResponse(
TaskId id,
String name
) {
public static TaskResponse from(Task task) {
return new TaskResponse(
new TaskId(task.getId()),
task.getName()
);
}
}
Entity側のIDはこれまで通りlongです。
task.getId()
によって取得した連番IDをTaskIdへ格納しているだけで、この時点ではSqidsへのエンコードはしていません。
実際にHTTPレスポンスとしてJSONへシリアライズされるタイミングで、先ほど作成したTaskIdSerializerが自動的に呼び出されます。
そのためControllerでは、
return TaskResponse.from(task);
とするだけで済みます。
6. PathVariableのコンバータを作成する
ここで1点注意が必要です。
JacksonのSerializer / Deserializerが担当するのはJSONとJavaオブジェクトの変換です。
したがって、
GET /api/tasks/C1MpO1qW
の、
C1MpO1qW
の部分、つまり@PathVariableはJacksonのDeserializerでは変換されません。
@PathVariableについてはSpring MVCの型変換機能を使用します。
Controllerにこの変換処理を書いてもいいのですが、せっかく連番ID⇔Sqidsの変換処理を書かずに済ませているので、極力薄いControllerを保てるようにStringからTaskIdへ変換する
Converterも用意します。
package com.example.demo.sqids;
import com.example.demo.task.TaskId;
import org.springframework.core.convert.converter.Converter;
public class TaskIdPathConverter implements Converter<String, TaskId> {
@Override
public TaskId convert(String source) {
return new TaskId(
SqidsIdCodec.decode(source)
);
}
}
そして、このConverterをSpring MVCへ登録します。
package com.example.demo.config;
import com.example.demo.sqids.TaskIdPathConverter;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new TaskIdPathConverter());
}
}
これによってControllerでは、
@PathVariable
TaskId taskId
と書くだけで、
C1MpO1qW
↓
TaskId(253)
という変換をSpring MVC側で行えるようになります。
なお、String -> LongのConverterを直接登録することも技術的には可能ですが、そうするとSqidsとは無関係なLong型のパラメータにも影響する可能性があります。
そのため今回は、Sqidsで表現されるタスクID専用のTaskId型を用意しています。
7. Controllerを変更する
Sqids導入前のControllerが次のようになっていたとします。
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping("/{taskId}")
public TaskResponse getTask(
@PathVariable long taskId
) {
Task task = taskService.findById(taskId.value());
return TaskResponse.from(task);
}
}
この状態では、
GET /api/tasks/253
というリクエストになり、DB上の連番IDがそのままURLに表れます。
Sqids導入後は次のように変更できます。
@GetMapping("/{taskId}")
public TaskResponse getTask(
@PathVariable TaskId taskId
) {
Task task = taskService.findById(taskId.value());
return TaskResponse.from(task);
}
変更前と比較すると、@PathVariableの型がlongからTaskIdになっただけです。
リクエストは、
GET /api/tasks/C1MpO1qW
となり、Spring MVCのTaskIdPathConverterによって、
C1MpO1qW
↓
253
へ変換されます。
一方、レスポンスについては、
return TaskResponse.from(task);
で作成されたTaskIdをJacksonのTaskIdSerializerが自動的にSqidsへ変換します。
これによりControllerはSqidsそのものをほとんど意識しません。
SqidsIdCodec.decode(taskId);
や、
SqidsIdCodec.encode(task.getId());
といった処理をControllerへ直接書く必要がなくなり、
- URLの値の変換はSpring MVCのConverter
- JSONの変換はJackson Serializer / Deserializer
- Sqidsそのものの設定と変換処理は
SqidsIdCodec
という形で責務を分離できます。
Sqidsに関して承知しておくべきこと
Sqidsは暗号化ではない
ここはSqidsを使用する上で特に重要です。
Sqidsで、
253
↓
C1MpO1qW
のような変換ができても、253という値が暗号化されているわけではありません。
Sqidsはデコード可能な仕組みなので、
SqidsIdCodec.decode("C1MpO1qW");
によって元の数値へ戻せます。
そのため、 「Sqidsにしたから他人のリソースIDを推測されなくなった!」 と考えて認可処理を省略することはできません。
Sqidsが担当するのは、見かけ上IDをわかりにくくする程度のことです。
同じIDからは同じSqidsが生成される
先に述べたようにSqidsは乱数を生成しているわけではありません。
同じ設定で、
SqidsIdCodec.encode(253L);
を何度実行しても、同じ文字列が生成されます。
この性質があるため、Sqids文字列をDBへ保存しておく必要がありません。
DB
253
に対して必要なときに毎回エンコードすれば、同じ公開用IDを取得できます。
逆に言えば、Sqidsは「アクセスするたびに新しいランダムURLを生成する仕組み」ではありません。
設定は途中で不用意に変更しない
前節の裏返しですが、Sqidsでは指定した条件が同じなら、同じIDからは必ず同じSqidsが返ります。反対に、条件を変えると結果が変わってしまいます。
そのため、公開済みURLが存在するサービスでは、alphabetやminLength、blockList
など、エンコード・デコード結果に影響する設定を途中で不用意に変更しない方がよいでしょう。
既存URLとの互換性を維持する必要があるためです。
おわりに
今回Sqidsというものがあって、回避策としてとても助かりました。
既存のDB構造をほとんど変更することなく、
/tasks/253
だったURLを、
/tasks/C1MpO1qW
のような形へ変更できました。
ただ今後はもう少し連番IDの採用は慎重にならなければいけないと痛感する出来事でもありました。
とはいえSqidsは生成する文字列の最低文字数を指定でき、卑語(悪い意味を持つ単語)が自動生成されることを避ける仕組みもあるので、使いようはかなりありそうです。
覚えておきたいと思います。