Windows 上安装 JDK 并设置环境变量

从 Oracle 官网下载 JDK

首先在浏览器打开 JDK8 下载页面

找到 Java SE Development Kit 8uXXX 勾选 Accept License Agreement,点击下载平台对应的二进制文件,这里以 Windows 平台为例,选择 jdk-8u191-windows-x64.exe 下载就好了

JDK8 下载

运行安装程序

双击打开 JDK 安装,其实基本上就是一路 Next 下去就好啦

JDK 安装 01

JDK 安装 02

JDK 安装 02

JDK 安装 03

这里需要注意一下,对于安装 JDK 的人来说,Java 安装是可有可无的,所以我们这里直接点 X 就好再确定就好。

不安装 Java

安装完成

安装完成,关闭安装窗口即可,然而我们并不能立刻开始使用 JDK,因为我们还需要设置 JDK 的环境变量

设置 JDK 的环境变量

  1. 此电脑 上右键选择 属性

此电脑右键属性

  1. 控制面板\系统和安全\系统 选择 高级系统设置

高级系统设置

  1. 系统属性 > 高级 选项卡选择 环境变量

环境变量

JDK:需要设置 3 个环境变量(如果找不到就添加)

附:这里一般设置到 系统变量 里面

环境变量设置

  • JAVA_HOME:浏览文件夹选择 JDK 安装路径就行了

    JAVA_HOME 环境变量

  • CLASSPATH:.;%JAVA_HOME%\lib\dt.jar;%JAVA_HOME%\lib\tools.jar;

    CLASSPATH 环境变量

  • Path:添加 %JAVA_HOME%\bin%JAVA_HOME%\jre\bin

    Path 环境变量

设置完环境变量之后一定要按确定依次关闭 环境变量系统属性 窗口

JDK 安装完成了,现在测试一下能否正常使用,使用 Win + R 输入 cmd 打开 CMD 命令行窗口,或者在菜单中找到 Windows 系统 > 命令提示符 点击也同样能打开 CMD。

测试 JDK 是否安装成功

在菜单中打开 CMD

在命令行输入 javac,你应该得到类似于下面的这些输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
用法: javac <options> <source files>
其中, 可能的选项包括:
-g 生成所有调试信息
-g:none 不生成任何调试信息
-g:{lines,vars,source} 只生成某些调试信息
-nowarn 不生成任何警告
-verbose 输出有关编译器正在执行的操作的消息
-deprecation 输出使用已过时的 API 的源位置
-classpath <路径> 指定查找用户类文件和注释处理程序的位置
-cp <路径> 指定查找用户类文件和注释处理程序的位置
-sourcepath <路径> 指定查找输入源文件的位置
-bootclasspath <路径> 覆盖引导类文件的位置
-extdirs <目录> 覆盖所安装扩展的位置
-endorseddirs <目录> 覆盖签名的标准路径的位置
-proc:{none,only} 控制是否执行注释处理和/或编译。
-processor <class1>[,<class2>,<class3>...] 要运行的注释处理程序的名称; 绕过默认的搜索进程
-processorpath <路径> 指定查找注释处理程序的位置
-parameters 生成元数据以用于方法参数的反射
-d <目录> 指定放置生成的类文件的位置
-s <目录> 指定放置生成的源文件的位置
-h <目录> 指定放置生成的本机标头文件的位置
-implicit:{none,class} 指定是否为隐式引用文件生成类文件
-encoding <编码> 指定源文件使用的字符编码
-source <发行版> 提供与指定发行版的源兼容性
-target <发行版> 生成特定 VM 版本的类文件
-profile <配置文件> 请确保使用的 API 在指定的配置文件中可用
-version 版本信息
-help 输出标准选项的提要
-A关键字[=值] 传递给注释处理程序的选项
-X 输出非标准选项的提要
-J<标记> 直接将 <标记> 传递给运行时系统
-Werror 出现警告时终止编译
@<文件名> 从文件读取选项和文件名

如果你得到了类似下面错误的输出,请务必重新检查上面的安装步骤

1
2
'javac' 不是内部或外部命令,也不是可运行的程序
或批处理文件。

到此 JDK 的安装就算完成了

Markdown 图片粘贴工具 PicGo

场景

使用 Markdown 的人都知道,想要在 Markdown 文件中插入图片并不是特别容易,因为你必须要先把图片上传到图床才行,一个好的图床能够节省很多时间。吾辈之前使用的是 smms,后来切换到了 GitHub,毕竟 GitHub 作为国外流行的托管网站,但事实上 GitHub 上传图片麻烦一点也不少。_add -> commit -> push -> browser -> copy url_,实在麻烦。直到,遇到了 PicGo。

PicGo 对于吾辈而言主要解决了下面的问题:

  • 上传之前重命名
  • 上传一键就好
  • 上传后图片管理

官网, GitHub

官网首页截图

基本使用

GitHub Releases 下载最新版,然后安装一下即可开箱即用啦!

注:上传图床默认是 smms,速度很快,也很稳定

启动之后会看到一个上传页,将图片拖到这里就可以自动上传,并在完成之后自动将链接复制到剪切板上。
图片上传页

在相册里可以对图片进行管理,方便浏览,复制 URL,删除本地记录等操作!
上传图片管理

GitHub 设置

如果你不玩 GitHub,可以跳过这一节

这里可以参考 PicGo 的 官方教程

基本上就是需要以下三个配置

  • 仓库名:准确地说是 [用户名/仓库名],例如吾辈的 GitHub 帐户名是 rxliuli,作为图床的仓库名是 img-bed,那么这里应该设置为 rxliuli/img-bed
  • 分支名:默认就是 master 分支,如果没什么特别的需求应该不用修改
  • Token:用来操作 GitHub 的钥匙,你可以在 Token 设置 中任意生成,但需要留意权限,默认选择第一个 repo 然后点击 Generator Token 按钮生成就行了

吾辈的配置

PicGo 配置

VSCode 插件

如果你不玩 VSCode,也可以跳过这一节了

如果你使用的编辑器是 VSCode 并且不需要管理图片的话,便可以使用 VSCode 插件 PicGo

基本上安装完成之后就可以直接使用了,默认使用 SMMS 图床

常用操作只有三个:

  • 截图上传 Ctrl-Alt-U
    截图上传
  • 文件管理器选择上传 Ctrl-Alt-E
    文件管理器选择上传
  • 输入文件路径上传 Ctrl-Alt-O
    输入文件路径上传

如果你不想使用 SMMS 图床,也可以配置 GitHub 或者其他的图床,具体参考 官方文档


那么,关于 Markdown 图片粘贴工具到这里便结束了,愉快的使用 Markdown 写作吧 o(〃^▽^〃)o

