
Reportify
用 Reportify 把 Ruby 查询结果集导出为 HTML 表格与 CSV 报表
Reportify 是一个 Ruby gem,用来把 ActiveRecord 结果集按列定义快速渲染成 HTML 表格或 CSV 下载。本文从安装、定义报表列、创建 Builder、选择输出格式讲到列格式化器与自定义 MIME 类型导出,并给出一个从模型到视图的完整示例。
Reportify 是一个 Ruby gem,解决的问题很具体:手里已经有一批 ActiveRecord 结果集(或者一组哈希),需要把它们按固定列顺序渲染成报表——既能在页面上显示成 HTML 表格,也能一键下载成 CSV。它把「取哪些字段、表头叫什么、数字怎么格式化、要不要加合计行」这些报表逻辑从视图里抽出来,集中到列定义对象上。适合正在维护 Rails 应用、需要给后台加导出功能、又不想引入完整报表引擎的开发者。如果你只是想快速确认某个报表工具是否值得接入,可以先看站内的 Reportify 条目,再回到本文按步骤落地。
需要先说明一点:Reportify 不是那种带可视化设计器、连数据库就能出图的 BI 工具。它的工作方式是代码驱动的——你在 Ruby 里声明列,它负责把结果集映射成行,并按你选的格式输出。因此本文的重点是「怎么把列定义写对」以及「怎么把输出接到控制器和视图上」,而不是界面操作。
准备工作
环境要求
Reportify 以 gem 形式分发,运行在 Ruby 环境中,示例代码基于 Rails 的控制器与视图约定。开始之前确认以下几点:
- 已经有一个可运行的 Ruby 项目,并且能正常执行
bundle。 - 项目使用 Bundler 管理依赖(推荐),或者你愿意用
gem install全局安装。 - 如果要做 CSV 下载,控制器里需要能调用
send_data,这是 Rails 提供的响应方法。 - 如果打算用自定义 MIME 类型的方式导出,需要能修改
config/initializers下的初始化文件。
安装
在应用的 Gemfile 中加入一行:
gem 'reportify'
然后执行:
bundle
如果不想走 Bundler,也可以直接安装到当前 Ruby 环境:
gem install reportify
安装完成后不需要额外的生成器或迁移,Reportify 不落库,它只在内存里把结果集转成报表结构。
先想清楚三件事
动手写代码之前,先把下面三点定下来,后面会省很多返工:
- 数据源是什么:是一个模型的查询结果(例如
Driver.all),还是一个数组套哈希的结构。Reportify 两种都支持,但取值方式不同。 - 要哪些列:每一列对应数据对象上的一个方法名,可以是普通属性,也可以是关联对象上的属性。
- 要不要合计:如果报表里有数字列需要求和,需要提前决定用行合计、列合计还是两者都要。
操作步骤
第一步:定义报表列
报表的骨架是一组 Reportify::ReportColumn 对象。每个对象描述一列:取哪个字段、表头显示什么、是否参与合计、用什么格式化器。
先准备一个空数组,然后逐列 push 进去:
report_cols = []
report_cols.push Reportify::ReportColumn.new( {:method_name => 'driver_fullname', :summarize => 'none'} )
report_cols.push Reportify::ReportColumn.new( {:method_name => 'make', :summarize => 'none'} )
report_cols.push Reportify::ReportColumn.new( {:method_name => 'races_won', :summarize => 'both'} )
这段代码定义了三列:车手全名、车型、获胜场次。前两列不参与合计,第三列同时参与行合计与列合计。列的先后顺序就是报表中从左到右的顺序,所以数组的 push 顺序要和你想要的表头顺序一致。
第二步:创建 Builder
列定义好之后,把数据源和列一起交给 Reportify::Builder:
@drivers = Driver.all
@report = Reportify::Builder.new(@drivers, report_cols)
Builder 的构造参数是「结果集 + 列定义数组」。这一步不会立刻生成表格,只是把两者绑定起来,后续可以针对同一个 Builder 反复以不同格式构建输出——比如页面上渲染 HTML,同时提供 CSV 下载,不需要重新组装数据。
第三步:选择输出格式并构建
Reportify 通过 ReportFormat.create 指定输出格式。要在视图中渲染成 HTML 表格,用 html_table:
report_rows = @report.build(Reportify::ReportFormat.create('html_table'))
这行代码通常放在控制器里,把构建好的行数据传给视图。也可以直接在视图中调用,取决于你希望把构建时机放在哪一层。
第四步:在视图中渲染 HTML 表格
下面是一个可以直接拿来改的 partial 骨架。它先遍历 headings 输出表头,再遍历构建结果输出每一行,每个单元格取 formatted_value:
<table class="no-style full tree" id="open_order_report">
<thead>
<tr>
<% reportify.headings.each do |field| %>
<th><%= field %></th>
<% end %>
</tr>
</thead>
<tbody>
<% reportify.build(reportify::ReportFormat.create('html_table')).each do |row| %>
<tr>
<% row.each do |col| %>
<td><%= col.formatted_value %></td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
注意两点:表头来自 headings,单元格内容来自每个列对象的 formatted_value 而不是原始值——这样格式化器(下一步会讲)才会生效。上面的片段里 reportify 是局部变量名,实际使用时替换成你在控制器里赋值的实例变量即可,同时注意 Ruby 常量首字母大写,Reportify::ReportFormat 不要写成小写。
第五步:导出 CSV
最直接的导出方式是在控制器里用 send_data 把构建结果作为附件发出去:
send_data(@report.build(Reportify::ReportFormat.create('ruby_csv')),
:type => 'text/csv; charset=iso-8859-1; header=present',
:disposition => "attachment; filename=race_stats.csv")
这里把格式换成 ruby_csv,其余参数是 Rails 的标准下载响应设置:MIME 类型、字符集、以附件形式下载、文件名。文件名可以按业务需要动态拼接。
第六步(可选):注册自定义 MIME 类型,让导出更顺手
如果导出入口很多,每次都写 send_data 会比较啰嗦。可以注册一个自定义 MIME 类型别名,然后用 respond_to 分派。先在 config/initializers/mime_types.rb 中注册:
Mime::Type.register_alias "text/csv", :csv_reportify
再在初始化文件里加一个渲染器,把「构建 CSV + 发送数据」封装起来:
require "action_controller"
ActionController::add_renderer :csv_reportify do |report, options|
name = options[:file_name] || "csv_reportify"
send_data(report.build(Reportify::ReportFormat.create('ruby_csv')),
:type => Mime::CSV_REPORTIFY,
:disposition => "attachment; filename=#{name}.csv")
end
之后控制器里就可以写成:
respond_to do |format|
format.html
format.csv_reportify { render :csv_reportify => @report, :file_name => "racecar.csv" }
end
这样同一个 action 既能返回 HTML 页面,也能在请求 .csv_reportify 格式时直接下载文件,文件名通过 :file_name 传入,不传则回退到默认名。
第七步:按需使用格式化器
默认情况下每一列都按字符串处理。如果某一列是数字、百分比或日期,需要显式指定格式化器。格式化器通过 ColumnFormat.create 创建,然后作为 :formatter 传给列定义:
whole_number_formatter = Reportify::ColumnFormat.create('whole_number')
ReportColumn.new({:method_name => 'weight', :formatter => whole_number_formatter})
内置格式化器包括:
| 格式化器名称 | 作用 | 示例 |
|---|---|---|
percent | 把数值显示为百分比 | 98 → %98.00 |
week_of_year | 把日期转成一年中的第几周 | 日期 → 1–52 的数字 |
whole_number | 取整 | 10.44 → 10 |
内置格式化器不够用时,可以自己写一个。做法是在 /lib/reportify/column_formatters 下新建类,实现一个 format 方法,签名如下:
def format(value, options={})
end
方法接收原始值和可选参数,返回最终要显示的内容。这样业务里特殊的金额格式、单位后缀、脱敏规则都可以封装成独立格式化器,在列定义里复用。
一个完整示例
下面把前面的步骤串起来,做一个「车手比赛统计」报表:页面上显示表格,同时提供 CSV 下载。
1. 控制器
class RaceStatsController < ApplicationController
def index
report_cols = []
report_cols.push Reportify::ReportColumn.new(
{:method_name => 'driver_fullname', :display_name => '车手', :summarize => 'none'}
)
report_cols.push Reportify::ReportColumn.new(
{:method_name => 'make', :display_name => '车型', :summarize => 'none'}
)
report_cols.push Reportify::ReportColumn.new(
{:method_name => 'races_won', :display_name => '获胜场次', :summarize => 'column'}
)
@drivers = Driver.all
@report = Reportify::Builder.new(@drivers, report_cols)
respond_to do |format|
format.html
format.csv_reportify { render :csv_reportify => @report, :file_name => "race_stats.csv" }
end
end
end
2. 视图
<table class="no-style full tree" id="race_stats_report">
<thead>
<tr>
<% @report.headings.each do |field| %>
<th><%= field %></th>
<% end %>
</tr>
</thead>
<tbody>
<% @report.build(Reportify::ReportFormat.create('html_table')).each do |row| %>
<tr>
<% row.each do |col| %>
<td><%= col.formatted_value %></td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
3. 下载链接
<%= link_to '下载 CSV', race_stats_path(:format => :csv_reportify) %>
这个例子里三列都指定了 display_name,所以表头显示中文;获胜场次列使用 column 合计,报表底部会出现该列的合计值。如果去掉 display_name,Reportify 会对 method_name 执行 titleize 生成表头,例如 races_won 会变成「Races Won」这类形式。
注意事项
列定义中的三个参数
method_name 是必填项,其余可选。理解这三个参数的行为能避免大部分问题:
- method_name:数据对象上要调用的方法名。如果数据源是对象数组,直接写属性名即可,例如
User(:name => 'Joe')对应ReportColumn.new({:method_name => 'name'})。如果数据源是「数组套哈希」而不是对象,同样的写法依然有效。 - 关联属性:需要取关联对象上的字段时,用点号连接,例如
ReportColumn.new({:method_name => 'user.address'})。这要求关联已经加载或可访问,否则取值会失败。 - display_name:表头文字,可选。不填时对
method_name做 titleize。 - summarize:合计方式,可选,默认
none。合法取值为row、column、both、none。
行合计与列合计不要混用
这是最容易踩的一个坑。如果同时存在行合计和列合计,右下角不会出现总计值——因为两种合计方式混在一起时,总计没有明确含义。想要总计,就要保持一致:全部用 row,或者全部用 column。
举个具体例子。假设数据是:
A | B | C
1 | 2 | 3
2 | 3 | 4
2 | 5 | 7
如果只给 B 列设置列合计,A 和 B 设置行合计,结果会变成:
A | B | C | Total
1 | 2 | 3 | 6
2 | 3 | 4 | 9
| 5 | |
可以看到右下角是空的。所以设计报表时先决定合计策略,再逐列设置,不要一边加行合计一边加列合计。
格式化器要显式指定
不指定 :formatter 时,列值按字符串处理。数字列如果不加格式化器,可能出现小数位不一致或显示为原始浮点数的情况。需要整数展示就挂 whole_number,需要百分比就挂 percent。
旧版 IE 的表单提交问题
使用自定义 MIME 类型配合表单标准提交时,在旧版 IE 上可能遇到格式参数不生效的问题。规避办法是在表单里放一个隐藏字段保存格式,下载按钮改为调用 JavaScript 函数:先把隐藏字段设为 csv_reportify,提交表单,再改回 html。
<%= hidden_field_tag 'format', 'html' %>
<%= link_to_function 'Download as CSV', "submit_as_csv_reportify();" %>
function submit_as_csv_reportify() {
$("#my_form #format").val("csv_reportify");
$('#my_form').submit();
$("#my_form #format").val("html");
return false;
}
如果项目已经不再支持旧版 IE,这一段可以跳过,直接用 respond_to 加格式后缀的链接即可。
关于版本与兼容性
Reportify 的具体版本、对 Ruby 与 Rails 版本的支持范围、以及格式化器是否新增了选项,都会随时间变化。本文示例中的类名与方法签名以素材描述为准,实际接入前请以官方当前发布的信息核对,尤其是 ReportFormat 与 ColumnFormat 的命名空间在不同版本间可能不一致——素材中同时出现过 Reportify::ReportFormat 与 ReportBuilder::ReportFormat 两种写法,落地时以你安装的版本为准。
自定义格式化器的放置位置
自定义格式化器要放在 /lib/reportify/column_formatters 目录下,并实现 format(value, options={})。放在其他目录不会被自动发现。写完记得在列定义里通过 ColumnFormat.create 引用。