1. JasperReport
Report Life Cycle:
JasperReports is an open source Java reporting tool that can write to a variety of targets, such as: screen, a printer, into PDF, HTML,Microsoft Excel, RTF, ODT, Comma-separated values or XML files.
生成 JasperReport 報表,必須完整走過三階段:design phase, execution phase, export phase。
1.1 Design Phase
為人熟知的設計工具是 iReport,目前版本是 4.8.0。iReport 當然不是隨意拉拉版面就能了事的,在 execution phase 我們看到 Data,也就是說,除了版面,我們更在意內容。
Data Source,報表的命。
JasperReports 支援很多 data source,因為事前有研究,JasperReports 對 PostgreSQL 的支援算不錯,原先我使用 SQLite 當 database,後來種種因素,採用品質更優良的 PostgreSQL 當 database ((這麼說或許不公平))。
Datasets,報表的資料集合。
有了 SQL database 當 data source,接著要下 SQL query 才能取出 database 的資料。一個 SQL query,代表一個 dataset
SQL query <=> dataset
舉例來說 ROE Dataset 裡的 query 就是:
<subDataset name="ROE Dataset" uuid="4233d94c-76a5-4422-97c8-3c347be0be3b">
<parameter name="STOCK_CODE" class="java.lang.String" isForPrompting="false">
<defaultValueExpression><![CDATA["1301"]]></defaultValueExpression>
</parameter>
<queryString>
<![CDATA[with T as
(
select
A.activity_date,
A.report_date,
A.number as shareholder_equity,
B.number as net_income,
C.number as operating_income,
D.number as total_assets
from
BalanceSheet as A,
IncomeStmt as B,
IncomeStmt as C,
BalanceSheet as D
where
A.stock_code = B.stock_code
and B.stock_code = C.stock_code
and C.stock_code = D.stock_code
and A.activity_date = B.activity_date
and B.activity_date = C.activity_date
and C.activity_date = D.activity_date
and A.report_date = B.report_date
and B.report_date = C.report_date
and C.report_date = D.report_date
and A.item = '股東權益總計'
and B.item = '合併總損益'
and C.item = '營業收入合計'
and D.item = '資產總計'
and A.report_type = 'C'
and B.report_type = 'C'
and C.report_type = 'C'
and D.report_type = 'C'
and A.stock_code = $P{STOCK_CODE}
and A.number != 0
and B.number != 0
and C.number != 0
and D.number != 0
)
select
V.activity_date,
V.annual_adjusted_net_income/V.shareholder_equity as roe,
V.annual_adjusted_net_income/V.annual_adjusted_operating_income as
net_profit_margin,
V.annual_adjusted_operating_income/V.total_assets as total_assets_turnover,
V.total_assets/V.shareholder_equity as equity_multiplier
from
(
select
T.activity_date,
T.shareholder_equity,
case
when date_part('month', T.activity_date) = 3 then T.net_income * 4/1
when date_part('month', T.activity_date) = 6 then T.net_income * 4/2
when date_part('month', T.activity_date) = 9 then T.net_income * 4/3
else T.net_income
end as annual_adjusted_net_income,
case
when date_part('month', T.activity_date) = 3 then
T.operating_income * 4/1
when date_part('month', T.activity_date) = 6 then
T.operating_income * 4/2
when date_part('month', T.activity_date) = 9 then
T.operating_income * 4/3
else T.operating_income
end as annual_adjusted_operating_income,
T.total_assets
from
T,
(
select activity_date, max(report_date) as report_date from T
group by activity_date
) as U
where
T.activity_date = U.activity_date
and T.report_date = U.report_date
order by T.activity_date
) as V]]>
</queryString>
<field name="activity_date" class="java.sql.Date"/>
<field name="roe" class="java.lang.Double"/>
<field name="net_profit_margin" class="java.lang.Double"/>
<field name="total_assets_turnover" class="java.lang.Double"/>
<field name="equity_multiplier" class="java.lang.Float"/>
</subDataset>
裡面有一個不尋常的東西,$P{STOCK_CODE},這是 dataset 自身的 parameters,與外面 main report 的 parameters 沒關連,這點特別注意,否則會踩到很多地雷。
若使用 iReport 生出 dataset,得注意 SQL query 不能使用 parameters,我們得先生出 dataset,再生出 $P{STOCK_CODE} parameter,接著再 edit query,把寫死的 stock code 改成 $P{STOCK_CODE}。
Parameters 有兩個重要 properties 可以設定,一個是 isForPrompting,另一個是 defaultValueExpression。設計之初,我把 Use as a prompting 取消,Default Value Expression 設成 "1301"
Chart/Table,報表的視覺元素。
這是我看重的部分。根據 dataset 的特性,我們選用最合適的 chart。舉例來說,ROE Dataset 是一串 time series 的資料,我們可以用 timeseries chart 來表現 dataset。
如果想在同一 timeseries chart 畫出兩組以上的資料,Y-axis 不免顯得壅擠,我們可以使用 multi axis chart 當 container,然後加入兩個以上的 timeseries chart,然後再把 Y-axis 分邊 ((Axis properties => Position => Right or Bottom))。
如果不想讓報表分頁,可以在報表 properties => Ignore pagination 打勾。當然還有其他小細節,例如在 Excel,我們可以將滿足某些條件的數字用紅色加以強調,那我們就要在 Styles 自己加 style:
當然,我們也能直接使用 Java JasperReports API 設計報表。這是很有用的功能,例如說我想動態改變 dataset 中 parameter 的值。最差的辦法就是直接開 JRXML,用 Java XML lib 直接染指 parameter 的 defaultValueExpression。
比較好的方法是用 JasperReports API
如果只是染指 main report 的 parameter,那倒是很容易:
1.2 Execution Phase
Execution phase 與 design phase 是相輔相成的。簡單說,就是直接在 iReport 按 Preview,記得將 Preview 設成 Internal Preview,增加開發速度。
1.3 Export Phase
Reference: http://community.jaspersoft.com/wiki/exporting-reports
在 iReport 將 Preview 設成 HTML Preview,按 Preview,然後按下 Save icon 就可以了。但這樣是沒辦法用程式自動化的 ((點 UI 真是可怕))。
1.4 Deploying Phase
為了讓程式自動產生報表,我必須重操舊業,重新複習生澀的 Java。我很感謝前同事 Taco 教我許多 Java programming 知識。
Reference: http://community.jaspersoft.com/wiki/deploying-reports
先來看我用哪些東西:
接著關心一下引用哪些 Java library ((以 jar 檔的方式包裝)):
若使用 iReport 生出 dataset,得注意 SQL query 不能使用 parameters,我們得先生出 dataset,再生出 $P{STOCK_CODE} parameter,接著再 edit query,把寫死的 stock code 改成 $P{STOCK_CODE}。
Parameters 有兩個重要 properties 可以設定,一個是 isForPrompting,另一個是 defaultValueExpression。設計之初,我把 Use as a prompting 取消,Default Value Expression 設成 "1301"
Chart/Table,報表的視覺元素。
這是我看重的部分。根據 dataset 的特性,我們選用最合適的 chart。舉例來說,ROE Dataset 是一串 time series 的資料,我們可以用 timeseries chart 來表現 dataset。
如果想在同一 timeseries chart 畫出兩組以上的資料,Y-axis 不免顯得壅擠,我們可以使用 multi axis chart 當 container,然後加入兩個以上的 timeseries chart,然後再把 Y-axis 分邊 ((Axis properties => Position => Right or Bottom))。
如果不想讓報表分頁,可以在報表 properties => Ignore pagination 打勾。當然還有其他小細節,例如在 Excel,我們可以將滿足某些條件的數字用紅色加以強調,那我們就要在 Styles 自己加 style:
<style name="red current_ratio">
<conditionalStyle>
<conditionExpression><![CDATA[$F{current_ratio} < 2]]></conditionExpression>
<style forecolor="#FF0000"/>
</conditionalStyle>
</style>
有時候動手直接改 XML 反而比較快,但要小心改爛的風險。改之前最好備份一下設計檔 ((JRXML,以 XML 呈現報表排版及內容))當然,我們也能直接使用 Java JasperReports API 設計報表。這是很有用的功能,例如說我想動態改變 dataset 中 parameter 的值。最差的辦法就是直接開 JRXML,用 Java XML lib 直接染指 parameter 的 defaultValueExpression。
比較好的方法是用 JasperReports API
JasperDesign jasperDesign = JRXmlLoader.load(designFile);
Map<String, JRDataset> datasetMap = jasperDesign.getDatasetMap();
Object[] names = datasetMap.keySet().toArray();
for (Object name : names) {
JRDesignDataset dataset = (JRDesignDataset)datasetMap.get((String)name);
jasperDesign.removeDataset(dataset);
Map<String, JRParameter> parametersMap = dataset.getParametersMap();
if (!parametersMap.containsKey(STOCK_CODE)) {
continue;
}
JRDesignParameter stockCodeParameter = (JRDesignParameter)parametersMap.get(STOCK_CODE);
JRDesignExpression expression = new JRDesignExpression(stockCode);
stockCodeParameter.setDefaultValueExpression(expression);
jasperDesign.addDataset(dataset);
}
不怎麼好看,但這是我想出來還算可以的辦法。如果只是染指 main report 的 parameter,那倒是很容易:
HashMap<String, Object> parameters = new HashMap<String, Object>();
parameters.put(STOCK_CODE, stockCode);
JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, parameters, conn);
超簡單,也超乾淨的。1.2 Execution Phase
Execution phase 與 design phase 是相輔相成的。簡單說,就是直接在 iReport 按 Preview,記得將 Preview 設成 Internal Preview,增加開發速度。
1.3 Export Phase
Reference: http://community.jaspersoft.com/wiki/exporting-reports
在 iReport 將 Preview 設成 HTML Preview,按 Preview,然後按下 Save icon 就可以了。但這樣是沒辦法用程式自動化的 ((點 UI 真是可怕))。
1.4 Deploying Phase
為了讓程式自動產生報表,我必須重操舊業,重新複習生澀的 Java。我很感謝前同事 Taco 教我許多 Java programming 知識。
Reference: http://community.jaspersoft.com/wiki/deploying-reports
先來看我用哪些東西:
- Java 開發環境: Eclipse。Eclipse IDE for Java EE Developers,有 Windows 版本跟 Mac 版本,只要解壓縮完就可以直接用了。
- Java build tool: Apache Ant。
- Java argparse: Commons CLI。用過 Python argparse 都知道 argparse 真是有夠好用的,因此我想找相對應的 argparse。
- Java logging: log4j。用過的都說好。
- Java configuration: Commons Configuration。
接著關心一下引用哪些 Java library ((以 jar 檔的方式包裝)):
- JasperReports Library: http://community.jaspersoft.com/project/jasperreports-library/releases ((這是 login 後得到的連結)),目前我使用 jasperreports-4.8.0-project.zip,這包除了 JasperReports library,還有 3rd-party dependency library,這樣我們就不用重新再一個一個找 library。
- PostgreSQL JDBC: http://jdbc.postgresql.org/download.html,目前我使用 PostgreSQL 9.2,所以使用 9.2-1002 JDBC 3。
- Others: Commons Configuration 有個陷阱,我們還得加入 Commons Lang 2.6,太高的版本還不可以用喔,蠻害人的。
以上是概說。
寫程式的過程,十足讓我傷透腦筋。我手上有 JRXML JasperReports 設計檔,但 JRXML 缺少 data source 設定,原先由 iReport 全權處理,這次得自己處理這段。
private Connection getConn() throws Exception {
Class.forName(config.getString("database.driver"));
String url = config.getString("database.url");
String username = config.getString("database.username");
String password = config.getString("database.password");
return DriverManager.getConnection(url, username, password);
}
其中 config 就是 org.apache.commons.configuration.XMLConfiguration,設定檔內容是
<?xml version="1.0" encoding="UTF-8"?>
<config>
<database>
<driver>org.postgresql.Driver</driver>
<url>jdbc:postgresql://localhost:5432/database</url>
<username>username</username>
<password>password</password>
</database>
<report>
<designFile>designFile.jrxml</designFile>
<destFilePathTemplate>report\designFile-%s.html</destFilePathTemplate>
</report>
</config>
關於 Class.forName() 怪招,可以參考 Establishing a Connection,細節頗多,留意一下。接著是 iReport 產出報表,這段得用 Java 來做。關於最困難的部分,設定 parameters,前面大致講過,就不再重複。
JasperDesign jasperDesign = JRXmlLoader.load(designFile);
JasperReport jasperReport = JasperCompileManager.compileReport(jasperDesign);
Connection conn = getConn();
JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, parameters, conn);
JasperExportManager.exportReportToHtmlFile(jasperPrint, reportFile);
conn.close();
基本骨幹就是這樣。其他細節偏題頗遠,將會另開文章討論 ((Python 如何抓資料、PostgreSQL、JRXML、更詳細的 Java 程式解說,等等))
附註:
[1] ReportGenerator.java
package stocktotal.report;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.HashMap;
import java.util.Map;
import net.sf.jasperreports.engine.JRDataset;
import net.sf.jasperreports.engine.JRParameter;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReport;
import net.sf.jasperreports.engine.design.JRDesignDataset;
import net.sf.jasperreports.engine.design.JRDesignExpression;
import net.sf.jasperreports.engine.design.JRDesignParameter;
import net.sf.jasperreports.engine.design.JasperDesign;
import net.sf.jasperreports.engine.xml.JRXmlLoader;
import org.apache.commons.configuration.XMLConfiguration;
import org.apache.log4j.Logger;
public class ReportGenerator {
public void generate(String stockCode, String configFile) throws Exception {
this.config = new XMLConfiguration(configFile);
logger.info("Prepare jrxml for JasperDesign");
String designFile = config.getString("report.designFile");
this.jasperDesign = JRXmlLoader.load(designFile);
logger.info("Set parameters for sub dataset");
setDatasetStockCode(stockCode);
logger.info("Compile to JasperReport file");
jasperReport = JasperCompileManager.compileReport(jasperDesign);
logger.info("Prepare parameters for main report");
HashMap<String, Object> parameters = new HashMap<String, Object>();
parameters.put(STOCK_CODE, stockCode);
logger.info("Prepare datasource for main report");
Connection conn = getConn();
logger.info("Set prepared parameters and datasource for JasperReport");
JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, parameters, conn);
logger.info("Export to HTML format report");
makeReportDirectory(stockCode);
JasperExportManager.exportReportToHtmlFile(jasperPrint, getReportFilePath(stockCode));
logger.info("Release datasource connection");
conn.close();
}
private void setDatasetStockCode(String stockCode) throws Exception {
Map<String, JRDataset> datasetMap = jasperDesign.getDatasetMap();
Object[] names = datasetMap.keySet().toArray();
for (Object name : names) {
JRDesignDataset dataset = (JRDesignDataset)datasetMap.get((String)name);
jasperDesign.removeDataset(dataset);
Map<String, JRParameter> parametersMap = dataset.getParametersMap();
if (!parametersMap.containsKey(STOCK_CODE)) {
continue;
}
JRDesignParameter stockCodeParameter = (JRDesignParameter)parametersMap.get(STOCK_CODE);
JRDesignExpression expression = new JRDesignExpression(stockCode);
stockCodeParameter.setDefaultValueExpression(expression);
jasperDesign.addDataset(dataset);
}
}
private Connection getConn() throws Exception {
Class.forName(config.getString("database.driver"));
String url = config.getString("database.url");
String username = config.getString("database.username");
String password = config.getString("database.password");
return DriverManager.getConnection(url, username, password);
}
private void makeReportDirectory(String stockCode) {
String reportFilePath = config.getString("report.destFilePathTemplate");
String reportDir = (new File(reportFilePath)).getParent();
(new File(reportDir)).mkdir();
}
private String getReportFilePath(String stockCode) {
return String.format(config.getString("report.destFilePathTemplate"), stockCode);
}
final static Logger logger = Logger.getLogger(ReportGenerator.class);
private String STOCK_CODE = "STOCK_CODE";
private XMLConfiguration config;
private JasperDesign jasperDesign;
private JasperReport jasperReport;
}
[2] Program.java ((程式進入點))
package stocktotal.report;
import org.apache.log4j.Logger;
public class Program {
public static void main(String[] args) {
try {
Argparser parser = new Argparser();
parser.parse(args);
String stockCode = parser.getStockCode();
String configFile = parser.getConfigFile();
logger.info(String.format("Stock Code: %s", stockCode));
logger.info(String.format("Config File: %s", configFile));
ReportGenerator generator = new ReportGenerator();
generator.generate(stockCode, configFile);
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
}
final static Logger logger = Logger.getLogger(Program.class);
}
[3] Argparser.java
package stocktotal.report;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.GnuParser;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.OptionBuilder;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
public class Argparser {
@SuppressWarnings("static-access")
public Argparser() {
Option help = new Option("help", "print this message");
Option stockCode = OptionBuilder.withArgName("stockCode")
.hasArg()
.withDescription("set stock code")
.create("stockcode");
Option configFile = OptionBuilder.withArgName("file")
.hasArg()
.withDescription("use given file for config")
.create("configfile");
this.options = new Options();
options.addOption(help);
options.addOption(stockCode);
options.addOption(configFile);
this.parser = new GnuParser();
}
public void parse(String[] args) throws ParseException {
this.cmdline = this.parser.parse(this.options, args);
}
public String getStockCode() {
assert this.cmdline != null;
return cmdline.hasOption("stockcode") ? cmdline.getOptionValue("stockcode") : null;
}
public String getConfigFile() {
assert this.cmdline != null;
return cmdline.hasOption("configfile") ? cmdline.getOptionValue("configfile") : null;
}
private Options options;
private CommandLineParser parser;
private CommandLine cmdline;
}
[4] log4j.xml ((放在 src 目錄底下))
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE log4j:configuration SYSTEM "log4j.dtd">
<log4j:configuration xmlns:log4j="http://jakarta.apache.org/log4j/" debug="false">
<appender name="console" class="org.apache.log4j.ConsoleAppender">
<layout class="org.apache.log4j.PatternLayout">
<param name="ConversionPattern" value="%d{yyyy-MM-dd HH:mm:ss} [%t] %-5p %c{2} %x - %m%n"/>
</layout>
</appender>
<appender name="logfile" class="org.apache.log4j.DailyRollingFileAppender">
<param name="file" value="logfile/logfile.log" />
<param name="append" value="true" />
<param name="datePattern" value="'.'yyyy-MM-dd" />
<layout class="org.apache.log4j.PatternLayout">
<param name="ConversionPattern" value="%d{yyyy-MM-dd HH:mm:ss} [%t] %-5p %c{2} %x - %m%n"/>
</layout>
</appender>
<root>
<level value="INFO"/>
<appender-ref ref="console"/>
<appender-ref ref="logfile"/>
</root>
</log4j:configuration>
沒有留言:
張貼留言