一个简单的小型薪酬管理系统,前端JavaFX+后端Spring Boot,功能倒没多少,主要精力放在了UI和前端的一些逻辑上面,后端其实作得很简单。css
主要功能:html
登陆界面:前端
用户界面:java
管理员界面:node
前端主要分为5个部分实现:控制器模块,视图模块,网络模块,动画模块还有工具类模块。mysql
css
:界面所用到的样式fxml
:一个特殊的xml文件,用于定义界面与绑定Controller中的函数,也就是绑定事件image
:程序用到的默认图片key
:证书文件,用于OkHttp中的HTTPSproperties
:项目一些常量属性主要依赖以下:linux
程序所须要的常量:git
CSSPath
:CSS路径,用于scene.getStylesheets.add(path)
FXMLPath
:FXML路径,用于FXMLLoader.load(getClass.getResource(path).openStream())
AllURL
:发送网络请求的路径BuilderKeys
:OkHttp中的FormBody.Builder
中使用的常量键名PaneName
:Pane名字,用于在同一个Scene切换不一样的PaneReturnCode
:后端返回码ViewSize
:界面尺寸重点说一下路径问题,笔者的css与fxml文件都放在resources下:github
其中fxml路径在项目中的用法以下:web
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(); }
效果以下:
实际处理是把<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 动画模块"。
简单的一个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
:换行这里前端的日志进行了简化处理,须要更多配置请自行搜索。
网络模块的核心使用了OkHttp实现,主要分为两个包:
request
:封装发送到后端的各类请求requestBuilder
:建立request的Builder类OKHTTP
:封装OkHttp的工具类,对外只有一个send方法,参数只有一个,request包中的类,使用requestBuilder生成,返回一个Object,至于Object怎么处理须要在用到OKHTTP的地方与返回方法对应封装了各类网络请求:
全部请求继承自BaseRequest,BaseRequest的公有方法包括:
setUrl
:设置发送的urlsetCellphone
:添加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
:电话号码不匹配,也就是数据库没有对应的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请求的方式,其中call中使用的url与body正是使用BaseRequest
做为基类的缘由,能够方便地获取url与body,若数据量大能够考虑异步请求。上面也提到后端返回的是经由Gson转换为String的ReturnCode,因此获取body后,先转换为ReturnCode再处理。
至于HTTPS,采用了war包部署,后端服务器Tomcat,须要在Tomcat里设置证书,同时也须要在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
为resources下的证书文件。
使用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,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" >
图片本来是想使用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进行处理。
包含了四类动画:淡入/淡出,位移,缩放,旋转,这四个类都实现了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();
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; }
采用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
:全局控制器,负责初始化以及绑定键盘事件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(); } });
界面基本上靠这些fxml文件控制,这部分没太多内容,基本上靠IDEA自带的Scene Builder设计,少部分靠代码控制,下面说几个注意事项:
fx:id
以便切换onMouseEntered="#xxx"
,其中里面的方法为对应的控制器(fx:controller="xxx.xxx.xxx.xxxController"
)中的方法<Image>
中的url属性须要带上@
,好比<Image url="@../../image/xxx.png">
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,总体分为三层:
总的来讲没有用到什么高大上的东西,逻辑也比较简单。
主要依赖以下:
控制器分为三类,一类处理图片,一类处理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); }
因为程序中的业务层与持久层都比较简单就合并一块儿说了,好比业务层的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; }
另外因为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>
。
日志用的是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; }
四个:
重点说一下备份,代码不长就直接整个类贴出来了:
@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 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.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能够跳过或自行搜索其余方法。
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<KeyCombination, Runnable>
响应以前须要让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须要的密钥等信息强烈
先后端完整代码以及打包程序:
一、CSDN-maven-shade-plugin介绍及使用
二、CSDN-Maven3种打包方式之一maven-assembly-plugin的使用
四、CSDN-使用exe4j将java文件打成exe文件运行详细教程
五、Github-jasypt-spring-boot issue
七、简书-Linux Tomcat+Openssl单向/双向认证
若是以为文章好看,欢迎点赞。
同时欢迎关注微信公众号:氷泠之路。