メインコンテンツにスキップ

2件の投稿件の投稿が「url」タグ付き

すべてのタグを見る

[システムデザインインタビュー] URL短縮サービスをゼロから実装する

· 7分の読み時間
Haril Song
Owner, Software Engineer at 42dot

banner

情報

コードはGitHubで確認できます。

概要

URLの短縮は、もともとメールやSMSでURLが分断されるのを防ぐために始まりました。しかし、現在ではTwitterやInstagramなどのソーシャルメディアプラットフォームで特定のリンクを共有するためにより積極的に使用されています。URLが冗長に見えないことで可読性が向上し、リダイレクト前にユーザーの統計情報を収集するなどの追加機能も提供できます。

この記事では、URL短縮サービスをゼロから実装し、その仕組みを探ります。

URL短縮サービスとは?

まず、結果を見てみましょう。

この記事で実装するURL短縮サービスは、以下のコマンドで直接実行できます。

docker run -d -p 8080:8080 songkg7/url-shortener

使用方法は簡単です。短縮したい長いURLをlongUrlの値として入力します。

curl -X POST --location "http://localhost:8080/api/v1/shorten" \
-H "Content-Type: application/json" \
-d "{
\"longUrl\": \"https://www.google.com/search?q=url+shortener&sourceid=chrome&ie=UTF-8\"
}"
# ランダムな値(例:tN47tML)が返されます。

次に、http://localhost:8080/tN47tMLにアクセスすると、

image

元のURLに正しくリダイレクトされることが確認できます。

短縮前

短縮後

では、どのようにしてURLを短縮するのか見てみましょう。

大まかな設計

URLの短縮

  1. longUrlを保存する前にIDを生成します。
  2. IDをbase62にエンコードしてshortUrlを作成します。
  3. ID、shortUrl、およびlongUrlをデータベースに保存します。

メモリは有限で比較的高価です。RDBはインデックスを通じて迅速にクエリを実行でき、メモリに比べて比較的安価なので、URLの管理にはRDBを使用します。

URLを管理するためには、まずID生成戦略を確保する必要があります。ID生成にはさまざまな方法がありますが、ここでは長くなるため省略します。今回は単純に現在のタイムスタンプを使用してIDを生成します。

Base62変換

ULIDを使用すると、タイムスタンプを含む一意のIDを生成できます。

val id: Long = Ulid.fast().time // 例:3145144998701、プライマリキーとして使用

この数値をbase62に変換すると、次のような文字列になります。

tN47tML

この文字列はshortUrlとしてデータベースに保存されます。

idshortlong
3145144998701tN47tMLhttps://www.google.com/search?q=url+shortener&sourceid=chrome&ie=UTF-8

取得プロセスは次のように進行します:

  1. localhost:8080/tN47tMLにGETリクエストを送信します。
  2. tN47tMLをbase62からデコードします。
  3. プライマリキー3145144998701を取得し、データベースをクエリします。
  4. リクエストをlongUrlにリダイレクトします。

これで大まかな流れを見たので、実装して詳細を掘り下げていきましょう。

実装

前回の記事「Consistent Hashing」と同様に、自分で実装します。幸いなことに、URL短縮サービスの実装はそれほど難しくありません。

モデル

まず、ユーザーからのリクエストを受け取るモデルを実装します。短縮するURLのみを受け取るように構造を簡略化しました。

data class ShortenRequest(
val longUrl: String
)

POSTリクエストを処理するためのコントローラーを実装します。

@PostMapping("/api/v1/shorten")
fun shorten(@RequestBody request: ShortenRequest): ResponseEntity<ShortenResponse> {
val url = urlShortenService.shorten(request.longUrl)
return ResponseEntity.ok(ShortenResponse(url))
}

Base62変換

最後に、最も重要な部分です。IDを生成した後、base62にエンコードして短縮します。この短縮された文字列がshortUrlになります。逆に、shortUrlをデコードしてIDを見つけ、それを使用してデータベースをクエリし、longUrlを取得します。

