Ohm

Ohmは、Redis専用のオブジェクトマッピングライブラリです。シンプルで美しいAPIによりRedisデータ構造をRubyオブジェクトとして扱えるようにし、NoSQLアプローチに最適化されています。Redisの高速性とRubyの表現力を組み合わせることで、スケーラブルなアプリケーション開発を可能にします。

NoSQLRubyRedisODM軽量シンプルキーバリュー

ライブラリ

Ohm

概要

Ohmは、Redis専用のオブジェクトマッピングライブラリです。シンプルで美しいAPIによりRedisデータ構造をRubyオブジェクトとして扱えるようにし、NoSQLアプローチに最適化されています。Redisの高速性とRubyの表現力を組み合わせることで、スケーラブルなアプリケーション開発を可能にします。

詳細

Ohm 2025年版は、Redis特化アプリケーションでの利用が継続しています。キャッシュ層、セッション管理、リアルタイム機能でRedisを活用するプロジェクトで採用されており、特にパフォーマンスが重要なWebアプリケーションで威力を発揮します。Active Recordのような複雑さを避け、Redisのデータ型(String、Hash、Set、List、Sorted Set)を直接活用する設計により、シンプルで高速な永続化層を実現しています。

主な特徴

  • Redis専用設計: Redisの全機能を活用
  • シンプルなAPI: 直感的で学習しやすい
  • 高速パフォーマンス: Redisのインメモリ性能を直接活用
  • インデックス機能: 効率的な検索をサポート
  • バリデーション: 組み込みのデータ検証機能
  • コレクション操作: Set操作によるリレーション管理

メリット・デメリット

メリット

  • 極めて高速な読み書き性能
  • シンプルで理解しやすいコード
  • Redisの機能を最大限活用
  • メモリ効率的なデータ構造
  • リアルタイムアプリケーションに最適
  • 軽量でオーバーヘッドが少ない

デメリット

  • Redis限定で他のデータベースは使用不可
  • 複雑なクエリやJOINが困難
  • メモリ制約によるデータ容量の制限
  • トランザクション機能が限定的
  • マイグレーション管理ツールの不足
  • 大規模データセットには不向き

参考ページ

書き方の例

基本セットアップ

# Gemfile
gem 'ohm'
gem 'redis'

# 初期設定
require 'ohm'

# Redis接続設定
Ohm.redis = Redic.new("redis://localhost:6379")

モデル定義

# models/user.rb
class User < Ohm::Model
  attribute :name
  attribute :email
  attribute :age
  attribute :created_at
  
  # インデックス定義
  index :email
  index :age
  
  # ユニーク制約
  unique :email
  
  # バリデーション
  def validate
    assert_present :name
    assert_present :email
    assert_email :email
    assert_numeric :age if age
  end
  
  # カスタムバリデーション
  def assert_email(attr)
    assert_format attr, /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  end
  
  # コレクション(1対多)
  collection :posts, :Post
  
  # 参照(多対1)
  reference :company, :Company
  
  # セット(多対多)
  set :roles, :Role
  
  # フック
  def before_create
    self.created_at = Time.now.to_i
  end
  
  # カスタムメソッド
  def adult?
    age.to_i >= 18
  end
  
  def posts_count
    posts.size
  end
end

# models/post.rb
class Post < Ohm::Model
  attribute :title
  attribute :content
  attribute :published
  attribute :view_count
  attribute :created_at
  
  index :published
  index :created_at
  
  # 参照
  reference :author, :User
  
  # コレクション
  collection :comments, :Comment
  
  # カウンター操作
  counter :view_count
  
  def validate
    assert_present :title
    assert_present :content
    assert_present :author_id
  end
  
  def publish!
    update(published: "true")
  end
  
  def unpublish!
    update(published: "false")
  end
  
  def published?
    published == "true"
  end
end

# models/comment.rb
class Comment < Ohm::Model
  attribute :content
  attribute :created_at
  
  reference :post, :Post
  reference :user, :User
  
  def validate
    assert_present :content
    assert_present :post_id
    assert_present :user_id
  end
end

# models/role.rb
class Role < Ohm::Model
  attribute :name
  
  unique :name
  set :users, :User
  
  def validate
    assert_present :name
  end
