🎬

UUIDを短くするライブラリを作った

に公開
8

はじめに

株式会社YOSHINANIに外部技術顧問として参加している、株式会社INFLUのNakano as a Serviceです。

WebサービスやAPIでよく使われるUUIDは、一意性が高い反面、36文字と長くてURLに埋め込むと見た目が悪くなりがちです。

例えば、

https://example.com/resource/f4b247fd-1f87-45d4-aa06-1c6fc0a8dfaf

のようなURLは冗長で、コピーや手入力もしづらいです。

そこで、UUIDをBase58(Bitcoinでも使われる、視認性が高くURLセーフな文字セット)でエンコードすると、

https://example.com/resource/XDY9dmBbcMBXqcRvYw8xJ2

のように、22文字の短くてスッキリしたIDに変換できます。

これを簡単に行うためのライブラリ「uuid58」を作成しました。

https://github.com/nakanoasaservice/uuid58

https://www.npmjs.com/package/@nakanoaas/uuid58

追記: また、これを簡単に試すためのウェブサイトも作成しました!

https://nakanoasaservice.github.io/uuid58-playground/

本記事ではuuid58を作った背景や使い方、特徴を紹介します。

動機

データベースの主キーにUUIDを使うことは多いですが、UUIDは36文字(ハイフン込み)と長く、URLに埋め込むと見た目も悪くなりがちです。
「短くしたい!」と思ってBase64エンコードを考える人も多いですが、Base64は/+=などURLで使いにくい文字が含まれてしまいます。
そこで、Bitcoinでも使われているBase58(視認性の悪い文字を除外したアルファベット)でエンコードすれば、

  • 22文字に短縮できる
  • URLセーフ
  • 見た目もスッキリ
    • 0OIlのような区別しづらい文字が除外されている
    • ダブルクリックで選択可能

という理想的なIDが作れます。

既存のUUID→Base58変換ライブラリを探してみたのですが、

  • Node.jsのcryptoに依存していてWeb標準で動かない
  • パフォーマンスに無駄がある
  • エラー処理が甘い(不正な文字列でも不完全にエンコード・デコードできてしまう)

など、なかなか「これだ!」というものが見つかりませんでした。
「じゃあ自分で作ろう!」と思い立ち、このuuid58ライブラリを開発しました。

使い方

uuid58は、UUIDのエンコード・デコード・生成を簡単に行うことができます。

import { uuid58, uuid58Decode, uuid58Encode } from "@nakanoaas/uuid58";

// 既存のUUIDをBase58に変換
const encoded = uuid58Encode("f4b247fd-1f87-45d4-aa06-1c6fc0a8dfaf");
// => "XDY9dmBbcMBXqcRvYw8xJ2"

// Base58からUUIDにデコード
const decoded = uuid58Decode("XDY9dmBbcMBXqcRvYw8xJ2");
// => "f4b247fd-1f87-45d4-aa06-1c6fc0a8dfaf"

// 新しいBase58エンコードUUIDを生成
const id = uuid58();
// 例: "XDY9dmBbcMBXqcRvYw8xJ2"

また、エラーを返すSafe系APIも用意されています。

import {
  Uuid58DecodeError,
  uuid58DecodeSafe,
  Uuid58EncodeError,
  uuid58EncodeSafe,
} from "@nakanoaas/uuid58";

const encodedSafe = uuid58EncodeSafe("invalid-uuid");
if (encodedSafe instanceof Uuid58EncodeError) {
  // エラー処理
  return;
}
// 以降encodedSafeはstring

const decodedSafe = uuid58DecodeSafe("invalid-base58");
if (decodedSafe instanceof Uuid58DecodeError) {
  // エラー処理
  return;
}
// 以降decodedSafeはstring

uuid58の特徴

  • Web標準のcrypto.getRandomValues()に依存
    Deno/Node.js/ブラウザなど、どこでも動きます。

  • 不正な入力にはしっかりエラーを返す
    Base58以外の文字・Base58デコード結果が128bitを超えてしまうなどの不正な入力はしっかりエラーになるようにしています。

  • Result型っぽいAPIも用意
    例外を投げるAPI(uuid58Encode/uuid58Decode)に加え、
    エラーオブジェクトを返すSafe系API(uuid58EncodeSafe/uuid58DecodeSafe)も用意しています。

    例外を投げるAPIだと

    let encoded;
    try {
      encoded = uuid58Encode(...);
    } catch (e) {
      // エラー処理
    }
    

    のようにletで変数を宣言しないといけませんが、
    Safe系APIなら

    const encoded = uuid58EncodeSafe(...);
    if (encoded instanceof Uuid58EncodeError) {
      // エラー処理
      return;
    }
    // encodedはstring型
    

    のように、constでスッキリ書けます。

    またエラーの可能性があることをTypeScriptのユニオン型で明示的に表現するため、結果がエラー型でないことを確かめなければ、結果を文字列型として扱うことができません。なので利用者にエラー処理を強制できるというメリットもあります。

  • 細かいパフォーマンスチューニング
    変換処理をできるだけ高速に、かつ安全に実装しています。

  • ファイル分割でtree-shakingしやすい
    encode/decode/uuid生成など用途ごとにファイルを分割し、必要な機能だけimportできます。

  • Denoで実装し、JSRにpublish
    Denoのエコシステムに乗せつつ、
    dntでnpm向けデュアルパッケージもビルドしてnpmにもpublishしています。

  • AIでTSDocやREADMEを英語で自動生成
    ドキュメント整備もAIの力で効率化しました。

データベース保存時の注意

