開発を担当している松尾です。
花粉飛び回るこの時期に集中力を掻き乱される度合いが年々ひどくなっている気がしてしょうがない昨今ですが、頑張って書いてみます。

イレギュラーなデータベースタスク

弊社では企業間向けサービスを提供しているという性質上、多岐にわたるバックオフィス業務からの要請としてイレギュラーな業務パターンが日々発生します。
全てのイレギュラーパターンに合わせて律儀にシステムを改修していくというのは費用も時間も掛かり過ぎるため、必然的に「過渡的」だったり「小規模」なものについては最小のコストで解決する手法が必要になります。
とはいえ、「人力でメールの大量送信」や「人力でSQLを使ったDB操作」は考えるまでもなく危険極まりないものです。
ここでは私の担当範囲で起こりがちな「システム化するには時間がかかるからすぐには無理」とはいえ「すぐに実施したい」ビジネス要件について、最小のコストと適度な安全性を担保しつつ「ちょっとだけ作って解決する」ことを目指した実践例の解説をしてみます。

rake

rakeとはrubyのビルドツールとして構築されたツールおよびライブラリのことです。

もともとはmakeに代わる高機能なビルドツールとして設計されたという経緯があり、シンプルに依存関係のあるタスクを記述できる使い勝手の良い枠組みが提供されています。

ruby-1.9.1より標準添付されるようになったため、比較的最近のrubyがインストールされている環境であればrakeコマンドが実行できます。

$ rake --version

rake, version 10.0.3

rakeは実行されるとカレントディレクトリ内のRakefileという名称のファイルを読み込みます。
意図的に別のファイル名を指定することも可能ではあるのですが、そんな酔狂な設定を喜ぶ人はいないのでやめておきましょう。

rakeで定義するタスク群を全てRakefile内に記述した場合はrakeタスク定義はひとつのファイルに完結します。
$ cat > Rakefile
task :hello do
puts "Hello"
end
$ rake hello
Hello
上記は"hello"という名前のタスクを定義して, rakeにタスクを指定して実行させるという最小の実行例になります。

Sequel

SequelはJeremy Evans氏によって開発されているrubyのためのデータベースツールキットです。
簡潔かつ多機能なDSLを備えており多くの種類のデータベースに対応しています。
また、SQLを抽象化するためのデータアクセス層とORM層が明解にモジュールで分離されているところが特徴的です。
$ gem install sequel
上記のようにgemを使ってインストールします。
sequel自身はデータベースのドライバを含まないため、rubyからデータベースへ接続するための各ドライバ(MySQLであればmysql2、Postgresqlであればpg、Oracleであればruby-oci8など)は別途インストールされている必要があります。

今回は私が現実に相手をする機会が多いからということと、MySQLやPostgresqlといったオープンソースのデータベースとはひと味ちがう商用データベースで使いこなすという目的から操作対象がOracleデータベースであるという前提になっています。

sequelの機能は多岐に渡るため、あえてここでその機能についての詳細は割愛します。
Cheat Sheetというドキュメントを眺めて貰えればひと通りの感触は掴んでもらえるのではないかと思います。

ファイル構成

データベースのタスクを定義するためのファイルは下記のような構成にしています。
また、参考例は架空のサービス「piyo」を運用するためのタスク定義であると仮定の上で作成しています。
  • config.yml
  • Rakefile
  • logs/
    • xxxxxx.log
    • ...
  • tasks/
    • piyo/
      • task1.rake
      • task2.rake
      • ...

config.yml

YAMLで記述された設定ファイルです。
log:
dir: /path/to/logs
databases:
piyo_db:
user: piyo
password: xxxxxxxx
database: //xxx.xxx.xxx.xxx:xxxx/piyo.oracle.local
例としてログの出力ディレクトリとデータベースへの接続情報をYAML形式で記述しています。
運用とともに複数データベースへの接続や他の設定値が必要になった場合は適宜この設定ファイルに追記していきます。

Rakefile

rakeコマンドが実行されると読み込まれるファイルです。
仕組みとして全てのタスクをこのRakefile内に記述することもできるのですが、内容が増大するにつれて間違いなくメンテナンスに苦労するハメになるのでタスク定義は別ファイルに分割してRakefileそのものはシンプルにしておきます。
# -*- coding: utf-8 -*-
# requires
%w(fileutils logger yaml time date csv sequel).each {|lib| require lib }
# tasks
Dir['tasks/**/*.rake'].each {|path| load path }
3行目ではタスクで頻繁に利用するであろうライブラリをまとめて読み込んでいます。
少々乱暴な気もしないでは無いですが、いちいち指定するのも煩わしいのでここで読み込みます。
5行目でtasksディレクトリ以下に配置されたタスク定義を読み込んでいます。
タスク定義のためのrubyスクリプトは、他のファイルとの区別がつくように*.rakeのような形式が良いでしょう。

logs

名前の通りログファイルの出力用ディレクトリで中身は空になっています。
rakeタスクで実行した全てのデータベース操作の内容がログとして出力されるように構成します。

