最終更新:

Deno KV実践ガイド: グローバル分散キーバリューストアの活用


Deno KVは、Denoに組み込まれたグローバル分散キーバリューストアです。SQLiteベースのローカルストレージと、グローバルに分散されたクラウドストレージの両方をサポートし、低レイテンシーとリアルタイム同期を実現します。

Deno KVの特徴

1. ゼロ設定のデータベース

外部データベースのセットアップ不要で、すぐに使えます。

// Deno KVを開く
const kv = await Deno.openKv()

// データの保存
await kv.set(['users', 'user123'], {
  id: 'user123',
  name: 'Alice',
  email: 'alice@example.com',
  createdAt: new Date().toISOString(),
})

// データの取得
const result = await kv.get(['users', 'user123'])
console.log(result.value) // { id: 'user123', name: 'Alice', ... }

// データの削除
await kv.delete(['users', 'user123'])

2. 階層的なキー構造

配列をキーとして使用し、階層的にデータを整理できます。

const kv = await Deno.openKv()

// ユーザーデータ
await kv.set(['users', 'user123'], {
  id: 'user123',
  name: 'Alice',
})

// ユーザーの投稿
await kv.set(['users', 'user123', 'posts', 'post456'], {
  id: 'post456',
  title: 'Hello Deno KV',
  content: 'This is my first post',
})

// ユーザーの設定
await kv.set(['users', 'user123', 'settings'], {
  theme: 'dark',
  notifications: true,
})

// プレフィックス検索
const posts = kv.list({ prefix: ['users', 'user123', 'posts'] })
for await (const entry of posts) {
  console.log(entry.key, entry.value)
}

3. ACID トランザクション

複数の操作をアトミックに実行できます。

// 銀行口座間の送金(アトミック操作)
const kv = await Deno.openKv()

async function transfer(fromId: string, toId: string, amount: number) {
  const fromKey = ['accounts', fromId]
  const toKey = ['accounts', toId]

  let success = false

  while (!success) {
    // 現在の残高を取得
    const [fromAccount, toAccount] = await kv.getMany([fromKey, toKey])

    if (fromAccount.value === null || toAccount.value === null) {
      throw new Error('Account not found')
    }

    const fromBalance = fromAccount.value.balance
    const toBalance = toAccount.value.balance

    if (fromBalance < amount) {
      throw new Error('Insufficient funds')
    }

    // アトミックトランザクション
    const result = await kv.atomic()
      .check(fromAccount) // バージョンチェック
      .check(toAccount)
      .set(fromKey, { ...fromAccount.value, balance: fromBalance - amount })
      .set(toKey, { ...toAccount.value, balance: toBalance + amount })
      .commit()

    success = result.ok

    // コミット失敗時は自動的にリトライ
  }
}

// 使用例
await kv.set(['accounts', 'alice'], { id: 'alice', balance: 1000 })
await kv.set(['accounts', 'bob'], { id: 'bob', balance: 500 })

await transfer('alice', 'bob', 100)

const alice = await kv.get(['accounts', 'alice'])
console.log(alice.value.balance) // 900

const bob = await kv.get(['accounts', 'bob'])
console.log(bob.value.balance) // 600

実践: ブログシステムの構築

Deno KVを使った実際のアプリケーション例を見ていきます。

データモデル設計

// types.ts
export interface User {
  id: string
  username: string
  email: string
  passwordHash: string
  createdAt: string
}

export interface Post {
  id: string
  authorId: string
  title: string
  content: string
  slug: string
  published: boolean
  createdAt: string
  updatedAt: string
}

export interface Comment {
  id: string
  postId: string
  authorId: string
  content: string
  createdAt: string
}

キー設計

// db.ts
export const keys = {
  // プライマリキー
  user: (userId: string) => ['users', userId],
  post: (postId: string) => ['posts', postId],
  comment: (commentId: string) => ['comments', commentId],

  // セカンダリインデックス
  userByUsername: (username: string) => ['users_by_username', username],
  userByEmail: (email: string) => ['users_by_email', email],
  postBySlug: (slug: string) => ['posts_by_slug', slug],
  postsByAuthor: (authorId: string, postId: string) =>
    ['posts_by_author', authorId, postId],
  commentsByPost: (postId: string, commentId: string) =>
    ['comments_by_post', postId, commentId],

  // カウンター
  postCount: (authorId: string) => ['post_count', authorId],
  commentCount: (postId: string) => ['comment_count', postId],
}

ユーザー管理

// users.ts
import { keys } from './db.ts'
import { User } from './types.ts'
import { hash, compare } from 'bcrypt'

