JavaFX+SpringBoot+驗證碼功能的小型薪酬管理系統


2020.07.22更新

1 概述

1.1 簡介

一個簡單的小型薪酬管理系統,前端JavaFX+后端Spring Boot,功能倒沒多少,主要精力放在了UI和前端的一些邏輯上面,后端其實做得很簡單。

主要功能:

  • 用戶注冊/登錄
  • 驗證碼找回密碼
  • 用戶修改信息,修改頭像
  • 柱狀圖形式顯示薪酬
  • 管理員管理用戶,錄入工資

1.2 響應流程

1.3 演示

登錄界面:

在這里插入圖片描述

在這里插入圖片描述

用戶界面:

在這里插入圖片描述

管理員界面:

在這里插入圖片描述

2 環境

2.1 本地開發環境

  • Manjaro 20.0.3
  • IDEA 2020.1.1
  • OpenJDK 11.0.7.u10-1
  • OepnJFX 11.0.3.u1-1
  • Spring Boot 2.3.0
  • MySQL 8.0.20

2.2 服務器環境

  • CentOS 8.1.1911
  • OpenJDK 11
  • Tomcat 9.0.33
  • MySQL 8.0.17

3 前端代碼部分

3.1 前端概述

前端主要分為5個部分實現:控制器模塊,視圖模塊,網絡模塊,動畫模塊還有工具類模塊。

  • 控制器模塊:負責交互事件
  • 視圖模塊:負責更新UI
  • 網絡模塊:向后台發送數據請求
  • 動畫模塊:位移、縮放、淡入/淡出、旋轉動畫
  • 工具類模塊:加密,檢查網路連通,居中界面等

3.2 概覽

3.2.1. 代碼目錄樹

在這里插入圖片描述

在這里插入圖片描述
說明:

  • constant包:項目所需要的字符串常量以及一些枚舉常量
  • controller包:控制器類,負責UI與用戶的交互
  • entity包:實體類
  • log包:日志類
  • network包:負責網絡請求,包括請求生成以及請求發送
  • transition包:負責處理動畫
  • utils包:工具類
  • view包:負責UI的初始化預計更新

3.2.2 資源目錄樹

在這里插入圖片描述

在這里插入圖片描述
說明:

  • css:界面所用到的樣式
  • fxml:一個特殊的xml文件,用於定義界面與綁定Controller中的函數,也就是綁定事件
  • image:靜態圖片
  • key:證書文件,用於OkHttp中的HTTPS連接
  • properties:項目中的一些常量屬性

3.2.3 項目依賴

主要依賴如下:

3.3 常量模塊

在這里插入圖片描述

包含程序所需要的字符串以及枚舉常量:

  • CSSPath:CSS路徑,用於給Scene添加樣式,如scene.getStylesheets.add(path)
  • FXMLPath:FXML路徑,用於FXMLLoader加載FXML文件,如FXMLLoader.load(getClass.getResource(path).openStream())
  • AllURL:發送請求到后端的URL
  • BuilderKeys:OkHttp中的FormBody.Builder中使用的常量鍵名
  • PaneName:Pane名字,用於在同一個Scene切換不同的Pane
  • ReturnCode:后端返回碼,需要與后端協商
  • ViewSize:界面尺寸

重點說一下路徑問題,筆者的css與fxml文件都放在resources下:

在這里插入圖片描述

其中fxml路徑在項目中的用法如下:

URL url = getClass().getResource(FXMLPath.xxxx);
FXMLLoader loader = new FXMLLoader();
loader.setLocation(url);
loader.load(url.openStream());

獲取路徑從根路徑獲取,比如上圖中的MessageBox.fxml:

private static final String FXML_PREFIX = "/fxml/";
private static final String FXML_SUFFIX = ".fxml";	
public static final String MESSAGE_BOX = FXML_PREFIX + "MessageBox" + FXML_SUFFIX;

若fxml文件直接放在resources根目錄下,可以使用:

getClass().getResource("/xxx.fxml");

直接獲取。

css同理:

private static final String CSS_PREFIX = "/css/";
private static final String CSS_SUFFIX = ".css";
public static final String MESSAGE_BOX = CSS_PREFIX + "MessageBox" + CSS_SUFFIX;

網絡請求的URL建議把路徑寫到配置文件中,比如這里的從配置文件讀取:

Properties properties = Utils.getProperties();
if (properties != null)
{
    String baseUrl = properties.getProperty("baseurl") + properties.getProperty("port") + "/" + properties.getProperty("projectName");
    SIGN_IN_UP_URL = baseUrl + "signInUp";
    //...
}

3.4 控制器模塊

控制器模塊用於處理用戶的交互事件,分為三類:

  • 登錄注冊界面控制器(start包)
  • 用戶界面控制器(worker包)
  • 管理員界面控制器(admin包)

在這里插入圖片描述

在這里插入圖片描述

3.4.1 登錄注冊界面

這是程序一開始進入的界面,會在這里綁定一些基本的關閉,最小化,標題欄拖拽事件:

public void onMousePressed(MouseEvent e)
{
    stageX = stage.getX();
    stageY = stage.getY();
    screexX = e.getScreenX();
    screenY = e.getScreenY();
}
public void onMouseDragged(MouseEvent e)
{
    stage.setX(e.getScreenX() - screexX + stageX);
    stage.setY(e.getScreenY() - screenY + stageY);
}
public void close()
{
    GUI.close();
}
public void minimize()
{
    GUI.minimize();
}

登錄界面的控制器也很簡單,就一個登錄/注冊功能加一個跳轉到找回密碼界面,代碼就不貼了。

至於找回密碼界面,需要做的比較多,首先需要判斷用戶輸入的電話是否在后端數據庫存在,另外還要檢查兩次輸入的密碼是否一致,還要判斷短信是否發送成功,並且檢查用戶輸入的驗證碼與后端返回的驗證碼是否一致(短信驗證碼部分其實不需要后端處理,原本是放在前端的,但是考慮到可能會泄漏一些重要的信息就放到后端處理了)。

3.4.2 用戶界面

接着是用戶登錄后進入的界面,加了漸隱與移動動畫:

public void userEnter()
{
    new Transition()
    .add(new Move(userImage).x(-70))
    .add(new Fade(userLabel).fromTo(0,1)).add(new Move(userLabel).x(95))
    .add(new Scale(userPolygon).ratio(1.8)).add(new Move(userPolygon).x(180))
    .add(new Scale(queryPolygon).ratio(1.8)).add(new Move(queryPolygon).x(180))
    .play();
}

public void userExited()
{
    new Transition()
    .add(new Move(userImage).x(0))
    .add(new Fade(userLabel).fromTo(1,0)).add(new Move(userLabel).x(0))
    .add(new Scale(userPolygon).ratio(1)).add(new Move(userPolygon).x(0))
    .add(new Scale(queryPolygon).ratio(1)).add(new Move(queryPolygon).x(0))
    .play();
}

效果如下:

在這里插入圖片描述

