1. 程式人生 > >springboot搭建檔案預覽解決方案,支援目前主流格式office檔案,txt檔案,png,jpg等圖片以及壓縮檔案的線上預覽功能

springboot搭建檔案預覽解決方案,支援目前主流格式office檔案,txt檔案,png,jpg等圖片以及壓縮檔案的線上預覽功能

前言

這些天在做一個檔案預覽的功能,由於之前沒有這方面的經驗,因此在市面上找了一些這方面的資料,發現目前市面上主流的檔案預覽服務是由幾家廠商提供的,做的比較好的有如永中軟體,officeweb365等,不過你們都懂得,是要收費的,所以即便做的再好,我也只能觀望觀望,然後也百度了其他的一些做法,基本上都是利用flexmapper+swf來做的,這種做法最終我沒有采用,因為要依賴的外部的東西實在是太多了,一個檔案線上預覽的服務真的要依賴那麼多外部的服務嗎?在網上搜索的同時,發現了一個開源專案,專案地址是點選開啟連結。我要說明的是,這篇文章確實參考了其中某些實現思路,但還是有很多不同的地方,比如ppt檔案的處理,比如利用map快取等等,希望大家能仔細閱讀之後再做出評論!

思路

這裡說一下我的應用場景:給定一個網址,輸入網址後立即顯示預覽檔案。就這麼一個簡單的場景,檔案的上傳在這裡就不講了,重點要實現的是檔案的預覽,花了大概兩個禮拜的時間終於把這個功能做得差不多了,在這裡特此記錄一下實現的過程以及中途遇到的問題。

步驟:1、先將檔案下載到本地,儲存到某個指定目錄    2、進行檔案轉換,此處是重點       3、進行檔案展示

主要步驟就三個,其中檔案的轉換是重點,因為此處涉及到檔案的操作,我也在這步花了較長的時間。那麼開始梳理實現的過程。

實現過程

首先,搭建一個springboot專案,搭建完畢後項目結構如圖:

之後我們要做的是建立目錄結構,如下圖:

目錄結構中static存放的是靜態資源,templates中存放頁面。其中配置檔案裡存放我們的配置資訊:application.yml

其中tmp:root下面是轉換後文件的存放位置,rootTemp則是下載檔案的臨時存放位置,後續會有定時器定時刪除該目錄下的內容,soffice:home配置的是openoffice的安裝目錄,因為office檔案的轉換要用到openoffice。其中的type則是可以預覽的檔案型別。好了,搭建完畢之後開始搭建Service層,Dao層(可用可不用),Controller層。整體搭建完畢之後如下圖:

相關依賴pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	

	<name>file-conventer</name>
	<description>Demo project for Spring Boot</description>

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>1.5.9.RELEASE</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
		<java.version>1.8</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<!-- jchardet檢查文字字元編碼 start -->
		<dependency>
			<groupId>net.sourceforge.jchardet</groupId>
			<artifactId>jchardet</artifactId>
			<version>1.0</version>
		</dependency>
		<!-- jchardet檢查文字字元編碼 end -->
		<dependency>
			<groupId>org.artofsolving.jodconverter</groupId>
			<artifactId>jodconverter-core</artifactId>
			<version>3.0.0</version>
		</dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-compress</artifactId>
            <version>1.11</version>
        </dependency>
		<!-- 解壓(apache) -->
		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-compress</artifactId>
			<version>1.9</version>
		</dependency>
		<!-- 解壓(rar)-->
		<dependency>
			<groupId>com.github.junrar</groupId>
			<artifactId>junrar</artifactId>
			<version>0.7</version>
		</dependency>
		<dependency>
			<groupId>com.google.guava</groupId>
			<artifactId>guava</artifactId>
			<version>19.0</version>
		</dependency>
		<dependency>
			<groupId>commons-codec</groupId>
			<artifactId>commons-codec</artifactId>
			<version>2.0-SNAPSHOT</version>
		</dependency>
		<!--解壓(zip4j) -->
		<dependency>
			<groupId>net.lingala.zip4j</groupId>
			<artifactId>zip4j</artifactId>
			<version>1.3.2</version>
		</dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context-support</artifactId>
            <version>4.3.10.RELEASE</version>
        </dependency>
		<dependency>
			<groupId>org.freemarker</groupId>
			<artifactId>freemarker</artifactId>
			<version>2.3.25-incubating</version>
		</dependency>
		<!-- https://mvnrepository.com/artifact/net.sf.json-lib/json-lib -->
		<dependency>
			<groupId>net.sf.json-lib</groupId>
			<artifactId>json-lib</artifactId>
			<version>2.4</version>
		</dependency>
		<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
		<dependency>
			<groupId>com.google.code.gson</groupId>
			<artifactId>gson</artifactId>
			<version>2.3.1</version>
		</dependency>
		<!-- openoffice 相關依賴 -->
		<dependency>
			<groupId>commons-io</groupId>
			<artifactId>commons-io</artifactId>
			<version>1.4</version>
		</dependency>
		<dependency>
			<groupId>org.openoffice</groupId>
			<artifactId>juh</artifactId>
			<version>3.2.1</version>
		</dependency>
		<dependency>
			<groupId>org.openoffice</groupId>
			<artifactId>ridl</artifactId>
			<version>3.2.1</version>
		</dependency>
		<dependency>
			<groupId>org.openoffice</groupId>
			<artifactId>unoil</artifactId>
			<version>3.2.1</version>
		</dependency>
		<dependency>
			<!-- for the command line tool -->
			<groupId>commons-cli</groupId>
			<artifactId>commons-cli</artifactId>
			<version>1.1</version>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.hyperic</groupId>
			<artifactId>sigar</artifactId>
			<version>1.6.5.132</version>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.json</groupId>
			<artifactId>json</artifactId>
			<version>20090211</version>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.testng</groupId>
			<artifactId>testng</artifactId>
			<version>6.0.1</version>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>


