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单向/双向认证

如果觉得文章好看,欢迎点赞。

同时欢迎关注微信公众号:氷泠之路。

在这里插入图片描述

阅读 1.2k
283 声望
631 粉丝
0 条评论
你知道吗?

283 声望
631 粉丝
宣传栏