1. 程式人生 > >springboot整合apache ftpserver詳細教程(看這一篇就夠了)

springboot整合apache ftpserver詳細教程(看這一篇就夠了)

原創不易,如需轉載,請註明出處https://www.cnblogs.com/baixianlong/p/12192425.html,否則將追究法律責任!!!

一、Apache ftpserver相關簡介

  Apache FtpServer是100%純Java FTP伺服器。它被設計為基於當前可用的開放協議的完整且可移植的FTP伺服器引擎解決方案。FtpServer可以作為Windows服務或Unix / Linux守護程式獨立執行,也可以嵌入Java應用程式中。我們還提供對Spring應用程式內整合的支援,並以OSGi捆綁軟體的形式提供我們的發行版。預設的網路支援基於高效能非同步IO庫Apache MINA。使用MINA,FtpServer可以擴充套件到大量併發使用者。

二、Apache ftpserver相關特性

  • 100%純Java,免費的開源可恢復FTP伺服器
  • 多平臺支援和多執行緒設計。
  • 使用者虛擬目錄,寫入許可權,空閒超時和上載/下載頻寬限制支援。
  • 匿名登入支援。
  • 上傳和下載檔案都是可恢復的。
  • 處理ASCII和二進位制資料傳輸。
  • 支援IP限制以禁止IP。
  • 資料庫和檔案可用於儲存使用者資料。
  • 所有FTP訊息都是可定製的。
  • 隱式/顯式SSL / TLS支援。
  • MDTM支援-您的使用者可以更改檔案的日期時間戳。
  • “模式Z”支援更快地上傳/下載資料。
  • 可以輕鬆新增自定義使用者管理器,IP限制器,記錄器。
  • 可以新增使用者事件通知(Ftplet)。

三、Apache ftpserver簡單部署使用(基於windows下,linux大同小異)

  • 1、根據需要下載對應版本的部署包:https://mina.apache.org/ftpserver-project/downloads.html
  • 2、解壓部署包並調整.\res\conf\users.properties和.\res\conf\ftpd-typical.xml配置檔案


users.properties檔案配置

    例如配置一個bxl使用者:
    #密碼 配置新的使用者
    ftpserver.user.bxl.userpassword=123456
    #主目錄,這裡可以自定義自己的主目錄
    ftpserver.user.bxl.homedirectory=./res/bxl-home
    #當前使用者可用
    ftpserver.user.bxl.enableflag=true
    #具有上傳許可權
    ftpserver.user.bxl.writepermission=true
    #最大登陸使用者數為20
    ftpserver.user.bxl.maxloginnumber=20
    #同IP登陸使用者數為2
    ftpserver.user.bxl.maxloginperip=2
    #空閒時間為300秒
    ftpserver.user.bxl.idletime=300
    #上傳速率限制為480000位元組每秒
    ftpserver.user.bxl.uploadrate=48000000
    #下載速率限制為480000位元組每秒
    ftpserver.user.bxl.downloadrate=48000000

ftpd-typical.xml檔案配置

    <server xmlns="http://mina.apache.org/ftpserver/spring/v1"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://mina.apache.org/ftpserver/spring/v1 http://mina.apache.org/ftpserver/ftpserver-1.0.xsd" id="myServer">
        <listeners>
            <nio-listener name="default" port="2121">
                <ssl>
                    <keystore file="./res/ftpserver.jks" password="password" />
                </ssl>
                <!--注意:如果要支援外網連線,需要使用被動模式passive,預設開啟主動模式-->
                <data-connection idle-timeout="60">
                    <active enabled="true" ip-check="true" />
                    <!-- <passive ports="2000-2222" address="0.0.0.0" external-address="xxx.xxx.xxx.xxx" /> -->
                </data-connection>
                <!--新增ip黑名單-->
                <blacklist>127.0.0.1</blacklist>
            </nio-listener>
        </listeners>
        
        <!--這裡新增encrypt-passwords="clear",去掉密碼加密-->
        <file-user-manager file="./res/conf/users.properties" encrypt-passwords="clear" />
    </server>
  • 3、啟動並訪問
    • 首先啟動服務,開啟cmd並cd到bin路徑執行.\ftpd.bat res/conf/ftpd-typical.xml,看到如下狀態說明啟動成功

    • 測試訪問,開啟瀏覽器輸入:ftp://localhost:2121/就會看到你的檔案目錄了,如果沒有配置匿名使用者,則會要求你輸入使用者名稱密碼,正是你在user.properties中配置的