end

# models/company.rb
class Company < Ohm::Model
  attribute :name
  attribute :industry
  
  index :industry
  collection :employees, :User
  
  def validate
    assert_present :name
  end
end

基本的なCRUD操作

# CREATE - 新規作成
user = User.create(
  name: "田中太郎",
  email: "[email protected]",
  age: 30
)

# バリデーションエラーのハンドリング
user = User.new(name: "", email: "invalid")
if user.valid?
  user.save
else
  puts user.errors  # バリデーションエラーを表示
end

# READ - 読み取り
# IDで検索
user = User[1]

# 属性で検索
user = User.find(email: "[email protected]").first

# インデックスを使った検索
adults = User.find(age: 18..99)
young_users = User.find(age: 18..25)

# 全件取得
all_users = User.all

# UPDATE - 更新
user = User[1]
user.update(name: "田中次郎", age: 31)

# 部分更新
user.name = "田中三郎"
user.save

# DELETE - 削除
user = User[1]
user.delete

# 条件付き削除
User.find(age: 0..17).each(&:delete)

高度な検索とフィルタリング

# 複数条件での検索
# Ohmは交差(intersection)を使用
active_adults = User.find(age: 18..99).find(active: "true")

# ソートと制限
recent_posts = Post.find(published: "true").sort_by(:created_at, order: "DESC", limit: [0, 10])

# カスタム検索メソッド
class User < Ohm::Model
  # ... 既存の定義 ...
  
  def self.adults
    find(age: 18..Float::INFINITY)
  end
  
  def self.by_email_domain(domain)
    all.select { |user| user.email.end_with?("@#{domain}") }
  end
  
  def self.with_posts
    all.select { |user| user.posts.size > 0 }
  end
  
  def self.recent(days = 7)
    threshold = Time.now.to_i - (days * 24 * 60 * 60)
    all.select { |user| user.created_at.to_i > threshold }
  end
end

# 使用例
adults = User.adults
gmail_users = User.by_email_domain("gmail.com")
active_bloggers = User.with_posts
new_users = User.recent(30)

リレーション操作

# 1対多の関係
user = User.create(name: "著者", email: "[email protected]")

# 投稿を作成
post = Post.create(
  title: "Ohmの使い方",
  content: "Redis ORMの基本...",
  author: user
)

# ユーザーの投稿を取得
user_posts = user.posts
puts "投稿数: #{user.posts_count}"

# 投稿の著者を取得
author = post.author
puts "著者: #{author.name}"

# 多対多の関係
admin_role = Role.create(name: "admin")
editor_role = Role.create(name: "editor")

# ロールをユーザーに追加
user.roles.add(admin_role)
user.roles.add(editor_role)

# ユーザーのロールを確認
user.roles.each do |role|
  puts "Role: #{role.name}"
end

# ロールを持つユーザーを検索
admin_users = admin_role.users

# 会社と従業員の関係
company = Company.create(name: "Tech Corp", industry: "IT")
user.company = company
user.save

# 会社の従業員を取得
employees = company.employees

トランザクションとアトミック操作

# Redisのマルチ/エグゼキュート
class TransferService
  def self.transfer_posts(from_user_id, to_user_id)
    from_user = User[from_user_id]
    to_user = User[to_user_id]
    
    return false unless from_user && to_user
    
    # Redisトランザクション
    Ohm.redis.queue("MULTI")
    
    from_user.posts.each do |post|
      # 投稿の著者を変更
      Ohm.redis.queue("HSET", post.key, "author_id", to_user_id)
    end
    
    Ohm.redis.queue("EXEC")
    Ohm.redis.commit
    
    true
  rescue => e
    Ohm.redis.queue("DISCARD")
    Ohm.redis.commit
    false
  end
end

# カウンターのアトミック操作
post = Post[1]
post.incr(:view_count)  # ビューカウントを1増やす
post.incr(:view_count, 10)  # ビューカウントを10増やす
post.decr(:view_count)  # ビューカウントを1減らす

実用例

# Sinatraアプリケーションでの使用例
require 'sinatra'
require 'json'
require_relative 'models/user'
require_relative 'models/post'