Chrome 踩坑笔记

Chrome 右键翻译不能使用

2018-10-11

原因

其实是 SwitchyOmega 这个插件,之前一直没出过问题,结果现在 Chrome69 出现了。Chrome 浏览器右键翻译的 API 和 Google 翻译网页版使用的不是同一个!

结果就出现了 Google 翻译网页版明明能正常使用,但 Chrome 浏览器右键翻译功能就是不行的奇怪现象。

解决方案

直接访问 https://translate.googleapis.com,嗯,访问失败了。然后设置这个域名使用代理就好了,正常情况下应该显示 404,并不是错误。

Chrome 强制复制粘贴

场景

如果你是一位开发者,可以看一下另一篇博客 使用 Greasemonkey 解除网页复制粘贴限制。或许,你能找到更好的方式

登录支付宝时突然发现密码框不能 C-V 粘贴,也无法使用右键了。提示说是为了安全,实际上不就是为了让人使用 App 扫码么。。。想尽办法为难 PC 用户,这次 Chrome 的强制复制插件也没能起到作用。然而不管其是如何实现禁用粘贴/禁用右键的,但其一定需要用到 JavaScript,有了这个思路,只要暂时禁用 JavaScript 就好了。

具体步骤

使用 F12 打开 Chrome 开发者工具,勾选 Setting -> Preferences -> Debugger -> Disable JavaScript,临时禁用掉 JavaScript,然后在粘贴密码之后记得要取消勾选哦,因为支付宝的登录还要用到 JavaScript 呢

  1. 打开 Chrome 开发者工具
    打开 Chrome 开发者工具
  2. 临时禁用 JavaScript
    临时禁用 JavaScript

快速切换的方法暂且还没有找到 Chrome 插件呢,有什么好推荐的也可以告诉吾辈哦

Git 常用命令

记录一些最常用的命令(从上往下使用频率依次降低),当然,这里只是吾辈个人的,其他功能可能暂时还没用到呢

详细的 git 相关知识强烈推荐 Pro Git

基本命令

关键词 命令格式 命令解释 命令示例 命令解释
init git init 为当前目录初始化 git 本地仓库
add git add [要追踪的文件/路径] 添加到 git 跟踪 git add -A 追踪仓库目录下的所有文件
commit git commit [路径] [选项] [提交说明] 提交当前的修改内容 git commit -a -m "提交全部的修改" 提交了全部的修改内容(仍在本机)
remote git remote [选项] [远程仓库名] 操作 git 远程仓库 git remote 显示所有远程仓库的别名
git remote add origin https://github.com/rxliuli/rxliuli.github.io.git 为本地仓库关联一个远程仓库
git remote show origin 显示 origin 远程仓库的详细信息
git remote remove origin 删除 origin 远程仓库
push git push [远程别名] [远程分支] 推送本地修改到远程 git push origin master 推送本地修改到远程 origin 的 master 分支上
pull git pull [远程别名] [远程分支] 拉取远程修改到本地 git pull origin master 拉去远程 origin 上 master 分支的修改到本地
status git status 查看本地仓库的状态,以此得知添加和修改的文件
clone git clone [远程仓库地址] 克隆一个远程仓库到本地,这里和 pull 不同点在于本地不存在要克隆的仓库 git clone https://github.com/rxliuli/rxliuli.github.io.git 克隆吾辈的博客 github 仓库到本地
log git log [选项] 查看 git 日志 git log 简单的查看 git commit 历史纪录
revert git revert [提交记录 hash] 撤销掉指定提交 git revert ab1c2d2 撤销一次提交内容,然后将撤销的内容作为修改提交一次,保留了所有的记录
reset git reset [提交记录 hash] 重置到某次提交上,和上面不一样的是不会添加新的提交记录,而是删除已有的提交记录 git reset ab1c2d2 不会在 log 中留下痕迹
git reset HEAD~[N 回退次数] 回退最近几次的提交, N 为几就回退几次 git reset HEAD~1 回退最近一次的提交
branch git branch [分支] git 分支(强大而又复杂的功能) git branch dev 创建 dev 分支
git branch 列出所有分支
git branch dev -D 删除名为 dev 的分支
checkout git checkout [分支名] 切换当前分支(分支之间不共享修改) git checkout master 切换当前分支到 master 分支
git checkout origin/dev -b dev 拉取远程分支到本地并切换
merge git merge [选项] 合并其他分支的修改到当前分支上 git merge dev 合并 dev 分支的修改到当前分支(一般是 master 分支)
git merge origin/master --allow-unrelated-histories 强行合并远程分支到本地
push git push [远程仓库名] :[分支名] 删除掉指定的远程分支(仓库还在,只是删除分支) git push origin :dev 删除远程仓库 origin 下的 dev 开发分支
stash git stash 暂存本地更改
git stash list 查看所有暂存更改
git stash apply [index] 重新应用指定暂存更改 git stash apply 重新应用最新的暂存更改
git stash drop [index] 删除掉指定的暂存更改 git stash drop 删除掉最新的暂存更改

复合命令

撤销掉本地所有的修改

  • 命令

    1
    git add -A && git stash && git stash drop
  • 解释

    1. 添加所有更改到 git 追踪中(如果没有被忽略的话)
    2. 添加所有本地更改到暂存区域中
    3. 删除掉刚添加的最新暂存更改
  • 应用场景
    修改了一些文件但又没有提交,突然发现有问题,想把它们全删除了重来,或者全部回到上次提交,先把这些修改暂存起来(不加最后一条命令)

区分文件名大小写

  • 命令

    1
    git config core.ignorecase false
  • 解释
    Windows 下默认不区分文件名大小写,所以需要特别设置一下。

Java 微信公众号开发

场景

公司需要做一个微信的公众号,以前没有玩过结果踩了一堆坑,也是无奈了,便在这里记录一下

注册微信公众号

首先在 微信公众平台 注册一个账号,这里选择了 _订阅号_,填写一堆乱七八糟的信息后就得到了一个微信公众号(订阅号)了。之后登录的话却是要进行扫码操作(反人类操作)。

基本配置

