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