uuid58でエンコードしたIDはURLや外部公開用には非常に便利ですが、データベースに保存する際は注意が必要です
多くのRDBではUUID型カラムがサポートされており、その場合はBase58文字列ではなく、UUID型として保存することを推奨します
Base58文字列で保存すると、22文字のテキスト(8bit x 22 = 176bit)となり、128bitのバイナリで保存されるUUID型よりも無駄にストレージを消費し、検索性能なども劣化します。

  • 外部公開やURL用:Base58文字列
  • DB保存:UUID型(バイナリ/UUIDカラム)

必要に応じてアプリケーション側で相互変換してください。

感想

AIの力を借りて、思ったよりも簡単に実装・ドキュメント整備までできました。
UUIDを短く・URLフレンドリーにしたい方、ぜひuuid58を使ってみてください!

質問・要望などあればIssueやX(@nakanoaas)までお気軽にどうぞ!

Base58 vs Base64URL

「UUIDを短くするなら、Base64URLの方が良いのでは?」という意見もよく聞かれます。確かにBase64URLもBase64の+/-_に置き換えることでURLセーフになり、有力な選択肢です。

両者を比較検討した結果、以下の理由からBase58を選択しました:

  1. 最大文字数の比較:
    128ビット(16バイト)のUUIDをエンコードする場合、どちらも最大22文字になります。

    • Base64URL: 16バイト × 8ビット = 128ビット 128ビット ÷ 6ビット/文字 =
      21.33... → 22文字

    • Base58: log₅₈(2¹²⁸) = 21.850... → 22文字

    もちろん最大長の発生確率はBase64URLの方が少ないですが、固定長にするとなるとどちらも22文字にせざるを得なくなるため差は無くなります。

  2. パフォーマンス:
    Base64URLはBase58と違い128bitのUUIDから6bitづつ取り出してエンコードすればよいためパフォーマンス的に有利なはずです。ですが実際にdenoで両方実装して比較したところ、エンコード・デコード速度に大きな差はありませんでした。

    これはJSにデータからN bit取り出すようなAPIが存在しないためです。メモリをより直接的に扱えるCやRustなどで実装する場合は優位に差が出るはずです。

  3. 視認性:
    Base58は数字の0、大文字のO、大文字のI、小文字のlなど、視認性の低い文字を除外しているため、人間が識別しやすいという利点があります。

バージョン1.0.0での仕様変更について

重要なお知らせ:v1.0.0でエンコード形式が「最大22文字の可変長」から「22文字固定長」に変更されました。

バージョン1.0.0より前のuuid58では、エンコード結果が最大22文字の可変長となっていましたが、
1.0.0以降は常に22文字の固定長となるよう仕様を変更しています。

これは「パディングの長さが違うエンコード文字列も同じUUIDとしてデコードできてしまい、1対1対応が崩れる」ことを回避するためです。

可変長にすることで削減できるデータサイズは0.1%程度とごくわずかであり、それよりも「UUID⇔Base58の1対1対応」「常に22文字で扱いやすい」ことを重視しました。詳しい削減率の比較は以下のスクラップを参照してください。

https://zenn.dev/naas/scraps/0b48b953c39a99

旧バージョンをお使いの方への注意

v1.0.0より前のバージョンでエンコードしたIDが22文字より短い場合、エンコードできません。

既にご活用されている方はお手数ですが移行をお願いします🙏

続きの記事を書きました!

uuid58を簡単に試すことのできるWebページを次世代のWebフレームワークQwikで作成しました。Qwikの技術的な面白さについてまとめています。

https://zenn.dev/yoshinani_dev/articles/bcb0ad18e75bac

UUID58生成の実装変更について

追記:より幅広い環境での動作を実現するため、UUID58生成部分を変更しました。

当初はcrypto.randomUUID()を使用してUUIDを生成しそれをUUID58形式に変換していましたが、この関数は比較的新しいWebAPIのため、古いブラウザやNode.jsバージョンで動作しない可能性がありました。

そこで、より広くサポートされているcrypto.getRandomValues()を使用して128bitのランダムな数値からUUID58を直接生成するように変更しました。これにより、以下のような環境でも安心してご利用いただけます:

  • 古いバージョンのNode.js
  • 古いブラウザ
  • より多くのJavaScript実行環境

この変更により機能や性能に変更はありませんが、対応環境がより広くなったため、レガシー環境でもuuid58をお使いいただけます。

YOSHINANI

Discussion

L4PhL4Ph

NanoIDと比較したときのメリットが気になるのですが、どうなのでしょうか?

Nakano as a ServiceNakano as a Service

NanoIDは新しくIDを生成することはできますが、既に存在するUUIDを短い文字列に変換する・元のUUIDに戻す機能はありません。
なので既にDBにUUIDのレコードが存在する・DBのIDはUUIDでないといけないと要件で決まっている時に使えるというメリットがあります。

またDBに保存する時を考えると、UUID型がサポートされているDBはUUIDをバイナリで保存するため、1文字を8bitで保存する文字列よりもサイズを小さくできます。サイズを比較すると次のような感じです。

  • UUIDの文字列: 36文字 x 8bit = 288bit
  • UUIDのバイナリ形式: 128bit
  • UUIDをuuid58でエンコードした文字列: 22文字 x 8bit = 176bit
  • NanoIDのデフォルト設定で生成した文字列: 21文字 x 8bit = 168bit

なのでUUIDをバイナリで保存できるならそれがDBにとって一番良く、その上でDBから取得したUUIDをuuid58で変換してURLなどに表示すればサイズと見た目の両方のメリットを享受できます。
一方で文字列で保存するしか無いならNanoIDがわずかに(5%ほど)良いです。