在【开发 > 基本配置】中设定好相关的信息,主要有

  • 开发者 ID(AppID):自动生成
  • 开发者密码(AppSecret):修改完之后记录下来,一会还要用到
  • IP 白名单:可以公网访问的服务器 IP 地址(没有也行,后面会说到 内网穿透
  • 服务器地址(URL):用于给微信校验的服务器地址,没有公网服务器也行
  • 令牌(Token):自定义,随机字符串即可,可以在 LastPass 生成一个
  • 消息加解密密钥(EncodingAESKey):点击随机生成即可
  • 消息加解密方式:目前选择明文模式

配置服务器地址时会报错,先不管了就行,后面会再回来配置的。

使用测试账号

有了自己的微信公众号当然很好,但不可能每次都直接修改真正的公众号吧,修改挂了怎么办?所以就有了测试公众号,而且测试公众号的权限是要高于普通的未认证订阅号的。

在【开发 > 公众平台测试帐号 > 公众平台测试帐号】中申请一个测试账号,如 基本配置 所述中配置一下。

安全域名设置:如果你有的自己的域名和服务器的话就配置,否则就先不管。

服务端编码

初始化项目

为了简化配置这里使用 SpringBoot Web 项目作为例子(注意勾上 web 模块依赖)

内网穿透

使用内网穿透工具 serveo 实现将本地内网服务映射到外网的 80 端口上

下面的命令要求系统已经安装了 SSH 客户端,Linux 已经默认安装了,如果是 Windows 可以使用 Cmder 或 Git For Windows 之类的。

1
ssh -o ServerAliveInterval=60 -R rx:80:localhost:8080 serveo.net

具体可以参考 官网使用 Serveo 进行内网穿透

现在,访问 https://rx.serveo.net/,是不是已经可以啦(出现的 Whitelabel Error Page 不用管,因为我们本来也没有处理 / 路径的访问)

微信服务器认证

引入额外的依赖(SpringBoot Web 项目默认引入 spring-boot-starterspring-boot-starter-webspring-boot-starter-test 模块)

1
2
3
4
5
6
<!--微信的公众号依赖-->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-mp</artifactId>
<version>3.1.0</version>
</dependency>

添加配置文件 application.yml

1
2
3
4
5
6
7
8
9
10
11
# 非必需,但这里还是设定一下端口,方便后面写启动脚本
server:
port: 8080
custom:
wx:
mp:
# 基本上都是微信公众号那边的设置(这里是测试的)
appId: appId
secret: secret
token: token
aesKey: aesKey

将配置读取到 Java Bean 对象上方便在程序中使用 WxMpPropertiesConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 微信公众号属性配置
*
* @author rxliuli
*/
@ConfigurationProperties(prefix = "custom.wx.mp")
public class WxMpPropertiesConfig {
private String appId;
private String secret;
private String token;
private String aesKey;
// getter() and setter()
}

添加微信相关的主配置类 WxMpMainConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 微信公众号主要的配置类
*
* @author rxliuli
*/
@Configuration
@EnableConfigurationProperties(WxMpPropertiesConfig.class)
public class WxMpMainConfig {
private final WxMpPropertiesConfig wxMpPropertiesConfig;

/**
* 微信公众号的服务对象
* 用户调用微信的各种 API, 例如获取 access_token
*/
private WxMpService wxMpService;

@Autowired
public WxMpMainConfig(WxMpPropertiesConfig wxMpPropertiesConfig) {
this.wxMpPropertiesConfig = wxMpPropertiesConfig;
}

/**
* 初始化路由列表和微信服务 api 对象
*/
@PostConstruct
public void init() {
//配置微信 api 对象的策略(目前在内存中)
final WxMpInMemoryConfigStorage storage = new WxMpInMemoryConfigStorage();
storage.setAppId(wxMpPropertiesConfig.getAppId());
storage.setSecret(wxMpPropertiesConfig.getSecret());
storage.setAesKey(wxMpPropertiesConfig.getAesKey());
storage.setToken(wxMpPropertiesConfig.getToken());
//设置策略到服务对象中
wxMpService = new WxMpServiceImpl();
wxMpService.setWxMpConfigStorage(storage);
}

@Bean
public WxMpService wxMpService() {
return wxMpService;
}
}

添加一个窗口 api 用于给微信调用 WxMpPortalApi

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 微信服务窗口 api
*
* @author rxliuli
*/
@RestController
@RequestMapping("/wx/portal")
public class WxMpPortalApi {
private final WxMpService wxMpService;

@Autowired
public WxMpPortalApi(WxMpService wxMpService) {
this.wxMpService = wxMpService;
}

/**
* 微信认证当前服务可用
*
* @param signature 微信加密签名,signature 结合了开发者填写的 token 参数和请求中的 timestamp 参数、nonce 参数
* @param timestamp 时间戳
* @param nonce 随机数
* @param echostr 成功后回传的随机字符串
* @return {@code echostr}
*/
@GetMapping
public String authGet(
String signature,
String timestamp,
String nonce,
String echostr
) {
if (StringUtils.isAnyEmpty(signature, timestamp, nonce, echostr)) {
throw new IllegalArgumentException("请求非法参数!");
}
if (wxMpService.checkSignature(timestamp, nonce, signature)) {
return echostr;
}
return "非法请求";
}
}

重启项目,将 https://rx.serveo.net/wx/portal 填到服务器配置中的 url 里面,点击 提交,应该可以看到 [修改成功] 的提示了。

消息处理

很显然,如果我们只让微信认证我们的服务器的话是做不了什么的,所以我们需要监听并处理用户在微信公众号中的操作并返回结果。

修改微信服务窗口 api WxMpPortalApi,添加对 post 请求的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/**
* 微信服务窗口 api
*
* @author rxliuli
*/
@RestController
@RequestMapping("/wx/portal")
public class WxMpPortalApi {
private final Logger log = LoggerFactory.getLogger(getClass());

private final WxMpService wxMpService;
private final WxMpMessageRouter router;

@Autowired
public WxMpPortalApi(WxMpService wxMpService, WxMpMessageRouter router) {
this.wxMpService = wxMpService;
this.router = router;
}

/**
* 微信认证当前服务可用
*
* @param signature 微信加密签名,signature 结合了开发者填写的 token 参数和请求中的 timestamp 参数、nonce 参数
* @param timestamp 时间戳
* @param nonce 随机数
* @param echostr 成功后回传的随机字符串
* @return {@code echostr}
*/
@GetMapping
public String authGet(
String signature,
String timestamp,
String nonce,
String echostr
) {
if (StringUtils.isAnyEmpty(signature, timestamp, nonce, echostr)) {
throw new IllegalArgumentException("请求非法参数!");
}
if (wxMpService.checkSignature(timestamp, nonce, signature)) {
return echostr;
}
return "非法请求";
}

/**
* 对所有来自微信服务器的消息进行预处理
*
* @param requestBody 请求体(xml 格式)
* @return 明文消息
*/
@PostMapping
public String authPost(
@RequestBody String requestBody,
@RequestParam("signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam(name = "encrypt_type", required = false) String encType,
@RequestParam(name = "msg_signature", required = false) String msgSignature
) throws JsonProcessingException {
if (!wxMpService.checkSignature(timestamp, nonce, signature)) {
throw new IllegalArgumentException("非法请求, 并非微信发来的");
}

WxMpXmlMessage inMessage = null;
if (encType == null) {
//明文传输
inMessage = WxMpXmlMessage.fromXml(requestBody);
} else if ("aes".equals(encType)) {
//aes 加密
inMessage = WxMpXmlMessage.fromEncryptedXml(requestBody, wxMpService.getWxMpConfigStorage(), timestamp, nonce, msgSignature);
}
WxMpXmlOutMessage outMessage = router.route(inMessage);
log.info("客户端发送的消息: {}", new ObjectMapper().writeValueAsString(outMessage));
return outMessage == null ? "" : outMessage.toXml();
}
}

添加一个用户消息处理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 基础微信消息处理器
*
* @author rxliuli
*/
public abstract class BaseHandler implements WxMpMessageHandler {
final Logger log = LoggerFactory.getLogger(getClass());

/**
* 默认空实现
*
* @param wxMessage 微信的消息
* @param context 上下文环境(用于在 handler 中传递信息)
* @param wxMpService 微信 api 服务
* @param sessionManager 会话管理
* @return xml 格式的消息, 异步可返回 null
*/
@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map<String, Object> context, WxMpService wxMpService, WxSessionManager sessionManager) {
return null;
}
}

/**
* 用户发送消息的处理器
*
* @author rxliuli
*/
@Component
public class MsgHandler extends BaseHandler {
@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map<String, Object> context, WxMpService wxMpService, WxSessionManager sessionManager) {
log.info("接收到消息: {}", wxMessage.getMsg());
final String content = "您发送的消息为: " + wxMessage.getContent();
return WxMpXmlOutMessage.TEXT().content(content)
.fromUser(wxMessage.getToUser())
.toUser(wxMessage.getFromUser())
.build();
}
}

修改微信公众号主要的配置类 WxMpMainConfig,添加路由管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/**
* 微信公众号主要的配置类
*
* @author rxliuli
*/
@Configuration
@EnableConfigurationProperties(WxMpPropertiesConfig.class)
public class WxMpMainConfig {
private final WxMpPropertiesConfig wxMpPropertiesConfig;
private final MsgHandler msgHandler;
/**
* 微信公众号监听管理路由映射表
* 其实就是监听用户在公众号的操作罢了, 比如点击了某个菜单, 发送了一些消息
*/
private WxMpMessageRouter wxMpMessageRouter;
/**
* 微信公众号的服务对象
* 用户调用微信的各种 API, 例如获取 access_token
*/
private WxMpService wxMpService;

@Autowired
public WxMpMainConfig(WxMpPropertiesConfig wxMpPropertiesConfig, MsgHandler msgHandler) {
this.wxMpPropertiesConfig = wxMpPropertiesConfig;
this.msgHandler = msgHandler;
}

/**
* 初始化路由列表和微信服务 api 对象
*/
@PostConstruct
public void init() {
//配置微信 api 对象的策略(目前在内存中)
final WxMpInMemoryConfigStorage storage = new WxMpInMemoryConfigStorage();
storage.setAppId(wxMpPropertiesConfig.getAppId());
storage.setSecret(wxMpPropertiesConfig.getSecret());
storage.setAesKey(wxMpPropertiesConfig.getAesKey());
storage.setToken(wxMpPropertiesConfig.getToken());
//设置策略到服务对象中
wxMpService = new WxMpServiceImpl();
wxMpService.setWxMpConfigStorage(storage);
//添加路由
wxMpMessageRouter = this.newRouter(wxMpService);
}

/**
* 根据微信 api 服务对象创建一个微信监听路由
*
* @param wxMpService 微信 api 服务
* @return 微信监听路由对象
*/
private WxMpMessageRouter newRouter(WxMpService wxMpService) {
WxMpMessageRouter router = new WxMpMessageRouter(wxMpService);
//发送消息(默认)
router.rule().async(false).handler(this.msgHandler).end();
return router;
}

@Bean
public WxMpService wxMpService() {
return wxMpService;
}

@Bean
public WxMpMessageRouter wxMpMessageRouter() {
return wxMpMessageRouter;
}
}

现在向公众号发送消息,就可以得到回复了(简单的)。还有日志,菜单,关注,取消关注等处理器这里就不赘述了

创建菜单

创建一个简单的公众号菜单 Api 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 微信公众号菜单
*
* @author rxliuli
*/
@RestController
@RequestMapping("/wx/menu/")
public class WxMpMenuApi extends WxMpBaseApi {
/**
* 创建一个默认的菜单
*
* @return 菜单 id
*/
@GetMapping("create")
public String createDefault() throws WxErrorException {
final WxMenu wxMenu = new WxMenu();
final WxMenuButton buttonLeft = new WxMenuButton();
buttonLeft.setType(WxConsts.MenuButtonType.CLICK);
buttonLeft.setName("点击");
buttonLeft.setKey(IdWorker.getIdStr());

final WxMenuButton buttonRight = new WxMenuButton();
buttonRight.setType(WxConsts.MenuButtonType.VIEW);
buttonRight.setName("链接");
buttonRight.setUrl("https://blog.rxliuli.com");
buttonRight.setKey(IdWorker.getIdStr());
wxMenu.getButtons().add(buttonLeft);
wxMenu.getButtons().add(buttonRight);
return wxMpService.getMenuService().menuCreate(wxMenu);
}
}

访问 https://rx.serveo.net/wx/menu/create 就可以为微信公众号创建一个简单的菜单了。点击左边的“点击”按钮会回复文字说点击了什么,右边的链接则会跳转到一个网页。

其他的功能就放到后面再实现吧,更多公众号开发相关的内容可以参考 微信官方文档微信开发工具包。当然,所有的示例代码吾辈都已经放到了 GitHub,却是可以参考一下的呢

使用 Serveo 进行内网穿透

官网
这里只是记录一些吾辈需要的常用的命令,官网上虽然很详细但终归是英文(好像也没什么),不过还是记录一下不容易忘记呢

基本使用:

1
2
# 使用 SSH 将本地的 localhost:3000 映射到到远程的 serveo.net:80
ssh -R 80:localhost:3000 serveo.net

保持连接不超时

添加 ssh 连接参数 -o ServerAliveInterval=60 就好了,每隔 60s 扫描一次

1
ssh -o ServerAliveInterval=60 -R 80:localhost:8888 serveo.net

连接到自定义的域名/子域名

添加 DNS 两条记录就行,吾辈推荐 cloudflare 这个网站,能让 DNS 更改在 1 分钟内生效真的很厉害!

  1. 添加 A 纪录 A | serveo | 159.89.214.31
  2. 添加 TXT 记录 TXT | serveo | authkeyfp=SHA256:g6VHWesncgnhwPjvENkhgrC3tkx9SzAKoMOl9xvXgPo

    authkeyfp 后面跟的那一串东西其实是 rsa 密钥指纹,使用 ssh-keygen -l 可以查看,一般都在 ~/.ssh/id_rsa,没有的话用 ssh-keygen 生成一个就好了

进行连接(其实也就是在远程端口前面加上自定义域名而已)

1
ssh -R serveo.rxliuli.com:80:localhost:3000 serveo.net

然后就可以通过 https://rxliuli.serveo.net/ 来访问本地部署的项目啦

SpringBoot 进行测试

概略

SpringBoot 中进行测试比 Spring 项目中更加简单,想了解 Spring 项目中测试的可以参考 使用 Spring 时进行测试

普通测试

假设要测试一个工具类 StringUtilcom.rxliuli.example.springboottest.util.StringUtil

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 用于测试的字符串工具类
*
* @author rxliuli
*/
public class StringUtil {
/**
* 判断是否为空
*
* @param string 要进行判断的字符串
* @return 是否为 null 或者空字符串
*/
public static boolean isEmpty(String string) {
return string == null || string.isEmpty();

}

/**
* 判断是否为空
*
* @param string 要进行判断的字符串
* @return 是否为 null 或者空字符串
*/
public static boolean isNotEmpty(String string) {
return !isEmpty(string);
}

/**
* 判断是否有字符串为空
*
* @param strings 要进行判断的一个或多个字符串
* @return 是否有 null 或者空字符串
*/
public static boolean isAnyEmpty(String... strings) {
return Arrays.stream(strings)
.anyMatch(StringUtil::isEmpty);
}

/**
* 判断字符串是否全部为空
*
* @param strings 要进行判断的一个或多个字符串
* @return 是否全部为 null 或者空字符串
*/
public static boolean isAllEmpty(String... strings) {
return Arrays.stream(strings)
.allMatch(StringUtil::isEmpty);
}
}

需要添加依赖 spring-boot-starter-test 以及指定 assertj-core 的最新版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.9.1</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>

这里指定 assertj-core 的版本是为了使用较新的一部分断言功能(例如属性 lambda 断言)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* @author rxliuli
*/
public class StringUtilTest {
private String strNull = null;
private String strEmpty = "";
private String strSome = "str";

@Test
public void isEmpty() {
//测试 null
assertThat(StringUtil.isEmpty(strNull))
.isTrue();
//测试 empty
assertThat(StringUtil.isEmpty(strEmpty))
.isTrue();
//测试 some
assertThat(StringUtil.isEmpty(strSome))
.isFalse();
}

@Test
public void isNotEmpty() {
//测试 null
assertThat(StringUtil.isNotEmpty(strNull))
.isFalse();
//测试 empty
assertThat(StringUtil.isNotEmpty(strEmpty))
.isFalse();
//测试 some
assertThat(StringUtil.isNotEmpty(strSome))
.isTrue();
}

@Test
public void isAnyEmpty() {
assertThat(StringUtil.isAnyEmpty(strNull, strEmpty, strSome))
.isTrue();
assertThat(StringUtil.isAnyEmpty())
.isFalse();
}

@Test
public void isAllEmpty() {
assertThat(StringUtil.isAllEmpty(strNull, strEmpty, strSome))
.isFalse();
assertThat(StringUtil.isAnyEmpty(strNull, strEmpty))
.isTrue();
}
}

这里和非 SpringBoot 测试时没什么太大的区别,唯一的一点就是引入 Jar 不同,这里虽然我们只引入了 spring-boot-starter-test,但它本身已经帮我们引入了许多的测试相关类库了。

Dao/Service 测试

从这里开始就和标准的 Spring 不太一样了

首先,我们需要 Dao 层,这里使用 H2DB 和 SpringJDBC 做数据访问层(比较简单)。

依赖

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

添加两个初始化脚本

数据库结构 db_schema.sqldb/db_schema.sql

1
2
3
4
5
6
7
8
9
10
11
drop table if exists user;
create table user (
id int auto_increment not null
comment '编号',
name varchar(20) not null
comment '名字',
sex boolean null
comment '性别',
age int null
comment '年龄'
);

数据库数据 db_data.sqldb/db_data.sql

1
2
3
4
insert into user (id, name, sex, age)
values
(1, '琉璃', false, 17),
(2, '月姬', false, 1000);

为 SpringBoot 配置一下数据源及初始化脚本

1
2
3
4
5
6
spring:
datasource:
driver-class-name: org.h2.Driver
platform: h2
schema: classpath:db/db_schema.sql
data: classpath:db/db_data.sql

然后是实体类与 Dao

用户实体类 Usercom.rxliuli.example.springboottest.entity.User

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* @author rxliuli
*/
public class User implements Serializable {
private Integer id;
private String name;
private Boolean sex;
private Integer age;

public User() {
}

public User(String name, Boolean sex, Integer age) {
this.name = name;
this.sex = sex;
this.age = age;
}

public User(Integer id, String name, Boolean sex, Integer age) {
this.id = id;
this.name = name;
this.sex = sex;
this.age = age;
}
//getter() and setter()
}

用户 Dao UserDaocom.rxliuli.example.springboottest.dao.UserDao

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* @author rxliuli
*/
@Repository
public class UserDao {
private final RowMapper<User> userRowMapper = (rs, rowNum) -> new User(
rs.getInt("id"),
rs.getString("name"),
rs.getBoolean("sex"),
rs.getInt("age")
);
@Autowired
private JdbcTemplate jdbcTemplate;

/**
* 根据 id 获取一个对象
*
* @param id id
* @return 根据 id 查询到的对象,如果没有查到则为 null
*/
public User get(Integer id) {
return jdbcTemplate.queryForObject("select * from user where id = ?", userRowMapper, id);
}

/**
* 查询全部用户
*
* @return 全部用户列表
*/
public List<User> listForAll() {
return jdbcTemplate.query("select * from user", userRowMapper);
}

/**
* 根据 id 删除用户
*
* @param id 用户 id
* @return 受影响行数
*/
public int deleteById(Integer id) {
return jdbcTemplate.update("delete from user where id = ?", id);
}
}

接下来才是正事,测试 Dao 层需要加载 Spring 容器,自动回滚以避免污染数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* {@code @SpringBootTest} 和 {@code @RunWith(SpringRunner.class)} 是必须的,这里貌似一直有人误会需要使用 {@code @RunWith(SpringJUnit4ClassRunner.class)},但其实并不需要了
* 下面的 {@code @Transactional} 和 {@code @Rollback}则是开启事务控制以及自动回滚
*
* @author rxliuli
*/
@SpringBootTest
@RunWith(SpringRunner.class)
@Transactional
@Rollback
public class UserDaoTest {
@Autowired
private UserDao userDao;

@Test
public void get() {
int id = 1;
User result = userDao.get(id);
//断言 id 和 get id 相同
assertThat(result)
.extracting(User::getId)
.contains(id);
}

@Test
public void listForAll() {
List<User> userList = userDao.listForAll();
//断言不为空
assertThat(userList)
.isNotEmpty();
}

@Test
public void deleteById() {
int result = userDao.deleteById(1);
assertThat(result)
.isGreaterThan(0);
}
}

Web 测试

与传统的 SpringTest 一样,SpringBoot 也分为两种。

  • 独立安装测试:
    手动加载单个 Controller,所以测试其他 Controller 中的接口会发生异常。但测试速度上较快,所以应当优先选择。
  • 集成 Web 环境测试:
    将启动并且加载所有的 Controller, 所以效率上之于 BaseWebUnitTest 来说非常低下, 仅适用于集成测试多个 Controller 时使用。

独立安装测试

主要是设置需要使用的 Controller 实例,然后用获得 MockMvc 对象进行测试即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* @author rxliuli
*/
@SpringBootTest
@RunWith(SpringRunner.class)
@Transactional
@Rollback
public class UserControllerUnitTest {
@Autowired
private UserController userController;
/**
* 用于测试 API 的模拟请求对象
*/
private MockMvc mockMvc;

@Before
public void before() {
//模拟一个 Mvc 测试环境,获取一个 MockMvc 实例
mockMvc = MockMvcBuilders.standaloneSetup(userController)
.build();
}

@Test
public void testGet() throws Exception {
//测试能够正常获取
Integer id = 1;
mockMvc.perform(
//发起 get 请求
get("/user/" + id)
)
//断言请求的状态是成功的(200)
.andExpect(status().isOk())
//断言返回对象的 id 和请求的 id 相同
.andExpect(jsonPath("$.id").value(id));
}

@Test
public void listForAll() throws Exception {
//测试正常获取
mockMvc.perform(
//发起 post 请求
post("/user/listForAll")
)
//断言请求状态
.andExpect(status().isOk())
//断言返回结果是数组
.andExpect(jsonPath("$").isArray())
//断言返回数组不是空的
.andExpect(jsonPath("$").isNotEmpty());
}
}

集成 Web 环境测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* @author rxliuli
*/
@SpringBootTest
@RunWith(SpringRunner.class)
@Transactional
@Rollback
public class UserControllerIntegratedTest {
@Autowired
private WebApplicationContext context;
/**
* 用于测试 API 的模拟请求对象
*/
private MockMvc mockMvc;

@Before
public void before() {
//这里把整个 WebApplicationContext 上下文都丢进去了,所以可以测试所有的 Controller
mockMvc = MockMvcBuilders.webAppContextSetup(context)
.build();
}

@Test
public void testGet() throws Exception {
//测试能够正常获取
Integer id = 1;
mockMvc.perform(
//发起 get 请求
get("/user/" + id)
)
//断言请求的状态是成功的(200)
.andExpect(status().isOk())
//断言返回对象的 id 和请求的 id 相同
.andExpect(jsonPath("$.id").value(id));
}

@Test
public void listForAll() throws Exception {
//测试正常获取
mockMvc.perform(
//发起 post 请求
post("/user/listForAll")
)
//断言请求状态
.andExpect(status().isOk())
//断言返回结果是数组
.andExpect(jsonPath("$").isArray())
//断言返回数组不是空的
.andExpect(jsonPath("$").isNotEmpty());
}
}

总结

其实上面的测试类的注解感觉都差不多,我们可以将一些普遍的注解封装到基类,然后测试类只要继承基类就能得到所需要的环境,吾辈自己的测试基类在 src/test/common 下面,具体使用方法便留到下次再说吧

以上代码已全部放到 GitHub 上面,可以直接 clone 下来进行测试

使用 Java8 新的时间 API

简介

Java8 面世以来已经 6 年了,许多人也开始使用起了 lambda,Stream<T>,Optional<T> 之类的新的语言特性,然而对于 Java8 提供的新的时间 API 虽然据说比旧版本的 Date 好很多,但并没有得到完全的使用。一方面是为了兼容旧的系统,另一方面 Java8 的时间 API 似乎太过于强大了,让人有些不知所措,不知道应该从何下手。再加上因为对 Date,Calendar 的熟悉,此消彼长之下自然是懒得去修改了。

其实对于时间 API,大致的需求是一样的

  • 创建/修改/比较/转换简单
  • 对遗留系统的时间可以集成/转换
  • 主流框架对其要有支持

API

常用的类

LocalDate

一个不可变(线程安全)的日期对象,用且表示 年-月-日 的时间,默认 toString() 格式是 yyyy-MM-dd

基本操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//获取当前的日期
final LocalDate now = LocalDate.now();
//toString 一下日期
//会得到 yyyy-MM-dd 格式
System.out.println("现在:" + now);
//1 天后的日期
final LocalDate localDatePlusDayOne = now.plusDays(1);
System.out.println("一天后:" + localDatePlusDayOne);
//一天前的日期
final LocalDate localDateMinusDayOne = now.minusDays(1);
System.out.println("一天前:" + localDateMinusDayOne);
//比较两个日期/时间的大小
final int nowEvenBigger = now.compareTo(localDatePlusDayOne);
System.out.println("当前时间更大么?" + nowEvenBigger);
//获取指定单位的日期(年/月/日/星期)
//获取当前月的时间
final int dayOfMonth = now.getDayOfMonth();
System.out.println("当前月的天数:" + dayOfMonth);
//更加通用获取方式
//使用枚举类 ChronoField
final int dayOfMonthForChronoField = now.get(ChronoField.DAY_OF_MONTH);
System.out.println("当前月的天数(通过 get() 获取):" + dayOfMonthForChronoField);
//比较两个日期的差值
final long between = ChronoUnit.DAYS.between(now, localDatePlusDayOne);
System.out.println(between);

上面有些地方看不太懂不碍事,先过一遍,下面对其中的部分代码会有解释

LocalTime

一个不可变的(线程安全)的时间对象,用于表示 时:分:秒:毫秒 的时间,默认 toString() 格式是 hh:mm:ss.SSS