實際處理是把<Image>以及<Label>放進一個<AnchorPane>中,然后為這個<AnchorPane>添加鼠標移入與移出事件。從代碼中可以知道圖片加上了位移動畫,文字同時加上了淡入與位移動畫,多邊形同時加上了縮放與位移動畫。以左下的<AnchorPane>事件為例,當鼠標移入時,首先把圖片左移:

.add(new Move(userImage).x(-70))

x表示橫向位移。

接着是淡入與位移文字:

.add(new Fade(userLabel).fromTo(0,1)).add(new Move(userLabel).x(95))    

fromTo表示透明度的變化,從0到1,相當於淡入效果。

最后放大多邊形1.8倍同時右移多邊形:

.add(new Scale(userPolygon).ratio(1.8)).add(new Move(userPolygon).x(180))

ratio表示放大的倍率,這里是放大到原來的1.8倍。

同理右上方同樣需要進行放大與移動:

.add(new Scale(queryPolygon).ratio(1.8)).add(new Move(queryPolygon).x(180))

其中用到的TransitionScaleFade是自定義的動畫處理類,詳情請看"3.8 動畫模塊"。

3.5 實體類模塊

簡單的一個Worker:

@Getter
@Setter
@NoArgsConstructor
public class Worker {
    private String cellphone;
    private String password;
    private String name = "無姓名";
    private String department = "無部門";
    private String position = "無職位";
    private String timeAndSalary;

    public Worker(String cellphone,String password)
    {
        this.cellphone = cellphone;
        this.password = password;
    }
}

注解使用了Lombok,Lombok介紹請戳這里,完整用法戳這里

timeAndSalary是一個使用Gson轉換為String的Map,鍵為對應的年月,值為工資。具體轉換方法請到工具類模塊查看。

3.6 日志模塊

日志模塊使用了Log4j2,resources下的log4j2.xml如下:

<configuration status="OFF">
    <appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="Time:%d{HH:mm:ss}     Level:%-5level %nMessage:%msg%n"/>
        </Console>
    </appenders>
    <loggers>
        <logger name="test" level="info" additivity="false">
            <appender-ref ref="Console"/>
        </logger>
        <root level="info">
            <appender-ref ref="Console"/>
        </root>
    </loggers>
</configuration>

這是最一般的配置,pattern里面是輸出格式,其中

  • %d{HH:mm:ss}:時間格式
  • level:日志等級
  • n:換行
  • msg:日志信息

這里前端的日志進行了簡化處理,需要更多配置請自行搜索。

3.7 網絡模塊

網絡模塊的核心使用了OkHttp實現,主要分為兩個包:

在這里插入圖片描述

  • request:封裝發送到后端的各種請求
  • requestBuilder:創建request的Builder類
  • OKHTTP:封裝OkHttp的工具類,對外只有一個靜態send方法,參數只有一個,request包中的類,使用requestBuilder生成。send方法返回一個Object,Object怎么處理需要在用到OKHTTP的地方與返回方法對應

3.7.1 request包

封裝了各種網絡請求:

在這里插入圖片描述

所有請求繼承自BaseRequest,BaseRequest的公有方法包括:

  • setUrl:設置發送的URL
  • setCellphone:添加cellphone參數
  • setPassword:添加password參數,注意會經過前端的SHA-512加密
  • setWorker:添加Worker參數
  • setWorkers:接受一個List<Worker>,管理員保存所有Worker時使用
  • setAvatar:添加頭像參數
  • setAvatars:接受一個HashMap<String,String>,鍵為電話,標識唯一的Worker,值為圖片經過Base64轉換為的String

唯一一個抽象方法是:

public abstract Object handleResult(ReturnCode code):

根據不同的請求處理返回的結果,后端返回一個ReturnCode,其中封裝了狀態碼,錯誤信息與返回值,由Gson轉為String,前端得到String后經Gson轉為ReturnCode,從里面獲取狀態碼以及返回值。

其余的請求類繼承自BaseRequest,並且實現不同的處理結果方法,以Get請求為例:

public class GetOneRequest extends BaseRequest {
    @Override
    public Object handleResult(ReturnCode code)
    {
        switch (code)
        {
            case EMPTY_CELLPHONE:
                MessageBox.emptyCellphone();
                return false;
            case INVALID_CELLPHONE:
                MessageBox.invalidCellphone();
                return false;
            case CELLPHONE_NOT_MATCH:
                MessageBox.show("獲取失敗,電話號碼不匹配");
                return false;
            case EMPTY_WORKER:
                MessageBox.emptyWorker();
                return false;
            case GET_ONE_SUCCESS:
                return Conversion.JSONToWorker(code.body());
            default:
                MessageBox.unknownError(code.name());
                return false;
        }
    }
}

獲取一個Worker,可能的返回值有(返回的是在ReturnCode中定義的枚舉值,需要前后端統一):

  • EMPTY_CELLPHOE:表示發送的get請求中電話為空
  • INVALID_CELLPHONE:非法電話號碼,判斷的代碼為:String reg = "^[1][358][0-9]{9}$";return !(Pattern.compile(reg).matcher(cellphone).matches());
  • CELLPHONE_NOT_MATCH:電話號碼不匹配,也就是數據庫沒有對應的Worker
  • EMPTY_WORKER:數據庫中存在這個Worker,但由於轉換為String時后端處理失敗,返回一個空的Worker
  • GET_ONE_SUCCESS:獲取成功,使用工具類轉換String為Worker
  • 其他:未知錯誤

3.7.2 requestBuilder包

包含了對應於request的Builder:

在這里插入圖片描述

除了默認的構造方法與build方法外,只有set方法,比如:

public class GetOneRequestBuilder {
    private final GetOneRequest request = new GetOneRequest();
    public GetOneRequestBuilder()
    {
        request.setUrl(AllURL.GET_ONE_URL);
    }
    public GetOneRequestBuilder cellphone(String cellphone)
    {
        if(Check.isEmpty(cellphone))
        {
            MessageBox.emptyCellphone();
            return null;
        }
        request.setCellphone(cellphone);
        return this;
    }
    public GetOneRequest build()
    {
        return request;
    }
}

在默認構造方法里面設置了URL,剩下就只需設置電話即可獲取Worker。

3.7.3 OKHTTP

這是一個封裝了OkHttp的靜態工具類,唯一一個公有靜態方法如下:

public static Object send(BaseRequest content)
{
    Call call = client.newCall(new Request.Builder().url(content.getUrl()).post(content.getBody()).build());
    try
    {
        ResponseBody body = call.execute().body();
        if(body != null)
            return content.handleResult(Conversion.stringToReturnCode(body.string()));
    }
    catch (IOException e)
    {
        L.error("Reseponse body is null");
        MessageBox.show("服務器無法連通,響應為空");
    }
    return null;
}

采用同步POST請求的方式,用BaseRequest作為基類是因為能在Call中方便地獲取URL以及請求體,若數據量大可以考慮異步請求。
另外上面也提到后端返回的是經由Gson轉換為StringReturnCode,所以獲取body后,先轉換為ReturnCode再處理。