private const val BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

class Base62Conversion : Conversion {
override fun encode(input: Long): String {
val sb = StringBuilder()
var num = BigInteger.valueOf(input)
while (num > BigInteger.ZERO) {
val remainder = num % BigInteger.valueOf(62)
sb.append(BASE62[remainder.toInt()])
num /= BigInteger.valueOf(62)
}
return sb.reverse().toString()
}

override fun decode(input: String): Long {
var num = BigInteger.ZERO
for (c in input) {
num *= BigInteger.valueOf(62)
num += BigInteger.valueOf(BASE62.indexOf(c).toLong())
}
return num.toLong()

}
}

短縮されたURLの長さはID番号のサイズに反比例します。生成されたID番号が小さいほど、URLを短くすることができます。

短縮されたURLの長さが8文字を超えないようにするには、IDのサイズが62^8を超えないようにする必要があります。したがって、IDの生成方法も重要です。前述のように、この記事では内容を簡略化するためにタイムスタンプ値を使用しました。

テスト

curlを使用してランダムなURLを短縮するためのPOSTリクエストを送信してみましょう。

curl -X POST --location "http://localhost:8080/api/v1/shorten" \
-H "Content-Type: application/json" \
-d "{
\"longUrl\": \"https://www.google.com/search?q=url+shortener&sourceid=chrome&ie=UTF-8\"
}"

http://localhost:8080/{shortUrl}にアクセスして、正しくリダイレクトされることを確認できます。

結論

改善の余地がある点:

  • ID生成戦略をより正確に制御することで、shortUrlをさらに短縮できます。
    • トラフィックが多い場合、同時実行性に関連する問題を考慮する必要があります。
    • Snowflake
  • ホスト部分にDNSを使用することで、URLをさらに短縮できます。
  • 永続化レイヤーにキャッシュを適用することで、応答速度を向上させることができます。

WebFluxでURLパラメータとしてDate型を使用する方法

· 7分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

LocalDateTimeのような時間形式をURLパラメータとして使用する場合、デフォルトの形式と一致しないと、以下のようなエラーメッセージが表示されることがあります。

Exception: Failed to convert value of type 'java.lang.String' to required type 'java.time.LocalDateTime';

特定の形式に変換を許可するためには、どのような設定が必要でしょうか?この記事では、その変換方法を探ります。

内容

まず、簡単なサンプルを作成してみましょう。

public record Event(
String name,
LocalDateTime time
) {
}

これは、イベントの名前と発生時間を含むシンプルなオブジェクトで、recordを使用して作成されています。

@RestController
public class EventController {

@GetMapping("/event")
public Mono<Event> helloEvent(Event event) {
return Mono.just(event);
}

}

ハンドラーは従来のコントローラーモデルを使用して作成されています。

ヒント

Spring WebFluxでは、ルータ関数を使用してリクエストを管理できますが、この記事では@RestControllerを使用することに焦点を当てています。

テストコードを書いてみましょう。

@WebFluxTest
class EventControllerTest {

@Autowired
private WebTestClient webTestClient;

@Test
void helloEvent() {
webTestClient.get().uri("/event?name=Spring&time=2021-08-01T12:00:00")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.name").isEqualTo("Spring")
.jsonPath("$.time").isEqualTo("2021-08-01T12:00:00");
}

}

image1

テストコードを実行すると、以下のリクエストがシミュレートされます。

$ http localhost:8080/event Accept=application/stream+json name==Spring time==2021-08-01T12:00
HTTP/1.1 200 OK
Content-Length: 44
Content-Type: application/stream+json

{
"name": "Spring",
"time": "2021-08-01T12:00:00"
}

リクエストがデフォルトの形式で行われると、正常なレスポンスが返されます。しかし、リクエスト形式が変更された場合はどうでしょうか?

image2

image3

