1 概述
1.1 简介
一个简单的小型薪酬管理系统,前端JavaFX+后端Spring Boot,功能倒没多少,主要精力放在了UI和前端的一些逻辑上面,后端其实做得很简单。
主要功能:
- 用户注册/登录
- 验证码找回密码
- 用户修改信息,修改头像
- 柱状图形式显示薪酬
- 管理员管理用户,录入工资
1.2 响应流程
1.3 演示
登录界面:
用户界面:
管理员界面:
2 环境
- IDEA 2020.1
- 本地JDK+服务器JDK:OpenJDK 11
- OepnJFX 11
- Spring Boot 2.3.0(Spring Boot的更新速度真是太快了)
- Tomcat 9.0.33
- MySQL 8.0.17
3 前端代码部分
3.1 前端概述
前端主要分为5个部分实现:控制器模块,视图模块,网络模块,动画模块还有工具类模块。
- 控制器模块:负责交互事件
- 视图模块:负责更新UI
- 网络模块:向后台发送数据请求
- 动画模块:移动、缩放、渐隐动画
- 工具类模块:加密,检查网路连通,居中界面等
3.2 概览
3.2.1. 代码目录树
3.2.2 资源目录树
分为css,fxml,默认图片,pem与项目配置文件。
- css:界面所用到的样式
- fxml:一个特殊的xml文件,用于定义界面与绑定Controller中的函数,也就是绑定事件
- image:程序用到的默认图片
- key:证书文件,用于OkHttp中的HTTPS
-
properties:项目一些常量属性
3.2.3 项目依赖
主要依赖如下:
- Gson
- Log4j2
- Lombok
- OkHttp3
- Apache Commons
- OpenJFX11
3.3 常量模块
程序所需要的常量:
- CSSPath:CSS路径,用于
scene.getStylesheets.add(path)
- FXMLPath:FXML路径,用于
FXMLLoader.load(getClass.getResource(path).openStream())
- AllURL:发送网络请求的路径
- 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 控制器模块
控制器模块用于处理用户的交互事件,本项目中分为三大类:
- 登录注册界面控制器
- 用户界面控制器
- 管理员界面控制器
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();
}
登录界面的控制器也很简单,就一个登录/注册功能加一个跳转到找回密码界面,代码就不贴了。
至于找回密码界面,需要做的比较多,首先需要判断用户输入的电话是否在后端数据库存在,另外还有检查两次输入的密码是否一致,还有判断短信是否发送成功与用户输入的验证码与后端返回的验证码是否一致(短信验证码部分其实不需要后端处理,因为是调用腾讯云的API,原本是放在前端的,凡是考虑到可能会泄漏一些重要的类似appid之类的信息就放到后端处理了)。
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))
其中用到的Transition,Scale,Fade是自定义的动画处理类,详情请查看"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生成,返回一个Object,至于Object怎么处理需要在用到OKHTTP的地方与返回方法对应
3.7.1 request包
封装了各种网络请求:
所有请求继承自BaseRequest,BaseRequest的公有方法包括:
- setUrl:设置发送的url
- setCellphone:添加cellphone参数
- setPassword:添加password参数,注意password经过前端的SHA512加密
- 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请求的方式,其中call中使用的url与body正是使用BaseRequest
作为基类的原因,可以方便地获取url与body,若数据量大可以考虑异步请求。上面也提到后端返回的是经由Gson转换为String的ReturnCode,所以获取body后先转换为ReturnCode再处理。
3.7.4 HTTPS
至于HTTPS,采用了war包部署,后端服务器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];
}
返回一个信任由输入流读取的证书的信任管理器,若证书没有被签名则抛出SSLHandsakeException,证书建议使用第三方签名的而不是自签名的(比如使用openssl生成),特别是在生产环境中,例子的注释也提到:
最后是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.pem为证书文件。
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,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版本:
<Connector ...
sslProtocol="TLS" sslEnabledProtocols="TLSv1.2"
>
3.7.5 图片处理
图片原本是想使用OkHttp的MultipartBody处理的,但是处理的图片都不太,貌似没有必要,而且实体类的数据都是以字符串的形式传输的,因此,笔者的想法是能不能统一都用字符串进行传输,于是找到了图片和String互转的函数,稍微改动,原来的函数需要外部依赖,现在改为了JDK自带的Base64:
public static String fileToString(String path)
{
File file = new File(path);
FileInputStream fis = null;
StringBuilder content = new StringBuilder();
try {
fis = new FileInputStream(file);
int length = 3 * 1024 * 1024;
byte[] byteAttr = new byte[length];
int byteLength;
while ((byteLength = fis.read(byteAttr, 0, byteAttr.length)) != -1) {
String encode;
if (byteLength != byteAttr.length) {
byte[] temp = new byte[byteLength];
System.arraycopy(byteAttr, 0, temp, 0, byteLength);
encode = Base64.getEncoder().encodeToString(temp);
content.append(encode);
} else {
encode = Base64.getEncoder().encodeToString(byteAttr);
content.append(encode);
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
assert fis != null;
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return content.toString();
}
public static void stirngToFile(String base64Code, String targetPath) {
byte[] buffer;
FileOutputStream out = null;
try {
buffer = Base64.getDecoder().decode(base64Code);
out = new FileOutputStream(targetPath);
out.write(buffer);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
Base64是一种基于64个可打印字符来表示二进制数据的方法,可以把二进制数据(图片/视频等)转为字符,或把对应的字符解码变为原来的二进制数据。
笔者实测这种方法转换速度不慢,只要有了正确的转换函数,服务器端可以轻松进行转换,但是对于大文件的支持不好:
这种方法对一般的图片来说足够了,但是对于真正的文件还是建议使用MultipartBody进行处理。
3.8 动画模块
包含了四类动画:淡入/淡出,位移,缩放,旋转,这四个类都实现了CustomTransitionOperation接口:
import javafx.animation.Animation;
public interface CustomTransitionOperation {
double defaultSeconds = 0.4;
Animation build();
void play();
}
其中defaultSeconds表示默认持续的秒数,build用于Transition中对各个动画类进行统一的生成操作,最后的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/String,Map/String,List/String之间进行转换
- Utils:加密,设置运行环境,居中Stage,检查网络连通等
这里说一下Utils与Conversion。
3.9.1 Conversion
转换类,利用Gson在String与List/Worker/Map之间进行转换,比如String转Map:
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;
}
采用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:全局控制器,负责初始化以及绑定键盘事件
- MessBox:提示信息框,对外提供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设置一个独立的fxid以便切换
- 事件绑定在对应的控件中,比如在一个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指的是控件的fxid,通常配合Controller中的@FXML
使用,比如一个Label设置了fxid为label1
<Label fx:id="label1" layoutX="450.0" layoutY="402.0" text="Label">
<font>
<Font size="18.0" />
</font>
</Label>
则可以在对应Controller中使用@FXML
获取,名字与fxid一致:
@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
,根据id获取对应控件,注意需要加上#
。处理好控件之后,居中并显示stage,同时,绑定键盘事件并让Parent获取焦点。
5 后端部分
5.1 后端概述
后端以Spring Boot框架为核心,部署方式为war,整体分为三层:
- 控制器层:负责接受前端的请求并调用业务层方法
- 业务层:处理主要业务,如CRUD,图片处理等
- 持久层:数据持久化,使用Hibernate+Spring Data JPA
总的来说没有用到什么高大上的东西,逻辑也比较简单。
5.2 概览
5.2.1 代码目录树
5.2.2 依赖
主要依赖如下:
- Spring Boot Starter Data JPA
- Guava
- Lombok
- Gson
- Apache Commons
- TencentCloud SDK Java
-
5.3 控制器层
控制器分为三类,一类处理图片,一类处理CRUD请求,一类处理短信发送请求,统一接受POST忽略GET请求。大概的处理流程是接收参数后首先进行判断操作,比如判空以及判断是否合法等等,接着调用业务层的方法并对返回结果进行封装,同时进行日志记录,最后利用Gson把返回结果转为字符串。代码大部分比较简单就不贴了,说一下短信验证码的部分。
验证码模块使用了腾讯云的功能,官网这里,搜索短信功能即可。
新用户默认赠送100条短信:
发送之前需要创建签名与正文模板,审核通过即可使用。
可以先根据快速开始试用一下短信功能,若能成功收到短信,可以戳这里查看API(Java版)。
下面的例子由文档例子简化而来:private void sendCode() { try { SmsClient client = new SmsClient(new Credential(TencentSDK.id,TencentSDK.key),""); SendSmsRequest request = new SendSmsRequest(); request.setSmsSdkAppid(TencentSDK.appId); request.setSign(TencentSDK.sign); request.setTemplateID(TencentSDK.templateId); randomCode = RandomStringUtils.randomNumeric(6); String [] templateParamSet = {randomCode}; request.setTemplateParamSet(templateParamSet); String [] phoneNumbers = {"+86"+cellphone.getText()}; request.setPhoneNumberSet(phoneNumbers); response = client.SendSms(request); } catch (Exception e) { L.error("Not send code or send code failed"); AlertView.show("验证码未发送或发送验证码失败"); } }
其中
TencentSDK.appId,TencentSDK.sign,TencentSDK.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方法保存:
```java
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;
}
另外由于CurdRepository<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在String与List/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
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还是需要一些额外操作,这里简单介绍一下打包过程。
6.1.1 IDEA一次打包
打包需要用到Maven插件,常用的Maven打包插件如下:
- mave-jar-plugin:默认的打包jar插件,生成的jar很小,但是需要把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端口,有关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-string
或jasypt.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_password
,resources
下有一个init.sql
,直接使用MySQL导入即可。
8.6 验证码
默认没有自带验证码功能,由于涉及隐私问题故没有开放,需要的话可以参考笔者的腾讯云短信API使用或者自行搜索其他短信验证API。一些写在配置文件中的API需要的密钥等信息强烈建议加密。
9 源码
前后端完整代码,带README.md说明:
10 参考
1、CSDN-maven-shade-plugin介绍及使用
2、CSDN-Maven3种打包方式之一maven-assembly-plugin的使用
4、CSDN-使用exe4j将java文件打成exe文件运行详细教程
5、Github-jasypt-spring-boot issue
7、简书-Linux Tomcat+Openssl单向/双向认证
如果觉得文章好看,欢迎点赞。
同时欢迎关注微信公众号:氷泠之路。
来源:oschina
链接:https://my.oschina.net/u/4408404/blog/4302470