3.7.4 HTTPS

至於HTTPS,由於在Tomcat上進行部署,需要在Tomcat里設置證書,同時也需要在OkHttp中設置以下三部分:

  • sslSocketFactory:ssl套接字工廠
  • HostnameVerifier:驗證主機名
  • X509TrustManager:證書信任器管理類

3.7.4.1 OkHttp配置

上面提到了需要設置三部分,下面來看看最簡單的一個驗證主機名部分,利用的是HostnameVerifier接口:

在這里插入圖片描述

OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(1500, TimeUnit.MILLISECONDS)
.hostnameVerifier((hostname, sslSession) -> {
    if ("www.test.com".equals(hostname)) {
        return true;
    } else {
        HostnameVerifier verifier = HttpsURLConnection.getDefaultHostnameVerifier();
        return verifier.verify(hostname, sslSession);
    }
}).build();

這里驗證主機名為www.test.com就返回true(也可是使用公網ip驗證),否則使用默認的HostnameVerifier。業務邏輯復雜的話可以結合配置中心,黑/白名單等進行動態校驗。

接着是X509TrustManager的處理(來源Java Code Example):

private static X509TrustManager trustManagerForCertificates(InputStream in)
            throws GeneralSecurityException
{
    CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
    Collection<? extends Certificate> certificates = certificateFactory.generateCertificates(in);
    if (certificates.isEmpty()) {
        throw new IllegalArgumentException("expected non-empty set of trusted certificates");
    }
    char[] password = "www.test.com".toCharArray(); // Any password will work.
    KeyStore keyStore = newEmptyKeyStore(password);
    int index = 0;
    for (Certificate certificate : certificates) {
        String certificateAlias = Integer.toString(index++);
        keyStore.setCertificateEntry(certificateAlias, certificate);
    }
    // Use it to build an X509 trust manager.
    KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(
            KeyManagerFactory.getDefaultAlgorithm());
    keyManagerFactory.init(keyStore, password);
    TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
            TrustManagerFactory.getDefaultAlgorithm());
    trustManagerFactory.init(keyStore);
    TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
    if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)){
        throw new IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers));
    }
    return (X509TrustManager) trustManagers[0];
}

private static KeyStore newEmptyKeyStore(char[] password) throws GeneralSecurityException {
    try {
        KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); // 這里添加自定義的密碼,默認
        InputStream in = null; // By convention, 'null' creates an empty key store.
        keyStore.load(in, password);
        return keyStore;
    } catch (IOException e) {
        throw new AssertionError(e);
    }
}

返回一個信任由輸入流讀取的證書的信任管理器,若證書沒有被簽名則拋出SSLHandsakeException,證書建議使用第三方簽名的而不是自簽名的(比如使用OpenSSL或者acme.sh生成),特別是在生產環境中千萬不要使用自簽名的,例子的注釋也提到:

在這里插入圖片描述

最后是SSL套接字工廠的處理:

private static SSLSocketFactory createSSLSocketFactory() {
    SSLSocketFactory ssfFactory = null;
    try {
        SSLContext sc = SSLContext.getInstance("TLS");
        sc.init(null, new TrustManager[]{trustManager}, new SecureRandom());
        ssfFactory = sc.getSocketFactory();
    } catch (Exception e) {
        e.printStackTrace();
    }
    return ssfFactory;
}

完整的OkHttpClient構造如下:

X509TrustManager trustManager = trustManagerForCertificates(OKHTTP.class.getResourceAsStream("/key/pem.pem"));
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(1500, TimeUnit.MILLISECONDS)
.sslSocketFactory(createSSLSocketFactory(), trustManager)
.hostnameVerifier((hostname, sslSession) -> {
    if ("www.test.com".equals(hostname)) {
        return true;
    } else {
        HostnameVerifier verifier = HttpsURLConnection.getDefaultHostnameVerifier();
        return verifier.verify(hostname, sslSession);
    }
})
.readTimeout(10, TimeUnit.SECONDS).build();

其中/key/pem.pemresources下的證書文件。

3.7.4.2 服務器設置證書

使用WAR進行部署,JAR部署的方式請自行搜索,服務器Tomcat,其他web服務器請自行搜索。

首先在Tomcat配置文件中的conf/server.xml修改域名:

在這里插入圖片描述

找到<Host>並復制,直接修改其中的name為對應域名:

在這里插入圖片描述

接着從證書廠商下載文件(一般都帶文檔,根據文檔部署),Tomcat的是兩個文件,一個是pfx,一個是密碼文件,繼續修改server.xml,搜索8443, 找到如下位置:

在這里插入圖片描述

其中上面的<Connector>是HTTP/1.1協議的,基於NIO實現,下面的<Connector>是HTTP/2的,基於APR實現。

使用HTTP/1.1會比較簡單一些,僅僅是修改server.xml即可,使用HTTP/2的話會麻煩一點,如果基於APR(Apache Portable Runtime)實現需要安裝APR,APR-util以及Tomcat-Native,可以參考這里,下面以HTTP/1.1的為例,修改如下:

<Connector port="8123" protocol="org.apache.coyote.http11.Http11NioProtocol"
	maxThreads="200" SSLEnabled="true" 
	scheme="https" secure="true"
	keystoreFile="/xxx/xxx/xxx/xxx.pfx" keystoreType="PKCS12"
	keystorePass="YOUR PASSWORD" clientAuth="false"
	sslProtocol="TLS">
</Connector>

修改證書位置以及密碼。如果想要更加安全的話可以指定使用某個TLS版本,比如使用TLS1.2版本:

<Connector ...
sslProtocol="TLS" sslEnabledProtocols="TLSv1.2"
>

3.7.5 圖片處理

圖片原本是想使用OkHttp的MultipartBody處理的,但是處理的圖片都不太,貌似沒有必要,而且實體類的數據都是以字符串的形式傳輸的,因此,筆者的想法是能不能統一都用字符串進行傳輸,於是找到了圖片和String互轉的函數,稍微改動,原來的函數需要外部依賴,現在改為了JDK自帶的Base64:

public static String avatarToString(Path path)
{
    try
    {
        return new String(encoder.encode(Files.readAllBytes(path)));
    }
    catch (IOException e)
    {
        MessageBox.avatarToStringFailed();
        L.error(e);
        return null;
    }
}

public static void stringToAvatar(String base64Code, String cellphone){
	try
	{
	    if(!Files.exists(TEMP_PATH))
	        Files.createDirectory(TEMP_PATH);
	    if(!Files.exists(getPath(cellphone)))
	        Files.createFile(getPath(cellphone));
	    Files.write(getPath(cellphone), decoder.decode(base64Code));
	}
	catch (IOException e) {
	    MessageBox.stringToAvatarFailed();
	    L.error(e);
	}
}

Base64是一種基於64個可打印字符來表示二進制數據的方法,可以把二進制數據(圖片/視頻等)轉為字符,或把對應的字符解碼變為原來的二進制數據。

筆者實測這種方法轉換速度不慢,只要有了正確的轉換函數,服務器端可以輕松進行轉換,但是對於大文件的支持不好:

在這里插入圖片描述

這種方法對一般的圖片來說足夠了,但是對於真正的文件還是建議使用MultipartBody進行處理。

3.8 動畫模塊

在這里插入圖片描述

包含了四類動畫:

  • 淡入/淡出
  • 位移
  • 縮放
  • 旋轉

這四個類都實現了CustomTransitionOperation接口:

import javafx.animation.Animation;

public interface CustomTransitionOperation {
    double defaultSeconds = 0.4;
    Animation build();
    void play();
}

其中:

  • defaultSeconds表示動畫默認持續的秒數
  • build用於Transition中對各個動畫類進行統一的build操作
  • play用於播放動畫

四個動畫類類似,以旋轉動畫類為例:

public class Rotate implements CustomTransitionOperation{
    private final RotateTransition transition = new RotateTransition(Duration.seconds(1));

    public Rotate(Node node)
    {
        transition.setNode(node);
    }

    public Rotate seconds(double seconds)
    {
        transition.setDuration(Duration.seconds(seconds));
        return this;
    }

    public Rotate to(double to)
    {
        transition.setToAngle(to);
        return this;
    }

    @Override
    public Animation build() {
        return transition;
    }

    @Override
    public void play() {
        transition.play();
    }
}

seconds設置秒數,to設置旋轉的角度,所有動畫類統一由Transition控制:

public class Transition {
    private final ArrayList<Animation> animations = new ArrayList<>();

    public Transition add(CustomTransitionOperation animation)
    {
        animations.add(animation.build());
        return this;
    }

    public void play()
    {
        animations.forEach(Animation::play);
    }
}

里面是一個動畫類的集合,每次add操作時先生成對應的動畫再添加進集合,最后統一播放,示例用法如下:

new Transition()
.add(new Move(userImage).x(-70))
.add(new Fade(userLabel).fromTo(0,1)).add(new Move(userLabel).x(95))
.add(new Scale(userPolygon).ratio(1.8)).add(new Move(userPolygon).x(180))
.add(new Scale(workloadPolygon).ratio(1.8)).add(new Move(workloadPolygon).x(180))
.play();

3.9 工具類模塊

在這里插入圖片描述

  • AvatarUtils:用於本地生成臨時圖片以及圖片轉換處理
  • Check:檢查是否為空,是否合法等
  • Conversion:轉換類,通過Gson在Worker/StringMap/StringList/String之間進行轉換
  • Utils:加密,設置運行環境,居中Stage,檢查網絡連通等

這里說一下UtilsConversion

3.9.1 Conversion

轉換類,利用Gson在StringList/Worker/Map之間進行轉換,比如StringMap

public static Map<String,Double> stringToMap(String str)
{
    if(Check.isEmpty(str))
        return null;
    Map<?,?> m = gson.fromJson(str,Map.class);
    Map<String,Double> map = new HashMap<>(m.size());
    m.forEach((k,v)->map.put((String)k,(Double)v));
    return map;
}

大部分的轉換函數類似,首先判空,接着進行對應的類型轉換,這里的Conversion與后端的基本一致,后端也需要使用Conversion類進行轉換操作。

3.9.2 Utils

獲取屬性文件方法如下:

//獲取屬性文件
public static Properties getProperties()
{
    Properties properties = new Properties();
    //項目屬性文件分成了config_dev.properties,config_test.properties,config_prod.properties
    String fileName = "properties/config_"+ getEnv() +".properties";
    ClassLoader loader = Thread.currentThread().getContextClassLoader();
    try(InputStream inputStream = loader.getResourceAsStream(fileName))
    {
        if(inputStream != null)
        {
        	//防止亂碼
            properties.load(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
            return properties;
        }
        L.error("Can not load properties properly.InputStream is null.");
        return null;
    }
    catch (IOException e)
    {
        L.error("Can not load properties properly.Message:"+e.getMessage());
        return null;
    }
}

另一個是檢查網路連通的方法:

public static boolean networkAvaliable()
{
    try(Socket socket = new Socket())
    {
        socket.connect(new InetSocketAddress("www.baidu.com",443));
        return true;
    }
    catch (IOException e)
    {
        L.error("Can not connect network.");
        e.printStackTrace();
    }
    return false;
}

public static boolean backendAvaliable()
{
    try(Socket socket = new Socket())
    {
        if(isProdEnvironment())
            socket.connect(new InetSocketAddress("www.test.com",8888));
        else
            socket.connect(new InetSocketAddress("127.0.0.1",8080));
        return true;
    }
    catch (IOException e)
    {
        L.error("Can not connect back end server.");
        L.error(ExceptionUtils.getStackTrace(e));
    }
    return false;
}

采用socket進行判斷,准確來說是包含檢查網絡連通以及后端是否連通。

最后是居中Stage的方法,盡管Stage中自帶了一個centerOnScreen,但是出來的效果並不好,筆者的實測是水平居中但是垂直偏上的,並不是垂直水平居中。

在這里插入圖片描述

因此根據屏幕高寬以及Stage的大小手動設置Stage的x和y。

public static void centerMainStage()
{
    Rectangle2D screenRectangle = Screen.getPrimary().getBounds();
    double width = screenRectangle.getWidth();
	double height = screenRectangle.getHeight();
	
	Stage stage = GUI.getStage();
    stage.setX(width/2 - ViewSize.MAIN_WIDTH/2);
    stage.setY(height/2 - ViewSize.MAIN_HEIGHT/2);
}

3.10 視圖模塊

在這里插入圖片描述

  • GUI:全局變量共享以及以及控制Scene的切換
  • MainScene:全局控制器,負責初始化以及綁定鍵盤事件
  • MessageBox:提示信息框,對外提供show()等的靜態方法

GUI中的方法主要為switchToXxx,比如:

public static void switchToSignInUp()
{
    if(GUI.isUserInformation())
    {
        AvatarUtils.deletePathIfExists();
        GUI.getUserInformationController().reset();
    }
    mainParent.requestFocus();
    children.clear();
    children.add(signInUpParent.lookup(PaneName.SIGN_IN_UP));
    scene.getStylesheets().add(CSSPath.SIGN_IN_UP);
    Label minimize = (Label) (mainParent.lookup("#minimize"));
    minimize.setText("-");
    minimize.setFont(new Font("System", 20));
    minimize.setOnMouseClicked(v->minimize());
}

跳轉到登錄注冊界面,是公有靜態方法,首先判斷是否為用戶信息界面,如果是進行一些清理操作,接着是讓Parent獲取焦點(為了讓鍵盤事件響應),然后將對應的AnchorPane添加到Children,並添加css,最后修改按鈕文字與事件。

另外還在MainScene中加了一些鍵盤事件響應,比如Enter:

ObservableMap<KeyCombination,Runnable> keyEvent = GUI.getScene().getAcclerators();
keyEvent.put(new KeyCodeCombination(KeyCode.ENTER),()->
{
    if (GUI.isSignInUp())
        GUI.getSignInUpController().signInUp();
    else if (GUI.isRetrievePassword())
        GUI.getRetrievePasswordController().reset();
    else if(GUI.isWorker())
        GUI.switchToUserInformation();
    else if(GUI.isAdmin())
        GUI.switchToUserManagement();
    else if(GUI.isUserInformation())
    {
        UserInformationController controller = GUI.getUserInformationController();
        if(controller.isModifying())
            controller.saveInformation();
        else
            controller.modifyInformation();
    }
    else if(GUI.isSalaryEntry())
    {
        GUI.getSalaryEntryController().save();
    }
});

4 前端UI部分

4.1 fxml

在這里插入圖片描述

界面基本上靠這些fxml文件控制,這部分沒太多內容,基本上靠IDEA自帶的Scene Builder設計,少部分靠代碼控制,下面說幾個注意事項:

  • 根節點為AnchorPane,每個fxml設置一個獨立的fx:id以便切換
  • 事件綁定在對應的控件中,比如在一個Label綁定鼠標進入事件,在這個Label上設置onMouseEntered="#xxx",其中里面的方法為對應的控制器(fx:controller="xxx.xxx.xxx.xxxController")中的方法
  • <Image>中的URL屬性需要帶上@,比如<Image url="@../../image/xxx.png">

4.2 css

JFX中集成了部分css的美化功能,比如:

-fx-background-radius: 25px;
-fx-background-color:#e2ff1f;

用法是需要先在fxml中設置id。

這里注意一下兩個id的不同:

  • fx:id
  • id

fx:id指的是控件的fx:id,通常配合Controller中的@FXML使用,比如一個Label設置了fx:id為label1

<Label fx:id="label1" layoutX="450.0" layoutY="402.0" text="Label">
   <font>
       <Font size="18.0" />
   </font>
</Label>

則可以在對應Controller中使用@FXML獲取,名字與fx:id一致:

@FXML
private Label label1;

id指的是css的id,用法是在css引用即可,比如上面的Label又同時設置了id(可以相同,也可不同):

<Label fx:id="label1" id="label1" layoutX="450.0" layoutY="402.0" text="Label">
   <font>
       <Font size="18.0" />
   </font>
</Label>

然后在css文件中像引用普通id一樣引用:

#label1
{
    -fx-background-radius: 20px; /*圓角*/
}

同時JFX還支持css的偽類,比如下面的最小化與關閉的鼠標移入效果是使用偽類實現的:

在這里插入圖片描述

#minimize:hover
{
    -fx-opacity: 1;
    -fx-background-radius: 10px;
    -fx-background-color: #323232;
    -fx-text-fill: #ffffff;
}

#close:hover
{
    -fx-opacity: 1;
    -fx-background-radius: 10px;
    -fx-background-color: #dd2c00;
    -fx-text-fill: #ffffff;
}

當然一些比較復雜的是不支持的,筆者嘗試過使用transition之類的,不支持。

最后需要在對應的Scene里面引入css:

Scene scene = new Scene();
scene.getStylesheets().add("xxx/xxx/xxx/xxx.css");

程序中的用法是:

scene.getStylesheets().add(CSSPath.SIGN_IN_UP);

4.3 Stage構建過程

下面以提示框為例,說明Stage的構建過程。

try {
    Stage stage = new Stage();
    Parent root = FXMLLoader.load(getClass().getResource(FXMLPath.MESSAGE_BOX));
    Scene scene = new Scene(root, ViewSize.MESSAGE_BOX_WIDTH,ViewSize.MESSAGE_BOX_HEIGHT);
    scene.getStylesheets().add(CSSPath.MESSAGE_BOX);
    Button button = (Button)root.lookup("#button");
    button.setOnMouseClicked(v->stage.hide());
    Label label = (Label)root.lookup("#label");
    label.setText(message);
    stage.initStyle(StageStyle.TRANSPARENT);
    stage.setScene(scene);
    Utils.centerMessgeBoxStage(stage);
    stage.show();

    root.requestFocus();
    scene.getAccelerators().put(new KeyCodeCombination(KeyCode.ENTER), stage::close);
    scene.getAccelerators().put(new KeyCodeCombination(KeyCode.BACK_SPACE), stage::close);
} catch (IOException e) {
	//...
}

首先新建一個Stage,接着利用FXMLLoader加載對應路徑上的fxml文件,獲取Parent后,利用該Parent生成Scene,再為Scene添加樣式。

接着是控件的處理,這里的lookup類似Android中的findViewById,根據fx:id獲取對應控件,注意需要加上#。處理好控件之后,居中並顯示Stage,同時,綁定鍵盤事件並讓Parent獲取焦點。

5 后端部分

5.1 后端概述

后端以Spring Boot框架為核心,部署方式為WAR,整體分為三層:

  • 控制器層:負責接受前端的請求並調用業務層方法
  • 業務層:處理主要業務,如CRUD,圖片處理等
  • 持久層:數據持久化,Hibernate+Spring Data JPA

總的來說沒有用到什么高大上的東西,邏輯也比較簡單。

5.2 概覽

5.2.1 代碼目錄樹

在這里插入圖片描述

5.2.2 依賴

主要依賴如下:

5.3 控制器層

控制器分為三類,一類處理圖片,一類處理CRUD請求,一類處理短信發送請求,統一接受POST忽略GET請求。大概的處理流程是接收參數后首先進行判斷操作,比如判空以及判斷是否合法等等,接着調用業務層的方法並對返回結果進行封裝,同時進行日志記錄,最后利用Gson把返回結果轉為字符串。代碼大部分比較簡單就不貼了,說一下短信驗證碼的部分。

驗證碼模塊使用了騰訊雲的接口,官網這里,搜索短信功能即可。

在這里插入圖片描述

新用戶默認贈送100條短信:

在這里插入圖片描述

發送之前需要創建簽名與正文模板,審核通過即可使用。

在這里插入圖片描述

可以先根據快速開始試用一下短信功能,若能成功收到短信,可以戳這里查看API(Java版)。

下面的例子由文檔例子簡化而來:

@PostMapping("sendSms")
public @ResponseBody
String sendSms(@RequestParam String cellphone)
{
    String randomCode = RandomStringUtils.randomNumeric(6);
    if(Check.isEmpty(cellphone))
    {
        L.sendSmsFailed("null",randomCode,"cellphone is empty");
        return toStr(ReturnCode.EMPTY_CELLPHONE);
    }
    if(Check.isInvalidCellphone(cellphone))
    {
        L.sendSmsFailed(cellphone,randomCode,"cellphone is not valid.");
        return toStr(ReturnCode.INVALID_CELLPHONE);
    }
    ReturnCode s = ReturnCode.SEND_SMS_SUCCESS;
    try
    {
        SmsClient client = new SmsClient(new Credential(secretId,secretKey),"");
        SendSmsRequest request = new SendSmsRequest();
        request.setSmsSdkAppid(appId);
        request.setSign(sign);
        request.setTemplateID(templateId);

        String [] templateParamSet = {randomCode};
        request.setTemplateParamSet(templateParamSet);

        String [] phoneNumbers = {"+86"+cellphone};
        request.setPhoneNumberSet(phoneNumbers);
        SendSmsResponse response = client.SendSms(request);

        if(response != null && response.getSendStatusSet()[0].getCode().equals("Ok"))
        {
            L.sendSmsSuccess(cellphone,randomCode);
            s.body(randomCode);
        }
    } catch (Exception e) {
        L.sendSmsFailed(cellphone,randomCode,e);
        s = ReturnCode.UNKNOWN_ERROR;
    }
    return toStr(s);
}

其中appId,sign,templateID分別是對應的appid,簽名id與正文模板id,申請通過之后會分配的,然后隨機生成六位數字的驗證碼。

request.setPhoneNumberSet()的參數為需要發送的手機號碼String數組,注意需要加上區號。發送成功的話手機會收到,失敗的話請根據異常信息自行判斷修改。

唯一要注意一下的是appid之類的數據通過@Value進行屬性注入時,如:

@Controller
@RequestMapping("/")
public class SmsController {
    @Value("${tencent.secret.id}")
    private String secretId;
    ...
}

但是由於sign部分含有中文,所以需要進行編碼轉換:

@Value("${tencent.sign}")
private String sign;

@PostConstruct
public void init()
{
    sign = new String(sign.getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8);
}

5.4 業務層與持久層

由於程序中的業務層與持久層都比較簡單就合並一起說了,比如業務層的saveOne方法,保存一個Worker,先利用Gson轉換為Worker后直接利用CrudRespository<T,ID>提供的save方法保存:

public ReturnCode saveOne(String json) {
    ReturnCode s = ReturnCode.SAVE_ONE_SUCCESS;
    Worker worker = Conversion.JSONToWorker(json);
    if (Check.isEmpty(worker)) {
        L.emptyWorker();
        s = ReturnCode.EMPTY_WORKER;
    }
    else
        workerRepository.save(worker);
    return s;
}

另外由於CrudRepository<T,ID>的saveAll方法參數為Iterable<S>,因此可以直接保存List<S>,比如:

public ReturnCode saveAll(List<Worker> workers)
{
    workerRepository.saveAll(workers);
    return ReturnCode.SAVE_ALL_SUCCESS;
}

需要在控制層中把前端發送的String轉換為List<S>

5.5 日志

日志用的是Spring Boot自帶的日志系統,只是簡單地配置了一下日志路徑,除此之外,日志的格式自定義(因為追求整潔輸出,感覺配置文件實現得不夠好,因此自定義了一個工具類)。

比如日志截取如下:

在這里插入圖片描述

自定義了標題以及每行固定輸出,前后加上了提示符,內容包括方法,級別,時間以及其他信息。

總的來說,除了格式化器外總共有7個類,其中L是主類,外部類只需要調用L的方法,大部分是公有靜態方法,其余6個是L調用的類:

在這里插入圖片描述

如備份成功時調用:

public Success
{
	public static void backup()
	{
	    l.info(new FormatterBuilder().title(getTitle()).info().position().time().build());
	}
	//...
}

其中FormatterBuilder是格式化器,用來格式化輸出的字符串,方法包括時間,位置,級別以及其他信息:

public FormatterBuilder info()
{
    return level("info");
}
public FormatterBuilder time()
{
    content("time",getCurrentTime());
    return this;
}
private FormatterBuilder level(String level)
{
    content("level",level);
    return this;
}
public FormatterBuilder cellphone(String cellphone)
{
    content("cellphone",cellphone);
    return this;
}
public FormatterBuilder message(String message)
{
    content("message",message);
    return this;
}

5.6 工具類

在這里插入圖片描述

四個:

  • Backup:定時數據庫備份
  • Check:檢查合法性,是否為空等
  • Conversion:轉換類,與前端的幾乎一致,利用Gson在StringList/Map/Worker之間進行轉換
  • ReturnCode:返回碼枚舉類

重點說一下備份,代碼不長就直接整個類貼出來了:

@Component
@EnableScheduling
public class Backup {
    private static final long INTERVAL = 1000 * 3600 * 12;
    @Value("${backup.command}")
    private String command;
    @Value("${backup.path}")
    private String strPath;
    @Value("${spring.datasource.username}")
    private String username;
    @Value("${spring.datasource.password}")
    private String password;
    @Value("${spring.datasource.url}")
    private String url;
    @Value("${backup.dataTimeFormat}")
    private String dateTimeFormat;

    @Scheduled(fixedRate = INTERVAL)
    public void startBackup()
    {
        try
        {
            String[] commands = command.split(",");
            String dbname = url.substring(url.lastIndexOf("/")+1);
            commands[2] = commands[2] + username + " --password=" + password + " " + dbname + " > " + strPath +
                    dbname + "_" + DateTimeFormatter.ofPattern(dateTimeFormat).format(LocalDateTime.now())+".sql";
            Path path = Paths.get(strPath);
            if(!Files.exists(path))
                Files.createDirectories(path);
            Process process = Runtime.getRuntime().exec(commands);
            process.waitFor();
            if(process.exitValue() != 0)
            {
                InputStream inputStream = process.getErrorStream();
                StringBuilder str = new StringBuilder();
                byte []b = new byte[2048];
                while(inputStream.read(b,0,2048) != -1)
                    str.append(new String(b));
                L.backupFailed(str.toString());
            }
            L.backupSuccess();
        }
        catch (IOException | InterruptedException e)
        {
            L.backupFailed(e.getMessage());
        }
    }
}

首先利用@Value獲取配置文件中的值,接着在備份方法加上@Scheduled@Scheduled是Spring Boot用於提供定時任務的注解,用於控制任務在某個指定時間執行或者每隔一段時間執行(這里是半天一次),主要有三種配置執行時間的方式:

  • cron
  • fixedRate
  • fixedDelay

這里不展開了,詳細用法可以戳這里

另外在使用前需要在類上加上@EnableScheduling。備份時首先利用URL獲取數據庫名,接着拼合備份命令,注意如果本地使用win開發備份命令會與linux不同:

//win(未經測試,筆者在Linux上開發)
command[0]=cmd
command[1]=/c
command[2]=mysqldump -u username --password=your_password dbname > backupPath+File.separator+dbname+datetimeFormmater+".sql"

//linux(本地Manjaro+服務器CentOS測試通過)
command[0]=/bin/sh
command[1]=-c
command[2]=/usr/bin/mysqldump -u username --password=your_password dbname > backupPath+File.separator+dbname+datetimeFormmater+".sql"

再判斷備份路徑是否存在,接着利用Java自帶的Process進行備份處理,若出錯則利用其中的getErrorStream()獲取錯誤信息並記錄日志。

5.7 配置文件

5.7.1 配置文件分類

在這里插入圖片描述

一個總的配置文件+三個是特定環境下(開發,測試,生產)的配置文件,可以使用spring.profiles.active切換配置文件,比如spring.profiles.active=dev,注意命名有規則,中間加一杠。另外自定義的配置需要在additional-spring-configuration-metadata.json中添加字段(非強制,只是IDE會提示),比如:

"properties": [
    {
        "name": "backup.path",
        "type": "java.lang.String",
        "defaultValue": null
    },
]

5.7.2 加密

都2020年了,還在配置文件中使用明文密碼就不太好吧?

該加密了。

使用的是Jasypt Spring Boot組件,官方github請戳這里

用法這里就不詳細介紹了,詳情看筆者的另一篇博客,戳這里

但是筆者實測目前最新的3.0.2版本(本文寫於2020.06.05,2020.05.31作者已更新3.0.3版本,但是筆者沒有測試過)會有如下問題:

Description:

Failed to bind properties under 'spring.datasource.password' to java.lang.String:

    Reason: Failed to bind properties under 'spring.datasource.password' to java.lang.String

Action:

Update your application's configuration

解決方案以及問題詳細描述戳這里

6 部署與打包

6.1 前端打包

先說一下前端的打包過程,簡單地說打成JAR即可跨平台運行,但是如果是特定平台的話比如Win,想打成無需額外JDK環境的EXE還是需要一些額外操作,這里簡單介紹一下打包過程。

(如果是JDK8可以使用mvn jfx:native打包,這個可以很方便地直接打成DMG或者EXE,但可惜JFX11行不通,反正筆者嘗試失敗了,如果有大神知道如何使用JavaFX-Maven-Plugin或者在IDEA中使用artifact直接打成exe或dmg歡迎留言補充)

6.1.1 IDEA一次打包

打包需要用到Maven插件,常用的Maven打包插件如下:

  • mave-jar-plugin:默認的打包jar插件,生成的JR很小,但是需要把lib放置與jar相同目錄下,用來打普通的JAR包
  • maven-shade-plugin:提供了兩大基本功能,將依賴的jar包打包到當前jar包,能對依賴的JAR包進行重命名以及取舍過濾
  • maven-assembly-plugin:支持定制化的打包方式,更多的是對項目目錄的重新組裝

本項目使用maven-shade-plugin打包。

需要先引入(引入之后可以把原來的Maven插件去掉),最新版本戳這里的官方github查看:

<build>
	<plugins>
		<plugin>
			<groupId>org.apache.maven.plugins</groupId>
			<artifactId>maven-shade-plugin</artifactId>
            <version>3.2.4</version>
	            <executions>
	            	<execution>
	                    <phase>package</phase>
	                    <goals>
	                        <goal>shade</goal>
	                    </goals>
	                    <configuration>
	                        <transformers>
	                            <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
	                                <mainClass>xxxx.xxx.xxx.Main</mainClass>
	                            </transformer>
	                        </transformers>
	                    </configuration>
	                </execution>
	            </executions>
          </plugin>
	</plugins>
</build>

只需要修改主類即可:

<mainClass>xxxx.xxx.xxx.Main</mainClass>

接着就可以從IDEA右側欄的Maven中一鍵打包:

在這里插入圖片描述

這樣在target下就有JAR包了,可以跨平台運行,只需提供JDK環境。

java -jar xxx.jar

下面的兩步是使用exe4j與Enigma Virtual Box打成一個單一EXE的方法,僅針對Win,使用Linux/Mac可以跳過或自行搜索其他方法。

6.1.2 exe4j二次打包

6.1.2.1 exe4j

exe4j能集成Java應用程序到Win下的java可執行文件生成工具,無論是用於服務器還是用於GUI或者命令行的應用程序。簡單地說,本項目用其將jar轉換為EXE。exe4j需要JRE,從JDK9開始模塊化,需要自行生成JRE,因此,需要先生成JRE再使用exe4j打包。

6.1.2.2 生成jre

各個模塊的作用可以這里查看:

在這里插入圖片描述

經測試本程序所需要的模塊如下:

java.base,java.logging,java.net.http,javafx.base,javafx.controls,javafx.fxml,javafx.graphics,java.sql,java.management

切換到JDK目錄下,使用jlink生成JRE:

jlink --module-path jmods --add-modules 
java.base,java.logging,java.net.http,javafx.base,javafx.controls,javafx.fxml,javafx.graphics,java.sql,java.management
--output jre

由於OpenJDK11不自帶JavaFX,需要戳這里自行下載Win平台的JFX jmods,並移動到JDK的jmods目錄下。生成的JRE大小為91M:

在這里插入圖片描述

如果實在不清楚使用哪一些模塊可以使用全部模塊,但是不建議:

jlink --module-path jmods --add-modules 
java.base,java.compiler,java.datatransfer,java.xml,java.prefs,java.desktop,java.instrument,java.logging,java.management,java.security.sasl,java.naming,java.rmi,java.management.rmi,java.net.http,java.scripting,java.security.jgss,java.transaction.xa,java.sql,java.sql.rowset,java.xml.crypto,java.se,java.smartcardio,jdk.accessibility,jdk.internal.vm.ci,jdk.management,jdk.unsupported,jdk.internal.vm.compiler,jdk.aot,jdk.internal.jvmstat,jdk.attach,jdk.charsets,jdk.compiler,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.crypto.mscapi,jdk.dynalink,jdk.internal.ed,jdk.editpad,jdk.hotspot.agent,jdk.httpserver,jdk.internal.le,jdk.internal.opt,jdk.internal.vm.compiler.management,jdk.jartool,jdk.javadoc,jdk.jcmd,jdk.management.agent,jdk.jconsole,jdk.jdeps,jdk.jdwp.agent,jdk.jdi,jdk.jfr,jdk.jlink,jdk.jshell,jdk.jsobject,jdk.jstatd,jdk.localedata,jdk.management.jfr,jdk.naming.dns,jdk.naming.rmi,jdk.net,jdk.pack,jdk.rmic,jdk.scripting.nashorn,jdk.scripting.nashorn.shell,jdk.sctp,jdk.security.auth,jdk.security.jgss,jdk.unsupported.desktop,jdk.xml.dom,jdk.zipfs,javafx.web,javafx.swing,javafx.media,javafx.graphics,javafx.fxml,javafx.controls,javafx.base 
--output jre

大小為238M:

在這里插入圖片描述

6.1.2.3 exe4j打包

exe4j使用參考這里,首先一開始的界面應該是這樣的:

在這里插入圖片描述

配置文件首次運行是沒有的,next即可。

選擇JAR in EXE mode:

在這里插入圖片描述

填入名稱與輸出目錄:

在這里插入圖片描述

這里的類型為GUI application,填上可執行文件的名稱,選擇圖標路徑,勾選允許單個應用實例運行:

在這里插入圖片描述

重定向這里可以選擇標准輸出流與標准錯誤流的輸出目錄,不需要的話默認即可:

在這里插入圖片描述

64位Win需要勾選生成64位的可執行文件:

在這里插入圖片描述

接着是Java類與JRE路徑設置:

在這里插入圖片描述

選擇IDEA生成的JAR,接着填上主類路徑:

在這里插入圖片描述

設置jre的最低支持與最高支持版本:

在這里插入圖片描述

下一步是指定JRE搜索路徑,首先把默認的三個位置刪除:

在這里插入圖片描述

接着選擇之前生成的JRE,把JRE放在與JAR同一目錄下,路徑填上當前目錄下的JRE:

在這里插入圖片描述

接下來全next即可,完成后會提示exe4j has finished,直接運行測試一遍:

在這里插入圖片描述

首先會提示一遍這是用exe4j生成的:

在這里插入圖片描述

若沒有缺少模塊應該就可以正常啟動了,有缺少模塊的話會默認在當前exe路徑生成一個error.log,查看並添加對應模塊再次使用jlink生成jre,並使用exe4j再次打包。

6.1.3 Enigma Virtual Box三次打包

使用exe4j打包后,雖然是也可以直接運行了,但是JRE太大,而且筆者這種有強迫症非得裝進一個EXE。所幸筆者之前用過Enigma Virtual Box這個打包工具,能把所有文件打包為一個獨立的EXE。

使用很簡單,首先添加exe4j打包出來的EXE:

在這里插入圖片描述

接着新建一個jre目錄,添加上一步生成的jre:

在這里插入圖片描述

在這里插入圖片描述

在這里插入圖片描述

最后選擇壓縮文件:

在這里插入圖片描述

打包出來的單獨exe大小為65M,相比起exe4j還要帶上的89M的jre,已經節省了空間。

在這里插入圖片描述

6.2 后端部署

后端部署的方式也簡單,采用WAR部署的方式,若項目為JAR包打包可以自行轉換為WAR包,具體轉換方式不難請自行搜索。由於Web服務器為Tomcat,因此直接把WAR包放置於webapps下即可,其他Web服務器自請自行搜索。

當然也可以使用Docker部署,但需要使用JAR而不是WAR,具體方式自行搜索。

7 運行

本項目已經打包,前端包括jar與exe,后端包括jar與war,首先把后端運行(先開啟數據庫服務):

使用jar:

java -jar Backend.jar

使用war直接放到Tomcat的webapps下然后到bin下:

./startup.sh

接着運行前端,Windows的話可以直接運行exe,當然也可以jar,Linux的話jar:

java -jar Frontend.jar

若運行失敗可以用IDEA打開項目直接在IDEA中運行或者自行打包運行。

8 注意事項

8.1 路徑問題

對於資源文件千萬千萬不要直接使用什么相對路徑或絕對路徑,比如:

String path1 = "/xxx/xxx/xxx/xx.png";
String path2 = "xxx/xx.jpg";

這樣會有很多問題,比如有可能在IDEA中直接運行與打成jar包運行的結果不一致,路徑讀取不了,另外還可能會出現平台問題,眾所周知Linux的路徑分隔符與Windows的不一致。所以,對於資源文件,統一使用如下方式獲取:

String path = getClass().getResource("/image/xx.png");

其中image直接位於resources資源文件夾下。其他類似,也就是說這里的/代表在resources下。

8.2 HTTPS

默認沒有提供HTTPS,證書文件沒有擺上去,走的是本地8080端口。

如果需要自定義HTTPS請修改前端部分的

  • com.test.network.OKHTTP
  • resources/key/pem.pem

同時后端需要修改Tomcat的server.xml

有關OkHttp使用HTTPS的文章有不少,但是大部分都是僅僅寫了前端如何配置HTTPS的,沒有提到后端如何部署,可以參考筆者的這篇文章,包含Tomcat的配置教程。

8.3 配置文件加密

配置文件使用了jasypt-spring-boot開源組件進行加密,設置口令可以有三種方式設置:

  • 命令行參數
  • 應用環境變量
  • 系統環境變量

目前最新的版本為3.0.3(2020.05.31更新3.0.3 ,筆者之前使用3.0.2的版本進行加密時本地測試沒問題,但是部署到服務器上老是提示找不到口令,無奈只好使用舊一點的2.x版本,但是新版本出了后筆者嘗試過部署到本地Tomcat沒有問題但是沒有部署到服務器上),建議使用最新版本進行部署:

在這里插入圖片描述

畢竟前后跨度挺大的,雖然說這是小的bug修復,但是還是建議試試,估計不會有3.0.2的問題了。

另外對於含有中文的字段記得進行編碼轉換:

str = new String(str.getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8);)