$ http localhost:8080/event Accept=application/stream+json name==Spring time==2021-08-01T12:00:00Z
HTTP/1.1 500 Internal Server Error
Content-Length: 131
Content-Type: application/stream+json

{
"error": "Internal Server Error",
"path": "/event",
"requestId": "ecc1792e-3",
"status": 500,
"timestamp": "2022-11-28T10:04:52.784+00:00"
}

上記のように、特定の形式でレスポンスを受け取るためには追加の設定が必要です。

1. @DateTimeFormat

最も簡単な解決策は、変換したいフィールドにアノテーションを追加することです。変換したい形式を定義することで、希望の形式でリクエストを行うことができます。

public record Event(
String name,

@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
LocalDateTime time
) {
}

再度テストを実行すると、正常に通過することが確認できます。

情報

リクエスト形式を変更しても、レスポンス形式は変更されません。レスポンス形式の変更は@JsonFormatなどのアノテーションを使用して設定できますが、この記事では取り上げません。

これは簡単な解決策ですが、必ずしも最良の方法ではありません。変換が必要なフィールドが多い場合、手動でアノテーションを追加するのは非常に面倒で、アノテーションをうっかり忘れるとバグの原因になります。ArchUnit1のようなテストライブラリを使用してチェックすることも可能ですが、コードの理解に必要な労力が増えます。

2. WebFluxConfigurer

WebFluxConfigurerを実装し、フォーマッタを登録することで、各LocalDateTimeフィールドにアノテーションを追加する必要がなくなります。

Eventから@DateTimeFormatを削除し、以下のように設定を行います。

@Configuration
public class WebFluxConfig implements WebFluxConfigurer {

@Override
public void addFormatters(FormatterRegistry registry) {
DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
registrar.setUseIsoFormat(true);
registrar.registerFormatters(registry);
}
}
危険

@EnableWebFluxを使用すると、マッパーが上書きされ、アプリケーションが意図した通りに動作しなくなる可能性があります。2

再度テストを実行すると、アノテーションなしで正常に通過することが確認できます。

image4

特定のフィールドに異なる形式を適用する

これは簡単です。フィールドに直接@DateTimeFormatを追加する方法が優先されるため、希望するフィールドに@DateTimeFormatを追加することができます。

public record Event(
String name,

LocalDateTime time,

@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH")
LocalDateTime anotherTime
) {
}
    @Test
void helloEvent() {
webTestClient.get().uri("/event?name=Spring&time=2021-08-01T12:00:00Z&anotherTime=2021-08-01T12")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.name").isEqualTo("Spring")
.jsonPath("$.time").isEqualTo("2021-08-01T12:00:00")
.jsonPath("$.anotherTime").isEqualTo("2021-08-01T12:00:00");
}

image5

ヒント

URIが長くなる場合、UriComponentsBuilderを使用するのが良いアプローチです。

String uri = UriComponentsBuilder.fromUriString("/event")
.queryParam("name", "Spring")
.queryParam("time", "2021-08-01T12:00:00Z")
.queryParam("anotherTime", "2021-08-01T12")
.build()
.toUriString();

結論

WebFluxConfigurerを使用することで、グローバルに一貫した形式を適用できます。異なるクラスにまたがる複数のフィールドが特定の形式を必要とする場合、WebFluxConfigurerを使用する方が、各フィールドに@DateTimeFormatを適用するよりもはるかに簡単です。状況に応じて適切な方法を選択してください。

  • @DateTimeFormat: 適用が簡単。グローバル設定よりも優先され、特定のフィールドに異なる形式を適用できます。
  • WebFluxConfigurer: 適用が比較的複雑ですが、一貫した設定が必要な大規模プロジェクトに有利です。@DateTimeFormatと比較して、アノテーションの追加忘れなどの人的エラーを防ぐことができます。
情報

すべてのサンプルコードはGitHubで確認できます。

参考

Footnotes

  1. ArchUnit

  2. LocalDateTime is representing in array format