</project>

專案開始,要做的第一件事是下載檔案,這裡下載檔案有一個要注意的地方,開啟HttpURLConnection的時候

這裡我沒有截全,因為檔案的下載在這篇文章裡不是重點,所以在這裡不做詳細記錄,只要最後將檔案儲存到臨時目錄就行了。

檔案下載完之後在進行文章的轉換,這裡我的轉換規則是:圖片不進行轉換,文字檔案轉換編碼為utf-8, office檔案選擇將word文件轉為pdf檔案,xls表格和ppt檔案轉為html檔案,因為表格和ppt檔案轉換為pdf格式之後不太美觀,ppt轉為html格式之後會變成許多張圖片,那麼後期的展示就是一個取本地圖片資料的過程,前端再稍微配點樣式就基本差不多了。那麼對於壓縮檔案呢, 我選擇的是先解壓到本地,展示的時候運用Ztree外掛(一個很輕大的檔案展示外掛)做前端的展示。這個在下文都會記錄。因為涉及檔案的操作,所以在這裡一定要注意檔案流的運用,用完了一定要及時關閉,還有檔案的其他操作也要注意,比如檔案地址的指向。當時我就是因為一個檔案地址未仍指向一個操作中的目錄導致後面臨時檔案的刪除出錯。

下面開始檔案的轉換,重點記錄文字檔案以及壓縮檔案的轉換,當時在這花了較長時間,office檔案的轉換可以參照其他人寫的。

文字檔案的轉換(這裡要注意的是文字檔案涉及到檔案的編碼問題,這裡採用文字編碼探測器進行探測):

文字探測器如下:

import org.mozilla.intl.chardet.nsDetector;
import org.mozilla.intl.chardet.nsICharsetDetectionObserver;

import java.io.*;

/**
 * Created by asus on 2017/12/28.
 */
public class FileCharsetDetector {

    /**
     * 傳入一個檔案(File)物件,檢查檔案編碼
     *
     * @param file
     *            File物件例項
     * @return 檔案編碼,若無,則返回null
     * @throws FileNotFoundException
     * @throws IOException
     */
    public static Observer guessFileEncoding(File file)
            throws FileNotFoundException, IOException {
        return guessFileEncoding(file, new nsDetector());
    }

    /**
     * <pre>
     * 獲取檔案的編碼
     * @param file
     *            File物件例項
     * @param languageHint
     *            語言提示區域程式碼 @see #nsPSMDetector ,取值如下:
     *             1 : Japanese
     *             2 : Chinese
     *             3 : Simplified Chinese
     *             4 : Traditional Chinese
     *             5 : Korean
     *             6 : Dont know(default)
     * </pre>
     *
     * @return 檔案編碼,eg:UTF-8,GBK,GB2312形式(不確定的時候,返回可能的字元編碼序列);若無,則返回null
     * @throws FileNotFoundException
     * @throws IOException
     */
    public static Observer guessFileEncoding(File file, int languageHint)
            throws FileNotFoundException, IOException {
        return guessFileEncoding(file, new nsDetector(languageHint));
    }

