書いた人: kwatch
YAML とは、データシリアライゼーション用のフォーマットです。読み書きのしやすさを考慮して設計されたため、構造化されたデータを表現するためによく用いられます。「構造化されたデータを表現する」という点では XML と似ていますが、XML と比べて読みやすい、書きやすい、わかりやすいという特徴があります。
YAML ではデータを次の 3 つの組み合わせで表現します。特別なデータ構造を必要としないので、XML と比べてわかりやすいです。
初級編では YAML の書き方と XML との比較について説明し、中級編では YAML 用ライブラリである Syck について説明しました。 今回の実践編では、YAML を使って実用的なツールを作成してみます。
実践編である今回は、YAML を使った実用的なツールを作成します。
作成するツールは、テーブル定義ファイルを読み込み、create table 文を自動生成するツールです。今流行りの Ruby on Rails ではデータベースからテーブル情報を自動的に読み込んでくれますが、テーブルを作成する機能はありません。そこで、テーブルを作成するツールを題材にしてみました。
テーブル定義ファイルは YAML で記述します。それを Ruby スクリプトで読み込んで加工し、eRuby のテンプレートで出力します。つまり、ツールは次のような構成になっています。
このような構成にする利点は次のとおりです。
この「YAML + Ruby + eRuby」という組み合わせは非常に強力です。もっと広まってもいいと思うのですが、あまり知られていません。これはきっと、この組み合わせに名前がないないせいだと思い、ここでは勝手に「DMT パターン」と呼ぶことにします。DMT とは、
の頭文字を表します。MVC パターンの一種と考えられなくもないですが、MVC パターンが主にインタラクティブな用途を前提にしているのに対し、DMT パターンはバッチ処理によるファイル出力を前提としているので、別物ととらえたほうがいいでしょう。
なお「YAML + Ruby + eRuby」のかわりに「XML + XSLT」を使っている方もいると思います。両者の比較はセクション「XML + XSLT との比較」で行います。
作成するツールは以下のような構成になっています。
まずはじめに、最終的にどんな出力を得たいのかを示します。そのあとで、それを得るためのデータファイルやメインプログラムや出力用テンプレートを説明します。
最終的には、MySQL 用と PostgreSQL 用の SQL スクリプトを出力します。この SQL スクリプトでは、テーブルを作成するための create table 文が生成されます。
MySQL と PostgreSQL では、create table 文の書き方に以下のような違いがあります。これらを意識して、それぞれの create table 文を生成します。
項目 | MySQL | PostgreSQL |
---|---|---|
予約語のエスケープ | ... |
“…” |
自動インクリメント | auto_increment 制約 | serial 型 |
列挙型 | 使用できる | 使用できない |
外部参照キー | 指定できない | 指定できる |
MySQL 用の SQL スクリプトは、例えば次のようになります。このあとの PostgreSQL 用 SQL スクリプトと比較してみてください。
ddl-mysql.sql - 出力ファイル (MySQL 用)
----------------------------------------------------------------------
-- DDL for MySQL
-- generated at Sun Oct 02 02:49:28 JST 2005
----------------------------------------------------------------------
-- グループマスタテーブル
create table groups (
id integer \{\{*auto_increment*\}\} primary key,
name varchar(63) not null,
\{\{*`*\}\}desc\{\{*`*\}\} varchar(255) ,
created_on timestamp not null,
updated_on timestamp not null
);
-- ユーザマスタテーブル
create table users (
id integer \{\{*auto_increment*\}\} primary key,
name varchar(63) not null,
\{\{*`*\}\}desc\{\{*`*\}\} varchar(255) ,
email varchar(63) not null,
group_id integer not null, -- references groups.id
age integer ,
gender \{\{*enum('M', 'F')*\}\} ,
created_on timestamp not null,
updated_on timestamp not null
);
PostgreSQL 用の SQL スクリプトは、例えば次のようになります。上の MySQL 用 SQL スクリプトと比較してみてください。
ddl-postgresql.sql - 出力ファイル (PostgreSQL 用)
----------------------------------------------------------------------
-- DDL for PostgreSQL
-- generated at Sun Oct 02 02:49:28 JST 2005
----------------------------------------------------------------------
-- グループマスタテーブル
create table groups (
id \{\{*serial*\}\} primary key,
name varchar(63) not null,
\{\{*"*\}\}desc\{\{*"*\}\} varchar(255) ,
created_on timestamp not null,
updated_on timestamp not null
);
-- ユーザマスタテーブル
create table users (
id \{\{*serial*\}\} primary key,
name varchar(63) not null,
\{\{*"*\}\}desc\{\{*"*\}\} varchar(255) ,
email varchar(63) not null,
group_id integer not null \{\{*references groups(id)*\}\},
age integer ,
gender \{\{*varchar(1)*\}\} , -- M,F
created_on timestamp not null,
updated_on timestamp not null
);
データベースごとの相違点は、出力用テンプレートを切り替えることで対応できます。アプリケーションを MySQL から PostgreSQL に切り替える (あるいはその逆) といった場合にも、柔軟に対応できます。
出力ファイルのイメージは掴めたでしょうか。以降では、この出力ファイルを得るためのデータファイルやメインプログラムや出力用テンプレートを説明します。
元データとなるデータファイル「datafile.yaml」には、テーブル定義を記述します。
重要なのは次の点です。
これにより、テーブル定義においてカラムの属性指定をかなり省略することができます。またカラムの説明など、出力には必要のない情報も含めることができます。
また、データファイルはなるべく汎用的に使えるようにしておきましょう。例えばデータ型で「varchar2」などと指定してしまうと、特定の RDBMS でしか使用できなくなります。できれば「string」や「text」のように抽象的に指定しておき、出力用テンプレートのほうで変換するのがよいでしょう。
なおデータファイル自体の詳しい解説は省略します (見ればわかりますよね?)。
datafile.yaml - データファイル
##
## テーブル定義ファイル
##
## カラムのデフォルト値
defaults:
columns:
- name: id
desc: ID
type: integer
primary-key: yes
serial: yes
- name: name
desc: 名前
type: string
not-null: yes
width: 63
- name: desc
desc: 摘要
type: string
- name: created_on
desc: 作成時刻
type: timestamp
not-null: yes
- name: updated_on
desc: 更新時刻
type: timestamp
not-null: yes
### テーブル定義
tables:
- name: groups
class: Group
desc: グループマスターテーブル
columns:
- &groups-id
name: id
desc: グループID
- name: name
- name: desc
- name: created_on
- name: updated_on
- name: users
class: User
desc: ユーザマスターテーブル
columns:
- &users-id
name: id
desc: ユーザID
- name: name
- name: desc
- name: email
desc: メールアドレス
type: string
width: 63
not-null: yes
- name: group_id
desc: 所属するグループのID
ref: *groups-id
not-null: yes
- name: age
desc: 年齢
type: integer
- name: gender
desc: 性別
type: string
width: 1
enum:
- M
- F
- name: created_on
- name: updated_on
なおここでは、外部参照キーの指定に YAML のアンカーとエイリアスを使いました。しかし YAML のアンカーとエイリアスでは、後方参照ができません。つまり、エイリアスではすでに定義されてあるアンカーしか参照できません (これは YAML を 1 パスで構文解析するための意図的な仕様です)。これを避けるためには、外部参照キーを例えば「テーブル名.カラム名」のように指定できるようにするとよいでしょう。
続いて、メインプログラム「creatable.rb」です。この中では 2 つのクラスがあります。
メインプログラムは思ったより長くなってしまいました。大事なところを強調しておきますので、他の部分はざっと目を通すだけで結構です。
creatable.rb - メインプログラム
#!/usr/bin/env ruby
##
## creatable - テーブル定義を読み込んで加工し、テンプレートで出力する
##
require 'yaml'
require 'erb'
##
## メインプログラムを表すクラス
##
## 使い方:
## main = MainProgram.new()
## output = main.execute(ARGV)
## print output if output
##
class MainProgram
def execute(argv=ARGV)
# コマンドオプションの解析
options = _parse_options(ARGV)
return usage() if options[:help]
raise "テンプレートが指定されていません。" unless options[:template]
# データファイルを読み込む。タブ文字は空白に展開する。
s = ''
while line = gets()
s << line.gsub(/([^\t]{8})|([^\t]*)\t/n){[$+].pack("A8")}
end
\{\{*doc = YAML.load(s)*\}\}
# 読み込んだデータを加工する
\{\{*manipulator = Manipulator.new(doc)*\}\}
\{\{*manipulator.manipulate()*\}\}
# テンプレートを読み込んで出力を生成する
\{\{*s = File.read(options[:template])*\}\}
trim_mode = '>' # '%>' で終わる行では改行を出力しない
erb = ERB.new(s, $SAFE, trim_mode)
\{\{*context = { 'tables' => doc['tables'] }*\}\}
\{\{*output = _eval_erb(erb, context)*\}\}
return output
end
private
## テンプレートを適用する。
def _eval_erb(__erb, context)
# このように ERB#result() だけを実行するメソッドを用意すると、
# 必要な変数 (この場合なら context) だけをテンプレートに渡し、
# 不必要なローカル変数は渡さなくてすむようになる。
return __erb.result(binding())
end
## ヘルプメッセージ
def usage()
s = ''
s << "Usage: ruby creatable.rb [-h] -f template datafile.yaml [...]\n"
s << " -h : ヘルプ\n"
s << " -f template : テンプレートのファイル名\n"
return s
end
## コマンドオプションを解析する
def _parse_options(argv)
options = {}
while argv[0] && argv[0][0] == ?-
opt = argv.shift
case opt
when '-h' # ヘルプ
options[:help] = true
when '-f' # テンプレート名
arg = argv.shift
raise "-f: テンプレート名を指定してください。" unless arg
options[:template] = arg
else
raise "#{opt}: コマンドオプションが間違ってます。"
end
end
return options
end
end
##
## 定義ファイルから読み込んだデータをチェックし、加工するクラス
##
## 使い方:
## yaml = YAML.load(file)
## manipulator = Manipulator.new()
## manipulator.manipulate(yaml)
##
class Manipulator
def initialize(doc)
@defaults = doc['defaults'] || {}
@tables = doc['tables'] || []
end
## 定義ファイルから読み込んだデータを操作する
def manipulate()
# 「カラム名→デフォルトカラム定義」の Hash を作成する
default_columns = {}
@defaults['columns'].each do |column|
colname = column['name']
raise "カラム名がありません。" unless colname
raise "#{colname}: カラム名が重複しています。" if default_columns[colname]
default_columns[colname] = column
end if @defaults['columns']
# テーブルのカラムをチェックし値を設定する
tablenames = {}
@tables.each do |table|
tblname = table['name']
raise "テーブル名がありません。" unless tblname
raise "#{tblname}: テーブル名が重複しています。" if tablenames[tblname]
tablenames[tblname] = true
colnames = {}
table['columns'].each do |column|
colname = column['name']
raise "#{tblname}: カラム名がありません。" unless colname
raise "#{tblname}.#{colname}: カラム名が重複しています。" if colnames[colname]
colnames[colname] = true
\{\{*# カラムのデフォルト値を設定*\}\}
\{\{*default_column = default_columns[colname]*\}\}
\{\{*default_column.each do |key, val|*\}\}
\{\{*column[key] = val unless column.key?(key)*\}\}
\{\{*end if default_column*\}\}
\{\{*# カラムからテーブルへのリンクを設定*\}\}
\{\{*column['table'] = table*\}\}
\{\{*# 外部キーで参照しているカラムの、データ型とカラム幅をコピーする*\}\}
\{\{*if (ref_column = column['ref']) != nil*\}\}
\{\{*column['type'] = ref_column['type']*\}\}
\{\{*column['width'] ||= ref_column['width'] if ref_column.key?('width')*\}\}
\{\{*end*\}\}
end if table['columns']
end
end
end
## メインプログラムを実行
main = MainProgram.new
output = main.execute(ARGV)
print output if output
最後に、MySQL 用と PostgreSQL 用の出力テンプレートです。eRuby で記述します。
テンプレートでは、以下のことを行います。
MySQL 用のテンプレート「ddl-mysql.eruby」は次のようになります。予約語が多いですが、気にしないでください。
ddl-mysql.eruby - 出力用テンプレート
<%
##
## MySQL用のDDL(create table文)を生成するテンプレート
##
## メインプログラムからデータを受け取る ・・・(1)
tables = context['tables']
## MySQL予約語
keywords = <<-END
add all alter analyze and as asc asensitive
before between bigint binary blob both by
call cascade case change char character check collate column
condition connection constraint continue convert create cross
current_date current_time current_timestamp current_user cursor
database databases day_hour day_microsecond day_minute day_second
dec decimal declare default delayed delete desc describe
deterministic distinct distinctrow div double drop dual
each else elseif enclosed escaped exists exit explain
false fetch float for force foreign from fulltext
goto grant group
having high_priority hour_microsecond hour_minute hour_second
if ignore in index infile inner inout insensitive insert
int integer interval into is iterate
join
key keys kill
leading leave left like limit lines load localtime
localtimestamp lock long longblob longtext loop low_priority
match mediumblob mediumint mediumtext middleint
minute_microsecond minute_second mod modifies
natural not no_write_to_binlog null numeric
on optimize option optionally or order out outer outfile
precision primary procedure purge
read reads real references regexp release rename repeat
replace require restrict return revoke right rlike
schema schemas second_microsecond select sensitive
separator set show smallint soname spatial specific sql
sqlexception sqlstate sqlwarning sql_big_result
sql_calc_found_rows sql_small_result ssl starting straight_join
table terminated then tinyblob tinyint tinytext to
trailing trigger true
undo union unique unlock unsigned update usage use using
utc_date utc_time utc_timestamp
values varbinary varchar varcharacter varying
when where while with write
xor
year_month
zerofill
END
@keywords = {}
keywords.split(/\s+/).each { |word| @keywords[word] = true }
## 予約語をエスケープする関数 ・・・(2)
def self._(word)
return @keywords[word.downcase] ? "`#{word}`" : word
end
%>
----------------------------------------------------------------------
-- DDL for MySQL
-- generated at <%= Time.now.to_s %>
----------------------------------------------------------------------
<% ## create table 文を出力 ・・・(3) %>
<% tables.each do |table| %>
-- <%= table['desc'] %>
create table <%= _(table['name']) %> (
<%
n = table['columns'].length
table['columns'].each_with_index do |column, i|
flag_last_loop = n == i + 1 # ループの最後かどうか
## カラムの名前と型
name = column['name']
type = column['type']
width = column['width']
## 型情報 ・・・(4)
case type
when 'char' ; type = 'tinyint'
when 'short' ; type = 'mediumint'
when 'int' ; type = 'integer'
when 'inteter' ;
when 'str' ; type = 'varchar'; width ||= 255
when 'string' ; type = 'varchar'; width ||= 255
when 'text' ;
when 'float' ;
when 'double' ;
when 'bool' ; type = 'boolean'
when 'boolean' ;
when 'date' ;
when 'timestamp' ;
when 'money' ; type = 'decimal'
end
type += "(#{width})" if width
## enum が指定されているときは型を enum() にする ・・・(5)
if column['enum']
type = "enum(" + column['enum'].collect{|v| "'#{v}'"}.join(", ") + ")"
width = nil
end
## 制約条件 ・・・(5)
flag_notnull = column['not-null'] && !column['serial'] && !column['primary-key']
constraints = []
constraints << 'auto_increment' if column['serial']
constraints << 'not null' if flag_notnull
constraints << 'primary key' if column['primary-key']
constraints << 'unique' if column['unique']
## カラム定義を出力 ・・・(6)
name_part = '%-20s' % _(name)
type_part = '%-20s' % type
const_part = constraints.join(' ')
comma = flag_last_loop ? '' : ','
ref = column['ref']
comment = ref ? " -- references #{ref['table']['name']}.#{ref['name']}" : ""
%>
<%= name_part %> <%= type_part %> <%= const_part %><%= comma %><%= comment %>
<%
end
%>
<% ## 主キーが複合キーの場合 %>
<% if table['primary-keys'] %>
<% pkeystr = table['primary-keys'].collect{|pkey| _(pkey)}.join(', ') %>
, primary key (<%= pkeystr %>)
<% end %>
);
<% end %>
eRuby テンプレートの中で新しいメソッドが必要な場合は、特異メソッドとして定義すると他のプログラムへの影響を少なくできます。 この例では、エスケープするメソッドを特異メソッドとして定義しています。 なお特異メソッドは、今回の例だと MainProgram インスタンスオブジェクトの特異メソッドとして定義されます。
続いて、PostgreSQL 用のテンプレート「ddl-postgresql.eruby」です。やってることは MySQL 用とあまり変わりません。
ddl-postgresql.eruby - 出力用テンプレート
<%
##
## PostgreSQL用のDDL(create table文)を生成するテンプレート
##
## メインプログラムからデータを受け取る ・・・(1)
tables = context['tables']
## PostgreSQL予約語
keywords = <<-END
abort admin all analyse analyze and any as asc
between binary bit both
case cast char character check cluster coalesce
collate column constraint copy cross current_date
current_time current_timestamp current_user
dec decimal default deferrable desc distinct do
else end except exists explain extend extract
false float for foreign from full
global group
having
ilike in initially inner inout intersect into is isnull
join
leading leftlike limit listen local lock
move
natural nchar new not notnull null nullif numeric
off offset old on only or order out outer overlaps
position precision primary public
references reset right
select session_user setof showsome substring
table then to trailing transaction trim true
union unique user using
vacuum varchar verbose
when where
END
@keywords = {}
keywords.split(/\s+/).each { |word| @keywords[word] = true }
## 予約語をエスケープする関数 ・・・(2)
def self._(word)
return @keywords[word] ? "\"#{word}\"" : word
end
%>
----------------------------------------------------------------------
-- DDL for PostgreSQL
-- generated at <%= Time.now.to_s %>
----------------------------------------------------------------------
<% ## create table 文を出力 ・・・(3) %>
<% tables.each do |table| %>
-- <%= table['desc'] %>
create table <%= _(table['name']) %> (
<%
n = table['columns'].length
table['columns'].each_with_index do |column, i|
flag_last_loop = n == i + 1 # ループの最後かどうか
## カラムの名前と型
name = column['name']
type = column['type']
width = column['width']
## 型情報 ・・・(4)
case type
when 'char' ;
when 'short' ; type = 'smallint'
when 'int' ; type = 'integer'
when 'inteter' ;
when 'str' ; type = 'varchar' ; width ||= 255
when 'string' ; type = 'varchar' ; width ||= 255
when 'text' ;
when 'float' ; type = 'real'
when 'double' ; type = 'double precision'
when 'bool' ; type = 'boolean'
when 'boolean' ;
when 'date' ;
when 'timestamp' ;
when 'money' ; type = 'decimal'
end
type += "(#{width})" if width
## serial 型の桁数が大きい場合は bigserial を使う ・・・(5)
if column['serial']
type = width && width >= 10 ? 'bigserial' : 'serial'
end
## 制約条件 ・・・(5)
flag_notnull = column['not-null'] && !column['serial'] && !column['primary-key']
constraints = []
constraints << 'not null' if flag_notnull
constraints << 'primary key' if column['primary-key']
constraints << 'unique' if column['unique']
if (ref = column['ref']) != nil
constraints << "references #{ref['table']['name']}(#{ref['name']})"
end
## カラム定義を出力 ・・・(6)
name_part = '%-20s' % _(name)
type_part = '%-20s' % type
const_part = constraints.join(' ')
comma = flag_last_loop ? '' : ','
comment = column['enum'] ? " -- #{column['enum'].join(',')}" : ""
%>
<%= name_part %> <%= type_part %> <%= const_part %><%= comma %><%= comment %>
<%
end
%>
<% ## 主キーが複合キーの場合 %>
<% if table['primary-keys'] %>
<% pkeystr = table['primary-keys'].collect{|pkey| _(pkey)}.join(', ') %>
, primary key (<%= pkeystr %>)
<% end %>
);
<% end %>
なお、2 つのテンプレートファイルが似ていることから、ひとつにまとめられるのでは?と思う方もいるでしょう。もちろんそうしたいところなのですが、筆者の経験からいうと、まとめたほうが逆に保守しにくいテンプレートとなりました。理想的だが複雑なものより、次善策だが単純なもののほうが、結局は保守しやすいというのが筆者なりの結論です。ただこれはあくまで筆者の私見なので、まとめられると思えばそうしていただいて結構です。
これらを実行するには、次のようにしてください。
SQL スクリプトが生成されることを確認してください。
今回作成したツールは、SQL 文を生成する以外にも活用できます。例として、Java の DTO (Data Transfer Object) クラスを生成するようにツールを拡張してみましょう。
Java の DTO クラスを生成する場合、次のような拡張が必要です。
メインプログラムでは、クラス「MainProgram」に対して次のような拡張を行います。
なおクラス「Manipulator」は変更する必要はありません。
以下に、変更点がわかるようにしたメインプログラムを示します。
creatable.rb - メインプログラム
#!/usr/bin/env ruby
##
## creatable - テーブル定義を読み込んで加工し、テンプレートで出力する
##
require 'yaml'
require 'erb'
##
## メインプログラムを表すクラス
##
## 使い方:
## main = MainProgram.new()
## output = main.execute(ARGV)
## print output if output
##
class MainProgram
def execute(argv=ARGV)
# コマンドオプションの解析
options\{\{*, properties*\}\} = _parse_options(ARGV)
return usage() if options[:help]
raise "テンプレートが指定されていません。" unless options[:template]
# データファイルを読み込む。タブ文字は空白に展開する。
s = ''
while line = gets()
s << line.gsub(/([^\t]{8})|([^\t]*)\t/n){[$+].pack("A8")}
end
doc = YAML.load(s)
# 読み込んだデータを加工する
manipulator = Manipulator.new(doc)
manipulator.manipulate()
\{\{*# テンプレートを検索する*\}\}
\{\{*t = options[:template]*\}\}
\{\{*if test(?f, t)*\}\}
\{\{*template = t*\}\}
\{\{*elsif ENV['CREATABLE_PATH']*\}\}
\{\{*path_list = ENV['CREATABLE_PATH'].split(File::PATH_SEPARATOR)*\}\}
\{\{*template = path_list.find { |path| test(?f, "#{path}/#{t}") }*\}\}
\{\{*end*\}\}
\{\{*raise "'#{t}': テンプレートが見つかりません。" unless template*\}\}
# テンプレートを読み込んで出力を生成する
s = File.read(template)
trim_mode = '>' # '%>' で終わる行では改行を出力しない
erb = ERB.new(s, $SAFE, trim_mode)
\{\{*if options[:multiple] # 複数ファイルへ出力*\}\}
\{\{*doc['tables'].each do |table|*\}\}
\{\{*context = { 'table' => table, 'properties' => properties }*\}\}
\{\{*output = _eval_erb(erb, context)*\}\}
\{\{*filename = context[:output_filename] # 出力ファイル名*\}\}
\{\{*filename = options[:directory] + "/" + filename if options[:directory]*\}\}
\{\{*File.open(filename, 'w') do |f|*\}\}
\{\{*f.write(output)*\}\}
\{\{*$stderr.puts "generated: #{filename}"*\}\}
\{\{*end if filename*\}\}
\{\{*end*\}\}
\{\{*output = nil*\}\}
\{\{*else # 標準出力へ出力*\}\}
context = { 'tables' => doc['tables']\{\{*, 'properties' => properties*\}\} }
output = _eval_erb(erb, context)
\{\{*end*\}\}
return output
end
private
## テンプレートを適用する。
def _eval_erb(__erb, context)
# このようにERB#result()だけを実行するメソッドを用意すると、
# 必要な変数(この場合ならcontext)だけをテンプレートに渡し、
# 不必要なローカル変数は渡さなくてすむようになる。
return __erb.result(binding())
end
## ヘルプメッセージ
def usage()
s = ''
s << "Usage: ruby creatable.rb [-h] \{\{*[-m]*\}\} -f template datafile.yaml [...]\n"
s << " -h : ヘルプ\n"
\{\{*s << " -m : multiple output file\n"*\}\}
s << " -f template : テンプレートのファイル名\n"
return s
end
## コマンドオプションおよびテンプレートプロパティを解析する
def _parse_options(argv)
options = {}
properties = {}
while argv[0] && argv[0][0] == ?-
opt = argv.shift
\{\{*if opt =~ /^--(.*)/*\}\}
\{\{*# テンプレートプロパティ*\}\}
\{\{*param_str = $1*\}\}
\{\{*if param_str =~ /\A([-\w]+)=(.*)/*\}\}
\{\{*key, value = $1, $2*\}\}
\{\{*else*\}\}
\{\{*key, value = param_str, true*\}\}
\{\{*end*\}\}
\{\{*properties[key] = value*\}\}
\{\{*else*\}\}
# コマンドオプション
case opt
when '-h' # ヘルプ
options[:help] = true
when '-f' # テンプレート名
arg = argv.shift
raise "-f: テンプレート名を指定してください。" unless arg
options[:template] = arg
\{\{*when '-m' # テーブルごとの出力ファイル*\}\}
\{\{*options[:multiple] = true*\}\}
\{\{*when '-d' # 出力先ディレクトリ*\}\}
\{\{*arg = argv.shift*\}\}
\{\{*raise "-d: ディレクトリ名を指定してください。" unless arg*\}\}
\{\{*options[:directory] = arg*\}\}
else
raise "#{opt}: コマンドオプションが間違ってます。"
end
\{\{*end*\}\}
end
return options\{\{*, properties*\}\}
end
end
##
## 定義ファイルから読み込んだデータをチェックし、加工するクラス
##
## 使い方:
## doc = YAML.load(file)
## manipulator = Manipulator.new()
## manipulator.manipulate(doc)
##
class Manipulator
def initialize(doc)
@defaults = doc['defaults'] || {}
@tables = doc['tables'] || []
end
## 定義ファイルから読み込んだデータを操作する
def manipulate()
# 「カラム名→カラム定義」の Hash を作成する
default_columns = {}
@defaults['columns'].each do |column|
colname = column['name']
raise "カラム名がありません。" unless colname
raise "#{colname}: カラム名が重複しています。" if default_columns[colname]
default_columns[colname] = column
end if @defaults['columns']
# テーブルのカラムをチェックし値を設定する
tablenames = {}
@tables.each do |table|
tblname = table['name']
raise "テーブル名がありません。" unless tblname
raise "#{tblname}: テーブル名が重複しています。" if tablenames[tblname]
tablenames[tblname] = true
colnames = {}
table['columns'].each do |column|
colname = column['name']
raise "#{tblname}: カラム名がありません。" unless colname
raise "#{tblname}.#{colname}: カラム名が重複しています。" if colnames[colname]
colnames[colname] = true
# カラムのデフォルト値を設定
default_column = default_columns[colname]
default_column.each do |key, val|
column[key] = val unless column.key?(key)
end if default_column
# カラムからテーブルへのリンクを設定
column['table'] = table
# 外部キーで参照しているカラムの、データ型とカラム幅をコピーする
if (ref_column = column['ref']) != nil
column['type'] = ref_column['type']
column['width'] ||= ref_column['width'] if ref_column.key?('width')
end
end if table['columns']
end
end
end
## メインプログラムを実行
main = MainProgram.new
output = main.execute(ARGV)
print output if output
続いて、Java の DTO 用クラスを生成するためのテンプレート「dto-java.eruby」を説明します。 ポイントは次のとおりです。
dto-java.eruby - 出力用テンプレート
<%
##
## Java用DTO(Data Transfer Object)クラスを生成するテンプレート
##
## テンプレートプロパティ:
## package - パッケージ名
## parent - 親クラス名
##
## コンテキスト
table = context['table'] # ・・・(1)
properties = context['properties'] # ・・・(2)
raise "コマンドオプション '-m' を指定してください。" unless table
unless defined?(KEYWORDS)
## Javaの予約語
keywords = <<-END
abstract assert boolean break byte case catch char class const
continue default do double else enum extends final finally float
for goto if implements import instanceof int interface long
native new package private protected public return short
static strictfp super switch synchronized this throw throws
transient try void volatile while
END
@keywords = {}
keywords.each { |word| @keywords[word] = true }
## 予約語をエスケープする関数
def self._(word)
return @keywords[word] ? "_#{word}" : word
end
## 文字列 "aaa_bbb_ccc" を "AaaBbbCcc" に変換する関数
def camel_case(name, flag_all=true)
s = ''
name.split('_').each_with_index do |word, i|
s << (!flag_all && i == 0 ? word.downcase : word.capitalize)
end
return s
end
end
## クラス定義情報
klass = {
:name => table['class'] || camel_case(table['name']),
:package => table['package'] || properties['package'], # ・・・(3)
:parent => table['parent'] || properties['parent'], # ・・・(3)
:desc => table['desc'],
}
## 出力ファイル名
context[:output_filename] = klass[:name] + ".java" # ・・・(4)
## インスタンス変数
variables = []
ref_variables = []
table['columns'].each do |column|
type = column['class']
unless type
type = column['type']
width = column['width']
case type
when 'char' ; type = (!width || width == 1) ? 'char' : 'String'
when 'short' ;
when 'int', 'integer' ; type = 'int'
when 'str', 'string' ; type = width == 1 ? 'char' : 'String'
when 'text' ; type = width == 1 ? 'char' : 'String'
when 'float' ;
when 'double' ;
when 'bool', 'boolean' ; type = 'boolean'
when 'date' ; type = 'java.util.Date' # or java.util.Calendar
when 'timestamp' ; type = 'java.util.Date' # or java.util.Calendar
when 'money' ; type = 'double'
end
end
var = {
:name => column['name'],
:type => type,
:desc => column['desc'],
}
variables << var
## 外部参照キー
if (ref = column['ref']) != nil
var = {
:name => column['ref-name'] || column['name'].sub(/_id$/, ''),
:type => ref['table']['class'],
:desc => ref['table']['desc']
}
ref_variables << var
end
end
variables.concat(ref_variables)
## ファイルの出力を開始
%>
/*
* DTO class for Java
* generated at <%= Time.now.to_s %>
*/
<% if klass[:package] %>
package <%= klass[:package] %>;
<% end %>
/**
* <%= klass[:desc] %>
*/
<% extends = klass[:parent] ? ' extends ' + klass[:parent] : '' %>
public class <%= klass[:name] %><% extends %> implements java.io.Serializable {
<% ## インスタンス変数 %>
<% variables.each do |var| %>
<% vartype = '%-12s' % var[:type] %>
<% varname = '%-12s' % (var[:name]+';') %>
private <%= vartype %> <%= _(varname) %> /* <%= var[:desc] %> */
<% end %>
<% ## getter/setter %>
<% variables.each do |var| %>
<% vname = var[:name] %>
<% vtype = var[:type] %>
<% getter = "#{vtype == 'boolean' ? 'is' : 'get'}#{camel_case(vname)}" %>
<% setter = "set#{camel_case(vname)}" %>
public <%= vtype %> <%= getter %>() { return <%= _(vname) %> }
public void <%=setter%>(<%=vtype%> <%=_(vname)%>) { this.<%=_(vname)%> = <%=_(vname)%>; }
<% end %>
}
このテンプレートを使うには、コマンドラインオプション「-m」とテンプレートプロパティ「–package=’my’」をつけます (パッケージ名は任意です)。
実行例
作成された Java クラスは次のようになります。
Group.java - 自動生成された DTO クラス
/*
* DTO class for Java
* generated at Sun Oct 02 02:49:29 JST 2005
*/
package my;
/**
* グループマスターテーブル
*/
public class Group implements java.io.Serializable {
private int id; /* グループID */
private String name; /* 名前 */
private String desc; /* 摘要 */
private java.util.Date created_on; /* 作成時刻 */
private java.util.Date updated_on; /* 更新時刻 */
public int getId() { return id }
public void setId(int id) { this.id = id; }
public String getName() { return name }
public void setName(String name) { this.name = name; }
public String getDesc() { return desc }
public void setDesc(String desc) { this.desc = desc; }
public java.util.Date getCreatedOn() { return created_on }
public void setCreatedOn(java.util.Date created_on) { this.created_on = created_on; }
public java.util.Date getUpdatedOn() { return updated_on }
public void setUpdatedOn(java.util.Date updated_on) { this.updated_on = updated_on; }
}
User.java では外部参照キーもインスタンス変数として追加されていることがわかります。
User.java - 自動生成された DTO クラス
/*
* DTO class for Java
* generated at Sun Oct 02 02:49:29 JST 2005
*/
package my;
/**
* ユーザマスターテーブル
*/
public class User implements java.io.Serializable {
private int id; /* ユーザID */
private String name; /* 名前 */
private String desc; /* 摘要 */
private String email; /* メールアドレス */
private int group_id; /* 所属するグループのID */
private int age; /* 年齢 */
private char gender; /* 性別 */
private java.util.Date created_on; /* 作成時刻 */
private java.util.Date updated_on; /* 更新時刻 */
private Group group; /* グループマスターテーブル */
public int getId() { return id }
public void setId(int id) { this.id = id; }
public String getName() { return name }
public void setName(String name) { this.name = name; }
public String getDesc() { return desc }
public void setDesc(String desc) { this.desc = desc; }
public String getEmail() { return email }
public void setEmail(String email) { this.email = email; }
public int getGroupId() { return group_id }
public void setGroupId(int group_id) { this.group_id = group_id; }
public int getAge() { return age }
public void setAge(int age) { this.age = age; }
public char getGender() { return gender }
public void setGender(char gender) { this.gender = gender; }
public java.util.Date getCreatedOn() { return created_on }
public void setCreatedOn(java.util.Date created_on) { this.created_on = created_on; }
public java.util.Date getUpdatedOn() { return updated_on }
public void setUpdatedOn(java.util.Date updated_on) { this.updated_on = updated_on; }
public Group getGroup() { return group }
public void setGroup(Group group) { this.group = group; }
}
本稿では「YAML + Ruby + eRuby」という組み合わせを使いました。しかし世の中では、「XML + XSLT」という組み合わせもよく見かけます。 そこで、両者の比較を行ってみましょう。
役割分担の違いについては、次の表のようになります。XSLT がデータの「加工や操作」と「出力」の両方を行っていることがわかります。
役割 | YAML+Ruby+eRuby | XML+XSLT |
---|---|---|
元データ | YAML | XML |
加工・操作 | Ruby | XSLT |
出力 | eRuby |
「データの加工・操作までは同じで、出力だけが違う」という場合、「YAML + Ruby + eRuby」だと eRuby のテンプレートだけを切り替えれば済みます。 しかし「XML + XSLT」の場合は、XSLT を切り替えると「加工」までも切り替えることになります。XSLT で「加工」部分と「出力」部分とを切り分けるには、明示的に別ファイルにしておき、<xsl:include> や <xsl:import> を使う必要があります。
また「YAML + Ruby + eRuby」の場合、元データは必ずしも YAML でなくてもよく、XML や CSV やタブ区切りのファイルも使用できます。その違いを Ruby スクリプトが吸収すればよいだけです。しかし「XML + XSLT」の場合、元データとしては XML しか利用できません。
データの記述は、素の状態では YAML のほうが書きやすく読みやすいです。ただし XML は入力支援機能をもつ専用エディタを使えば、書きやすくなります。
また構造化されたデータを記述するという点では YAML も XML も同じですが、どちらかというと YAML は設定ファイルのような定型的なデータに向き、XML は文章のような非定型的なデータに向きます。
なお既存のデータがすでに XML で蓄積されているという場合も多いでしょう。そのような場合は、無理して YAML にする必要はありません。また他人とデータ交換をする必要がある場合は、今のところ XML のほうが無難です。
XSLT はそれ自体が XML なので、簡潔な記述ができません。また汎用言語ではないので、Ruby と比べるとどうしても能力的に劣ります。 簡単な加工や操作であれば XSLT でもよいですが、本格的な加工や操作が必要になれば、Ruby のほうが圧倒的に有利でしょう。
XSLT はまるでパズルのような、たいへんわかりにくい記述になります。再帰的な構造をもつような非定型的な出力をする場合は XSLT が向くのだと思いますが、今回のような定型的な出力であれば、明らかに eRuby のほうがわかりやすく記述できます。
ただし、XSLT はブラウザのサポートが期待できます。つまりブラウザが XSLT を解釈する場合、XML と XSLT を用意しておけば、出力ファイルを生成する手間が省けます。これは用途によってはアドバンテージとなることもあるでしょう。
今回は YAML を使ったアプリケーションの例として、create table 文を自動生成するツールを作成しました。また応用例として Java クラスを生成するように拡張しました。
ツールの構成は次のとおりです (この構成を勝手に「DMT パターン」と呼んでいます)。
このような構成にすると次のような利点があります。
また「XML + XSLT」と比べると、データの記述がしやすい、加工や操作が強力でわかりやすい、などの利点があります。
今回紹介した「YAML + Ruby + eRuby」という組み合わせは大変強力です。応用範囲も広いので、ぜひ試してみてください。
さて、3 回で終了する予定だった本連載ですが、書きたいネタがあるので延長させてもらいました。次回は YAML 用のスキーマについて説明します。
名前:kwatch。三流プログラマー。今の高校生は平成生まれと聞いてがく然とした昭和ウン年世代。そのうち 21 世紀生まれがやってくるのかと思うと、今から鬱。最近のお気に入りは「いわみて」。