Ruby/Railsの正規表現パターンマッチング実践
はじめに
個人開発でワイン関連のアプリをつくっています。
ワインのレビューをユーザーが入力するとき、ユーザーが入力する文字列は様々です。「Bordeaux」「ボルドー」「bordeaux」など、表記揺れに対応しつつ、適切な処理を行う必要があります。
この記事では、実際のRails APIコントローラーのコードを例に、正規表現を使った実践的なパターンマッチング手法を紹介します。
基本的なパターンマッチング
シンプルなケース
まずは基本的な例から見ていきましょう。
def generate_description_from_name(wine_name)
name_lower = wine_name.downcase
case name_lower
when /bordeaux/
"威厳"
when /burgundy/
"繊細"
when /champagne/
"華麗"
else
"個性的"
end
end
このコードのポイント:
-
downcase
で小文字に統一し、大文字小文字を気にしない比較を実現 -
case
文と正規表現の組み合わせで読みやすいコードに - パターンマッチには
===
演算子が使われる
複数の表記に対応する
実際のアプリケーションでは、英語表記と日本語表記の両方に対応する必要があります。
case name_lower
when /bordeaux|ボルドー/
"威厳"
when /burgundy|bourgogne|ブルゴーニュ/
"繊細"
when /champagne|シャンパーニュ/
"華麗"
end
ポイント:
-
|
(パイプ)で複数のパターンを指定 - 英語名、フランス語名、日本語名をまとめて対応
より複雑なパターンマッチング
地名と品種の組み合わせ
ワイン名には地名や品種名が含まれることが多いです。より詳細なマッチングを見てみましょう。
case name_lower
when /bordeaux|margaux|medoc|ボルドー|マルゴー/
["威厳", "格調", "堂々", "風格"].sample
when /pinot.?noir|ピノ[・]?ノワール/
["エレガント", "繊細", "上品", "洗練"].sample
when /cabernet|カベルネ[・]?ソーヴィニヨン|カベルネ/
["重厚", "濃厚", "パワフル", "骨格"].sample
end
注目ポイント:
-
.?
- オプショナルな文字/pinot.?noir/ # "pinotnoir", "pinot noir", "pinot-noir"にマッチ
-
[・]?
- 中黒の有無に対応/ピノ[・]?ノワール/ # "ピノノワール"と"ピノ・ノワール"の両方にマッチ
-
複数の候補からランダム選択
["威厳", "格調", "堂々", "風格"].sample
同じワイン産地でも毎回違う表現を返すことで、バリエーションを持たせる
国際化対応のテクニック
日本語の表記揺れに対応
日本語のワイン名には様々な表記揺れがあります。
when /dom.?perignon|ドン[・]?ペリ/
"華麗"
when /gewurztraminer|ゲヴュルツ[・]?トラミネール/
"香り高い"
when /savigny|savigny.les.beaune|サヴィニー[・]?レ[・]?ボーヌ/
"エレガント"
対応している揺れ:
- ドットの有無:
dom.perignon
vsdomperignon
- 中黒の有無:
ドン・ペリ
vsドンペリ
- 省略形:
ドン・ペリ
(フルネームはドン・ペリニヨン
)
フランス語のアクセント記号対応
フランス語のワイン名にはアクセント記号が含まれますが、入力時に省略されることがあります。
when /rose|rosé|ロゼ/
"可憐"
when /cremant|crémant|クレマン/
"軽やか"
when /millesime|millésime|ミレジム/
"ヴィンテージ"
階層的なパターンマッチング
より一般的なパターンは後ろに配置することで、階層的なマッチングを実現できます。
case name_lower
# 具体的な銘柄
when /margaux|マルゴー/
"格調"
# 地域
when /bordeaux|ボルドー/
"威厳"
# ぶどう品種
when /cabernet|カベルネ/
"重厚"
# 色
when /red|赤|rouge|ルージュ/
"深い"
# 国
when /france|フランス/
"エレガント"
# 最も一般的なフォールバック
else
"個性的"
end
マッチングの優先順位:
- 最も具体的な銘柄名(例: シャトー・マルゴー)
- 地域名(例: ボルドー)
- ぶどう品種(例: カベルネ)
- ワインの色(例: 赤ワイン)
- 国名(例: フランス)
- デフォルト
この順序により、「Margaux Rouge」という名前なら、最初のmargaux
パターンにマッチします。
実践的な実装例
実際のコントローラーで使用する完全な例です。
def generate_description_from_name(wine_name)
name_lower = wine_name.downcase
is_generic = false
description = case name_lower
# フランス高級ワイン産地
when /bordeaux|margaux|medoc|ボルドー|マルゴー/
["威厳", "格調", "堂々", "風格"].sample
when /burgundy|bourgogne|ブルゴーニュ/
["繊細", "優雅", "魅惑", "官能"].sample
when /champagne|シャンパーニュ|dom.?perignon|ドン[・]?ペリ/
["華麗", "祝祭", "きらめき", "泡立ち"].sample
# ぶどう品種
when /pinot.?noir|ピノ[・]?ノワール/
["エレガント", "繊細", "上品", "洗練"].sample
when /cabernet|カベルネ[・]?ソーヴィニヨン|カベルネ/
["重厚", "濃厚", "パワフル", "骨格"].sample
when /chardonnay|シャルドネ/
["まろやか", "クリーミー", "豊潤", "バランス"].sample
# ワインの種類
when /sparkling|スパークリング/
["弾ける", "喜び", "躍る", "爽快"].sample
when /red|赤|rouge|ルージュ/
["深い", "芳醇", "コクのある", "温かい"].sample
when /white|白|blanc|ブラン/
["爽やか", "すっきり", "清らか", "軽やか"].sample
# 国
when /france|フランス/
["エレガント", "洗練", "気品", "伝統"].sample
when /italy|italia|イタリア/
["陽気", "情熱", "温かい", "親しみやすい"].sample
# どれにもマッチしない場合
else
is_generic = true
["個性的", "ユニーク", "印象的", "魅力的"].sample
end
[description, is_generic]
end
この実装のメリット:
- 柔軟性: 様々な表記に対応
- 拡張性: 新しいパターンの追加が容易
- 可読性: case文で構造化されており理解しやすい
- 国際化: 複数言語に対応
パフォーマンスの考慮
正規表現のコンパイル
頻繁に使用する正規表現は定数として定義することで、パフォーマンスを改善できます。
class WineDescriptionGenerator
# 正規表現を定数化
BORDEAUX_PATTERN = /bordeaux|margaux|medoc|ボルドー|マルゴー/i
BURGUNDY_PATTERN = /burgundy|bourgogne|ブルゴーニュ/i
CHAMPAGNE_PATTERN = /champagne|シャンパーニュ|dom.?perignon|ドン[・]?ペリ/i
def self.generate(wine_name)
name = wine_name.downcase
case name
when BORDEAUX_PATTERN
["威厳", "格調", "堂々", "風格"].sample
when BURGUNDY_PATTERN
["繊細", "優雅", "魅惑", "官能"].sample
when CHAMPAGNE_PATTERN
["華麗", "祝祭", "きらめき", "泡立ち"].sample
else
"個性的"
end
end
end
ポイント:
- 正規表現リテラルは一度だけコンパイルされる
-
i
フラグで大文字小文字を無視できる(downcase
が不要に)
マッチング順序の最適化
頻繁にマッチするパターンを上に配置することで、平均的なマッチング時間を短縮できます。
# よくマッチするパターンを上に
case name_lower
when /red|赤/ # 最も一般的
"深い"
when /white|白/ # 2番目に一般的
"爽やか"
when /margaux/ # 特定の銘柄(頻度低)
"格調"
end
テストの書き方
正規表現を使ったコードは、エッジケースが多いためテストが重要です。
require 'rails_helper'
RSpec.describe 'WineDescriptionGenerator' do
describe '#generate_description_from_name' do
context 'ボルドーワインの場合' do
it '英語名でマッチする' do
result = generate_description_from_name('Bordeaux Rouge')
expect(result[0]).to be_in(['威厳', '格調', '堂々', '風格'])
end
it '日本語名でマッチする' do
result = generate_description_from_name('ボルドー赤')
expect(result[0]).to be_in(['威厳', '格調', '堂々', '風格'])
end
it '大文字小文字を区別しない' do
result = generate_description_from_name('BORDEAUX')
expect(result[0]).to be_in(['威厳', '格調', '堂々', '風格'])
end
end
context '表記揺れに対応する' do
it '中黒の有無に対応する' do
result1 = generate_description_from_name('ピノノワール')
result2 = generate_description_from_name('ピノ・ノワール')
expect(result1[0]).to be_in(['エレガント', '繊細', '上品', '洗練'])
expect(result2[0]).to be_in(['エレガント', '繊細', '上品', '洗練'])
end
it 'スペースの有無に対応する' do
result1 = generate_description_from_name('pinotnoir')
result2 = generate_description_from_name('pinot noir')
expect(result1[0]).to be_in(['エレガント', '繊細', '上品', '洗練'])
expect(result2[0]).to be_in(['エレガント', '繊細', '上品', '洗練'])
end
end
context 'マッチしない場合' do
it 'デフォルト値を返す' do
result = generate_description_from_name('Unknown Wine')
expect(result[1]).to be true # is_generic
expect(result[0]).to be_in(['個性的', 'ユニーク', '印象的', '魅力的'])
end
end
end
end
よくあるミスと対策
1. 正規表現の順序ミス
# ❌ 悪い例:一般的なパターンが先
case name
when /red/
"赤ワイン"
when /bordeaux red/ # このパターンには到達しない!
"ボルドー赤"
end
# ✅ 良い例:具体的なパターンが先
case name
when /bordeaux red/
"ボルドー赤"
when /red/
"赤ワイン"
end
2. エスケープ漏れ
# ❌ 悪い例:ドットがメタ文字として解釈される
when /dom.perignon/ # "domXperignon"にもマッチしてしまう
# ✅ 良い例:ドットをオプショナルに
when /dom.?perignon/ # "domperignon"と"dom perignon"にマッチ
3. 文字コードの問題
# ✅ ファイルの先頭でエンコーディングを指定
# frozen_string_literal: true
# encoding: utf-8
when /ブルゴーニュ/
"繊細"
まとめ
正規表現を使ったパターンマッチングは、以下のような場合に非常に有効です:
- 表記揺れへの対応: 英語/日本語、大文字/小文字、記号の有無など
- 柔軟な検索: 部分一致や複数パターンの同時マッチング
- 国際化対応: 複数言語の入力に対応
実装時のポイント:
- 具体的なパターンから一般的なパターンへ順序付ける
-
.?
や[・]?
で表記揺れに対応 - 定数化でパフォーマンスを改善
- テストでエッジケースをカバー
この手法は、ワインに限らず、商品名、地名、人名など、表記が統一されていないデータを扱う際に応用できます。
Discussion