    /**
     * 獲取檔案的編碼
     *
     * @param file
     * @param det
     * @return
     * @throws FileNotFoundException
     * @throws IOException
     */
    private static Observer guessFileEncoding(File file, nsDetector det)
            throws FileNotFoundException, IOException {
        // new Observer
        Observer observer = new Observer();
        // set Observer
        // The Notify() will be called when a matching charset is found.
        det.Init(observer);

        BufferedInputStream imp = new BufferedInputStream(new FileInputStream(
                file));
        byte[] buf = new byte[1024];
        int len;
        boolean done = false;
        boolean isAscii = false;

        while ((len = imp.read(buf, 0, buf.length)) != -1) {
            // Check if the stream is only ascii.
            isAscii = det.isAscii(buf, len);
            if (isAscii) {
                break;
            }
            // DoIt if non-ascii and not done yet.
            done = det.DoIt(buf, len, false);
            if (done) {
                break;
            }
        }
        imp.close();
        det.DataEnd();

        if (isAscii) {
            observer.encoding = "ASCII";
            observer.found = true;
        }

        if (!observer.isFound()) {
            String[] prob = det.getProbableCharsets();
            // // 這裡將可能的字符集組合起來返回
            // for (int i = 0; i < prob.length; i++) {
            // if (i == 0) {
            // encoding = prob[i];
            // } else {
            // encoding += "," + prob[i];
            // }
            // }
            if (prob.length > 0) {
                // 在沒有發現情況下,去第一個可能的編碼
                observer.encoding = prob[0];
            } else {
                return null;
            }
        }
        return observer;
    }

    /**
     * @Description: 檔案字元編碼觀察者,但判斷出字元編碼時候呼叫
     */
    public static class Observer implements nsICharsetDetectionObserver {

        /**
         * @Fields encoding : 字元編碼
         */
        private String encoding = null;
        /**
         * @Fields found : 是否找到字符集
         */
        private boolean found = false;

        @Override
        public void Notify(String charset) {
            this.encoding = charset;
            this.found = true;
        }

        public String getEncoding() {
            return encoding;
        }

        public boolean isFound() {
            return found;
        }

        @Override
        public String toString() {
            return "Observer [encoding=" + encoding + ", found=" + found + "]";
        }
    }

}

壓縮檔案轉換(因為前端要生成檔案樹,所以在這裡要先進行檔案解壓,在進行檔案的讀取,最終要生成的是一段字串裡面包含所有檔案的資訊):

解壓檔案的操作這裡也不詳細記錄了,很多網上的資料,這裡記錄一下檔案樹的生成,首先我們定義一個檔案節點,裡面包含子檔案,檔名稱,判斷是否為資料夾以及檔案絕對路徑:

/**
     * 檔案節點(區分檔案上下級)
     */
    public static class FileNode{

        private String originName;
        private boolean directory;
        private String fullPath;

        private List<FileNode> childList;

        public FileNode(String originName, List<FileNode> childList, boolean directory, String fullPath) {
            this.originName = originName;
            this.childList = childList;
            this.directory = directory;
            this.fullPath = fullPath;

        }

        public String getFullPath() {
            return fullPath;
        }

        public void setFullPath(String fullPath) {
            this.fullPath = fullPath;
        }

        public List<FileNode> getChildList() {
            return childList;
        }

        public void setChildList(List<FileNode> childList) {
            this.childList = childList;
        }

        @Override
        public String toString() {
            try {
                return new ObjectMapper().writeValueAsString(this);
            } catch (JsonProcessingException e) {
                e.printStackTrace();
                return "";
            }
        }

        public String getOriginName() {
            return originName;
        }

        public void setOriginName(String originName) {
            this.originName = originName;
        }

        public boolean isDirectory() {
            return directory;
        }

        public void setDirectory(boolean directory) {
            this.directory = directory;
        }
    }

 