tasks

このディレクトリ以下にタスク定義を配置していきます。
後々のタスクの分類や整理に困らないように、もう1階層グループ名のようなディレクトリを配置してrakeのnamespace機能と整合させるために、ここではtasks/piyo/*.rakeのように配置することにします。

基本タスク

tasks/piyo/configuration.rake

namespace :piyo do
  task :configuration do
    # YAML設定ファイルの読み込み
    CONTEXT = YAML.load_file('config.yml')
  end
end
まずは、設定ファイルを読み込むタスクを定義します。
YAML形式で記述されたファイルを読み込むだけの単純極まりない内容です。
他のタスクから簡便に参照できるようにCONTEXTという名前の定数に放り込んでいます。

またtaskの定義自体をnamespaceで囲っていますが、rakeではnamespace機能を利用してネストしたタスクを定義することができます。
ネストしたタスクは階層化された名前を「:」で区切った文字列がタスク名となります。
上記の例でいえば「piyo:configuration」がタスク名となります。

tasks/piyo/logging.rake

namespace :piyo do
task :logging => :configuration do
raked_at = Time.now.strftime('%Y-%m-%d_%H%M%S')
log_dir = CONTEXT['log']['dir']

# ログ出力ディレクトリが存在しなければ作成しておく
FileUtils.mkdir_p(log_dir) unless FileTest.directory?(log_dir)

# CONTEXTへlogger生成メソッドを追加する
CONTEXT.instance_eval do
self.class.send :define_method, :logger do |name, level=Logger::INFO|
logger = Logger.new(File.join(log_dir,
[raked_at, name, 'log'].join('.')))
logger.level = level
logger
end
end
end end
次はloggingのためのタスク定義です。
task :logging => :configuration do
rubyの文法としてはHashリテラルに該当する上記の書き方でタスクの依存関係を定義します。
上記の場合は「configuration」タスクに依存した「logging」タスクの定義、という意味になります。
ちなみに依存するタスクが複数存在する場合は下記のように複数記述することもできます。
task :new_task => [:dependent1, :dependent2, dependent3] do
タスク内で実行されたデータベース操作を記録したログを集約しやすくするために、「configuration」タスクで定義したCONTEXTオブジェクト(単なるHashのインスタンスですが)にlogger生成用の特異メソッドを定義しています。
rubyの特異メソッド定義は下記のように、
s = "string"
class << s
def hoge
"hoge"
end
end
puts s.hoge
# => "hoge"
classキーワードを利用して簡潔に書く方が簡潔なのですが、タスク内のローカル変数であるraked_atとlog_dirを特異メソッド内にバインディングしたかったという事情から、少々黒魔術っぽくなっています。

tasks/piyo/connect_piyo.rake

namespace :piyo do
task :connect_piyo => :logging do
PIYO_DB = Sequel.oracle(CONTEXT['databases']['piyo_db'])
PIYO_DB.loggers << CONTEXT.logger('piyo')

class << PIYO_DB
def literal(s)
Sequel::LiteralString.new(s.to_s)
end
end
end
end
さて、本丸に近いデータベースへの接続タスクです。
ここでは設定ファイルと同様に定数PIYO_DBに、設定ファイル内の接続情報から作成したSequel::Oracle::Databaseオブジェクトを保存しています。
また、sequelのDatabaseオブジェクトは複数のLoggerを保持できるようになっているのですが、ここに「logging」タスクで定義したCONTEXT#loggerメソッド経由で作成したLoggerを放り込んでおきます。
これでsequelによって発行されるSQLなどの情報は全てログに出力されるようになります。

さらに、このタスクではPIYO_DBにliteralという特異メソッドを定義しています。
与えられたStringオブジェクトをSequel::LiteralStringにラップしているだけの単純な内容ですが、これはデータベース特有のSQLリテラルを簡便に使用したいという欲求から定義しています。

仮に接続先データベースがMySQLであれば、
PIYO_DB[:users].select(:name, Sequel.function(:now))
#=> SELECT name, now() FROM users
上記のようにSequel.functionを利用してMySQLの関数呼び出しを自然に組み込むことができるのですが、リテラルやFunctionの呼び出し記法に独特なところを持つOracleデータベースの場合は上手く動作しないことがあります。
(引数ゼロの関数やプロシージャ呼び出しに空カッコが書けない、などなど)

Sequel::LiteralStringを使用することで、
PIYO_DB[:users].select(:name, PIYO_DB.literal(:sysdate))
#=> SELECT name, sysdate FROM users
上記のようにデータベース特有のリテラル表現を含んだSQLの生成に役立ちます。
特に大きな使い所としては、INSERT文を実行する場合にOracleのSequenceを利用して連番のプライマリキーを発行する下記のようなパターンでしょうか。
PIYO_DB[:users].insert(:id => PIYO_DB.literal('users_seq.nextval'), :name => 'Matsuo')
#=> INSERT INTO users (id, name) VALUES (users_seq.nextval, 'Matsuo')

tasks/piyo/available_users.rake

さて、データベースへ接続するタスク定義まで準備が整ったので、あとはsequelの機能を駆使して様々なタスクを定義していくだけです。
下記のコードはPIYO_DBよりavailable=1であるレコーをSELECTして各レコードをカンマ区切りで出力するだけの簡単なサンプルです。

namespace :piyo do
desc 'ログイン可能であるユーザの一覧を出力する'
task :available_users => :connect_piyo do
# SELECT * FROM users WHERE available = 1
PIYO_DB[:users].where(:available => 1).each do |row|
puts row.values_at(:id, :name, :email).join(',')
end
end
end
実行すると下記のような出力が得られるイメージです。
$ rake piyo:available_users
(in /home/piyo/tasks)
1,Taro Yamada,yamada@example.com
2,Hanako Tanaka,tanaka@example.com
3,Mitsu Dan,dan@example.com
...
ログファイルの出力イメージは下記の通り。
# Logfile created on 2013-03-26 16:44:33 +0900 by logger.rb/31641
I, [2013-03-26T16:44:33.887404 #3899] INFO -- : (0.002629s) SELECT * FROM "USERS" WHERE ("AVAILABLE" = 1)
発行されたSQLの内容と実行にかかった時間が記録されていることが確認できます。

このタスクで初めてdescというメソッドが登場して来ましたが、ここにはタスクの説明を付加することができます。
descを付加することで何が嬉しいかというと、
$ rake -T
(in /home/piyo/tasks)
rake piyo:available_users # ログイン可能であるユーザの一覧を出力する
このようにrakeコマンドに-Tオプションを付加することで定義済みのタスクの一覧を説明付きで出力できるようになります。
内部的な共通タスクにはdescをつけず、実際に目的をもって実行するタスクについてはdescを付加して公開タスクとするというルールがオススメです。

単純なSELECT文にとどまらず、トランザクション制御+CRUDといった局面も例示してみたいのは山々ながら、長くなるので割愛しちゃいますが、特にトランザクションの取扱いについてはrubyの柔軟性を活かして非常にシンプルな記述ができる、とだけ叫んでおきます。

適用範囲の拡張のために

さて、ここまでの準備でデータベースタスク定義の枠組みは最低限揃いました。
あとは必要に応じたタスクを追加していくことで日々の業務に役立てていけると思います。

最後に細かいtipsをいくつか提示したいと思います。

HighLineによるパスワードの隠匿

今回の例では設定ファイル内にデータベース接続のためのパスワードを直書きしていますが、こういったパスワードを設定ファイルに持たずに実行時に入力させたいといった状況も十分に考えられます。
こういう場合にはHighLineがピッタリです。
$ gem install highline
sequelと同様にgemでインストール出来ます。
ask("Enter your password:  ") { |q| q.echo = false }
ask("Enter your password:  ") { |q| q.echo = "x" }
一般にパスワード入力中の内容は画面上に表示しないことが望ましいため、上記のどちらかのパターンを使ってタスク中の任意のタイミングでパスワード入力を求めることができます。

Net::SSHを利用したサーバタスク

ruby上でsshセッションを気軽に扱えるNet::SSHNet::SCPといったgemを導入すると、サーバリソースの操作も含めたタスクの作成に役立ちます。
Net::SSHを利用すればサーバにログインしてコンソールから実行できることの大部分が自動化できます。

JRuby+JDBCを利用する

sequelを使う利点のひとつがJRubyとJDBCへの対応です。
DB = Sequel.connect(:adapter => 'jdbc',
:url => 'jdbc:mysql://localhost/test',
:user => 'piyo',
:password => 'xxxxxxxx')
adapterにjdbcを指定することで簡単にJRuby環境からJDBCデータソースを利用することができます。
(ちなみにJDBCドライバの配置場所は$JRUBY_HOME/lib直下になります。)

JRuby+sequelの構成が素晴らしいのは「真のマルチスレッドが実現できる」ところにあります。
いわゆるCRuby(MRI)では外部ライブラリも含む環境全体でThread-safeが保証できないという前提からGIL(Global Interpreter Lock)機構によってプロセス内で同時に実行されるスレッド数が1つに制限されます。
JVMを基盤にしたJRubyにおいてはこの制限が無いため、CPUのコア数を活かした並列処理が容易に実現可能です。
つまるところ、Thread-safeに設計されているsequelと組み合わせることでデータベースへの並列処理を組み立てることが可能になります。

おわりに

たまたまrubyと知り合って10年以上愛用していますが、railsのようなフレームワークの材料として使うよりも、こういった小さなタスクを簡潔にこなすために利用する局面の方が、なんとなくrubyらしいと感じてしまうのは私だけでしょうか?
DRYでもなければRESTFULでもなく、ちょっと泥臭い(けれども現実にはありふれた)課題をスクリプト言語の足回りを活かして手早く解決していく、rubyをスーパーカブのように乗りこなすスタイルも悪く無いと思う昨今です。