另外筆者已寫好了測試文件,直接首先替換掉配置文件原來的密文,填上明文重新加密:
在這里插入圖片描述

注意如果沒有在配置文件中設置jasypt.encryptor.password的話可以在運行配置中設置VM Options(建議不要把口令直接寫在配置文件中,當然這個默認是使用PBE加密,非對稱加密可以使用jasypt.encryptor.private-key-stringjasypt.encryptor.private-key-location):

在這里插入圖片描述

8.4 鍵盤事件

添加鍵盤事件可以使用如下代碼:

scene.getAccelerators().put(new KeyCodeCombination(KeyCode.ENTER), ()->{xxx});
//getAccelerators返回ObservableMap<KeyCombination, Runnable>

響應之前需要讓parent獲取焦點:

parent.requestFocus();

8.5 數據庫

默認使用的數據庫名為app_test,用戶名test_user,密碼test_passwordresources下有一個init.sql,直接使用MySQL導入即可。

在這里插入圖片描述

8.6 驗證碼

默認沒有自帶驗證碼功能,由於涉及隱私問題故沒有開放。

如果像筆者一樣使用騰訊雲的短信API,直接修改配置文件中的對應屬性即可,建議加密。

如果使用其他API請自行對接,前端需要修改的部分包括:

  • com.test.network.OKHTTP
  • com.test.network.request.SendSmsRequest
  • com.test.network.requestBuilder.SendSmsRequestBuilder
  • com.test.controller.start.RetrievePasswordController