class BlogAPI < Sinatra::Base
  # ユーザー一覧
  get '/users' do
    users = User.all.map do |user|
      {
        id: user.id,
        name: user.name,
        email: user.email,
        age: user.age,
        posts_count: user.posts_count
      }
    end
    
    content_type :json
    users.to_json
  end
  
  # ユーザー詳細
  get '/users/:id' do
    user = User[params[:id]]
    halt 404, { error: 'User not found' }.to_json unless user
    
    content_type :json
    {
      id: user.id,
      name: user.name,
      email: user.email,
      age: user.age,
      posts: user.posts.map { |p| { id: p.id, title: p.title } }
    }.to_json
  end
  
  # ユーザー作成
  post '/users' do
    data = JSON.parse(request.body.read)
    
    user = User.new(
      name: data['name'],
      email: data['email'],
      age: data['age']
    )
    
    if user.save
      status 201
      { id: user.id, name: user.name, email: user.email }.to_json
    else
      status 422
      { errors: user.errors }.to_json
    end
  end
  
  # 投稿一覧
  get '/posts' do
    posts = Post.find(published: "true")
      .sort_by(:created_at, order: "DESC", limit: [0, 20])
      .map do |post|
        {
          id: post.id,
          title: post.title,
          content: post.content,
          author: post.author.name,
          view_count: post.view_count,
          created_at: Time.at(post.created_at.to_i).iso8601
        }
      end
    
    content_type :json
    posts.to_json
  end
  
  # 投稿作成
  post '/posts' do
    data = JSON.parse(request.body.read)
    author = User[data['author_id']]
    
    halt 404, { error: 'Author not found' }.to_json unless author
    
    post = Post.new(
      title: data['title'],
      content: data['content'],
      author: author,
      published: data['published'] || "false"
    )
    
    if post.save
      status 201
      { id: post.id, title: post.title }.to_json
    else
      status 422
      { errors: post.errors }.to_json
    end
  end
  
  # ビューカウント増加
  post '/posts/:id/view' do
    post = Post[params[:id]]
    halt 404 unless post
    
    post.incr(:view_count)
    { view_count: post.view_count }.to_json
  end
end

# キャッシュサービス
class CacheService
  CACHE_TTL = 3600  # 1時間
  
  def self.cache_key(type, id)
    "cache:#{type}:#{id}"
  end
  
  def self.get_user_data(user_id)
    key = cache_key("user", user_id)
    cached = Ohm.redis.call("GET", key)
    
    if cached
      JSON.parse(cached)
    else
      user = User[user_id]
      return nil unless user
      
      data = {
        id: user.id,
        name: user.name,
        email: user.email,
        posts_count: user.posts_count
      }
      
      Ohm.redis.call("SETEX", key, CACHE_TTL, data.to_json)
      data
    end
  end
  
  def self.invalidate_user(user_id)
    Ohm.redis.call("DEL", cache_key("user", user_id))
  end
end

# リアルタイム統計
class StatsService
  def self.increment_page_view(page)
    key = "stats:pageviews:#{Date.today}"
    Ohm.redis.call("HINCRBY", key, page, 1)
    Ohm.redis.call("EXPIRE", key, 7 * 24 * 3600)  # 7日間保持
  end
  
  def self.get_popular_pages(date = Date.today)
    key = "stats:pageviews:#{date}"
    views = Ohm.redis.call("HGETALL", key)
    
    Hash[*views]
      .map { |page, count| { page: page, views: count.to_i } }
      .sort_by { |item| -item[:views] }
      .first(10)
  end
  
  def self.active_users_count
    # 最近5分間のアクティブユーザー数
    cutoff = Time.now.to_i - 300
    Ohm.redis.call("ZCOUNT", "active_users", cutoff, "+inf").to_i
  end
  
  def self.track_user_activity(user_id)
    Ohm.redis.call("ZADD", "active_users", Time.now.to_i, user_id)
    # 古いエントリを削除
    cutoff = Time.now.to_i - 3600
    Ohm.redis.call("ZREMRANGEBYSCORE", "active_users", "-inf", cutoff)
  end
end