/**
     * 通過遞迴得到某一路徑下所有的目錄及其檔案
     */
    public static List<FileNode> getFiles(String filePath){
        File root = new File(filePath);
        File[] files = root.listFiles();
        String originName = "";
        boolean isDirectory = false;
        String fullPath = "";

        List<FileNode> fileNodes = new ArrayList<>();
        for(File file:files){

            List<FileNode> childList = new ArrayList<>();

            if(file.isDirectory()){
                isDirectory = true;
                originName = file.getName();
                fullPath = file.getAbsolutePath();
                childList = getFiles(file.getPath());
            } else {
                originName = file.getName();
                isDirectory = false;
                fullPath = file.getAbsolutePath();
            }
            // 進行轉義,否則json解析不了
            fullPath = fullPath.replace("\\", "/");
            FileNode fileNode = new FileNode(originName, childList, isDirectory, fullPath);
            fileNodes.add(fileNode);
        }
        return fileNodes;
    }

這樣我們就得到了檔案樹,前端的展示就簡單多了,後面只需要把檔案樹轉為字串傳到前端j就行了,注意這裡的檔案絕對路徑的寫法,預設是生成“\\”,這裡要換成“/”。到這步,檔案的轉換基本就差不多了,剩下我們要做的就是檔案的展示。這裡還有一個地方需要注意的是,生成檔案儲存目錄的時候增加一層目錄用於區分唯一檔案,這裡我採用的是取檔案的hash值,作為檔案的上級儲存目錄,這樣就不會有重複的檔案目錄了。

檔案展示 :前面一直沒有說Controller層如何寫,這裡開始說明

大體就是這樣,說明一下,原理就是轉換完檔案之後再定位到檔案存放的目錄,將本地檔案以流的方式輸出到頁面。

這樣寫完之後便可以寫頁面了,頁面顯示規則:1、圖片的顯示可以用viewer.js外掛(一個圖片顯示器,支援主流的圖片顯示操作),我這裡直接寫到頁面上,後續再加上。2、pdf檔案的顯示可以用pdf.js外掛。3、壓縮檔案顯示用Ztree外掛。4、ppt檔案的顯示這裡推薦一個開源js,個人覺得還不錯,點選開啟連結。總體上就是這樣。

每個人有所好,所以這裡我只記錄壓縮檔案的顯示,加粗的部分表示與後臺的互動:

<!DOCTYPE html>

<html lang="en">
<head>
    <link href="css/zTreeStyle.css" rel="stylesheet" type="text/css">
    <style type="text/css">
        html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, font, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td {
            margin: 0;padding: 0;border: 0;outline: 0;font-weight: inherit;font-style: inherit;font-size: 100%;font-family: inherit;vertical-align: baseline;}
        body {color: #2f332a;font: 15px/21px Arial, Helvetica, simsun, sans-serif;background: #f0f6e4 \9;}
        body{
            margin:0 auto;
            width: 600px;
            background-color: #333333;
            font-size: 4em;
        }
        h1, h2, h3, h4, h5, h6 {color: #2f332a;font-weight: bold;font-family: Helvetica, Arial, sans-serif;padding-bottom: 5px;}
        h1 {font-size: 24px;line-height: 34px;text-align: center;}
        h2 {font-size: 14px;line-height: 24px;padding-top: 5px;}
        h6 {font-weight: normal;font-size: 12px;letter-spacing: 1px;line-height: 24px;text-align: center;}
        a {color:#3C6E31;text-decoration: underline;}
        a:hover {background-color:#3C6E31;color:white;}
        input.radio {margin: 0 2px 0 8px;}
        input.radio.first {margin-left:0;}
        input.empty {color: lightgray;}
        code {color: #2f332a;}
        div.zTreeDemoBackground {width:600px;text-align:center;background-color: #ffffff;}
    </style>
</head>
<body>

<div class="zTreeDemoBackground left">
    <ul id="treeDemo" class="ztree"></ul>
</div>
</body>
<script type="text/javascript" src="js/jquery-3.0.0.min.js"></script>
<script type="text/javascript" src="js/jquery.ztree.core.js"></script>
<script type="text/javascript">

    var data = JSON.parse('${fileTree}');
    var setting = {
        view: {
            fontCss : {"color":"blue"},
            showLine: true
        },
        data: {
            key: {
                children: 'childList',
                name: 'originName'
            }
        },
        callback:{
            beforeClick:function (treeId, treeNode, clickFlag) {
                console.log("節點引數:treeId-" + treeId + "treeNode-"
                        + JSON.stringify(treeNode) + "clickFlag-" + clickFlag);
            },
            onClick:function (event, treeId, treeNode) {
                if (!treeNode.directory) {
                    /**實現視窗最大化**/
                    var fulls = "left=100,screenX=600,top=0,screenY=0,scrollbars=1";    //定義彈出視窗的引數
                    if (window.screen) {
                        var ah = screen.availHeight - 30;
                        var aw = (screen.availWidth - 10) / 2;
                        fulls += ",height=" + ah;
                        fulls += ",innerHeight=" + ah;
                        fulls += ",width=" + aw;
                        fulls += ",innerWidth=" + aw;
                        fulls += ",resizable"
                    } else {
                        fulls += ",resizable"; // 對於不支援screen屬性的瀏覽器,可以手工進行最大化。 manually
                    }
                    // 傳遞檔案路徑到後臺
                    var fileFullPath = treeNode.fullPath;
                    // 後臺返回檔案
                    window.open("viewer/document/${pathId}?fileFullPath=" + fileFullPath, "_blank",fulls);
                }
            }
        }
    };
    var height = 0;
    $(document).ready(function(){
        var treeObj = $.fn.zTree.init($("#treeDemo"), setting, data);
        treeObj.expandAll(true);
        height = getZtreeDomHeight();
        $(".zTreeDemoBackground").css("height", height);
    });

    /**
     *  計算ztreedom的高度
     */
    function getZtreeDomHeight() {
        return $("#treeDemo").height() > window.document.documentElement.clientHeight - 1
                ? $("#treeDemo").height() : window.document.documentElement.clientHeight - 1;
    }
    /**
     * 頁面變化調整高度
     */
    window.onresize = function(){
        height = getZtreeDomHeight();
        $(".zTreeDemoBackground").css("height", height);
    }
    /**
     * 滾動時調整高度
     */
    window.onscroll = function(){
        height = getZtreeDomHeight();
        $(".zTreeDemoBackground").css("height", height);
    }
</script>
</html>fileTree}');
    var setting = {
        view: {
            fontCss : {"color":"blue"},
            showLine: true
        },
        data: {
            key: {
                children: 'childList',
                name: 'originName'
            }
        },
        callback:{
            beforeClick:function (treeId, treeNode, clickFlag) {
                console.log("節點引數:treeId-" + treeId + "treeNode-"
                        + JSON.stringify(treeNode) + "clickFlag-" + clickFlag);
            },
            onClick:function (event, treeId, treeNode) {
                if (!treeNode.directory) {
                    /**實現視窗最大化**/
                    var fulls = "left=100,screenX=600,top=0,screenY=0,scrollbars=1";    //定義彈出視窗的引數
                    if (window.screen) {
                        var ah = screen.availHeight - 30;
                        var aw = (screen.availWidth - 10) / 2;
                        fulls += ",height=" + ah;
                        fulls += ",innerHeight=" + ah;
                        fulls += ",width=" + aw;
                        fulls += ",innerWidth=" + aw;
                        fulls += ",resizable"
                    } else {
                        fulls += ",resizable"; // 對於不支援screen屬性的瀏覽器,可以手工進行最大化。 manually
                    }
                    // 傳遞檔案路徑到後臺
                    var fileFullPath = treeNode.fullPath;
                    // 後臺返回檔案
                    window.open("viewer/document/${pathId}?fileFullPath=" + fileFullPath, "_blank",fulls);
                }
            }
        }
    };
    var height = 0;
    $(document).ready(function(){
        var treeObj = $.fn.zTree.init($("#treeDemo"), setting, data);
        treeObj.expandAll(true);
        height = getZtreeDomHeight();
        $(".zTreeDemoBackground").css("height", height);
    });

    /**
     *  計算ztreedom的高度
     */
    function getZtreeDomHeight() {
        return $("#treeDemo").height() > window.document.documentElement.clientHeight - 1
                ? $("#treeDemo").height() : window.document.documentElement.clientHeight - 1;
    }
    /**
     * 頁面變化調整高度
     */
    window.onresize = function(){
        height = getZtreeDomHeight();
        $(".zTreeDemoBackground").css("height", height);
    }
    /**
     * 滾動時調整高度
     */
    window.onscroll = function(){
        height = getZtreeDomHeight();
        $(".zTreeDemoBackground").css("height", height);
    }
</script>
</html>

最後,顯示效果如下:

壓縮檔案:
文件:

報表:

PPT檔案:

圖片:

總結

個人專案可以採用和我一樣的方式,寫的有點亂,有問題歡迎在底部留言。雖然花了較多的時間,但最後的結果還是值得的。最後附上專案地址:https://github.com/Chenchicheng/file_viewer