一个简单的小型薪酬管理系统,前端JavaFX+后端Spring Boot,功能倒没多少,主要精力放在了UI和前端的一些逻辑上面,后端其实做得很简单。
主要功能:
登录界面:
用户界面:
管理员界面:
前端主要分为5个部分实现:控制器模块,视图模块,网络模块,动画模块还有工具类模块。
constant
包:项目所需要的字符串常量以及一些枚举常量controller
包:控制器类,负责UI与用户的交互entity
包:实体类log
包:日志类network
包:负责网络请求,包括请求生成以及请求发送transition
包:负责处理动画utils
包:工具类view
包:负责UI的初始化预计更新css
:界面所用到的样式fxml
:一个特殊的xml文件,用于定义界面与绑定Controller中的函数,也就是绑定事件image
:静态图片key
:证书文件,用于OkHttp中的HTTPS连接properties
:项目中的一些常量属性主要依赖如下:
包含程序所需要的字符串以及枚举常量:
CSSPath
:CSS路径,用于给Scene添加样式,如scene.getStylesheets.add(path)
FXMLPath
:FXML路径,用于FXMLLoader
加载FXML
文件,如FXMLLoader.load(getClass.getResource(path).openStream())
AllURL
:发送请求到后端的URLBuilderKeys
:OkHttp中的FormBody.Builder
中使用的常量键名PaneName
:Pane名字,用于在同一个Scene切换不同的PaneReturnCode
:后端返回码,需要与后端协商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";
//...
}
控制器模块用于处理用户的交互事件,分为三类:
这是程序一开始进入的界面,会在这里绑定一些基本的关闭,最小化,标题栏拖拽事件:
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();
}
登录界面的控制器也很简单,就一个登录/注册功能加一个跳转到找回密码界面,代码就不贴了。
至于找回密码界面,需要做的比较多,首先需要判断用户输入的电话是否在后端数据库存在,另外还要检查两次输入的密码是否一致,还要判断短信是否发送成功,并且检查用户输入的验证码与后端返回的验证码是否一致(短信验证码部分其实不需要后端处理,原本是放在前端的,但是考虑到可能会泄漏一些重要的信息就放到后端处理了)。
接着是用户登录后进入的界面,加了渐隐与移动动画:
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();
}
效果如下:
实际处理是把
以及放进一个
中,然后为这个
添加鼠标移入与移出事件。从代码中可以知道图片加上了位移动画,文字同时加上了淡入与位移动画,多边形同时加上了缩放与位移动画。以左下的
事件为例,当鼠标移入时,首先把图片左移:
.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 动画模块"。
简单的一个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,键为对应的年月,值为工资。具体转换方法请到工具类模块查看。
日志模块使用了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
:日志信息这里前端的日志进行了简化处理,需要更多配置请自行搜索。
网络模块的核心使用了OkHttp实现,主要分为两个包:
request
:封装发送到后端的各种请求requestBuilder
:创建request的Builder类OKHTTP
:封装OkHttp的工具类,对外只有一个静态send方法,参数只有一个,request包中的类,使用requestBuilder生成。send方法返回一个Object,Object怎么处理需要在用到OKHTTP的地方与返回方法对应封装了各种网络请求:
所有请求继承自BaseRequest,BaseRequest的公有方法包括:
setUrl
:设置发送的URLsetCellphone
:添加cellphone参数setPassword
:添加password参数,注意会经过前端的SHA-512加密setWorker
:添加Worker参数setWorkers
:接受一个ListsetAvatar
:添加头像参数setAvatars
:接受一个HashMap唯一一个抽象方法是:
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
:电话号码不匹配,也就是数据库没有对应的WorkerEMPTY_WORKER
:数据库中存在这个Worker,但由于转换为String时后端处理失败,返回一个空的WorkerGET_ONE_SUCCESS
:获取成功,使用工具类转换String为Worker包含了对应于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。
这是一个封装了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转换为String
的ReturnCode
,所以获取body后,先转换为ReturnCode
再处理。
至于HTTPS,由于在Tomcat上进行部署,需要在Tomcat里设置证书,同时也需要在OkHttp中设置以下三部分:
sslSocketFactory
:ssl套接字工厂HostnameVerifier
:验证主机名X509TrustManager
:证书信任器管理类上面提到了需要设置三部分,下面来看看最简单的一个验证主机名部分,利用的是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.pem
为resources
下的证书文件。
使用WAR进行部署,JAR部署的方式请自行搜索,服务器Tomcat,其他web服务器请自行搜索。
首先在Tomcat配置文件中的conf/server.xml
修改域名:
找到
并复制,直接修改其中的name
为对应域名:
接着从证书厂商下载文件(一般都带文档,根据文档部署),Tomcat的是两个文件,一个是pfx,一个是密码文件,继续修改server.xml
,搜索8443, 找到如下位置:
其中上面的
是HTTP/1.1协议的,基于NIO实现,下面的
是HTTP/2的,基于APR实现。
使用HTTP/1.1会比较简单一些,仅仅是修改server.xm
l即可,使用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"
>
图片原本是想使用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进行处理。
包含了四类动画:
这四个类都实现了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();
AvatarUtils
:用于本地生成临时图片以及图片转换处理Check
:检查是否为空,是否合法等Conversion
:转换类,通过Gson在Worker/String
,Map/String
,List/String
之间进行转换Utils
:加密,设置运行环境,居中Stage
,检查网络连通等这里说一下Utils
与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类进行转换操作。
获取属性文件方法如下:
//获取属性文件
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);
}
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();
}
});
界面基本上靠这些fxml文件控制,这部分没太多内容,基本上靠IDEA自带的Scene Builder设计,少部分靠代码控制,下面说几个注意事项:
fx:id
以便切换onMouseEntered="#xxx"
,其中里面的方法为对应的控制器(fx:controller="xxx.xxx.xxx.xxxController"
)中的方法
中的URL属性需要带上@
,比如
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);
下面以提示框为例,说明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
获取焦点。
后端以Spring Boot框架为核心,部署方式为WAR,整体分为三层:
总的来说没有用到什么高大上的东西,逻辑也比较简单。
主要依赖如下:
Iterable
转换为集合控制器分为三类,一类处理图片,一类处理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);
}
由于程序中的业务层与持久层都比较简单就合并一起说了,比如业务层的saveOne方法,保存一个Worker,先利用Gson转换为Worker后直接利用CrudRespository
提供的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
的saveAll方法参数为Iterable
,因此可以直接保存List
,比如:
public ReturnCode saveAll(List<Worker> workers)
{
workerRepository.saveAll(workers);
return ReturnCode.SAVE_ALL_SUCCESS;
}
需要在控制层中把前端发送的String转换为List
。
日志用的是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;
}
四个:
String
与List/Map/Worker
之间进行转换重点说一下备份,代码不长就直接整个类贴出来了:
@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用于提供定时任务的注解,用于控制任务在某个指定时间执行或者每隔一段时间执行(这里是半天一次),主要有三种配置执行时间的方式:
这里不展开了,详细用法可以戳这里。
另外在使用前需要在类上加上@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()
获取错误信息并记录日志。
一个总的配置文件+三个是特定环境下(开发,测试,生产)的配置文件,可以使用spring.profiles.active
切换配置文件,比如spring.profiles.active=dev
,注意命名有规则,中间加一杠。另外自定义的配置需要在additional-spring-configuration-metadata.json
中添加字段(非强制,只是IDE会提示),比如:
"properties": [
{
"name": "backup.path",
"type": "java.lang.String",
"defaultValue": null
},
]
都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
解决方案以及问题详细描述戳这里。
先说一下前端的打包过程,简单地说打成JAR即可跨平台运行,但是如果是特定平台的话比如Win,想打成无需额外JDK环境的EXE还是需要一些额外操作,这里简单介绍一下打包过程。
(如果是JDK8可以使用mvn jfx:native
打包,这个可以很方便地直接打成DMG或者EXE,但可惜JFX11行不通,反正笔者尝试失败了,如果有大神知道如何使用JavaFX-Maven-Plugin
或者在IDEA中使用artifact
直接打成exe或dmg欢迎留言补充)
打包需要用到Maven插件,常用的Maven打包插件如下:
本项目使用maven-shade-plugin打包。
需要先引入(引入之后可以把原来的Maven插件去掉),最新版本戳这里的官方github查看:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-shade-pluginartifactId>
<version>3.2.4version>
<executions>
<execution>
<phase>packagephase>
<goals>
<goal>shadegoal>
goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>xxxx.xxx.xxx.MainmainClass>
transformer>
transformers>
configuration>
execution>
executions>
plugin>
plugins>
build>
只需要修改主类即可:
<mainClass>xxxx.xxx.xxx.MainmainClass>
接着就可以从IDEA右侧栏的Maven中一键打包:
这样在target下就有JAR包了,可以跨平台运行,只需提供JDK环境。
java -jar xxx.jar
下面的两步是使用exe4j与Enigma Virtual Box打成一个单一EXE的方法,仅针对Win,使用Linux/Mac可以跳过或自行搜索其他方法。
exe4j能集成Java应用程序到Win下的java可执行文件生成工具,无论是用于服务器还是用于GUI或者命令行的应用程序。简单地说,本项目用其将jar转换为EXE。exe4j需要JRE,从JDK9开始模块化,需要自行生成JRE,因此,需要先生成JRE再使用exe4j打包。
各个模块的作用可以这里查看:
经测试本程序所需要的模块如下:
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:
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再次打包。
使用exe4j打包后,虽然是也可以直接运行了,但是JRE太大,而且笔者这种有强迫症非得装进一个EXE。所幸笔者之前用过Enigma Virtual Box这个打包工具,能把所有文件打包为一个独立的EXE。
使用很简单,首先添加exe4j打包出来的EXE:
接着新建一个jre目录,添加上一步生成的jre:
最后选择压缩文件:
打包出来的单独exe大小为65M,相比起exe4j还要带上的89M的jre,已经节省了空间。
后端部署的方式也简单,采用WAR部署的方式,若项目为JAR包打包可以自行转换为WAR包,具体转换方式不难请自行搜索。由于Web服务器为Tomcat,因此直接把WAR包放置于webapps下即可,其他Web服务器自请自行搜索。
当然也可以使用Docker部署,但需要使用JAR而不是WAR,具体方式自行搜索。
本项目已经打包,前端包括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中运行或者自行打包运行。
对于资源文件千万千万不要直接使用什么相对路径或绝对路径,比如:
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
下。
默认没有提供HTTPS,证书文件没有摆上去,走的是本地8080端口。
如果需要自定义HTTPS请修改前端部分的
com.test.network.OKHTTP
resources/key/pem.pem
同时后端需要修改Tomcat的server.xml
。
有关OkHttp使用HTTPS的文章有不少,但是大部分都是仅仅写了前端如何配置HTTPS的,没有提到后端如何部署,可以参考笔者的这篇文章,包含Tomcat的配置教程。
配置文件使用了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
):
添加键盘事件可以使用如下代码:
scene.getAccelerators().put(new KeyCodeCombination(KeyCode.ENTER), ()->{xxx});
//getAccelerators返回ObservableMap
响应之前需要让parent获取焦点:
parent.requestFocus();
默认使用的数据库名为app_test
,用户名test_user
,密码test_password
,resources
下有一个init.sql
,直接使用MySQL导入即可。
默认没有自带验证码功能,由于涉及隐私问题故没有开放。
如果像笔者一样使用腾讯云的短信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需要的密钥等信息强烈建议加密。
前后端完整代码以及打包程序:
其实整个项目还有很多的不足之处,比如:
不过目前暂时不考虑更新,如果有读者有自己的想法可以按需修改,这里提一下修改的思路。
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单向/双向认证
如果觉得文章好看,欢迎点赞。
同时欢迎关注微信公众号:氷泠之路。