export async function createUser(
  kv: Deno.Kv,
  username: string,
  email: string,
  password: string
): Promise<User> {
  const userId = crypto.randomUUID()

  // ユーザー名とメールの重複チェック
  const [existingUsername, existingEmail] = await kv.getMany([
    keys.userByUsername(username),
    keys.userByEmail(email),
  ])

  if (existingUsername.value !== null) {
    throw new Error('Username already taken')
  }

  if (existingEmail.value !== null) {
    throw new Error('Email already registered')
  }

  const user: User = {
    id: userId,
    username,
    email,
    passwordHash: await hash(password),
    createdAt: new Date().toISOString(),
  }

  // アトミックにユーザーとインデックスを作成
  const result = await kv.atomic()
    .check(existingUsername) // ユーザー名が取得されていないことを確認
    .check(existingEmail) // メールが取得されていないことを確認
    .set(keys.user(userId), user)
    .set(keys.userByUsername(username), userId)
    .set(keys.userByEmail(email), userId)
    .commit()

  if (!result.ok) {
    throw new Error('Failed to create user (race condition)')
  }

  return user
}

export async function getUserByUsername(
  kv: Deno.Kv,
  username: string
): Promise<User | null> {
  // インデックスからユーザーIDを取得
  const userIdEntry = await kv.get<string>(keys.userByUsername(username))

  if (userIdEntry.value === null) {
    return null
  }

  // ユーザーIDから実際のユーザーデータを取得
  const userEntry = await kv.get<User>(keys.user(userIdEntry.value))
  return userEntry.value
}

export async function authenticateUser(
  kv: Deno.Kv,
  username: string,
  password: string
): Promise<User | null> {
  const user = await getUserByUsername(kv, username)

  if (!user) {
    return null
  }

  const isValid = await compare(password, user.passwordHash)
  return isValid ? user : null
}

投稿管理

// posts.ts
import { keys } from './db.ts'
import { Post } from './types.ts'

export async function createPost(
  kv: Deno.Kv,
  authorId: string,
  title: string,
  content: string,
  slug: string
): Promise<Post> {
  const postId = crypto.randomUUID()

  // スラグの重複チェック
  const existingSlug = await kv.get(keys.postBySlug(slug))

  if (existingSlug.value !== null) {
    throw new Error('Slug already exists')
  }

  const post: Post = {
    id: postId,
    authorId,
    title,
    content,
    slug,
    published: false,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  }

  // 投稿とインデックスを作成
  await kv.atomic()
    .check(existingSlug)
    .set(keys.post(postId), post)
    .set(keys.postBySlug(slug), postId)
    .set(keys.postsByAuthor(authorId, postId), postId)
    .sum(keys.postCount(authorId), 1n) // カウンターを1増やす
    .commit()

  return post
}

export async function getPostsByAuthor(
  kv: Deno.Kv,
  authorId: string,
  options?: { limit?: number; reverse?: boolean }
): Promise<Post[]> {
  const posts: Post[] = []

  const entries = kv.list<string>({
    prefix: ['posts_by_author', authorId],
    limit: options?.limit,
    reverse: options?.reverse,
  })

  for await (const entry of entries) {
    const postId = entry.value
    const postEntry = await kv.get<Post>(keys.post(postId))

    if (postEntry.value !== null) {
      posts.push(postEntry.value)
    }
  }

  return posts
}

export async function updatePost(
  kv: Deno.Kv,
  postId: string,
  updates: Partial<Pick<Post, 'title' | 'content' | 'published'>>
): Promise<Post> {
  const postEntry = await kv.get<Post>(keys.post(postId))

  if (postEntry.value === null) {
    throw new Error('Post not found')
  }

  const updatedPost: Post = {
    ...postEntry.value,
    ...updates,
    updatedAt: new Date().toISOString(),
  }

  await kv.atomic()
    .check(postEntry) // バージョンチェック
    .set(keys.post(postId), updatedPost)
    .commit()

  return updatedPost
}

export async function deletePost(
  kv: Deno.Kv,
  postId: string
): Promise<void> {
  const postEntry = await kv.get<Post>(keys.post(postId))

  if (postEntry.value === null) {
    throw new Error('Post not found')
  }

  const post = postEntry.value

  // 関連するコメントも削除
  const comments = kv.list({ prefix: ['comments_by_post', postId] })

  const atomic = kv.atomic()
    .check(postEntry)
    .delete(keys.post(postId))
    .delete(keys.postBySlug(post.slug))
    .delete(keys.postsByAuthor(post.authorId, postId))
    .sum(keys.postCount(post.authorId), -1n)

  for await (const comment of comments) {
    atomic.delete(comment.key)
  }

  await atomic.commit()
}

コメント管理

// comments.ts
import { keys } from './db.ts'
import { Comment } from './types.ts'

export async function createComment(
  kv: Deno.Kv,
  postId: string,
  authorId: string,
  content: string
): Promise<Comment> {
  const commentId = crypto.randomUUID()

  const comment: Comment = {
    id: commentId,
    postId,
    authorId,
    content,
    createdAt: new Date().toISOString(),
  }

  await kv.atomic()
    .set(keys.comment(commentId), comment)
    .set(keys.commentsByPost(postId, commentId), commentId)
    .sum(keys.commentCount(postId), 1n)
    .commit()

  return comment
}