基本操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//获得当前时间
final LocalTime now = LocalTime.now();
//默认格式 hh:mm:ss.SSS
System.out.println("现在:" + now);
//一个小时后的时间
final LocalTime localTimePlusHourOne = now.plusHours(1);
System.out.println("一小时后:" + localTimePlusHourOne);
//一分钟前的时间
final LocalTime localTimeMinusMinuteOne = now.minusMinutes(1);
System.out.println(localTimeMinusMinuteOne);
//比较时间大小(实现了 Comparable 接口)
final int nowEvenBigger = now.compareTo(localTimeMinusMinuteOne);
System.out.println("当前时间更大么?" + nowEvenBigger);
//获取指定单位的时间
//当前小时数
final int hour = now.getHour();
System.out.println("当前时间的小时数:" + hour);
//使用枚举类 ChronoField 获取
final int hourOfDay = now.get(ChronoField.HOUR_OF_DAY);
System.out.println("当前时间的小时数(通过 get() 获取):" + hourOfDay);
//比较两个日期的差值
final long between = ChronoUnit.HOURS.between(now, localTimePlusHourOne);
System.out.println(between);

LocalDateTime

不可变的日期时间对象,用于表示 日-月-年 时:分:秒:毫秒 的日期时间,默认格式化格式是 yyyy-MM-ddThh:mm:ss.SSS

基本操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//当前日期时间
final LocalDateTime now = LocalDateTime.now();
//默认格式 yyyy-MM-ddThh:mm:ss.SSS
System.out.println("现在:" + now);
//一个小时后的时间
final LocalDateTime localTimePlusHourOne = now.plusHours(1);
System.out.println("一小时后:" + localTimePlusHourOne);
//一分钟前的时间
final LocalDateTime localTimeMinusMinuteOne = now.minusMinutes(1);
System.out.println(localTimeMinusMinuteOne);
//比较时间大小(实现了 Comparable 接口)
final int nowEvenBigger = now.compareTo(localTimeMinusMinuteOne);
System.out.println("当前时间更大么?" + nowEvenBigger);
//获取指定单位的时间
//当前小时数
final int hour = now.getHour();
System.out.println("当前时间的小时数:" + hour);
//使用枚举类 ChronoField 获取
final int hourOfDay = now.get(ChronoField.HOUR_OF_DAY);
System.out.println("当前时间的小时数(通过 get() 获取):" + hourOfDay);
//比较两个日期的差值
final long between = ChronoUnit.HOURS.between(now, localTimePlusHourOne);
System.out.println(between);
//获取一天的开始和结束
final LocalDateTime start = LocalDateTime.of(yesterday, LocalTime.MIN);
final LocalDateTime end = LocalDateTime.of(yesterday, LocalTime.MAX);
System.out.println(start);
System.out.println(end);

可以看到,和上面的 LocalTime 除了类型不同外,代码是完全相同的,因为 LocalDateTime 是包含 LocalDateLocalTime 的。在源码中也可以看到其包含了两个属性。

1
2
3
4
5
//获取日期/时间对象
final LocalDate localDate = now.toLocalDate();
final LocalTime localTime = now.toLocalTime();
System.out.println("当前日期:" + localDate);
System.out.println("当前时间:" + localTime);

OffsetDateTime

代表偏移标准 UTC 时间的日期时间不可变对象,用于表示 _日-月-年 时:分:秒:毫秒时区_,默认格式是 yyyy-MM-ddThh:mm:ss.SSSZoneId

基本操作

1
2
3
4
5
6
7
8
9
10
11
//当前偏移的日期时间
final OffsetDateTime now = OffsetDateTime.now();
System.out.println(now);
//其实个人感觉就是多了一个时区
//获取到时区
ZoneId zone = now.toZonedDateTime().getZone();
System.out.println(zone);
//转换时区
ZonedDateTime zonedDateTime = now.atZoneSameInstant(ZoneId.of("+00:00"));
System.out.println(zonedDateTime);
//其他基本操作和上面的差不多,就不啰嗦啦

Temporal/TemporalAccessor

上面的 LocalDate, LocalTime, LocalDateTime, OffsetDateTime 的基类,并定义了一系列非常通用的方法

  • minus: 减少时间
  • plus: 增加时间
  • with: 获取时间指定单位的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final LocalDateTime now = LocalDateTime.now();
//获取上个星期
final LocalDateTime lastWeek = now.minus(1, ChronoUnit.WEEKS);
//获取下个月
final LocalDateTime nextMonth = now.plus(1, ChronoUnit.MONTHS);
//获取当前是今年的第几个星期
final int weekValue = now.get(ChronoField.ALIGNED_WEEK_OF_YEAR);
//获取这周星期一的时间
final LocalDateTime nowOfMonday = now.with(DayOfWeek.MONDAY);
final LocalDateTime nowOfMonday2 = now.with(ChronoField.DAY_OF_WEEK, 1);
System.out.println(
"lastWeek: " + lastWeek
+ "\nnextMonth: " + nextMonth
+ "\nweekValue: " + weekValue
+ "\nnowOfMonday: " + nowOfMonday
+ "\nnowOfMonday2: " + nowOfMonday2
);

Period

代表两个日期的差值,默认 toString() 格式是 P([时间][单位])*

基本操作

1
2
3
4
5
6
7
8
9
10
final LocalDate now = LocalDate.now();
final LocalDate localTimePlusMonthOne = now.plusDays(1);
//计算相差的时间
final Period betweenForDay = Period.between(now, localTimePlusMonthOne);
//获取相差的天数
System.out.println("相差的天数:" + betweenForDay.getDays());
//使用 get() 方法获取通用的相差的天数
System.out.println("相差的天数(使用 get() 获取):" + betweenForDay.get(ChronoUnit.DAYS));
//对相差的日期减去一天并判断是否为 0
System.out.println("相差的日期减去 1 天是不是就相同了呢?" + betweenForDay.minusDays(1).isZero());

Duration

代表两个日期时间的差值,默认 toString() 格式是 PT([时间][单位])*

基本操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
final LocalDateTime now = LocalDateTime.now();
final LocalDateTime localDateTimeForPlusHourOne = now.plusHours(1);
//计算两个时间的差值
final Duration between = Duration.between(now, localDateTimeForPlusHourOne);
//默认格式是 PT 时间 单位
System.out.println("相差的时间:" + between);
//获取日期
final long betweenSeconds = between.getSeconds();
System.out.println("相差的时间(/秒):" + betweenSeconds);
//根据指定单位获取相差时间的大小
final long betweenSecondsForGet = between.get(ChronoUnit.SECONDS);
System.out.println("相差的时间(/秒):" + betweenSecondsForGet);
//转换成毫秒
System.out.println("相差的时间(/毫秒):" + between.toMillis());
//获取相差时间支持的单位列表(其实感觉上没太大意义)
final List<TemporalUnit> temporalUnitList = between.getUnits();
System.out.println("相差的时间列表:" + temporalUnitList);
//在创建一个时间差
final Duration betweenForMinutes = Duration.between(now, now.minusMinutes(100));
//判断两个时间差哪个更大
System.out.println("between 的时间差更大么?" + between.compareTo(betweenForMinutes));
//获取绝对值
System.out.println("相差的绝对时间:" + betweenForMinutes.abs());

