Java後端使用Freemarker匯出word文件的各種細節
1.前言
最近在專案中,因客戶要求,需要做一個匯出成word的功能(比如月度報表等),技術選型也考慮過幾種,比如easypoi,itext,但發現這兩種在實現起來有困難,所以最終還是選Freemarker模板進行匯出,靈活性比較好。
2.實現步驟
1.準備好標準文件的word,標題格式間距什麼的先設計好,這是減少後面修改模板文很重要一步;
2.開啟word原件把需要動態修改的內容替換成***,如果有圖片,儘量選擇較小的圖片幾十K左右,並調整好位置;
3.另存為,選擇儲存型別Word 2003 XML 文件(*.xml)【這裡說一下為什麼用Microsoft Office Word開啟且要儲存為Word 2003XML,本人親測,用WPS找不到Word 2003XML選項,如果儲存為Word XML,會有相容問題,避免出現匯出的word文件不能用Word 2003開啟的問題】,還有儲存的檔名儘量不要是中文;
4.用NotePad開啟檔案,notepad預先裝好xml的外掛,然後格式化,當然也可以用Firstobject free XML editor開啟檔案,選擇Tools下的Indent【或者按快捷鍵F8】格式化檔案內容。看個人喜歡;
notepad xml外掛下載地址:https://sourceforge.net/projects/npp-plugins/files/XML%20Tools/
5. 將文件內容中需要動態修改內容的地方,換成freemarker的標識。其實就是Map<String, Object>中key,如${userName};
6.在加入了圖片佔位的地方,會看到一片base64編碼後的程式碼,把base64替換成${image},也就是Map<String, Object>中key,值必須要處理成base64;
程式碼如:<w:binData w:name="wordml://自定義.png" xml:space="preserve">${image}</w:binData>
注意:
(1)“>${image}<”這尖括號中間不能加任何其他的諸如空格,tab,換行等符號。
(2)如果是多張圖片需要迴圈圖片 w:name 和v:imagedata 的src需要變化的
(3)如果圖片的寬高最好是在後端自定義(我這裡是固定寬然後高比例變化),不至於圖片很寬匯出的word圖片變形
完整例項如下
<w:binData w:name="${"wordml://03000001"+ins_index+1+".jpg"}" xml:space="preserve">${ins.insHealthImg.code}</w:binData>
<v:shape id="圖片 10" o:spid="_x0000_i1032" type="#_x0000_t75" style="width:${ins.insHealthImg.width}pt;height:${ins.insHealthImg.height}pt;visibility:visible;mso-wrap-style:square">
<v:imagedata src="${"wordml://03000001"+ins_index+1+".jpg"}" o:title=""/>
</v:shape>
7. 標識替換完之後,模板就弄完了,另存為.ftl字尾檔案即可。注意:一定不要用word開啟ftl模板檔案,否則xml內容會發生變化,導致前面的工作白做了。
3.程式碼實現
引入依賴
<dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.28</version> </dependency>
匯出的工具類FreemarkerBase
import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.servlet.http.HttpServletResponse; import java.io.*; import java.util.Map; /** * @author lpf * @create 2018-11-03 17:27 **/ public class FreemarkerBase { protected final Logger logger = LoggerFactory.getLogger(getClass()); private Configuration configuration = null; /** * 獲取freemarker的配置. freemarker本身支援classpath,目錄和從ServletContext獲取. */ protected Configuration getConfiguration() { if (null == configuration) { configuration = new Configuration(Configuration.VERSION_2_3_28); configuration.setDefaultEncoding("utf-8"); //ftl是放在classpath下的一個目錄 configuration.setClassForTemplateLoading(this.getClass(), "/template/"); } return configuration; } /** * 匯出word * * @param response * @param templateName * @param dataMap */ public void downLoad(HttpServletResponse response, String templateName, Map<String, Object> dataMap) throws IOException { OutputStream os = response.getOutputStream(); Writer writer = new OutputStreamWriter(os, "utf-8"); Template template = null; try { template = getConfiguration().getTemplate(templateName, "utf-8"); template.process(dataMap,writer); os.flush(); writer.close(); os.close(); } catch (TemplateException e) { logger.error("模板檔案異常,請檢查模板檔案路徑和檔名:" + e.getMessage()); } catch (IOException e) { logger.error("IO異常,匯出到瀏覽器出錯:" + e.getMessage()); } } }
這裡因為是瀏覽器匯出,使用輸出流用的response,而網上一般的教程都是先生存臨時檔案在讀取檔案流輸出,然後刪除臨時檔案,我任務是多餘的步驟;
匯出程式碼
@RequestMapping(value = "/download") public void downWord(HttpServletRequest request, HttpServletResponse response) throws IOException { Map<String, Object> dataMap = this.getWordData(request);//封裝資料的方法 FreemarkerBase freemarkerBase = new FreemarkerBase(); String fileName = "XXXXX.doc"; response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment;filename=" + new String(fileName.getBytes("gb2312"), "ISO8859-1")); freemarkerBase.downLoad(response, "templete_min.ftl", dataMap); }
核心程式碼就上面這些,當然一個比較複雜的word匯出在封裝資料的時候肯定會碰到問題
4.遇到的問題
1.圖片資料來源
如果插入圖片是本地已經存在的圖片那很好辦,讀取圖片轉成base64即可,但是在專案中圖片本地並沒有而是在前端頁面用echart生成的圖片。
我的思路是利用phantomjs模擬瀏覽器請求前端頁面利用echart生成圖片將生成圖片的base64傳入後端
程式碼邏輯
前端請求下載word
@RequestMapping(value = "/download") public void download(HttpServletRequest request,HttpServletResponse response) throws IOException { String rptId = request.getParameter("rptId"); User userInfo = (User) request.getSession().getAttribute("user"); Long startTime= System.currentTimeMillis(); Long currentTime = null; WordWrite.Domain(rptId);//模擬瀏覽器請求生成圖片 while (true){// if(WordWrite.imgsMap.get(rptId)!=null){//監聽圖片是否已經生成好 reportWordService.downWord(request,response); WordWrite.imgsMap.remove(rptId); break; }else{ currentTime = System.currentTimeMillis(); if((currentTime-startTime)/1000>60){//新增下載超時的判斷避免死迴圈 break; } } } }
模擬瀏覽器請求方法
生成圖片工具類
public static void Domain(String rptId) throws IOException { ReportService reportService = SpringContextHolder.getBean("reportService"); List<Map<String, Object>> instanceList = reportService.getRelationInstanceByReportId(rptId); StringBuffer sb = new StringBuffer(); for(int i =0;i<instanceList.size();i++){ String _uid = (String)instanceList.get(i).get("target_id"); sb.append(_uid+","); } String uids = sb.substring(0,sb.length()-1); String paramStr = "target_ids="+uids+";rptId="+rptId; paramStr = URLEncoder.encode(paramStr ,"UTF-8"); propPath = WordWrite.class.getResource("/").toString(); String[] ps = propPath.split("file:/")[1].split("/"); String[] newPaths = Arrays.copyOfRange(ps, 0, ps.length-6); propPath = StringUtils.join(newPaths, "/") + "/conf"; if(propPath.indexOf(":") == -1){ propPath = "/"+propPath; System.out.println("propPath linux"); }else if(propPath.indexOf(":") != -1){ System.out.println("propPath windows"); } System.out.println("phantomjs.properties檔案所在目錄:"+propPath+"/phantomjs.properties"); FileInputStream in = new FileInputStream(propPath+"/phantomjs.properties"); String[] _path = Arrays.copyOfRange(ps,0,ps.length-2); WordWritePath = StringUtils.join(_path, "/")+"/jsp/pages/"; if(WordWritePath.indexOf(":") == -1){ WordWritePath = "/"+WordWritePath; System.out.println("WordWritePath linux"); }else if(WordWritePath.indexOf(":") != -1){ System.out.println("WordWritePath windows"); } System.out.println("截圖時需要用到的js路徑:"+WordWritePath); proper = new Properties(); proper.load(in); in.close(); // 生成月報圖片 dopng(proper,"month",paramStr); }
/** * 儲存網頁中的圖片 * @return * @throws IOException */ public static String dopng(Properties pro,String type, String jsParam) throws IOException{ String jspUrl = pro.getProperty("jsp"); //"http://localhost:8080/RtManageCon/jsp/pages/nobrowserpages/chartsByNoBrowser.jsp"; if(jsParam != null){ jspUrl = jspUrl+"?"+jsParam; } String jsurl = ""; switch (type) { case "day": jsurl = " "+WordWrite.WordWritePath+"phantomjs/"+pro.getProperty("dayjs")+" "; break; case "week": jsurl = " "+WordWrite.WordWritePath+"phantomjs/"+pro.getProperty("weekjs")+" "; break; case "month": jsurl = " "+WordWrite.WordWritePath+"phantomjs/"+pro.getProperty("monthjs")+" "; break; default: jsurl = " "+WordWrite.WordWritePath+"phantomjs/"+pro.getProperty("monthjs")+" "; break; } return downloadImage(jsurl,jspUrl); }
public static String downloadImage(String jsurl,String url) throws IOException { String cmdStr = PHANTOM_PATH + jsurl + url; //String cmdStr = "C:/develop/phantomjs-2.1.1-windows/bin/phantomjs.exe " + jsurl + url; System.out.println("命令列字串:"+cmdStr); Runtime rt = Runtime.getRuntime(); try { rt.exec(cmdStr); } catch (IOException e) { System.out.println("執行phantomjs的指令失敗!請檢查是否安裝有PhantomJs的環境或配置path路徑!"); } return cmdStr; }
public static final ConcurrentMap<String,Object> imgsMap = new ConcurrentHashMap<>();用來接收圖片的base64編碼
//接收圖片base64編碼 public static void doExecutoer(Map<String,Object> map){ imgsMap.putAll(map); /*原子操作,如果期望值是false時,則執行賦值 if(exists.compareAndSet(false,true)){ imgsMap.clear(); imgsMap = map; }*/ }
前端js
var system = require('system'); var page = require('webpage').create(); // 如果是windows,設定編碼為gbk,防止中文亂碼,Linux本身是UTF-8 var osName = system.os.name; console.log('os name:' + osName); if ('windows' === osName.toLowerCase()) { phantom.outputEncoding="gbk"; } // 獲取第二個引數(即請求地址url). var url = system.args[1]; console.log('url:' + url); // 顯示控制檯日誌. page.onConsoleMessage = function(msg, lineNum, sourceId) { console.log('CONSOLE: ' + msg + ' (from line #' + lineNum + ' in "' + sourceId + '")'); }; //開啟給定url的頁面. var start = new Date().getTime(); // 頁面大小 ------------------------------------------------------------------------------ page.viewportSize={width:650,height:400}; // ----------------------------------------------------------------------------------------- page.open(url, function(status) { if (status == 'success') { console.log('echarts頁面載入完成,載入耗時:' + (new Date().getTime() - start) + ' ms'); page.evaluate(function() { console.log("月報js"); getAjaxRequest("month");//改方法去實現生成圖片並傳入後端 }); } else { console.log("頁面載入失敗 Page failed to load!"); } // 5秒後再關閉瀏覽器. setTimeout(function() { phantom.exit(); }, 15*1000); });
有不熟悉phantomjs的可以查詢下資料大概瞭解就行。
2.匯出的word比較大
用模版匯出的方式,這個問題不可避免,因為模版是XML,本身帶有大量的標籤,注意在XML裡寫迴圈的時候注意 不要生成不必要的 標籤,另外XML模版弄好後壓縮一下,然後匯出的word大小就減少很多啦。
3.由於下載時間長,避免重複下載,客戶希望在前端有一個載入等待框
利用iframe實現下載等待,用iframe實現下載等待的原理是把下載的路徑給iframe的src,然後監聽iframe的onload事件,當後臺處理完成並返回檔案時,會觸發iframe的onload事件。
這裡有一個帖子的詳細說明:https://blog.csdn.net/fgx_123456/article/details/79603455
但是我在專案中總是無法監聽到onload事件。瀏覽器給的提示是請求一直沒完成。後面也一直沒找到原因,沒有找到解決辦法,不知道誰遇到過著個問題沒。
後面沒辦法用了框架中的WebSocket主動向前端相應下載完成,等待載入結束。在上面下載介面的程式碼上改造如下
@RequestMapping(value = "/download") public void download(HttpServletRequest request,HttpServletResponse response) throws IOException { String rptId = request.getParameter("rptId"); User userInfo = (User) request.getSession().getAttribute("user"); Long startTime= System.currentTimeMillis(); Long currentTime = null; WordWrite.Domain(rptId); while (true){ if(WordWrite.imgsMap.get(rptId)!=null){ reportWordService.downWord(request,response); for(WebSocketForJSP item: WebSocketForJSP.webSocketSet){ if(userInfo.getUsername().equals(item.userName)){ JSONObject resultObj = new JSONObject(); resultObj.put("reportCode", 0); resultObj.put("msg", "月報表匯出成功"); item.sendMessage(resultObj.toJSONString()); } } WordWrite.imgsMap.remove(rptId); break; }else{ currentTime = System.currentTimeMillis(); if((currentTime-startTime)/1000>60){ for(WebSocketForJSP item: WebSocketForJSP.webSocketSet){ if(userInfo.getUsername().equals(item.userName)){ JSONObject resultObj = new JSONObject(); resultObj.put("reportCode", -1); resultObj.put("msg", "月報表匯出超時"); item.sendMessage(resultObj.toJSONString()); } } break; } } } }
WebSocket的一些實現程式碼就沒貼了,有需要歡迎留言。
5.結束語
如果對Freemarker標籤不熟的,可以在網上先學習下,瞭解文件結構,模板需要足夠的耐心和仔細。
Firstobject free XML editor下載地址:http://www.firstobject.com/dn_editor.htm
freemarker 官網:http://freemarker.org/
phantomjs下載 http://phantomjs.org/download.html