2012年11月26日 星期一

《Stocktotal》之一:使用 JasperReport 產生報表

這是很大的題目,把我腦細胞搞死好多個。



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:
    <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 檔的方式包裝)):

以上是概說。


寫程式的過程,十足讓我傷透腦筋。我手上有 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>

沒有留言:

張貼留言