TemporalField/ChronoField

日期/时间单位字段,TemporalField 是接口,ChronoField 则是实现类。

基本操作

1
2
3
4
5
6
7
8
9
10
11
12
final LocalDateTime now = LocalDateTime.now();
final ChronoField hourOfDay = ChronoField.HOUR_OF_DAY;
System.out.println(hourOfDay);
//获取当前小时数
final long nowForHours = hourOfDay.getFrom(now);
System.out.println("当前时间的小时数:" + nowForHours);
//获取到指定单位的时间大小
final int hoursForGet = now.get(ChronoField.HOUR_OF_DAY);
System.out.println("当前时间的小时数(get()):" + hoursForGet);
//获取到特定单位的时间对比对象 Duration
final TemporalUnit baseUnit = hourOfDay.getBaseUnit();
System.out.println(baseUnit);

TemporalUnit/ChronoUnit

也是时间单位,TemporalUnit 是接口,ChronoUnit 则是实现类。和上面的不同的地方在于上面的不能 ChronoField 不能对时间进行对比差值,只能根据指定单位获取时间的大小。

基本操作

1
2
3
4
5
6
7
8
9
10
11
//获取一个小时数
final ChronoUnit chronoUnit = ChronoUnit.MILLIS;
System.out.println(chronoUnit);
//获取两个时间的差值毫秒数
final LocalDateTime now = LocalDateTime.now();
final LocalDateTime localDateTimePlusHourOne = now.plusHours(1);
final long betweenForMillis = chronoUnit.between(now, localDateTimePlusHourOne);
System.out.println("相差的毫秒数:" + betweenForMillis);
//根据指定的单位修改时间也需要使用这个
final LocalDateTime localDateTimePlusMinuteOne = now.plus(1, ChronoUnit.MINUTES);
System.out.println("一分钟后的时间:" + localDateTimePlusMinuteOne);

DateTimeFormatter

日期时间格式化类,基本上没什么好说的(标准的格式化一般就足够了,毕竟显示是前端的事情,而标准的格式化确实是国际标准呢)

基本使用

1
2
3
4
5
6
7
8
9
//获取一个标准 iso 日期时间格式化对象
final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ISO_DATE_TIME;
System.out.println(dateTimeFormatter);
//格式化日期时间
final LocalDateTime now = LocalDateTime.now();
System.out.println("当前日期时间:" + dateTimeFormatter.format(now));
//根据自定义的格式格式化时间
final DateTimeFormatter customDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日 hh时mm分ss秒");
System.out.println(customDateTimeFormatter.format(now));

兼容 Date

使用新的时间 API 固然很令人舒服,但有时候不得不兼容旧的 Date 类型,这时候如何转换就很重要了呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
final LocalDateTime now = LocalDateTime.now();
//转换为 Instant(包括时区)
final Instant instant = now.toInstant(ZoneOffset.UTC);
System.out.println(instant);
//转换为 Date
final Date date = Date.from(instant);
System.out.println("当前时间:" + date);
//转换为 Instant
final Instant dateToInstant = date.toInstant();
System.out.println("两次转换后的时间还是相同的吧?" + dateToInstant.equals(instant));
//转换为 LocalDateTime
final ZoneId zoneId = ZoneId.systemDefault();
final LocalDateTime toLocalDateTime = LocalDateTime.ofInstant(dateToInstant, zoneId);
System.out.println("转换后的日期时间:" + toLocalDateTime);

主流框架支持

现如今大部分的包应该都支持 Java8 时间 API 了吧

例如:

  • Jackson:jackson-datatype-jsr310
  • Mybatis:mybatis-typehandlers-jsr310

那么,关于 Java8 新的时间 API 的使用暂且到这里了,想到什么吾辈再补充啦(=´∇ `=)

SpringBoot 使用 Jackson 处理 Java8 时间 API

场景

在项目中的实体类里面使用了 Java8 新的时间 API,例如 LocalDate, LocalTime, LocalDateTime 等。然而在将这些时间类型的字段序列化返回到前端时,但格式却感觉有些异常。

嗯,大概就是下面这种样子的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"dayOfMonth": 27,
"dayOfWeek": "MONDAY",
"dayOfYear": 239,
"month": "AUGUST",
"monthValue": 8,
"year": 2018,
"hour": 10,
"minute": 0,
"nano": 370000000,
"second": 52,
"chronology": {
"id": "ISO",
"calendarType": "iso8601"
}
}

这是什么鬼的格式。。。

解决方案

添加依赖

1
2
3
4
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>

修改 application.yml,配置 spring.jackson.serialization.write_dates_as_timestamps 禁用 _打印日期为时间戳的功能_。

1
2
3
4
spring:
jackson:
serialization:
write_dates_as_timestamps: false

如此,在返回数据到前端的时候 SpringBoot 就会自动将日期格式化为 yyyy-MM-ddThh:mm:ss

然而,如果我们想要在程序中手动的序列化日期怎么办呢?实际上也很简单,使用代码 .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) 来禁止序列化时间为时间戳并注册 jackson-datatype-jsr310 中的 JavaTimeModule 模块。

下面是吾辈在项目里使用的一个全局 ObjectMapper 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 提供一个全局可用的序列化 Bean
*/
public static final ObjectMapper OM = new ObjectMapper()
//Date 对象的格式
.setDateFormat(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"))
//禁止序列化值为 null 的属性
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
//禁止序列化时间为时间戳
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.registerModules(
//注册 Jsr310(Java8 的时间兼容模块)
new JavaTimeModule(),
//序列化 Long 为 String
new SimpleModule()
//大数字直接序列化为 String
.addSerializer(Long.class, ToStringSerializer.instance)
.addSerializer(Long.TYPE, ToStringSerializer.instance)
.addSerializer(long.class, ToStringSerializer.instance)
.addSerializer(BigInteger.class, ToStringSerializer.instance)
//大浮点数直接序列化为 String
.addSerializer(BigDecimal.class, new JsonSerializer<BigDecimal>() {
@Override
public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeString(value.setScale(10, BigDecimal.ROUND_HALF_UP).toPlainString());
}
})
)
//JSON 序列化移除 transient 修饰的 Page 无关紧要的返回属性(Mybatis Plus)
.configure(MapperFeature.PROPAGATE_TRANSIENT_MARKER, true);

那么,以上就是 SpringBoot 序列化 Java8 时间 API 的问题和解决方案啦 ヾ(@^▽^@)ノ