四、Springboot整合Apache ftpserver(重點)

方式一:獨立部署ftpserver服務

  這種方式比較簡單,只要把服務部署好即可,然後通過FtpClien來完成相關操作,同jedis訪問redis服務一個道理,沒啥可說的。主要注意一下ftpserver的訪問模式,如果要支援外網連線,需要使用被動模式passive。

方式二:將ftpserver服務內嵌到springboot服務中

  這種方式需要和springboot整合在一起,相對比較複雜,但這種方式下ftpserver會 隨著springboot服務啟動或關閉而開啟或銷燬。具體使用哪種方式就看自己的業務需求了。

  簡單說一下我的實現的方案,ftpserver支援配置檔案和db兩種方式來儲存賬號資訊和其它相關配置,如果我們的業務系統需要將使用者資訊和ftp的賬號資訊打通,並且還有相關的業務統計,比如統計系統中每個人上傳檔案的時間、個數等等,那麼使用資料庫來儲存ftp賬號資訊還是比較方便靈活的。我這裡就選擇使用mysql了。

開始整合

  • 1、專案新增依賴

    //這些只是apache ftpserver相關的依賴,springboot專案本身的依賴大家自己新增即可
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-log4j12</artifactId>
      <version>1.7.25 </version>
    </dependency>
    <dependency>
      <groupId>org.apache.ftpserver</groupId>
      <artifactId>ftpserver-core</artifactId>
      <version>1.1.1</version>
    </dependency>
    <dependency>
      <groupId>org.apache.ftpserver</groupId>
      <artifactId>ftplet-api</artifactId>
      <version>1.1.1</version>
    </dependency>
    
    <dependency>
      <groupId>org.apache.mina</groupId>
      <artifactId>mina-core</artifactId>
      <version>2.0.16</version>
    </dependency>   
  • 2、資料庫建表用來儲存相關的賬戶資訊(大家可以手動新增幾條用來測試),具體欄位意思參考users.properties檔案配置(可以想象一下以後我們的系統每註冊一個使用者都可以為其新增一條ftp_user資訊,用來指定儲存使用者的上傳資料等等)

    CREATE TABLE FTP_USER (      
       userid VARCHAR(64) NOT NULL PRIMARY KEY,       
       userpassword VARCHAR(64),      
       homedirectory VARCHAR(128) NOT NULL,             
       enableflag BOOLEAN DEFAULT TRUE,    
       writepermission BOOLEAN DEFAULT FALSE,       
       idletime INT DEFAULT 0,             
       uploadrate INT DEFAULT 0,             
       downloadrate INT DEFAULT 0,
       maxloginnumber INT DEFAULT 0,
       maxloginperip INT DEFAULT 0
    );
  • 3、配置ftpserver,提供ftpserver的init()、start()、stop()方法

    import com.mysql.cj.jdbc.MysqlDataSource;
    import com.talkingdata.tds.ftpserver.plets.MyFtpPlet;
    import org.apache.commons.io.IOUtils;
    import org.apache.ftpserver.DataConnectionConfigurationFactory;
    import org.apache.ftpserver.FtpServer;
    import org.apache.ftpserver.FtpServerFactory;
    import org.apache.ftpserver.ftplet.FtpException;
    import org.apache.ftpserver.ftplet.Ftplet;
    import org.apache.ftpserver.listener.Listener;
    import org.apache.ftpserver.listener.ListenerFactory;
    import org.apache.ftpserver.ssl.SslConfigurationFactory;
    import org.apache.ftpserver.usermanager.ClearTextPasswordEncryptor;
    import org.apache.ftpserver.usermanager.DbUserManagerFactory;
    import org.apache.ftpserver.usermanager.PropertiesUserManagerFactory;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.core.io.ClassPathResource;
    import org.springframework.stereotype.Component;
    
    
    import javax.sql.DataSource;
    import java.io.File;
    import java.io.FileOutputStream;
    import java.io.IOException;
    import java.util.HashMap;
    import java.util.Map;
    
    /**
     * 注意:被@Configuration標記的類會被加入ioc容器中,而且類中所有帶 @Bean註解的方法都會被動態代理,因此呼叫該方法返回的都是同一個例項。
     * ftp服務訪問地址:
     *      ftp://localhost:3131/
     */
    @Configuration("MyFtp")
    public class MyFtpServer {
    
        private static final Logger logger = LoggerFactory.getLogger(MyFtpServer.class);
    
        //springboot配置好資料來源直接注入即可
        @Autowired
        private DataSource dataSource;
        protected FtpServer server;
    
        //我們這裡利用spring載入@Configuration的特性來完成ftp server的初始化
        public MyFtpServer(DataSource dataSource) {
            this.dataSource = dataSource;
            initFtp();
            logger.info("Apache ftp server is already instantiation complete!");
        }
    
        /**
         * ftp server init
         * @throws IOException
         */
        public void initFtp() {
            FtpServerFactory serverFactory = new FtpServerFactory();
            ListenerFactory listenerFactory = new ListenerFactory();
            //1、設定服務埠
            listenerFactory.setPort(3131);
            //2、設定被動模式資料上傳的介面範圍,雲伺服器需要開放對應區間的埠給客戶端
            DataConnectionConfigurationFactory dataConnectionConfFactory = new DataConnectionConfigurationFactory();
            dataConnectionConfFactory.setPassivePorts("10000-10500");
            listenerFactory.setDataConnectionConfiguration(dataConnectionConfFactory.createDataConnectionConfiguration());
            //3、增加SSL安全配置
    //        SslConfigurationFactory ssl = new SslConfigurationFactory();
    //        ssl.setKeystoreFile(new File("src/main/resources/ftpserver.jks"));
    //        ssl.setKeystorePassword("password");
            //ssl.setSslProtocol("SSL");
            // set the SSL configuration for the listener
    //        listenerFactory.setSslConfiguration(ssl.createSslConfiguration());
    //        listenerFactory.setImplicitSsl(true);
            //4、替換預設的監聽器
            Listener listener = listenerFactory.createListener();
            serverFactory.addListener("default", listener);
            //5、配置自定義使用者事件
            Map<String, Ftplet> ftpLets = new HashMap();
            ftpLets.put("ftpService", new MyFtpPlet());
            serverFactory.setFtplets(ftpLets);
            //6、讀取使用者的配置資訊
            //注意:配置檔案位於resources目錄下,如果專案使用內建容器打成jar包釋出,FTPServer無法直接直接讀取Jar包中的配置檔案。
            //解決辦法:將檔案複製到指定目錄(本文指定到根目錄)下然後FTPServer才能讀取到。
    //        PropertiesUserManagerFactory userManagerFactory = new PropertiesUserManagerFactory();
    //        String tempPath = System.getProperty("java.io.tmpdir") + System.currentTimeMillis() + ".properties";
    //        File tempConfig = new File(tempPath);
    //        ClassPathResource resource = new ClassPathResource("users.properties");
    //        IOUtils.copy(resource.getInputStream(), new FileOutputStream(tempConfig));
    //        userManagerFactory.setFile(tempConfig);
    //        userManagerFactory.setPasswordEncryptor(new ClearTextPasswordEncryptor());  //密碼以明文的方式
    //        serverFactory.setUserManager(userManagerFactory.createUserManager());
            //6.2、基於資料庫來儲存使用者例項
            DbUserManagerFactory dbUserManagerFactory = new DbUserManagerFactory();
            //todo....
            dbUserManagerFactory.setDataSource(dataSource);
            dbUserManagerFactory.setAdminName("admin");
            dbUserManagerFactory.setSqlUserAdmin("SELECT userid FROM FTP_USER WHERE userid='{userid}' AND userid='admin'");
            dbUserManagerFactory.setSqlUserInsert("INSERT INTO FTP_USER (userid, userpassword, homedirectory, " +
                    "enableflag, writepermission, idletime, uploadrate, downloadrate) VALUES " +
                    "('{userid}', '{userpassword}', '{homedirectory}', {enableflag}, " +
                    "{writepermission}, {idletime}, uploadrate}, {downloadrate})");
            dbUserManagerFactory.setSqlUserDelete("DELETE FROM FTP_USER WHERE userid = '{userid}'");
            dbUserManagerFactory.setSqlUserUpdate("UPDATE FTP_USER SET userpassword='{userpassword}',homedirectory='{homedirectory}',enableflag={enableflag},writepermission={writepermission},idletime={idletime},uploadrate={uploadrate},downloadrate={downloadrate},maxloginnumber={maxloginnumber}, maxloginperip={maxloginperip} WHERE userid='{userid}'");
            dbUserManagerFactory.setSqlUserSelect("SELECT * FROM FTP_USER WHERE userid = '{userid}'");
            dbUserManagerFactory.setSqlUserSelectAll("SELECT userid FROM FTP_USER ORDER BY userid");
            dbUserManagerFactory.setSqlUserAuthenticate("SELECT userid, userpassword FROM FTP_USER WHERE userid='{userid}'");
            dbUserManagerFactory.setPasswordEncryptor(new ClearTextPasswordEncryptor());
            serverFactory.setUserManager(dbUserManagerFactory.createUserManager());
            //7、例項化FTP Server
            server = serverFactory.createServer();
        }
    
    
        /**
         * ftp server start
         */
        public void start(){
            try {
                server.start();
                logger.info("Apache Ftp server is starting!");
            }catch(FtpException e) {
                e.printStackTrace();
            }
        }
    
    
        /**
         * ftp server stop
         */
        public void stop() {
            server.stop();
            logger.info("Apache Ftp server is stoping!");
        }
    
    }
  • 4、配置監聽器,使spring容器啟動時啟動ftpserver,在spring容器銷燬時停止ftpserver

    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.context.WebApplicationContext;
    import org.springframework.web.context.support.WebApplicationContextUtils;
    
    import javax.servlet.ServletContextEvent;
    import javax.servlet.ServletContextListener;
    import javax.servlet.annotation.WebListener;
    
    @WebListener
    public class FtpServerListener implements ServletContextListener {
    
        private static final Logger logger = LoggerFactory.getLogger(MyFtpServer.class);
        private static final String SERVER_NAME="FTP-SERVER";
    
        @Autowired
        private MyFtpServer server;
    
        //容器關閉時呼叫方法stop ftpServer
        public void contextDestroyed(ServletContextEvent sce) {
    //        WebApplicationContext ctx= WebApplicationContextUtils.getWebApplicationContext(sce.getServletContext());
    //        MyFtpServer server=(MyFtpServer)ctx.getServletContext().getAttribute(SERVER_NAME);
            server.stop();
            sce.getServletContext().removeAttribute(SERVER_NAME);
            logger.info("Apache Ftp server is stoped!");
        }
    
        //容器初始化呼叫方法start ftpServer
        public void contextInitialized(ServletContextEvent sce) {
    //        WebApplicationContext ctx= WebApplicationContextUtils.getWebApplicationContext(sce.getServletContext());
    //        MyFtpServer server=(MyFtpServer) ctx.getBean("MyFtp");
            sce.getServletContext().setAttribute(SERVER_NAME,server);
            try {
                //專案啟動時已經載入好了
                server.start();
                logger.info("Apache Ftp server is started!");
            } catch (Exception e){
                e.printStackTrace();
                throw new RuntimeException("Apache Ftp server start failed!", e);
            }
        }
    
    }
  • 5、通過繼承DefaultFtplet抽象類來實現一些自定義使用者事件(我這裡只是舉例)

    import org.apache.ftpserver.ftplet.*;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    import java.io.IOException;
    
    public class MyFtpPlet extends DefaultFtplet {
    
        private static final Logger logger = LoggerFactory.getLogger(MyFtpPlet.class);
    
        @Override
        public FtpletResult onUploadStart(FtpSession session, FtpRequest request)
                throws FtpException, IOException {
            //獲取上傳檔案的上傳路徑
            String path = session.getUser().getHomeDirectory();
            //獲取上傳使用者
            String name = session.getUser().getName();
            //獲取上傳檔名
            String filename = request.getArgument();
            logger.info("使用者:'{}',上傳檔案到目錄:'{}',檔名稱為:'{}',狀態:開始上傳~", name, path, filename);
            return super.onUploadStart(session, request);
        }
    
    
        @Override
        public FtpletResult onUploadEnd(FtpSession session, FtpRequest request)
                throws FtpException, IOException {
            //獲取上傳檔案的上傳路徑
            String path = session.getUser().getHomeDirectory();
            //獲取上傳使用者
            String name = session.getUser().getName();
            //獲取上傳檔名
            String filename = request.getArgument();
            logger.info("使用者:'{}',上傳檔案到目錄:'{}',檔名稱為:'{},狀態:成功!'", name, path, filename);
            return super.onUploadEnd(session, request);
        }
    
        @Override
        public FtpletResult onDownloadStart(FtpSession session, FtpRequest request) throws FtpException, IOException {
            //todo servies...
            return super.onDownloadStart(session, request);
        }
    
        @Override
        public FtpletResult onDownloadEnd(FtpSession session, FtpRequest request) throws FtpException, IOException {
            //todo servies...
            return super.onDownloadEnd(session, request);
        }
    
    }
  • 6、 配置springboot靜態資源的訪問

    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    
    @Configuration
    public class FtpConfig implements WebMvcConfigurer {
    
        @Override
        public void addResourceHandlers(ResourceHandlerRegistry registry) {
            //可以通過os來判斷
            String os = System.getProperty("os.name");
            //linux設定
    //        registry.addResourceHandler("/ftp/**").addResourceLocations("file:/home/pic/");
            //windows設定
            //第一個方法設定訪問路徑字首,第二個方法設定資源路徑,既可以指定專案classpath路徑,也可以指定其它非專案路徑
            registry.addResourceHandler("/ftp/**").addResourceLocations("file:D:\\apache-ftpserver-1.1.1\\res\\bxl-home\\");
            registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
        }
    
    }
  • 7、以上6步已經完成ftpserver的配置,隨著springboot專案的啟動就會開啟ftpserver服務,下面在給大家貼一下客戶端的訪問的util,大家可以自行封裝一下即可。

    import org.apache.commons.net.ftp.FTPClient;
    import org.apache.commons.net.ftp.FTPFile;
    import org.apache.commons.net.ftp.FTPReply;
    import org.apache.commons.net.ftp.FTPSClient;
    
    import java.io.*;
    
    public class FtpClientUtil {
    
        // ftp伺服器ip地址
        private static String FTP_ADDRESS = "localhost";
        // 埠號
        private static int FTP_PORT = 3131;
        // 使用者名稱
        private static String FTP_USERNAME = "bxl";
        // 密碼
        private static String FTP_PASSWORD = "123456";
        // 相對路徑
        private static String FTP_BASEPATH = "";
    
        public static boolean uploadFile(String remoteFileName, InputStream input) {
            boolean flag = false;
            FTPClient ftp = new FTPClient();
            ftp.setControlEncoding("UTF-8");
            try {
                int reply;
                ftp.connect(FTP_ADDRESS, FTP_PORT);// 連線FTP伺服器
                ftp.login(FTP_USERNAME, FTP_PASSWORD);// 登入
                reply = ftp.getReplyCode();
                System.out.println("登入ftp服務返回狀態碼為:" + reply);
                if (!FTPReply.isPositiveCompletion(reply)) {
                    ftp.disconnect();
                    return flag;
                }
                ftp.setFileType(FTPClient.BINARY_FILE_TYPE);
                //設定為被動模式
                ftp.enterLocalPassiveMode();
                ftp.makeDirectory(FTP_BASEPATH);
                ftp.changeWorkingDirectory(FTP_BASEPATH);
                //originFilePath就是上傳檔案的檔名,建議使用生成的唯一命名,中文命名最好做轉碼
                boolean a = ftp.storeFile(remoteFileName, input);
    //            boolean a = ftp.storeFile(new String(remoteFileName.getBytes(),"iso-8859-1"),input);
                System.out.println("要上傳的原始檔名為:" + remoteFileName + ", 上傳結果:" + a);
                input.close();
                ftp.logout();
                flag = true;
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if (ftp.isConnected()) {
                    try {
                        ftp.disconnect();
                    } catch (IOException ioe) {
                    }
                }
            }
            return flag;
        }
    
    //    public static Boolean uploadFile(String remoteFileName, InputStream inputStream, String ftpAddress, int ftpPort,
    //                                     String ftpName, String ftpPassWord, String ftpBasePath) {
    //        FTP_ADDRESS = ftpAddress;
    //        FTP_PORT = ftpPort;
    //        FTP_USERNAME = ftpName;
    //        FTP_PASSWORD = ftpPassWord;
    //        FTP_BASEPATH = ftpBasePath;
    //        uploadFile(remoteFileName,inputStream);
    //        return true;
    //    }
    
        public static boolean deleteFile(String filename) {
            boolean flag = false;
            FTPClient ftpClient = new FTPClient();
            try {
                // 連線FTP伺服器
                ftpClient.connect(FTP_ADDRESS, FTP_PORT);
                // 登入FTP伺服器
                ftpClient.login(FTP_USERNAME, FTP_PASSWORD);
                // 驗證FTP伺服器是否登入成功
                int replyCode = ftpClient.getReplyCode();
                if (!FTPReply.isPositiveCompletion(replyCode)) {
                    return flag;
                }
                // 切換FTP目錄
                ftpClient.changeWorkingDirectory(FTP_BASEPATH);
                ftpClient.dele(filename);
                ftpClient.logout();
                flag = true;
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (ftpClient.isConnected()) {
                    try {
                        ftpClient.logout();
                    } catch (IOException e) {
    
                    }
                }
            }
            return flag;
        }
    
        public static boolean downloadFile(String filename, String localPath) {
            boolean flag = false;
    //        FTPSClient ftpClient = new FTPSClient("TLS", true);
            FTPClient ftpClient = new FTPClient();
            try {
                // 連線FTP伺服器
                ftpClient.connect(FTP_ADDRESS, FTP_PORT);
                // 登入FTP伺服器
                ftpClient.login(FTP_USERNAME, FTP_PASSWORD);
                // 驗證FTP伺服器是否登入成功
                int replyCode = ftpClient.getReplyCode();
                if (!FTPReply.isPositiveCompletion(replyCode)) {
                    return flag;
                }
                // 切換FTP目錄
                ftpClient.changeWorkingDirectory(FTP_BASEPATH);
                //此處為demo方法,正常應該到資料庫中查詢fileName
                FTPFile[] ftpFiles = ftpClient.listFiles();
                for (FTPFile file : ftpFiles) {
                    if (filename.equalsIgnoreCase(file.getName())) {
                        File localFile = new File(localPath + "/" + file.getName());
                        OutputStream os = new FileOutputStream(localFile);
                        ftpClient.retrieveFile(file.getName(), os);
                        os.close();
                    }
                }
                ftpClient.logout();
                flag = true;
                System.out.println("檔案下載完成!!!");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (ftpClient.isConnected()) {
                    try {
                        ftpClient.logout();
                    } catch (IOException e) {
    
                    }
                }
            }
            return flag;
        }
    }

五、總結

到此,所有的配置已經完成,我們的業務系統也同時也承擔了一個角色,那就是ftp伺服器,整個配置是沒有加入SSL/TLS安全機制的,大家如果感興趣可以自行研究下。我程式碼中註釋那那部分,只是注意下通過客戶端訪問時,需要使用FtpsCliet,而非FtpCliet。當然還需要配置你自己的ftpserver.jks檔案,也就是java key store。百度下一下如何生成,很簡單哦!

個人部落格地址:

cnblogs:https://www.cnblogs.com/baixianlong/

github:https://github.com/xianlongbai