export async function getCommentsByPost(
  kv: Deno.Kv,
  postId: string
): Promise<Comment[]> {
  const comments: Comment[] = []

  const entries = kv.list<string>({
    prefix: ['comments_by_post', postId],
  })

  for await (const entry of entries) {
    const commentId = entry.value
    const commentEntry = await kv.get<Comment>(keys.comment(commentId))

    if (commentEntry.value !== null) {
      comments.push(commentEntry.value)
    }
  }

  return comments
}

リアルタイム機能の実装

Deno KVはwatch()メソッドで変更を監視できます。

// realtime.ts
export async function watchPost(
  kv: Deno.Kv,
  postId: string,
  callback: (post: Post | null) => void
): Promise<void> {
  const stream = kv.watch([keys.post(postId)])

  for await (const entries of stream) {
    const postEntry = entries[0]
    callback(postEntry.value as Post | null)
  }
}

// 使用例
const kv = await Deno.openKv()

watchPost(kv, 'post123', (post) => {
  if (post) {
    console.log('Post updated:', post.title)
  } else {
    console.log('Post deleted')
  }
})

WebSocketと組み合わせてリアルタイム同期を実装できます。

// server.ts
import { serve } from 'https://deno.land/std/http/server.ts'

const kv = await Deno.openKv()

serve((req) => {
  if (req.headers.get('upgrade') !== 'websocket') {
    return new Response('Expected websocket', { status: 400 })
  }

  const { socket, response } = Deno.upgradeWebSocket(req)

  socket.onopen = () => {
    console.log('WebSocket connected')
  }

  socket.onmessage = async (event) => {
    const { type, postId } = JSON.parse(event.data)

    if (type === 'subscribe') {
      // 投稿の変更を監視
      const stream = kv.watch([keys.post(postId)])

      for await (const entries of stream) {
        const post = entries[0].value
        socket.send(JSON.stringify({ type: 'update', post }))
      }
    }
  }

  return response
})

パフォーマンス最適化

1. バッチ処理

// 複数の取得を一度に実行
const [user, post, comments] = await kv.getMany([
  keys.user('user123'),
  keys.post('post456'),
  keys.commentsByPost('post456', 'comment789'),
])

2. ページネーション

export async function getPostsPaginated(
  kv: Deno.Kv,
  authorId: string,
  cursor?: string,
  limit = 10
): Promise<{ posts: Post[]; cursor?: string }> {
  const posts: Post[] = []

  const entries = kv.list<string>({
    prefix: ['posts_by_author', authorId],
    limit: limit + 1, // 次のページがあるかチェック
    cursor,
  })

  for await (const entry of entries) {
    if (posts.length >= limit) {
      // 次のページがある
      return {
        posts,
        cursor: entry.cursor,
      }
    }

    const postId = entry.value
    const postEntry = await kv.get<Post>(keys.post(postId))

    if (postEntry.value !== null) {
      posts.push(postEntry.value)
    }
  }

  return { posts }
}

3. キャッシュ戦略

// TTL付きキャッシュ
export async function getCachedData<T>(
  kv: Deno.Kv,
  key: Deno.KvKey,
  fetchFn: () => Promise<T>,
  ttlMs = 60000
): Promise<T> {
  const cached = await kv.get<{ data: T; expiresAt: number }>(key)

  if (cached.value !== null && cached.value.expiresAt > Date.now()) {
    return cached.value.data
  }

  const data = await fetchFn()

  await kv.set(key, {
    data,
    expiresAt: Date.now() + ttlMs,
  })

  return data
}

Deno Deployでのグローバル分散

Deno Deployにデプロイすると、Deno KVが自動的にグローバル分散されます。

// main.ts
import { serve } from 'https://deno.land/std/http/server.ts'

const kv = await Deno.openKv() // Deno Deployでは自動的にグローバルKV

serve(async (req) => {
  const url = new URL(req.url)

  if (url.pathname === '/api/posts') {
    const posts = await getPostsPaginated(kv, 'author123')
    return Response.json(posts)
  }

  return new Response('Not found', { status: 404 })
})

デプロイ:

$ deno deploy --project=my-blog

特徴:

  • 世界中のエッジロケーションで自動レプリケーション
  • 読み取りは最寄りのリージョンから(低レイテンシー)
  • 書き込みは強い整合性を保証
  • 追加設定不要

まとめ

Deno KVは、シンプルながら強力な分散データベースです。

主な利点:

  • ゼロ設定で使える
  • グローバル分散とリアルタイム同期
  • ACIDトランザクション
  • 型安全なTypeScript統合
  • Deno Deployで自動スケール

適用シーン:

  • エッジアプリケーション
  • リアルタイムアプリ
  • プロトタイプ開発
  • 中小規模のアプリケーション

従来のデータベースと比較して、運用コストとインフラ複雑性を大幅に削減できます。