后端需要修改的部分:

  • com.test.controller.SmsController

需要的話可以參考筆者的騰訊雲短信API使用或者自行搜索其他短信驗證API。一些寫在配置文件中的API需要的密鑰等信息強烈

9 源碼

前后端完整代碼以及打包程序:

10 項目不足之處

其實整個項目還有很多的不足之處,比如:

  • 前端的部分Scene切換有問題
  • 可以使用Jackson代替Gson來換取更快的轉換速度
  • 沒有緩存機制
  • 前端日志不能發送到后端分析
  • 可以使用二進制代替JSON實現更快的傳輸

不過目前暫時不考慮更新,如果有讀者有自己的想法可以按需修改,這里提一下修改的思路。

11 參考

1、CSDN-maven-shade-plugin介紹及使用

2、CSDN-Maven3種打包方式之一maven-assembly-plugin的使用

3、知乎-制作包含Java 11和JavaFX的JRE

4、CSDN-使用exe4j將java文件打成exe文件運行詳細教程

5、Github-jasypt-spring-boot issue

6、w3cschool-JavaFX

7、簡書-Linux Tomcat+Openssl單向/雙向認證

如果覺得文章好看,歡迎點贊。

同時歡迎關注微信公眾號:氷泠之路。

在這里插入圖片描述


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM