开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

linux极简小知识:39、终端清屏的快捷命令ctrl+l,

发表于 2021-10-28

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

Ctrl+l清除屏幕显示的内容

由于太孤陋寡闻,看到一个快速清屏的操作,查了下发现,Ctrl+l在终端下是快速清屏的快捷键。

Ctrl + l快速清屏,比输入 clear 命令清屏方便快捷多了。

Ctrl + l(l 为 L 键)。

除此之外,还有 history -c 命令,用于清除终端命令的所有历史记录,也可以达到清屏的效果,只不过清除的是执行过的命令的所有历史。

shell终端中常用快捷键

  • ctrl + l:即 clear 命令。清除终端命令屏幕显示的内容,比如当终端命令屏显示的内容太多时,想清掉屏幕上显示的内容,就可以用clear命令或ctrl+l。(Clear screen)
  • ctrl + c:终止命令的执行。(Kill foreground process)
  • ctrl + d: 退出 shell,相当于 exit 命令;在输入中 ctrl + d 也表示EOF。(Terminate input, or exit shell)【当前shell行没有任何内容时,才会退出shell】
  • ctrl + z:将当前进程置于后台并挂起,可使用 fg 命令还原到前台继续执行(bg 命令将一个在后台暂停的命令,变成继续执行)。(Suspend foreground process)
  • ctrl + s:挂起当前shell输出。(Suspend output)
  • ctrl + q:重新启用shell输出。(Resume output)
  • Ctrl + o:丢弃输出。(Discard output)
  • ctrl + r:从命令历史查找。
  • ctrl + a:光标移到行首。
  • ctrl + e:光标移到行尾。
  • ctrl + u:清除光标到行首的字符 。
  • ctrl + w:清除光标之前一个单词 。
  • ctrl + k:清除光标到行尾的字符。
  • ctrl + t:交换光标前两个字符。
  • ctrl + y:粘贴前一ctrl+u类命令删除的字符。
  • ctrl + p:上一条命令。
  • ctrl + n:下一条命令。
  • ctrl + v:输入控制字符 如ctrl+v ,会输入^M
  • ctrl + f:光标后移一个字符。
  • ctrl + b:光标前移一个字符。
  • ctrl + h:删除光标前一个字符。

参考

linux系统清屏命令是什么,及其他一些资料

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring Boot 进行优雅的字段校验,写得太好了!

发表于 2021-10-28

作者:何甜甜在吗

来源:juejin.cn/post/6913735652806754311

前段时间提交代码审核,同事提了一个代码规范缺陷:参数校验应该放在controller层。到底应该如何做参数校验呢

Controller层 VS Service层

去网上查阅了一些资料,一般推荐与业务无关的放在Controller层中进行校验,而与业务有关的放在Service层中进行校验。

那么如何将参数校验写的优雅美观呢,如果都是if - else,就感觉代码写的很low,还好有轮子可以使用

常用校验工具类

使用Hibernate Validate

引入依赖

1
2
3
4
5
xml复制代码<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>4.3.1.Final</version>
</dependency>

常用注解说明

使用姿势

Spring Boot 基础就不介绍了,推荐下这个实战教程:
www.javastack.cn/categories/…

需要搭配在Controller中搭配@Validated或@Valid注解一起使用,@Validated和@Valid注解区别不是很大,一般情况下任选一个即可,区别如下:

虽然@Validated比@Valid更加强大,在@Valid之上提供了分组功能和验证排序功能,不过在实际项目中一直没有用到过

Hibernate-validate框架中的注解是需要加在实体中一起使用的

  • 定义一个实体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
less复制代码public class DataSetSaveVO {
//唯一标识符为空
@NotBlank(message = "user uuid is empty")
//用户名称只能是字母和数字
@Pattern(regexp = "^[a-z0-9]+$", message = "user names can only be alphabetic and numeric")
@Length(max = 48, message = "user uuid length over 48 byte")
private String userUuid;

//数据集名称只能是字母和数字
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "data set names can only be letters and Numbers")
//文件名称过长
@Length(max = 48, message = "file name too long")
//文件名称为空
@NotBlank(message = "file name is empty")
private String name;

//数据集描述最多为256字节
@Length(max = 256, message = "data set description length over 256 byte")
//数据集描述为空
@NotBlank(message = "data set description is null")
private String description;
}

说明:message字段为不符合校验规则时抛出的异常信息

  • Controller层中的方法
1
2
3
4
less复制代码@PostMapping
public ResponseVO createDataSet(@Valid @RequestBody DataSetSaveVO dataSetVO) {
return ResponseUtil.success(dataSetService.saveDataSet(dataSetVO));
}

说明:在校验的实体DataSetSaveVO旁边添加@Valid或@Validated注解

使用commons-lang3

引入依赖

1
2
3
4
5
xml复制代码<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.4</version>
</dependency>

常用方法说明

测试代码

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
less复制代码//StringUtils.isEmpty
System.out.println(StringUtils.isEmpty("")); //true
System.out.println(StringUtils.isEmpty(" ")); //false
//StringUtils.isNotEmpty
System.out.println(StringUtils.isNotEmpty("")); //false

//StringUtils.isBlank
System.out.println(StringUtils.isBlank("")); //true
System.out.println(StringUtils.isBlank(" ")); //true
//StringUtils.isNotBlank
System.out.println(StringUtils.isNotBlank(" ")); //false

List<Integer> emptyList = new ArrayList<>();
List<Integer> nullList = null;
List<Integer> notEmptyList = new ArrayList<>();
notEmptyList.add(1);

//CollectionUtils.isEmpty
System.out.println(CollectionUtils.isEmpty(emptyList)); //true
System.out.println(CollectionUtils.isEmpty(nullList)); //true
System.out.println(CollectionUtils.isEmpty(notEmptyList)); //false

//CollectionUtils.isNotEmpty
System.out.println(CollectionUtils.isNotEmpty(emptyList)); //false
System.out.println(CollectionUtils.isNotEmpty(nullList)); //false
System.out.println(CollectionUtils.isNotEmpty(notEmptyList)); //true

自定义注解

当上面的方面都无法满足校验的需求以后,可以考虑使用自定义注解。

近期热文推荐:

1.1,000+ 道 Java面试题及答案整理(2021最新版)

2.别在再满屏的 if/ else 了,试试策略模式,真香!!

3.卧槽!Java 中的 xx ≠ null 是什么新语法?

4.Spring Boot 2.5 重磅发布,黑暗模式太炸了!

5.《Java开发手册(嵩山版)》最新发布,速速下载!

觉得不错,别忘了随手点赞+转发哦!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

这一篇 K8S(Kubernetes)我觉得可以了解一下!!

发表于 2021-10-28

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

点赞再看,养成习惯,微信搜索【牧小农】关注我获取更多资讯,风里雨里,小农等你,很高兴能够成为你的朋友。

什么是Kubernetes?

Kubernetes 是Google开源的分布式容器管理平台,是为了更方便的在服务器中管理我们的容器化应用。

Kubernetes 简称 K8S,为什么会有这个称号?因为K和S是 Kubernetes 首字母和尾字母,而K和S中间有八个字母,所以简称 K8S,加上 Kubernetes比较绕口,所以一般使用简称 K8S。

Kubernetes 即是一款容器编排工具,也是一个全新的基于容器技术的分布式架构方案,在基于Docker的基础上,可以提供从 创建应用>应用部署>提供服务>动态伸缩>应用更新一系列服务,提高了容器集群管理的便捷性。

K8S产生的原因

大家可以先看一下,下面一张图,里面有我们的mysql,redis,tomcat,nginx等配置信息,如果我们想要安装里面的数据,我们需要一个一个手动安装,好像也可以,反正也就一个,虽然麻烦了一点,但也不耽误。

在这里插入图片描述

但是随着技术的发展和业务的需要,单台服务器已经不能满足我们日常的需要了,越来越多的公司,更多需要的是集群环境和多容器部署,那么如果还是一个一个去部署,运维恐怕要疯掉了,一天啥也不干就去部署机器了,有时候,可能因为某一个环节出错,要重新,那真的是吐血。。。。。,如下图所示:

在这里插入图片描述

如果我想要部署,以下几台机器:

3台 - Nginx

5台 - Redis
7台 - ZooKeeper
4台 - Tomcat
6台 - MySql
5台 - JDK
10台 - 服务器

如果要一个一个去部署,人都要傻掉了,这什么时候是个头,如果是某里巴的两万台机器,是不是要当场提交辞职信,所以 K8S 就是帮助我们来做这些事情的,方便我们对容器的管理和应用的自动化部署,减少重复劳动,并且能够自动化部署应用和故障自愈。

并且如果 K8S 对于微服务有很好的支持,并且一个微服务的副本可以跟着系统的负荷变化进行调整,K8S 内在的服务弹性扩容机制也能够很好的应对突发流量。

容器编排工具对比

Docker-Compose

在这里插入图片描述

Docker-Compose 是用来管理容器的,类似用户容器管家,我们有N多台容器或者应用需要启动的时候,如果手动去操作,是非常耗费时间的,如果有了 Docker-Compose 只需要一个配置文件就可以帮我们搞定,但是 Docker-Compose 只能管理当前主机上的 Docker,不能去管理其他服务器上的服务。意思就是单机环境。

Docker Swarm

在这里插入图片描述

Docker Swarm 是由Docker 公司研发的一款用来管理集群上的Docker容器工具,弥补了 Docker-Compose 单节点的缺陷,Docker Swarm 可以帮助我们启动容器,监控容器的状态,如果容器服务挂掉会重新启动一个新的容器,保证正常的对外提供服务,也支持服务之间的负载均衡。而且这些东西 Docker-Compose是不支持的,

Kubernetes

在这里插入图片描述

Kubernetes 它本身的角色定位是和Docker Swarm 是一样的,也就是说他们负责的工作在容器领域来说是相同的部分,当然也要一些不一样的特点,Kubernetes 是谷歌自己的产品,经过大量的实践和宿主机的实验,非常的成熟,所以 Kubernetes 正在成为容器编排领域的领导者,其 可配置性、可靠性和社区的广大支持,从而超越了 Docker Swarm,作为谷歌的开源项目,它和整个谷歌的云平台协调工作。

K8S的职责

  1. 自动化容器的部署和复制
  2. 随时扩展或收缩容器规模
  3. 容器分组Group,并且提供容器间的负载均衡
  4. 实时监控:及时故障发现,自动替换

K8S的基本概念

在下图中,是K8S的一个集群,在这个集群中包含三台宿主机,这里的每一个方块都是我们的物理虚拟机,通过这三个物理机,我们形成了一个完整的集群,从角色划分,可以分为两种

  • 一种是 Kubernetes Master主服务器,它是整个集群的管理者,它可以对整个集群的节点进行管理,通过主服务器向这些节点,发送创建容器、自动部署、自动发布等功能,并且所有来自外部的数据都会由 Kubernetes Master进行接收并进行分配。
  • 还有一种就是 node节点 节点可以是一台独立的物理机也可以是一个虚拟机,在每个节点中都有一个非常重要的 K8S 独有的概念,就是我们的Pod,Pod是K8S最重要也是最基础的概念

在这里插入图片描述

Pod

在这里插入图片描述

  • Pod是 Kubernetes 控制的最小单元,一个Pod就是一个进程。
  • 一个Pod可以被一个容器化的环境看做应用层的“逻辑宿主机”,可以理解为容器的容器,可以包含多个“Container”;
  • 一个Pod的多个容器应用通常是紧密耦合的,Pod在Node上创建、启动或销毁;
  • 每个Pod里面运行着一个特殊的被称为Pause的容器,其他的容器被称为业务容器,这些业务容器共享Pause容器的网络栈和Volume挂载卷;
  • Pod的内部容器网络是互通的,每个Pod都有独立的虚拟IP。
  • Pod都是部署完整的应用或模块,同一个Pod容器之间只需要通过localhsot就能互相通信。
  • Pod的生命周期是通过 Replication Controller 来管理的,通过模板进行定义,然后分配到一个Node上运行,在Pod锁包含容器运行结束后,Pod结束。

打一个比较形象的比喻,我们可以把Pod理解成一个豆荚,容器就是里面的豆子,是一个共生体。

在这里插入图片描述

Pod里面到底装的是什么?

  • 在一些小公司里面,一个Pod就是一个完整的应用,里面安装着各种容器,一个Pod里面可能包含(Redis、Mysql、Tomcat等等),当把这一个Pod部署以后就相当于部署了一个完整的应用,多个Pod部署完以后就形成了一个集群,这是Pod的第一种应用方式
  • 还有一种使用方式就是在Pod里面只服务一种容器,比如在一个Pod里面我只部署Tomcat

具体怎么部署Pod里面的容器,是按照我们项目的特性和资源的分配进行合理选择的。

pause容器:

Pause容器 全称infrastucture container(又叫infra)基础容器,作为init pod存在,其他pod都会从pause 容器中fork出来,这个容器对于Pod来说是必备的
一个Pod中的应用容器共享同一个资源:

  1. PID命名空间:Pod中的不同应用程序可以看到其他的应用程序的进程ID
  2. 网络命名空间:Pod中的多个容器能够访问同一个IP和端口范围
  3. IPC命名空间:Pod的多个容器能够使用,SystemV IPC或POSIX消息队列进行通信
  4. UTS命名空间:Pod中的多个容器共享一个主机名;Volumes(共享存储卷)
  5. Pod中的各个容器可以访问在Pod级别定义的Volumes

在这里插入图片描述

在上图中如果没有pause容器,我们的Nginx和Ghost,Pod内的容器想要彼此通信的话,都需要使用自己的IP地址和端口,才可以彼此进行访问,如果有pause容器,对于整个Pod来说,我们可以看做一个整体,也就是我们的Nginx和Ghost直接使用localhost就可以进行访问了,他们唯一不同的就只是端口,这里面可能看着觉得比较简单,但其实是使用了很多网络底层的东西才实现的,感兴趣的小伙伴可以自行了解一下。

Service(服务)

在 Kubernetes 中,每个Pod都会被分配一个单独的IP地址,但是Pod和Pod之间,是无法直接进行交互的,如果想要进行网络通信,必须要通过另外一个组件才能交流,也就是我们的 Service

Service 是服务的意思,在K8S中Service主要工作就是将多个不同主机上的Pod,通过Service进行连通,让Pod和Pod之间可以正常的通信

我们可以把Service看做一个域名,而相同服务的Pod集群就是不同的ip地址,Service 是通过 Label Selector 来进行定义的。

  • Service 拥有一个指定的名字,类似于域名这种,并且它还拥有一个虚拟的IP地址和端口号,只能内网进行访问,如果Service想要进行外网访问或提供外网服务,需要指定公共的IP和NodePort或者外部的负载均衡器。

使用NodePort提供外部访问,只需要在每个Node上打开一个主机的真实端口,这样就可以通过Node的客户端访问到内部的Service。

Label(标签)

Label 一般以 kv的方式附件在各种对象上,Label 是一个说明性的标签,它有着很重要的作用,我们在部署容器的时候,在哪些Pod进行操作,都需要根据Label进行查找和筛选,我们可以理解Label是每一个Pod的别名,只有取了名称,作为K8S的Master主节点才能找到对应的Pod进行操作。

Replication Controller(复制控制器)

  1. 存在Master主节点上,这个兄弟的作用主要是对Pod的数量进行监控,比如我们在下面的节点中我们需要三个相同属性的Pod,但是目前只有两个,当Replication Controller看到只有两个的时候,就会自动帮助我们按照我们制定的规则创建一个额外的副本,放到我们的节点中。
  2. Replication Controller还可以对Pod进行实时监控,当我们某一个Pod它失去了响应,这个Pod就会被剔除,如果我们需要,Replication Controller会自动创建一个新的
  3. Kubernetes通过RC中定义的Lable筛选出对应的Pod实例,并实时监控其状态和数量,如果实例数量少于定义的副本数量(Replicas),则会根据RC中定义的Pod模板来创建一个新的Pod,然后将此Pod调度到合适的Node上启动运行,直到Pod实例数量达到预定目标。

K8S的总体架构

  • Kubernetes将集群中的机器划分为一个Master节点和一群工作节点(Node)
  • Master节点上运行着集群管理相关的一组进程etcd、API Server、Controller Manager、Scheduler,后三个组件构成了Kubernetes的总控中心,这些进程实现了整个集群的资源管理、Pod调度、弹性伸缩、安全控制、系统监控和纠错等管理功能,并且全都是自动完成。
  • Node上运行kubelet、kube-proxy、docker三个组件,是真正支持K8S的技术方案。负责对本节点上的Pod的生命周期进行管理,以及实现服务代理的功能。

在这里插入图片描述

用户通过 Kubectl提交一个创建 Replication Controller请求,这个请求通过 API Server 写入 etcd 中,这个时候Controller Manager通过 API Server的监听到了创建的命名,经过它认真仔细的分析以后,发现当前集群里面居然还没有对应的Pod实例,赶紧根据Replication Controller模板定义造一个Pod对象,再通 过Api Server 写到我们 etcd 里面

到下面,如果被Scheduler发现了,好家伙不告诉我???,无业游民,这家伙一看就不是一个好人啊,它就会立即运行一个复杂的调度流程,为这个新的Pod选一个可以落户的Node,总算有个身份了,真是让人操心,然后通过 API Server 将这个结果也写到etcd中,随后,我们的 Node上运行的小管家Kubelet进程通过 API Server 检测到这个 新生的小宝宝——“Pod”,就会按照它,就会按照这个小宝宝的特性,启动这个Pod并任劳任怨的负责它的下半生,直到Pod的生命结束。

然后我们通过Kubectl提交一个新的映射到这个Pod的Service的创建请求,Controller Manager会通过Label标签查询到相关联的Pod实例,生成Service的Endpoints的信息,并通过 API Server 写入到etcd中,接下来,所有Node上运行的Proxy进程通过 Api Server 查询并监听Service对象与其对应的Endpoints信息,建立一个软件方式的负载均衡器来实现Service访问到后端Pod的流量转发功能。

kube-proxy: 是一个代理,充当这多主机通信的代理人,前面我们讲过Service实现了跨主机、跨容器之间的网络通信,在技术上就是通过kube-proxy来实现的,service是在逻辑上对Pod进行了分组,底层是通过kube-proxy进行通信的

kubelet: 用于执行K8S的命令,也是K8S的核心命令,用于执行K8S的相关指令,负责当前Node节点上的Pod的创建、修改、监控、删除等生命周期管理,同时Kubelet定时“上报”本Node的状态信息到API Server里

etcd: 用于持久化存储集群中所有的资源对象,API Server提供了操作 etcd的封装接口API,这些API基本上都是对资源对象的操作和监听资源变化的接口

API Server : 提供资源对象的操作入口,其他组件都需要通过它提供操作的API来操作资源数据,通过对相关的资源数据“全量查询”+ “变化监听”,可以实时的完成相关的业务功能。

Scheduler : 调度器,负责Pod在集群节点中的调度分配。

Controller Manager: 集群内部管理控制中心,主要是实现 Kubernetes集群的故障检测和恢复的自动化工作。比如Pod的复制和移除,Endpoints对象的创建和更新,Node的发现、管理和状态监控等等都是由 Controller Manager完成。

总结

到这里K8S的基本情况我们就讲解完毕了,有喜欢的小伙伴记得 点赞关注,相比如Docker来说K8S有着更成熟的功能,经过谷歌大量实践的产物,是一个比较成熟和完善的系统。

关于K8S大家有什么想要了解或者疑问的地方欢迎大家留言告诉我。

我是牧小农,一个卑微的打工人,如果觉得文中的内容对你有帮助,记得一键三连,你们的三连是小农最大的动力。

1
markdown复制代码	—— 怕什么真理无穷,进一步 有进一步的欢喜,大家加油~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring Security OAuth2 入门 1 概

发表于 2021-10-28
    1. 概述
    1. 引入 Spring Security OAuth2 依赖
    1. 配置资源服务器
    1. 配置授权服务器
  • 4.1 授权码模式
  • Spring Security Setting
  • 4.2 密码模式
  • 4.3 简化模式
  • 4.4 客户端模式
  • 4.5 如何选择?
  • 4.6 为什么有 Client 编号和密码
    1. 刷新令牌
  • 5.1 获取刷新令牌
  • 5.2 “刷新”访问令牌
  • 5.3 为什么需要有刷新令牌
    1. 删除令牌
  • 6.1 删除访问令牌
  • 6.2 删除刷新令牌
  • 6.3 RFC7009 - OAuth2 Token Revocation
    1. 令牌元数据
    1. 彩蛋
  1. 概述

本文,我们来入门 Spring Security OAuth2.0 的使用。通过本文,希望你对 OAuth2.0 有一次身临其境的感受。

另外,这是一篇入门的文章,所以实际场景下,需要做一些微调。当然,需要微调的地方,笔者会在示例中说明,以免误导。

如果你是 OAuth2.0 的萌新,建议先通读阮一峰大神的 《理解OAuth 2.0》。因为,本文不会去阐述 OAuth2.0 概念部分的内容。或者,也可以看看 《OAuth 2.0最简向导》 ,比较生动形象。

阅读完本文后,你想要更加深入的理解 OAuth2.0 ,可以阅读如下两本书籍:

  • 《OAuth2 in Action》 重原理
  • 《OAuth2 2.0 Cookbook》 重实践,基于 Spring Security OAuth2 。

阅读完本文后,你想要了解源码,可以阅读老徐的两篇文章:

  • 《Re:从零开始的Spring Security OAuth2(二)》
  • 《Re:从零开始的Spring Security OAuth2(三)》

OK,一波安利之后,我们来一起进入正文。对于 Spring Security OAuth2 的配置,大体来说,就是两步:

  1. 配置授权服务器( AuthorizationServer )
  2. 配置资源服务器( ResourceServer )
  1. 引入 Spring Security OAuth2 依赖

在 pom.xml 文件中,引入如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
xml复制代码<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.16.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<dependencies>
<!-- for Spring MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- for Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- for OAuth 2.0 -->
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
</dependencies>

因为,我们使用的是 SpringBoot 的版本为 1.5.16.RELEASE ,所以使用的 Spring Security 的版本为 4.2.8.RELEASE ,Spring Security OAuth2 的版本为 2.2.0.15.RELEASE 。

  1. 配置资源服务器

一般情况下,资源服务器指的是,我们提供 API 的应用或服务。例如,订单服务、商品服务。考虑到让整个示例更加简单,本文先将它和授权服务器放在一个 Maven 项目中。

① 创建一个 Controller 类

1
2
3
4
5
6
7
8
9
10
11
less复制代码/**
* 示例模块 Controller
*/
@RestController
@RequestMapping("/api/example")
public class ExampleController {
@RequestMapping("/hello")
public String hello() {
return "world";
}
}
  • 非常简单,这是一个示例模块的 Controller ,提供 /api/example/hello 接口。

② 配置资源服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scala复制代码// 资源服务配置
@Configuration
@EnableResourceServer
public class OAuth2ResourceServer extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 对 "/api/**" 开启认证
.anyRequest()
.authenticated()
.and()
.requestMatchers()
.antMatchers("/api/**");
}
}
  • @Configuration 注解,保证 OAuth2ResourceServer 能够被 SpringBoot 扫描到配置。
  • @EnableResourceServer 注解,开启资源服务器。
  • 继承( extends ) ResourceServerConfigurerAdapter 类,并覆写 #configure(HttpSecurity http) 方法,配置对 HTTP 请求中,匹配 /api/**“ 路径,开启认证的验证。
  1. 配置授权服务器

在 OAuth2.0 中,定义了四种授权模式:

  • 授权码模式( authorization code )
  • 密码模式( resource owner password credentials )
  • 简化模式( implicit )
  • 客户端模式( client credentials )

所以,笔者在 SpringBoot-Labs/lab-02 目录下,每一种方式,都提供了一个 Maven 项目示例。

4.1 授权码模式

Maven 项目结构如下:

Spring Security OAuth2 入门

Maven 项目结构

对应 GitHub 地址:

github.com/YunaiV/Spri…

① 配置授权服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
scala复制代码// 授权服务器配置
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory() // <1>
// <2> begin ...
.withClient("clientapp").secret("112233") // Client 账号、密码。
.redirectUris("http://localhost:9001/callback") // 配置回调地址,选填。
.authorizedGrantTypes("authorization_code") // 授权码模式
.scopes("read_userinfo", "read_contacts") // 可授权的 Scope
// <2> end ...
// .and().withClient() // 可以继续配置新的 Client // <3>
;
}
}
  • @Configuration 注解,保证 OAuth2AuthorizationServer 能够被 SpringBoot 扫描到配置。
  • @EnableAuthorizationServer 注解,开启授权服务器。
  • <1> 处,基于内存,为了方便测试。实际情况下,最好放入数据库中,方便管理。
  • <2> 处,创建一个 Client 配置。
  • <3> 处,可以使用 #and() 方法,继续添加另外的 Client 配置。

② 配置登陆账号

创建 application.properties 文件,并配置如下:

1
2
3
ini复制代码# Spring Security Setting
security.user.name=yunai
security.user.password=1024
  • 这里配置了一个账号为 “yunai” ,密码为 “1024” 的登陆账户。
  • 实际生产环境下,登陆账号的数据,肯定是放在数据库中。

③ 启动项目

1
2
3
4
5
6
typescript复制代码@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

启动项目

④ 获取授权码

4.1 浏览器打开

http://localhost:8080/oauth/authorize?client\_id=clientapp&redirect\_uri=http://localhost:9001/callback&response\_type=code&scope=read\_userinfo

  • client_id 参数,必传,为我们在 OAuth2AuthorizationServer 中配置的 Client 的编号。
  • redirect_url 参数,可选,回调地址。当然,如果 client_id 对应的 Client 未配置 redirectUris 属性,会报错。
  • response_type 参数,必传,返回结果为授权码。
  • scope 参数,可选,申请授权的 Scope 。如果多个,使用逗号分隔。
  • state 参数,可选,表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。
  • 未在上述 URL 中体现出来

。

4.2 浏览器打开后,效果如下:

Spring Security OAuth2 入门

浏览器

  • 输入在 「② 配置登陆账号」 中配置的登陆账号 “yunai” / “1024” 。
  • 实际生产情况下,我们以 QQ 三方登陆作为例子,如下图:

Spring Security OAuth2 入门

  • QQ 示例

4.3 登陆成功,选择允许所有申请的 Scope ,点击【Authorize】按钮,确认授权。如下图:

Spring Security OAuth2 入门

Authorize

4.4 授权完成,回调 redirect_uri 地址。如下图所示:

Spring Security OAuth2 入门

回调地址

  • code 参数,就是返回的授权码。

⑤ 获取访问令牌

1
perl复制代码curl -X POST --user clientapp:112233 http://localhost:8080/oauth/token -H "content-type: application/x-www-form-urlencoded" -d "code=UydkmV&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A9001%2Fcallback&scope=read_userinfo"
  • –user clientapp:112233 处,填写我们在 OAuth2AuthorizationServer 中配置的 Client 的编号和密码。
  • code=UydkmV 处,填写在 「④ 获取授权码」 中获取的授权码( code ) 。

返回结果示例如下:

1
2
3
4
5
6
json复制代码{
"access_token": "e60e41f2-2ad0-4c79-97d5-49af38e5c2e8",
"token_type": "bearer",
"expires_in": 43199,
"scope": "read_userinfo"
}
  • access_token 属性,访问令牌。非空。
  • token_type 属性,令牌类型,可以是 “bearer” 或 “mac” 类型。非空。
  • expires_in 属性,过期时间,单位为秒。一般情况下,非空。
  • scope 属性,权限范围。如果与 Client 申请的范围一致,此项可省略。
  • refresh_token 属性,刷新令牌,用来获取下一次的访问令牌。
  • 在授权码模式下,允许为空。

可能有部分胖友是 Windows 电脑,可以参考 《windows(64位)下使用 curl 命令》 来安装一个 curl 命令。

当然,如果胖友使用 Postman ,可以参看如下两图:

Spring Security OAuth2 入门

  • 图 1

Spring Security OAuth2 入门

  • 图 2

⑥ 调用资源服务器的 API

1
bash复制代码curl -X GET http://localhost:8080/api/example/hello -H "authorization: Bearer e60e41f2-2ad0-4c79-97d5-49af38e5c2e8"
  • authorization: Bearer e60e41f2-2ad0-4c79-97d5-49af38e5c2e8 处,填写指定的访问令牌类型和访问令牌。例如此处分别为,”Bearer”、”e60e41f2-2ad0-4c79-97d5-49af38e5c2e8” 。

如果胖友使用 Postman ,可以参看如下图:

Spring Security OAuth2 入门

  • 图

4.2 密码模式

Maven 项目结构如下:

Spring Security OAuth2 入门

Maven 项目结构

对应 GitHub 地址:

github.com/YunaiV/Spri…

① 配置授权服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
scala复制代码// 授权服务器配置
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {
// 用户认证
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("clientapp").secret("112233") // Client 账号、密码。
.authorizedGrantTypes("password") // 密码模式
.scopes("read_userinfo", "read_contacts") // 可授权的 Scope
// .and().withClient() // 可以继续配置新的 Client
;
}
}
  • 配置 Client 的方式,和【授权码模式】基本一致。差别在于:
  • 无需配置 redirectUris 属性,因为不需要回调地址。
  • 配置授权模式为【密码模式】。
  • 另外,需要引入 AuthenticationManager 来支持【密码模式】,否则会报 “Resolved [error=”unsupported_grant_type”, error_description=”Unsupported grant type: password”]” 异常。

② 配置登陆账号

和【授权码模式】一致。

③ 启动项目

和【授权码模式】一致。

④ 获取访问令牌

1
bash复制代码curl -X POST --user clientapp:112233 http://localhost:8080/oauth/token -H "accept: application/json" -H "content-type: application/x-www-form-urlencoded" -d "grant_type=password&username=yunai&password=1024&scope=read_userinfo"
  • 和【授权码模式】差异比较大。
  • 直接请求 oauth/token 接口,获得访问令牌。
  • 请求参数带上了 username 和 password ,就用户的登陆账号和密码。
  • 请求参数 grant_type 为 password ,表示【密码模式】。

返回结果示例如下:

1
2
3
4
5
6
json复制代码{
"access_token": "68de6eb9-5672-4e47-a3e6-110404285ba9",
"token_type": "bearer",
"expires_in": 43199,
"scope": "read_userinfo"
}
  • 和【授权码模式】一致。

⑤ 调用资源服务器的 API

和【授权码模式】一致。

4.3 简化模式

Maven 项目结构如下:

Spring Security OAuth2 入门

Maven 项目结构

对应 GitHub 地址:

github.com/YunaiV/Spri…

① 配置授权服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
scala复制代码// 授权服务器配置
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("clientapp").secret("112233") // Client 账号、密码。
.redirectUris("http://localhost:9001/callback") // 配置回调地址,选填。
.authorizedGrantTypes("implicit") // 授权码模式
.scopes("read_userinfo", "read_contacts") // 可授权的 Scope
// .and().withClient() // 可以继续配置新的 Client
;
}
}
  • 和【授权码模式】基本一致。差别仅仅在于:配置授权模式为【简化模式】。

FROM 《理解 OAuth 2.0》

简化模式(implicit grant type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了”授权码”这个步骤,因此得名。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。

② 配置登陆账号

和【授权码模式】一致。

③ 启动项目

和【授权码模式】一致。

④ 获取授权码

4.1 浏览器打开

http://localhost:8080/oauth/authorize?client\_id=clientapp&redirect\_uri=http://localhost:9001/callback&response\_type=implicit&scope=read\_userinfo

  • 和【授权码模式】基本一致。差别仅仅在于:请求参数 response_type 为 “implicit” 简化模式。

4.2 浏览器打开后,效果如下:

Spring Security OAuth2 入门

浏览器

  • 和【授权码模式】基本一致,输入在 「② 配置登陆账号」 中配置的登陆账号 “yunai” / “1024” 。

4.3 登陆成功,直接授权完成,回调 redirect_uri 地址。如下图所示:

Spring Security OAuth2 入门

浏览器

  • 和【授权码模式】基本不一致的有两点:
  • 登陆成功后,无需选择允许所有申请的 Scope ,直接授权完成。
  • 返回的不是授权码,而是访问令牌。

总的来说,【简化模式】是【授权码模式】的简化模式。

⑤ 调用资源服务器的 API

和【授权码模式】一致。

4.4 客户端模式

Maven 项目结构如下:

Spring Security OAuth2 入门

Maven 项目结构

对应 GitHub 地址:

github.com/YunaiV/Spri…

① 配置授权服务器

和【密码模式】一致。

② 配置登陆账号

它无需配置登陆账号。因为它没有用户的概念,直接与授权服务器交互,通过 Client 的编号( client_id )和密码( client_secret )来保证安全性。

③ 启动项目

和【密码模式】一致。

④ 获取访问令牌

1
arduino复制代码curl -X POST "http://localhost:8080/oauth/token" --user clientapp:112233 -d "grant_type=client_credentials&scope=read_contacts"
  • 和【密码模式】基本一致,差别如下:
  • 请求参数无需带上了 username 和 password 。
  • 请求参数 grant\_type 为 client\_credentials ,表示【密码模式】。

返回结果示例如下:

1
2
3
4
5
6
json复制代码{
"access_token":"cb2bdfd8-18fa-4b8f-b525-10587bd672e8",
"token_type":"bearer",
"expires_in":43199,
"scope":"read_contacts"
}
  • 和【密码模式】一致。

⑤ 调用资源服务器的 API

和【密码模式】一致。

总的来说,【客户端模式】是【密码模式】的简化模式。

4.5 如何选择?

可能很多胖友,有跟笔者一样的困惑。下面笔者引用杨波老师的一张图,相信能解决我们的困扰。如下图所示:

FROM 《深度剖析 OAuth2 和微服务安全架构》

Spring Security OAuth2 入门

授权类型选择

当然,对于黄框部分,对于笔者还是比较困惑的。笔者认为,第三方的单页应用 SPA ,也是适合采用 Authorization Code Grant 授权模式的。例如,《微信网页授权》 :

具体而言,网页授权流程分为四步:

1、引导用户进入授权页面同意授权,获取code

2、通过code换取网页授权access_token(与基础支持中的access_token不同)

3、如果需要,开发者可以刷新网页授权access_token,避免过期

4、通过网页授权access_token和openid获取用户基本信息(支持UnionID机制)

所以,笔者猜测,之所以图中画的是 Implicit Grant 的原因是,受 Google 的 《OAuth 2.0 for Client-side Web Applications》 一文中,推荐使用了 Implicit Grant 。

当然,具体使用 Implicit Grant 还是 Authorization Code Grant 授权模式,没有定论。笔者,偏向于使用 Authorization Code Grant,对于第三方客户端的场景。

4.6 为什么有 Client 编号和密码

我们看到上述四种授权模式,无论是哪一种,最终调用授权服务器时,都会传递 Client 编号和密码,这是为什么呢?通过 Client 编号和密码,授权服务器可以知道调用的来源以及正确性。这样,即使“坏人”拿到 Access Token ,但是没有 Client 编号和密码,也不能和授权服务器发生有效的交互。

  1. 刷新令牌

在 「4. 配置授权服务器」 中,我们一直没有看到我们期盼的刷新令牌( refresh token )的身影。这是为什么呢?因为我们在配置 Spring Security OAuth2 并未配置,获取访问令牌的同时,获取刷新令牌。

那么,怎么配置开启获取刷新令牌的功能呢?我们来看看 「5.1 获取刷新令牌」 。

5.1 获取刷新令牌

因为【密码模式】相对简单,我们直接在原有程序上做改造。对应 GitHub 地址:

github.com/YunaiV/Spri… 。

在步骤上,如果和原有【密码模式】保持一致的地方,下文会进行省略,并标注“和原有一致”。

① 配置授权服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
scala复制代码// 授权服务器配置
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServer extends AuthorizationServerConfigurerAdapter {
// 用户认证
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("clientapp").secret("112233") // Client 账号、密码。
.authorizedGrantTypes("password", "refresh_token") // 密码模式 // <1>
.scopes("read_userinfo", "read_contacts") // 可授权的 Scope
// .and().withClient() // 可以继续配置新的 Client
;
}
}
  • 在 <1> 处,我们很神奇的多配置了一个 “refresh_token” ,用于开启获取刷新令牌的功能。但是但是但是,OAuth2 的授权模式说好的是四种的么,怎么又出现了 “refresh_token” 这种授权模式?淡定,在 Spring Security OAtuh2 中,”refresh_token” 作为一种特殊的授权模式配置,用于开启获取刷新令牌的功能。所以,其它授权模式如果开启获取刷新令牌的功能,需要在 #authorizedGrantTypes(…) 设置时,多传入 “refresh_token” 方法参数。

② 配置登陆账号

和原有一致。

③ 启动项目

和原有一致。

④ 获取访问令牌

1
bash复制代码curl -X POST --user clientapp:112233 http://localhost:8080/oauth/token -H "accept: application/json" -H "content-type: application/x-www-form-urlencoded" -d "grant_type=password&username=yunai&password=1024&scope=read_userinfo"
  • 和原有一致。

返回结果示例如下:

1
2
3
4
5
6
7
json复制代码{
"access_token":"092a2286-04e7-4e7d-8c20-19fbe25865ff",
"token_type":"bearer",
"refresh_token":"afeeb083-997f-4ea8-9334-aab6c1696cca",
"expires_in":43199,
"scope":"read_userinfo"
}
  • 在原有的基础上,多返回了 “refresh_token” 刷新令牌。美滋滋。

⑤ 调用资源服务器的 API

和原有一致。

5.2 “刷新”访问令牌

因为访问访问令牌会自动过期,通过使用刷新令牌,可以获得新的访问令牌。注意,访问令牌获取到的是新的,不是老的哈。这也是为什么,在标题上,笔者对刷新加了双引号。

1
bash复制代码curl -i -X POST -u 'clientapp:112233' http://localhost:8080/oauth/token -H "accept: application/json" -d 'grant_type=refresh_token&refresh_token=afeeb083-997f-4ea8-9334-aab6c1696cca'
  • 调用接口还是 “oauth/token” ,差别在于传入的请求参数 grant_type 为 “refresh_token”,使用刷新令牌。
  • 请求参数 refresh_token 为上面获取到的刷新令牌 “afeeb083-997f-4ea8-9334-aab6c1696cca” 。

返回结果示例如下:

1
2
3
4
5
6
7
json复制代码{
"access_token":"507eb761-4b25-4159-b927-ef3eff5e7eff",
"token_type":"bearer",
"refresh_token":"afeeb083-997f-4ea8-9334-aab6c1696cca",
"expires_in":43199,
"scope":"read_userinfo"
}
  • 获得的访问令牌为 “507eb761-4b25-4159-b927-ef3eff5e7eff” ,是新的。并且,过期时间也变成新的。

笔者在看 OAuth2.0 的刷新令牌时,一直有个疑惑:刷新令牌是否有过期时间?答案是,有。但是,笔者不太确定,在 Spring Security OAuth2 中,如果不设置刷新令牌的过期时间,刷新时间是否无限长?当然,这个貌似也并不重要。因为,在实际使用中,我们肯定是需要显示( 主动 )设置刷新令牌的过期时间,使用 ClientBuilder#

refreshTokenValiditySeconds(int refreshTokenValiditySeconds) 方法,示例如下:

1
2
3
4
5
6
7
8
9
10
java复制代码@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("clientapp").secret("112233") // Client 账号、密码。
.authorizedGrantTypes("password", "refresh_token") // 密码模式
.scopes("read_userinfo", "read_contacts") // 可授权的 Scope
.refreshTokenValiditySeconds(1200) // 1200 秒过期
// .and().withClient() // 可以继续配置新的 Client
;
}

刷新令牌过期时,返回结果示例如下:

1
2
3
4
json复制代码{
"error":"invalid_token",
"error_description":"Invalid refresh token (expired): 7139d075-c4ea-48f0-9dbb-6f65fa6dbeb0"
}
  • 如果胖友要测试这个效果,可以把刷新令牌过期时间设置为 1 秒。

5.3 为什么需要有刷新令牌

出于安全性的考虑,访问令牌的过期时间比较短,刷新令牌的过期时间比较长。这样,如果访问令牌即使被盗用走,那么在一定的时间后,访问令牌也能在较短的时间吼过期。当然,安全也是相对的,如果使用刷新令牌后,获取到新的访问令牌,访问令牌后续又可能被盗用。

另外,刷新令牌是可选项,不一定会返回。

笔者整理了下,大家常用开放平台的令牌过期时间,让大家更好的理解:

  • 小米开放平台
  • 《Access Token 生命周期》
  • Access Token :90 天有效期
  • Refresh Token :10 年有效期
  • 微信开放平台
  • 《网站应用微信登录开发指南》
  • Access Token :2 小时有效期
  • Refresh Token :未知有效期
  • 腾讯开放平台
  • 《获取 Access_Token》
  • Access Token :90 天有效期
  • Refresh Token :未知有效期
  1. 删除令牌

实际在 OAuth2 时,有删除访问令牌和刷新令牌的需求。例如:用户登出系统。虽然说,可以通过客户端本地删除令牌的方式实现。但是,考虑到真正的彻底的实现删除令牌,必然服务端自身需要删除令牌。

在 Spring Security OAuth2 中,并没有提供内置的接口,所以需要自己去实现。笔者参看 《Spring Security OAuth2 – Simple Token Revocation》 文档,实现删除令牌的 API 接口。

因为【密码模式】相对简单,我们直接在原有程序上做改造。对应 GitHub 地址: 。注意,如下仅仅是 Demo ,实际生产环境下需要做改造。

6.1 删除访问令牌

① 新增删除访问令牌的 API 接口

1
2
3
4
5
6
7
less复制代码@Autowired
private ConsumerTokenServices tokenServices;
@RequestMapping(method = RequestMethod.POST, value = "api/access_token/revoke")
public String revokeToken(@RequestParam("token") String token) {
tokenServices.revokeToken(token);
return token;
}
  • 使用 ConsumerTokenServices#revokeToken(String tokenValue) 方法,删除访问令牌。

注意,实际生产环境下,授权服务器和资源服务器是不在一起的,所以此处仅仅是示例。主要是为了介绍 ConsumerTokenServices#revokeToken(String tokenValue) 方法的使用。

② 访问删除访问令牌的 API 接口。

1
bash复制代码curl -X POST http://localhost:8080/api/access_token/revoke -H "authorization: Bearer 23874e0b-a1d8-4337-9551-7b9be1ebaebe" -d "token=23874e0b-a1d8-4337-9551-7b9be1ebaebe"

移除成功后,在使用当前访问令牌,就会报如下错误:

1
2
3
4
json复制代码{
"error":"invalid_token",
"error_description":"Invalid access token: 23874e0b-a1d8-4337-9551-7b9be1ebaebe"
}

另外,也可以参考

github.com/geektime-ge… 的实现。

6.2 删除刷新令牌

① 新增删除访问令牌的 API 接口

1
2
3
4
5
6
7
less复制代码@Autowired(required = false) // <1>
private TokenStore tokenStore;
@RequestMapping(method = RequestMethod.POST, value = "api/refresh_token/revoke")
public String revokeRefreshToken(@RequestParam("token") String token) {
tokenStore.removeRefreshToken(new DefaultOAuth2RefreshToken(token));
return token;
}
  • <1> 处,使用了 required = false 的原因是,本示例并未显示声明 TokenStore Bean 对象交给 Spring 管理,所以无法注入。 所以 「6.2 删除刷新令牌」 是一个无法跑通的示例。
  • 重点在于,调用 TokenStore#removeRefreshToken(OAuth2RefreshToken token) 方法,删除刷新令牌。

② 访问删除刷新令牌的 API 接口。

1
bash复制代码curl -X POST http://localhost:8080/api/refresh_token/revoke -H "authorization: Bearer 52e85411-ac1d-4844-bf03-cf5633e4eecd" -d "token=ead4734a-ca5c-45bf-ac25-9a92291a9fe1"

移除成功后,在使用当前刷新令牌,就会报如下错误:

1
2
3
4
json复制代码{
"error":"invalid_token",
"error_description":"Invalid refresh token: ead4734a-ca5c-45bf-ac25-9a92291a9fe1"
}

另外,也可以参考

github.com/geektime-ge… 的实现。

6.3 RFC7009 - OAuth2 Token Revocation

在 OAuth2 中,删除令牌,标准的说法为 OAuth2 Token 撤销,对应 RFC7009 。感兴趣的胖友,可以看看。

FROM 《OAuth2 Token 撤销(RFC7009 - OAuth2 Token Revocation)》

简单来说,这个协议规定了一个Authorization server提供一个怎样的API来供Client撤销access_token或者refresh_token。

比如Client发起一个如下的请求:

POST /revoke HTTP/1.1

Host: server.example.com

Content-Type: application/x-www-form-urlencoded

Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW

token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token

其中各项含义如下:

  1. /revoke:是Authorization Server需要提供的API地址,Client使用Post方式请求这个地址。
  2. Content-Type: application/x-www-form-urlencoded:固定此格式。
  3. Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW:访问受保护资源的授权凭证。
  4. token:必选,可以是access_token或者refresh_token的内容。
  5. token_type_hint:可选,表示token的类型,值为”access_token“或者”refresh_token”。

如果撤销成功,则返回一个HTTP status code为200的响应就可以了。

  1. 令牌元数据

FROM 《OAuth2 Token 元数据(RFC7662 - OAuth2 Token Introspection)》

简单的总结来说,这个规范是为OAuth2扩展了一个API接口(Introspection Endpoint),让第三方Client可以查询上面提到的那些信息(比如,access_token是否还有效,谁颁发的,颁发给谁的,scope又哪些等等的元数据信息)。

比如Client发起一个如下的请求:

POST /introspect HTTP/1.1

Host: server.example.com

Accept: application/json

Content-Type: application/x-www-form-urlencoded

Authorization: Bearer 23410913-abewfq.123483

token=2YotnFZFEjr1zCsicMWpAA&token_type_hint=access_token

看起来和上面的撤销Token的请求差不多,其中各项含义如下:

  1. /introspect:是Authorization Server需要提供的API地址,Client使用Post方式请求这个地址。
  2. Accept:application/json:表示Authorization Server需要返回一个JSON格式的数据。
  3. Content-Type: application/x-www-form-urlencoded:固定此格式。
  4. Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW:访问受保护资源的授权凭证。
  5. token:必选,可以是access_token或者refresh_token的内容。
  6. token_type_hint:可选,表示token的类型,值为”access_token“或者”refresh_token”。

如果请求成功,则会返回如下的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
json复制代码 1 {
2 "active": true,
3 "client_id": "l238j323ds-23ij4",
4 "token_type":"access_token",
5 "username": "jdoe",
6 "scope": "read write dolphin",
7 "sub": "Z5O3upPC88QrAjx00dis",
8 "aud": "https://protected.example.net/resource",
9 "iss": "https://server.example.com/",
10 "exp": 1419356238,
11 "iat": 1419350238,
12 "nbf": 1419350238,
13 "jti": "abcdefg"
14 "extension_field": "twenty-seven"
15 }

JSON各项属性含义如下(其中有些信息是在JSON Web Token中定义的,参考链接有详细的介绍):

  1. active:必须的。表示token是否还是有效的。
  2. client_id:可选的。表示token所属的Client。比如上面的在线打印并且包邮的网站。
  3. token_type:可选的。表示token的类型。对应传递的token_type_hint。
  4. user_name:可选的。表示token的授权者的名字。比如上面的小明。
  5. scope:可选的。和上篇5.1.1 Authorization Request中的可选参数scope对应,表示授权给Client访问的范围,比如是相册,而不是小明的日志以及其他受保护资源。
  6. sub:可选的。token所属的资源拥有者的唯一标识,JWT定义的。也就是小明的唯一标识符。
  7. aud:可选的。token颁发给谁的,JWT定义的。
  8. iss:可选的。token的颁发者,JWT定义的。
  9. exp:可选的。token的过期时间,JWT定义的。
  10. iat:可选的。iss颁发token的时间,JWT定义的。
  11. nbf:可选的。token不会在这个时间之前被使用,JWT定义的。
  12. jti:可选的。token的唯一标识,JWT定义的。
  13. extension_field:可以自己扩展相关其他属性。

其中大量的信息都是可选的信息,而且可以自己扩展需要的属性信息,从这些属性中就可以解决我们上面提到的access_token对于Client不透明的问题。

我们注意到其中有很多属于JWT定义的属性,那么这个JWT是什么东西?它解决了什么问题?感兴趣的胖友,可以看看 《JSON Web Token (JWT)》 。

对于令牌元数据 API 接口的实现,笔者这里就暂时不提供。如果有需要的胖友,可以看看 TokenStore 的两个 API :

  • #readAccessToken(String tokenValue) 方法,读取指定的访问令牌的信息。
  • #readRefreshToken(String tokenValue) 方法,读取指定的刷新令牌的信息。
  1. 彩蛋

一万个注意,本文仅仅是 Spring Security OAuth2 的入门文章。实际生产使用时,还需要做很多事情。例如:

  • 使用关系数据库,持久化存储 Client 和令牌信息。例如,使用 JdbcTokenStore 。
  • 授权服务器和资源服务器分离。例如,使用 RemoteTokenServices 。
  • 使用缓存服务器,提升 Client 和令牌信息的访问速度。例如,使用 RedisTokenStore 。

推荐阅读文章:

  • CatalpaFlat 《Spring Security OAuth2 深入解析》
  • 小东子 《Spring Security OAuth2 开发指南》
  • 聊聊架构 《轻松筹 1.6 亿注册用户的 Passport 账户体系架构设计》

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

7天开发一个点餐系统 - 采用 Typescript 开发一

发表于 2021-10-28

ui
Food Truck 是一个基于 Javascript(Typescript) 开发的餐车点餐系统, 该系统包括了用户短信注册, 点餐, 下单支付*的完整流程.
将开发过程分为7个步骤, 每个步骤在 Github 的代码仓库中都有对应的分支(从 *phase/1
到 phase/7), 方便参考.

Food Truck is an online food ordering app based on Javascript(Typescript), this system covers the flow from registering, menu navigating and placing orders. I split this flow into 7 steps, each step corresponds to a branch of this repository.

  • Day 1 搭建前后端项目脚手架 Building scaffolding for the back-end and the front-end projects
  • Day 2 重构后端项目, 前端引入状态管理 refactoring back-end project, and introducing state management into the front-end project
  • Day 3 前端的 UI 设计和开发 Designing and developing UI at the front-end
  • Day 4 手机短信注册和登录 Signin / signup with OTP
  • Day 5 数据库的支持 Database supporting
  • Day 6 支付 Making a payment with Stripe
  • Day 7 持续集成 Continuous integration and continuous deployment (CI/CD)
  • 编外 Others

虽然说7天从头到尾开发一个全栈的应用有些夸张, 但是本文展示了如何运用成熟的云原生技术, 高效的搭建一个应用可能性. 这是一个针对有一定开发经验的初级教程, 所以并不会深入讨论每一个技术点, 但是会较全面的覆盖开发一个全栈应用的所有方面, 也包括持续集成(CI/CD) 方面的内容.

Although it is an exaggeration to develop a full-stack application in 7 days, but this article shows how to use mature cloud technology to build an application possibility. This is a primary tutorial for new bees with a little development experience, so it will not deep dive every technical point, but it will cover all aspects of developing a full-stack application, including continuous integration (CI/ CD).

服务端采用了 Node.js 和 Koa2 框架. 用户鉴权是基于 AWS 的 Cognito, 注册登入涉及的后台 Lambda Function 是在前端的工程里, 由 AWS Amplify 提供支持. 支付采用了 Stripe 提供的服务.

The server uses the Node.js and Koa2 frameworks. User authentication is based on Cognito of AWS, the backend Lambda Function involved in registration and login is in the front-end project and is supported by AWS Amplify. Payment uses the service provided by Stripe.

前端采用 Vue3, 考虑第三方库的兼容性问题, 没有采用 Vite 作为开发和打包工具.

The front-end uses Vue3

作为抛砖引玉, 这篇文章提供一个大家可以学习交流的起点, 有兴趣的同学欢迎加微信和笔者互相交流.

As an introduction, this article provides a starting point for everyone to learn and communicate. It is welcome to add WeChat to communicate with the author.
IMG_3482
相关的代码仓库如下:

The relevant code repositories are as follows:

后端代码仓库 Back-end

前端代码仓库 Front-end

共享库 Shared library

代码仓库暂时不公开, 有需要交流学习的同学可以添加联系方式。

Day 1 搭建前后端项目脚手架

”工欲善其事,必先利其器“
If a worker wants to do his job well, he must first sharpen his tools

  • 高效的开发环境和顺畅的持续集成流程决定了开发的效率. 在 MacOS 下建议安装 iTerm2, Oh-My-Zsh 并配置 Powerlevel10K 的 theme. 这里不详细展开这些工具的安装了, 有兴趣的同学可以上网搜一下. 这是我的开发环境.

An efficient development environment and a smooth continuous integration process guarantees the efficiency of development. Under MacOS, it is recommended to install iTerm2, Oh-My-Zsh and then configure the theme of Powerlevel10K. The installation of these tools will not be discussed here, if you are interested in these tools, you can google it. This is my development environment.
oh-my-zsh-powerlevel10k

  • 如果是一个团队参与一个项目的开发, 在开发时统一代码规范也是很重要的, 这个章节会重点描述 ESLint 和 Prettier 的配置来规范团队的代码.

It is very important to unify the code style during development if a team is involved in a project. This section will focus on the configuration of ESLint and Prettier to standardize the team’s code style.

从零到一创建一个后端的工程(create backend project from scratch)

  1. 先在 Github 上创建一个仓库 ft-node
    create ft-node repository on Github first
  • 初始化本地 git 仓库, 并推送到 Github 仓库中
    initialize the local repository, and push it onto the remote repository under Github
1
2
3
4
5
6
7
shell复制代码mkdir backend && cd backend
echo "# ft-node" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin git@github.com:quboqin/ft-node.git
git push -u origin main
  • 添加 .gitignore
    add .gitignore file
1
2
3
4
5
6
bash复制代码# .gitignore
# Dependency directory
node_modules

# Ignore built ts files
dist
  1. 初始化 npm 和 Typescript 项目
    Intialize the node.js project with npm, and configure Typescript
1
2
3
4
5
6
7
8
9
10
11
12
shell复制代码# 创建代码目录
mkdir src
# 初始化 npm 项目
npm init -y
# 在初始化 Typescript 项目前先在本地安装 typescript
npm install typescript --save-dev
# 初始化 typescript 项目
npx tsc --init --rootDir src --outDir dist \
--esModuleInterop --target esnext --lib esnext \
--module commonjs --allowJs true --noImplicitAny true \
--resolveJsonModule true --experimentalDecorators true --emitDecoratorMetadata true \
--sourceMap true --allowSyntheticDefaultImports true
  1. 安装 Koa, Koa Router 和 @Koa/cors(支持跨域), 以及对应的类型依赖
1
2
shell复制代码npm i koa koa-router @koa/cors
npm i @types/koa @types/koa-router types/koa__cors --save-dev
  1. 创建一个简单 Koa 服务
  • 在 src 目录下创建 server.ts 文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typescript复制代码// server.ts
import Koa from 'koa'
import Router from 'koa-router'
import cors from '@koa/cors'

const app = new Koa()
const router = new Router()

app.use(cors())

router.get('/checkHealth', async (ctx) => {
ctx.body = {
code: 0,
data: 'Hello World!',
}
})

app.use(router.routes())

app.listen(3000)

console.log('Server running on port 3000')
  • 测试
1
2
3
shell复制代码npx tsc
node dist/server.js
Server running on port 3000
  1. 安装 ts-node 和 nodemon, Running the server with Nodemon and TS-Node, 并修改 package.json 中的脚本
1
2
3
shell复制代码npm i ts-node nodemon --save-dev
# 如果 typescript 安装在全局环境下
# npm link typescript

修改 package.json

1
2
3
4
5
json复制代码  "scripts": {
"dev": "nodemon --watch 'src/**/*' -e ts,tsx --exec ts-node ./src/server.ts",
"build": "rm -rf dist && tsc",
"start": "node dist/server.js"
},
  1. 配置 ESLint 和 Prettier

Lint

  • 安装 eslint 和相关依赖
1
shell复制代码npm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev
  • eslint: The core ESLint linting library
  • @typescript-eslint/parser: The parser that will allow ESLint to lint TypeScript code
  • @typescript-eslint/eslint-plugin: A plugin that contains a bunch of ESLint rules that are TypeScript specific
  • 添加 .eslintrc.js 配置文件
    可以通过, 以交互的方式创建
1
shell复制代码npx eslint --init

但是建议手动在项目的 root 目录下创建这个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
javascript复制代码// .eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser', // Specifies the ESLint parser
parserOptions: {
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
},
extends: [
'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin
],
rules: {
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
// e.g. "@typescript-eslint/explicit-function-return-type": "off",
},
}
  • 添加 Prettier
1
shell复制代码npm i prettier eslint-config-prettier eslint-plugin-prettier --save-dev
  • prettier: The core prettier library
  • eslint-config-prettier: Disables ESLint rules that might conflict with prettier
  • eslint-plugin-prettier: Runs prettier as an ESLint rule

In order to configure prettier, a .prettierrc.js file is required at the root project directory. Here is a sample .prettierrc.js file:

1
2
3
4
5
6
7
javascript复制代码// .prettierrc.js
module.exports = {
tabWidth: 2,
semi: false,
singleQuote: true,
trailingComma: 'all',
}

Next, the .eslintrc.js file needs to be updated:

1
2
3
4
5
javascript复制代码// eslintrc.js
extends: [
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
"plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
],
  • Automatically Fix Code in VS Code
    • 安装 eslint 扩展, 这里不需要安装 prettier 扩展
      并点击右下角激活 eslint 扩展
      eslint-vscode-extension-enable
    • 在 VSCode 中创建 Workspace 的配置
1
2
3
4
5
6
json复制代码{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
}
  • Run ESLint with the CLI
    • A useful command to add to the package.json scripts is a lint command that will run ESLint.
1
2
3
4
5
json复制代码{
"scripts": {
"lint": "eslint '*/**/*.{js,ts,tsx}' --quiet --fix"
}
}
  • 添加 .eslintignore
1
2
3
4
bash复制代码# don't ever lint node_modules
node_modules
# don't lint build output (make sure it's set to your correct build folder name)
dist
  • Preventing ESLint and formatting errors from being committed
1
shell复制代码npm install husky lint-staged --save-dev

To ensure all files committed to git don’t have any linting or formatting errors, there is a tool called lint-staged that can be used. lint-staged allows to run linting commands on files that are staged to be committed. When lint-staged is used in combination with husky, the linting commands specified with lint-staged can be executed to staged files on pre-commit (if unfamiliar with git hooks, read about them here).

To configure lint-staged and husky, add the following configuration to the package.json file:

1
2
3
4
5
6
7
8
9
10
11
12
json复制代码{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,ts,tsx}": [
"eslint --fix"
]
}
}

初始化 Vue3 前端

  1. 通过 Vue Cli 创建前端项目
1
2
3
4
5
6
shell复制代码# 参看 vue cli 的版本
vue --version
# 更新一下最新版本
npm update -g @vue/cli
# 创建项目
vue create frontend

vue-cli-5

  1. 用 Vue Cli 创建的脚手架已经配置好大部分 ESLint 和 Prettier, 这里只要统一一下前后端的 Prettier 设置
  • 添加 .prettierrc.js 文件
1
2
3
4
5
6
7
javascript复制代码// .prettierrc.js
module.exports = {
tabWidth: 2,
semi: false,
singleQuote: true,
trailingComma: 'all',
}
  • 同样修改 workspace 的配置
1
2
3
4
5
6
json复制代码{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
  1. 运行 npm run lint

前端引入 Axois, 访问接口(health interface)

  1. 安装 axios 模块
1
shell复制代码npm i axios --save
  1. 封装 axios 模块, 设置默认 URL
1
2
typescript复制代码// src/utils/axios.ts
axios.defaults.baseURL = 'http://localhost:3000'
  1. 封装 src/apis/health.ts 文件
1
2
3
4
5
6
typescript复制代码// src/apis/health.ts
import { result } from '@/utils/axios'

export function checkHealth<U>(): Promise<U | void> {
return result('get', '/checkHealth')
}
  1. 在 App.vue 中调用 checkHealth 接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vue复制代码<script lang="ts">
import { defineComponent, onMounted } from 'vue'

import { checkHealth } from '@/apis/health'

export default defineComponent({
name: 'App',
setup() {
const init = async () => {
checkHealth()
}
onMounted(init)
},
})
</script>

这个阶段完成了一个最简单 APP 的前后端工程的搭建. 我这里规范了前后端的代码, 同学可以根据自己团队的代码规范添加不同的规则.

Javascript & Typescript Essential

Day 2 重构后端项目 前端引入状态管理

重构服务端

引入服务端日志库 logger 和 winston

1
2
3
4
5
shell复制代码# logger 作为记录请求的中间件
npm install koa-logger --save
npm install @types/koa-logger --save-dev
# winston 用于后端其他地方的日志记录
npm install winston --save

效果如下
koa-logger-winston

引入 dotenv , 从环境变量和环境变量配置文件读取配置

  1. node 的后端服务是在运行时动态加载这些环境变量的,代码里我采用了 dotenv 模块来加载环境变量
1
shell复制代码npm install dotenv --save
  1. 读取环境变量
1
2
3
4
5
typescript复制代码// src/config/index.ts
import { config as dotenv } from 'dotenv'

const env = dotenv({ path: `.env.${process.env.NODE_ENV}` })
if (env.error) throw env.error
  1. ** 两个注意点 **
  • 如果 NODE_ENV 设置为 production, npm ci 不会安装 devDependencies 中的依赖包,如果在运行的 EC2 上编译打包,编译会报错。所以打包编译我放在了 Github Actions 的容器中了,所以避免了这个问题

We have installed all our packages using the –save-dev flag. In production, if we use npm install –production these packages will be skipped.

  • 操作系统下设置的环境变量会覆盖 .env 文件中定义的环境变量, 一般会把敏感的信息, 例如密码和密钥直接定义在系统的环境变量里, 而不是放在 .env 的文件下, 这样避免将代码提交到公开的代码仓库带来安全的隐患.
  1. 配置文件的加载逻辑都放到 config 目录下
    dotenv-config

服务端拆解 App 和 Server

  1. index.ts 只保留了启动 http 服务和其他连接数据库的服务
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
typescript复制代码// src/index.ts
import * as http from 'http'
import app from './app'

import wsLogger from './utils/ws-logger'

async function initializeDB(): Promise<void> {
wsLogger.info(`Connect database...`)

return
}

async function bootstrap(port: number) {
const HOST = '0.0.0.0'

return http.createServer(app.callback()).listen(port, HOST)
}

try {
;(async function (): Promise<void> {
await initializeDB()

const HTTP_PORT = 3000
await bootstrap(HTTP_PORT)

wsLogger.info(`🚀 Server listening on port ${HTTP_PORT}!`)
})()
} catch (error) {
setImmediate(() => {
wsLogger.error(
`Unable to run the server because of the following error: ${error}`,
)
process.exit()
})
}
  1. app.ts 负责各种中间件的加载, 并安装 bodyParser
1
2
shell复制代码npm install koa-bodyparser --save
npm install @types/koa-bodyparser --save-dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typescript复制代码// src/app.ts
import Koa from 'koa'
import cors from '@koa/cors'
import logger from 'koa-logger'
import bodyParser from 'koa-bodyparser'

import wrapperRouter from './apis'

const app = new Koa()

app.use(cors())
app.use(logger())
app.use(bodyParser())

const wpRouter = wrapperRouter()
app.use(wpRouter.routes()).use(wpRouter.allowedMethods())

export default app

搭建 RCS 模型, 将 server.ts 拆成 http 服务, Koa2 中间件和 koa-router 三个模块, 符合单一职责的设计原理

the logic should be divided into these directories and files.

  • Models - The schema definition of the Model
  • Routes - The API routes maps to the Controllers
  • Controllers - The controllers handles all the logic behind validating request parameters, query, Sending Responses with correct codes.
  • Services - The services contains the database queries and returning objects or throwing errors
  1. router 拆解到 apis 目录下, 按模块提供不同的接口, 并通过动态的方式加载
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
typescript复制代码// src/apis/index.ts
import * as fs from 'fs'
import * as path from 'path'
import Router from 'koa-router'

import { serverConfig } from '../config/server'
const { apiVersion } = { apiVersion: serverConfig.apiVersion }

export default function wrapperRouter(): Router {
const router = new Router({
prefix: `/api/${apiVersion}`,
})

const baseName = path.basename(__filename)

fs.readdirSync(__dirname)
// 过滤掉 apis 根目录下的 index.js 和 index.js.map 文件
.filter(
(file) =>
file.indexOf('.') !== 0 && file !== baseName && !file.endsWith('.map'),
)
.forEach((file) => {
import(path.join(__dirname, file)).then((module) => {
router.use(module.router().routes())
})
})

return router
}
  1. apis 下各个子模块的拆解如下图
    rcs

构建前端路由, 引入 Vue 3.0 的状态管理 Provide 和 Inject

用 Vue3 的 Provide 和 Inject 替代 Vuex
参考 Using provide/inject in Vue.js 3 with the Composition API

用 npm 搭建共享的 Lib, 共享数据对象(Building and publishing an npm typescript package)

参考 Step by step: Building and publishing an NPM Typescript package.

  1. 创建目录
1
shell复制代码mkdir lib && cd lib
  1. 初始化 git 仓库
1
2
3
4
shell复制代码git init
git checkout -b main
echo "# Building and publishing an npm typescript package" >> README.md
git add . && git commit -m "Initial commit"
  1. 初始化 npm 项目
1
2
3
shell复制代码npm init -y
echo "node_modules" >> .gitignore
npm install --save-dev typescript
  1. 配置 tsconfig.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
json复制代码{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"outDir": "./lib",
"sourceMap": true,
"strict": true,
"noImplicitAny": true,
"declaration": true,
"strictPropertyInitialization": false,
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
"emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */
},
"include": ["src"],
"exclude": ["node_modules", "**/__tests__/*"]
}
  1. ignore /lib
1
shell复制代码echo "/lib" >> .gitignore
  1. 配置 eslint
  • 安装 eslint 和 typescript 相关依赖
1
shell复制代码npm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev
  • 创建 .eslintrc.js
1
2
3
4
5
6
7
8
9
10
javascript复制代码// .eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser',
extends: ['plugin:@typescript-eslint/recommended', 'prettier/@typescript-eslint', 'plugin:prettier/recommended'],
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
rules: {},
}
  • 安装 prettier
1
shell复制代码npm i prettier eslint-config-prettier eslint-plugin-prettier -D
  • 创建 .prettierrc.js
1
2
3
4
5
6
7
8
javascript复制代码// .prettierrc.js
module.exports = {
semi: false,
trailingComma: 'all',
singleQuote: true,
printWidth: 120,
tabWidth: 2,
}
  • 配置 vscode workspace
1
2
3
4
5
6
json复制代码{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
}
  • 添加 .eslintignore
1
2
bash复制代码node_modules
/lib
  1. 配置 npm 库的输出文件

However, blacklisting files is not a good practice. Every new file/folder added to the root, needs to be added to the .npmignore file as well! Instead, you should whitelist the files /folders you want to publish. This can be done by adding the files property in package.json:

1
2
3
json复制代码  "files": [
"lib/**/*"
],
  1. Setup Testing with Jest
  • 安装 Jest
1
shell复制代码npm install --save-dev jest ts-jest @types/jest
  • Create a new file in the root and name it jestconfig.json:
1
2
3
4
5
6
7
json复制代码{
"transform": {
"^.+\\.(t|j)sx?$": "ts-jest"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
}
  • Remove the old test script in package.json and change it to:
1
json复制代码"test": "jest --config jestconfig.json",
  • 准备一个待测试的函数 index.ts
1
2
typescript复制代码// index.ts
export const Greeter = (name: string): string => `Hello ${name}`
  • Write a basic test

In the src folder, add a new folder called tests and inside, add a new file with a name you like, but it has to end with test.ts, for example greeter.test.ts

1
2
3
4
5
typescript复制代码// greeter.test.ts
import { Greeter } from '../index';
test('My Greeter', () => {
expect(Greeter('Carl')).toBe('Hello Carl');
});

then try to run

1
shell复制代码npm test
  1. Use the magic scripts in NPM
  • 修改脚本 package.json
1
2
3
4
5
6
json复制代码    "prepare": "npm run build",
"prepublishOnly": "npm test && npm run lint",
"preversion": "npm run lint",
"version": "git add -A src",
"postversion": "git push && git push --tags",
"patch": "npm version patch && npm publish"
  • Updating your published package version number
    To change the version number in package.json, on the command line, in the package root directory, run the following command, replacing <update_type> with one of the semantic versioning release types (patch, major, or minor):
1
shell复制代码npm version <update_type>
  • Run npm publish.
  1. Finishing up package.json
1
2
3
json复制代码  "description": "share library between backend and frontend",
"main": "lib/index.js",
"types": "lib/index.d.ts",
  1. Publish you package to NPM!
  • run npm login
1
2
3
4
5
6
shell复制代码╰─ npm login                                                                                                                                  ─╯
npm notice Log in on https://registry.npmjs.org/
Username: quboqin
Password:
Email: (this IS public) qubo.qin.2018@gmail.com
Logged in as quboqin on https://registry.npmjs.org/.
  1. install quboqin-lib on both sides(frontend and backend), and add test code
1
shell复制代码npm i quboqin-lib

Day 3 前端的界面设计和开发

创建数据模型, 更新 quboqin-lib

前端引入 Tailwind CSS UI 库

  1. 用 Figma 设计一个 UI 的原型
    figma
  2. 安装 Tailwind
    这是一个基于 utility-first 思想的 UI 库, 在它基础上上可以方便搭建 UI 界面, 基本不用在代码里写一行 css. 也方便后期维护
    安装中要注意 PostCSS 7 的兼容性
  • 安装
1
shell复制代码npm install -D tailwindcss@npm:@tailwindcss/postcss7-compat postcss@^7 autoprefixer@^9
  • Add Tailwind as a PostCSS plugin
    Add tailwindcss and autoprefixer to your PostCSS configuration.
1
2
3
4
5
6
7
javascript复制代码// postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
}
}
  • Customize your Tailwind installation
1
shell复制代码npx tailwindcss init
  • Include Tailwind in your CSS
1
2
3
4
css复制代码/* index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

then import this file in main.ts

1
2
3
4
5
6
7
typescript复制代码import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

import './index.css'

createApp(App).use(router).mount('#app')
  • When building for production, be sure to configure the purge option to remove any unused classes for the smallest file size:
1
2
3
4
5
6
7
8
9
10
11
12
13
javascript复制代码// tailwind.config.js
module.exports = {
purge: [
'./src/**/*.html',
'./src/**/*.js',
],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {},
plugins: [],
}
  1. 其他组件库
    可以用 Tailwind 开发 UI 的组件, 这个工程里为了快速开发, 这里还引入了 Vant, 这是一个较早支持 Vue 3 的 UI 库
1
2
shell复制代码# Install Vant 3 for Vue 3 project
npm i vant@next -S
  1. 用到了 materialdesignicons , 在 html 中添加
1
html复制代码<link href="https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css" rel="stylesheet">

封装 Axios 和接口, 提供 Mock 数据

1
2
3
4
5
6
typescript复制代码// src/apis/goods.ts
import goods from '@/mock/goods.json'

export function getAllGoods(): Promise<Good[] | void> {
return result('get', '/goods', null, goods as Good[])
}

本地支持HTTPS[Server]

  1. 在项目的 root 目录下创建 local-ssl 子目录, 在 local-ssl 子目录下,新建一个配置文件 req.cnf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ini复制代码[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
C = US
ST = California
L = Folsom
O = MyCompany
OU = Dev Department
CN = www.localhost.com
[v3_req]
keyUsage = critical, digitalSignature, keyAgreement
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = www.localhost.com
DNS.2 = localhost.com
DNS.3 = localhost
  1. 在 local-ssl 目录下创建本地证书和私钥
1
shell复制代码openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout cert.key -out cert.pem -config req.cnf -sha256
  1. 修改 server 端代码支持本地 https
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typescript复制代码// src/index.ts
interface CERT_FILE {
key: string
cert: string
ca?: string
}

const certPaths: Record<string, CERT_FILE> = {}

certPaths[`${SERVER.LOCALHOST}`] = {
key: './local-ssl/cert.key',
cert: './local-ssl/cert.pem',
}

const httpsOptions = {
key: fs.readFileSync(certPaths[config.serverConfig.server].key),
cert: fs.readFileSync(certPaths[config.serverConfig.server].cert),
}

return https.createServer(httpsOptions, app.callback()).listen(port, HOST)
  1. 启动 https 服务后,仍然报错
1
2
3
4
5
6
7
shell复制代码curl https://localhost:3000/api/v1/users
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above

在浏览器链接该 https 服务,也报错误,下载证书,双击导入密钥管理器,手动让证书受信
always-trust

  1. 前端接口也改成 https 访问
1
2
typescript复制代码// src/utils/axios.ts
axios.defaults.baseURL = 'https://localhost:3000/api/v1'

Day 4 手机登录和注册

用户鉴权和手机登录用到了 AWS 的 Cognito 和 Amplify 等服务

在前端工程里初始化 Amplify 和 Cognito

  1. 初始化 Amplify
1
shell复制代码amplify init

amplify-init
默认创建了 dev 后端环境

  1. 添加 Auth 服务
1
shell复制代码amplify add auth

**一定要选择 Manual Configuration **, 具体参数如下
cognito-manual
否则 Cognito 后台配置会默认为 Username 方式鉴权, 而且无法修改

  1. 添加 PreSignup 的函数[选择 Manual Configuration 可以在命令行里添加, 这步可以跳过]
1
shell复制代码amplify fucntion add

amplify-function add

  1. 更新这个四个 Lambda Functions 的逻辑
  2. 推送 dev 环境
1
shell复制代码amplify push
  1. 进入 AWS Console 的 Cognito, 手动绑定 PreSignup 函数[选择 Manual Configuration 在命令行里已经绑定, 这步可以跳过]
    cognito-presignup
  2. 给 CreateAuthChallenge 添加发送短信权限
    进入 Lambda 的 后台页面,找到对应环境下 dev 的 foodtruck4d6aa6e5CreateAuthChallenge 函数
    找到对应的 Role,在Role的后台添加 SNS 权限
    sns-role
  3. 配置 SNS
    aws-sns-profile
  4. 前端添加 Amplify 模块
  • 安装依赖包
1
shell复制代码npm i aws-amplify aws-amplify-vue --save
  • 初始化 Amplify 配置
1
2
3
4
typescript复制代码// main.ts
import Amplify from 'aws-amplify'
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)

** 在 tsconfig.json 中添加

1
json复制代码"allowJs": true,

添加 aws-exports.d.ts

1
2
3
typescript复制代码// eslint-disable-next-line @typescript-eslint/ban-types
declare const awsmobile: {}
export default awsmobile

这两步都要, 否则打包会报以下错误

1
2
3
4
5
6
7
8
shell复制代码TS7016: Could not find a declaration file for module './aws-exports'. '/Users/qinqubo/magic/projects/full-stack/food-truck/frontend/src/aws-exports.js' implicitly has an 'any' type.
4 |
5 | import Amplify from 'aws-amplify'
> 6 | import awsconfig from './aws-exports'
| ^^^^^^^^^^^^^^^
7 | Amplify.configure(awsconfig)
8 |
9 | import './index.css'
  1. 团队其他成员如果在新的环境下开发
    clone 代码仓库后, 在项目根目录下
1
shell复制代码amplify init

amplify-clone-backend
Do you want to use an existing environment 选 Yes

后端工程添加 JWT 模块

  1. 添加 JWT 的中间件的依赖
1
2
shell复制代码npm i jsonwebtoken jwk-to-pem --save
npm i @types/jsonwebtoken @types/jwk-to-pem --save-dev
  1. 添加 JWT 中间件, 检查 cognito 的有效性
1
typescript复制代码// src/jwt/cognito.ts
  1. 修改 app.ts
1
2
3
4
5
6
7
8
9
typescript复制代码// src/app.ts
const unprotectedRouter = wrapperRouter(false)
const protectedRouter = wrapperRouter(true)

app.use(unprotectedRouter.routes()).use(unprotectedRouter.allowedMethods())
app.use(jwtCognito(config.awsCognitoConfig))
app.use(protectedRouter.routes()).use(protectedRouter.allowedMethods())

export default app
  1. apis 目录下分受保护的和不受保护的两类目录, 并修改路由加载逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typescript复制代码// src/apis/index.ts
export default function wrapperRouter(isProtected: boolean): Router {
const router = new Router({
prefix: `/api/${apiVersion}`,
})

const subFolder = path.resolve(
__dirname,
isProtected ? './protected' : './unprotected',
)

// Require all the folders and create a sub-router for each feature api
fs.readdirSync(subFolder).forEach((file) => {
import(path.join(subFolder, file)).then((module) => {
router.use(module.router().routes())
})
})

return router
}

用 Postman 测试

  1. 设置Content-Type为application/json
    postman-content-type
  2. 设置Token
    postman-token
  3. Query parameters
1
2
bash复制代码https://localhost:3000/api/v1/goods
https://localhost:3000/api/v1/users

Day 5 数据库的支持

Postgres 和数据模型

  1. 在后端项目安装 postgres 相关 package
1
shell复制代码npm i typeorm reflect-metadata pg --save
  1. 改造共享库
  • 在共享库安装 typeorm
1
shell复制代码npm i typeorm --save

之后更新前后端的依赖

  1. 在后端工程里安装 uuid
1
2
shell复制代码npm i uuid --save
npm i @types/uuid --save-dev
  1. 通过 docker 连接数据库
  • 添加 docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
yaml复制代码version: '3.1'
services:
db:
image: postgres:10-alpine
ports:
- '5432:5432'
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: apidb
admin:
image: adminer
restart: always
depends_on:
- db
ports:
- 8081:8080
  • 添加 postgres 配置和连接代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typescript复制代码// src/config/index.ts
export async function initializePostgres(): Promise<Connection | void> {
if (postgresConfig) {
try {
return createConnection({
type: 'postgres',
url: postgresConfig.databaseUrl,
ssl: postgresConfig.dbsslconn
? { rejectUnauthorized: false }
: postgresConfig.dbsslconn,
synchronize: true,
logging: false,
entities: postgresConfig.dbEntitiesPath,
})
} catch (error) {
logger.error(error)
}
}
}
  • 导入 goods 数据
1
shell复制代码npm run import-goods

Jest 测试服务层和数据库

  1. 安装 Jest
1
shell复制代码npm i jest @types/jest ts-jest --save-dev
  1. 配置 Jest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
javascript复制代码// jest.config.js
/* eslint-disable max-len */
module.exports = {
clearMocks: true,
maxWorkers: 1,
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'!**/__tests__/coverage/**',
'!**/__tests__/utils/**',
'!**/__tests__/images/**',
// "**/?(*.)+(spec|test).[tj]s?(x)"
],
globalSetup: './src/utils/jest.setup.ts',
}
1
2
3
4
5
6
7
8
9
10
11
12
13
javascript复制代码// ormconfig.js
module.exports = {
name: 'default',
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'user',
password: 'pass',
database: 'test',
logging: false,
synchronize: true,
entities: ['node_modules/quboqin-lib/lib/**/*.js'],
}
  1. 在 src/tests 下添加两个测试用例

axois 不直接支持 URL的 Path Variable, .get(‘/:phone’, controller.getUser) 路由无法通过 axois 直接访问, 修改 getUsers, 通过 Query Parameter 提取 phone

  1. Path parameters
    ** axois发出的请求不支持这种方式 **,可以在浏览器和Postman里测试这类接口
1
url复制代码https://localhost:3000/api/v1/addresses/24e583d5-c0ff-4170-8131-4c40c8b1e474

对应的route是

1
2
typescript复制代码  router
.get('/:id', controller.getAddress)

下面的控制器里演示是如何取到参数的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码  public static async getAddress(ctx: Context): Promise<void> {
const { id } = ctx.params
const address: Address = await postgre.getAddressById(id)

const body = new Body()
body.code = ERROR_CODE.NO_ERROR

if (address) {
body.data = address
} else {
body.code = ERROR_CODE.UNKNOW_ERROR
body.message = `the card you are trying to retrieve doesn't exist in the db`
}

ctx.status = 200
ctx.body = body
}
  • 如何现在Postman里测试接口

设置Content-Type为application/json
postman-content-type

设置Token
postman-token

  1. Query parameters
1
bash复制代码https://localhost:3000/api/v1/addresses?phone=%2B13233013227&id=24e583d5-c0ff-4170-8131-4c40c8b1e474
  1. Koa2 对应 Axios 返回的值, 以下是伪代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typescript复制代码// Koa ctx: Context
interface ctx {
status,
body: {
code,
data,
message
}
}

// Axois response
interface response {
status,
data
}

response.data 对应 ctx.body, 所以在 Axios 获取 response 后, 是从 response.data.data 得到最终的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typescript复制代码function get<T, U>(path: string, params: T): Promise<U> {
return new Promise((resolve, reject) => {
axios
.get(path, {
params: params,
})
.then((response) => {
resolve(response.data.data)
})
.catch((error) => {
reject(error)
})
})
}

Day 6 手机支付

服务端的实现

  1. 引入 stripe 库
1
shell复制代码npm i stripe --save
  1. 配置 stripe
1
2
3
4
5
6
7
8
9
10
11
typescript复制代码// src/utils/stripe.ts
import Stripe from 'stripe'
import { config } from '../config'
import { PAYMENT_TYPE } from 'quboqin-lib/lib/card'

export const stripe = new Stripe(
config.paymentType === PAYMENT_TYPE.STRIPE_ONLINE
? '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'
: 'sk_test_DU1VhfGGYrEv5EvJS2my4yhD',
{ apiVersion: '2020-08-27' },
)
  1. 在订单接口中发起支付调用

前端的实现

  1. 在 html 文件里添加 stripe 全局模块
1
html复制代码    <script src="https://js.stripe.com/v3/"></script>
  1. 添加 stripe 的封装
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
javascript复制代码// scr/utils/stripe.js

// eslint-disable-next-line no-undef
export const stripe = Stripe(
process.env.VUE_APP_ONLINE_PAYMENT === '1'
? 'pk_live_51HZCSNLZTTlHwkSOdYTRnFdh0AxF7JNwXShbMrKfEPzxnXLPzGz0hXJJzKxybnWngvF89FKJRXxnr2fo8zpNlZ5700Nm864NNM'
: 'pk_test_GrY8g9brTmOaRZ6XKks0woG0',
)

export const elements = stripe.elements()

const elementStyles = {
base: {
color: '#32325D',
fontWeight: 500,
fontFamily: 'Source Code Pro, Consolas, Menlo, monospace',
fontSize: '16px',
fontSmoothing: 'antialiased',

'::placeholder': {
color: '#CFD7DF',
},
':-webkit-autofill': {
color: '#e39f48',
},
},
invalid: {
color: '#E25950',

'::placeholder': {
color: '#FFCCA5',
},
},
}

const elementClasses = {
focus: 'focused',
empty: 'empty',
invalid: 'invalid',
}

// export const cardIBan = elements.create('iban', {
// ...{
// style: elementStyles,
// classes: elementClasses,
// },
// ...{
// supportedCountries: ['SEPA'],
// placeholderCountry: 'US',
// hideIcon: true,
// },
// })

export const cardNumber = elements.create('cardNumber', {
style: elementStyles,
classes: elementClasses,
})

export const cardExpiry = elements.create('cardExpiry', {
style: elementStyles,
classes: elementClasses,
})

export const cardCvc = elements.create('cardCvc', {
style: elementStyles,
classes: elementClasses,
})
  1. 添加开发环境配置
    .env
    .env.development
    改造 axois 读取配置
1
2
3
4
5
6
7
8
9
typescript复制代码// src/utils/axios.ts
const port = process.env.VUE_APP_PORT
const url = process.env.VUE_APP_BASE_URL
axios.defaults.baseURL = port ? `${url}:${process.env.VUE_APP_PORT}` : `${url}`
axios.defaults.timeout = process.env.VUE_APP_TIMEOUT
? +process.env.VUE_APP_TIMEOUT
: 5000
axios.defaults.headers.post['Content-Type'] =
'application/x-www-form-urlencoded;charset=UTF-8'
  1. 修改 CreditCardDetail.vue

Day 7 持续集成

前端编译环境和 Heroku, Amplify, 自建 Oracle VPS 的部署

部署到 Heroku

  1. 创建 staging 和 production 两个独立的 APP
    与服务端部署不一样,两个环境都是在 Webpack 打包时注入了环境变量,两个环境都需要走 CI 的编译打包流程,所以通过 Pipeline 的 Promote 是没有意义的。我们这里创建两个独立的 APP:[staging-ft, production-ft]
  2. 为每个 APP 添加编辑插件, 因为是静态部署,比 Node Server 多了一个插件
1
2
shell复制代码heroku buildpacks:add heroku/nodejs
heroku buildpacks:add https://github.com/heroku/heroku-buildpack-static

并且要添加 static.json 文件在项目的根目录下

1
2
3
4
5
6
7
json复制代码{
"root": "dist",
"clean_urls": true,
"routes": {
"/**": "index.html"
}
}
  1. 在两个 APP 的设置中绑定关联的 Github 仓库和分支
    这里的 VUE_APP_URL 是在 编译 时候,覆盖 axios 默认的 BASE_URL,指向对应的 Node Server,不同的分支也可以有不同的 Value. 与 Amplify 不同, 这里要设置 NODE_ENV=production,设置了这个后 npm ci 不会影响 install devDependencies 下的模块

通过 Amplify 部署

创建 amplify.yaml 文件,修改 build 的脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
yml复制代码version: 0.2
backend:
phases:
build:
commands:
- '# Execute Amplify CLI with the helper script'
- amplifyPush --simple
frontend:
phases:
preBuild:
commands:
- npm ci
build:
commands:
- nvm use $VERSION_NODE_10
- node -v
- if [ "${AWS_BRANCH}" = "aws-staging" ]; then echo "staging branch" && npm run build:staging; elif [ "${AWS_BRANCH}" = "aws-production" ]; then echo "production branch" && npm run build:production; else echo "test branch" && npm run build:mytest; fi
artifacts:
baseDirectory: dist
files:
- '**/*'
cache:
paths:
- node_modules/**/*

当推送到对应的分支后,Amplify 会调用这个脚本执行编译,打包和部署

设置环境相关的变量

amplify-env
这里的 VUE_APP_URL 是在 编译 时候,覆盖 axios 默认的 BASE_URL,指向对应的 Node Server,不同的分支也可以有不同的 Value

  • 注意不要添加 NODE_ENV=production,设置了这个后 npm ci 不会 install devDependencies 下的模块,会导致 npm run build 报错无法找到 vue-cli-service
  • Vue 的 Webpack 会根据 --mode [staging | production ] 找到对应的 .env.\* 文件, 在这些中再声明 NODE_ENV=production
创建 Amplify 角色

创建 Amplify APP 时,好像没有自动创建关联的 Role, 我手动创建了一个
aws-role-amplify

部署到 Oracle CentOS 8 服务器中的 Nginx 下

  1. 配置 Nginx 支持单个端口对应多个二级域名的静态服务
  • 编辑 /etc/nginx/nginx.conf 支持同一个端口,不同的静态服务器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ini复制代码server {
listen 80;
server_name test.magicefire.com;
root /usr/share/nginx/html/test;
location / {}
}

server {
listen 80;
server_name staging.magicefire.com;
root /usr/share/nginx/html/staging;
location / {}
}

server {
listen 80;
server_name production.magicefire.com;
root /usr/share/nginx/html/production;
location / {}
}

建立对应的目录,在目录下放测试 html

  • 修改 Cloudflare,添加三条 A 记录,支持 VPS 的 IP
    cloudflare-static
  • 通过 Let's Encrypt 修改 nginx 的 https 支持
    安装 certbot 见 Node Server 的部署
1
shell复制代码certbot -nginx

certbot-static

  1. 在 .github/workflows 下添加 Github Actions, 编写 Github Actions 部署脚本

注意不要添加 NODE_ENV=production,设置了这个后 npm ci 不会 install devDependencies 下的模块,会导致 npm run build 报错无法找到 vue-cli-service
Vue 的 Webpack 会根据 –mode [staging | production ] 找到对应的 .env.* 文件, 在这些中再声明 NODE_ENV=production
3. 在 Github 的仓库设置中,给 Actions 用到的添加加密的 Secrets
github-secrets
DEPLOY_ORACLE=/usr/share/nginx/html

后端运行环境和 Heroku/EC2 部署

运行时根据环境加载配置项

  1. 在 package.json 的脚本中通过 NODE_ENV 定义环境 [development, test, staging, production]
  2. 安装 dotenv 模块, 该模块通过 NODE_ENV 读取工程根目录下对应的 .env.[${NODE_ENV}] 文件, 加载该环境下的环境变量
  3. 按 topic[cognito, postgres, server, etc] 定义不同的配置, 并把 process.env.IS_ONLINE_PAYMENT 之类字符串形式的转成不同的变量类型
  4. 可以把环境变量配置在三个不同的level
  • 运行机器的 SHELL 环境变量中, 主要是定义环境的 NODE_ENV 和其他敏感的密码和密钥
  • 在以 .${NODE_ENV} 结尾的 .env 文件中
  • 最后汇总到 src/config 目录下按 topic 区分的配置信息
  • 代码的其他地方读取的都是 src/config 下的配置信息
  1. 在后端环境下设置 NODE_ENV 有一个副作用,在 Typescript 编译打包前

** 如果 NODE_ENV 设置为 production, npm ci 不会安装 devDependencies 中的依赖包,如果在运行的 EC2 上编译打包,编译会报错。所以打包编译我放在了Github Actions 的容器中了,所以避免了这个问题 **

We have installed all our packages using the –save-dev flag. In production, if we use npm install –production these packages will be skipped.

Amplify 创建线上用户库

  1. 在前端,通过 Amplify 添加线上环境
1
shell复制代码amplify env add prod
  1. 检查状态, 并部署到 Amplify 后台
1
shell复制代码amplify status

amplify-env-add-prod

1
shell复制代码amplify push

进入 AWS Cognito 后台, 会增加一个线上的 UserPool
aws-cognito-user-pool

  1. 添加 foodtruck2826b8ad2826b8adCreateAuthChallenge-prod 的短信权限
  2. 修改线上配置文件中 AWS_COGNITO_USER_POOL_ID
1
2
3
4
arduino复制代码// .env.oracle
// .env.staging
// .env.production
AWS_COGNITO_USER_POOL_ID=ap-northeast-1_2ae9TN9FH

主要区分三个模块在不同环境的部署

  • Postgres 数据库
  • 支付
  • 用户鉴权

修改 HTTPS 的证书加载

Heroku 部署

Heroku 是我们介绍的三种 CI/CD 流程中最简单的方式

  1. 创建一条 Pipeline, 在 Pipeline 下创建 staging 和 production 两个应用
    heroku-pipeline
  2. 在 APP 的设置里关联 Github 上对应的仓库和分支
    heroku-github
  • APP staging 选择 heroku-staging 分支
  • APP production 选择 heroku-production 分支
  1. 为每个 APP 添加 heroku/nodejs 编译插件
1
2
shell复制代码heroku login -i
heroku buildpacks:add heroku/nodejs -a staging-api-food-truck
  1. 设置运行时的环境变量
    heroku-vars-buildpack
    这里通过 SERVER 这个运行时的环境变量,告诉 index.ts 不要加载 https 的服务器, 而是用 http 的服务器。
    ** Heroku 的 API 网关自己已支持 https,后端起的 node server 在内网里是 http, 所以要修改代码 换成 http server,否者会报 503 错误**
  2. 修改 index.ts 文件,在 Heroku 下改成 HTTP
  3. APP production 一般不需要走 CI/CD 的流程,只要设置 NODE_ENV=production,然后在 APP staging 验证通过后, promote 就可以完成快速部署。
  4. 查看 heroku 上的日志
1
shell复制代码heroku logs --tail -a staging-api-food-truck

AWS EC2 部署

aws-cicd

在 AWS 上搭建环境和创建用户和角色
CodeDeploy

We’ll be using CodeDeploy for this setup so we have to create a CodeDeploy application for our project and two deployment groups for the application. One for staging and the other for production.

  1. To create the api-server CodeDeploy application using AWS CLI, we run this on our terminal:
1
2
3
shell复制代码aws deploy create-application \
--application-name api-server \
--compute-platform Server
  1. Before we run the cli command to create the service role, we need to create a file with IAM specifications for the role, copy the content below into it and name it code-deploy-trust.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
json复制代码{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": [
"codedeploy.amazonaws.com"
]
},
"Action": "sts:AssumeRole"
}
]
}
  1. We can now create the role by running:
1
2
3
shell复制代码aws iam create-role \
--role-name CodeDeployServiceRole \
--assume-role-policy-document file://code-deploy-trust.json
  1. After the role is created we attach the AWSCodeDeployRole policy to the role
1
2
3
shell复制代码aws iam attach-role-policy \
--role-name CodeDeployServiceRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole
  1. To create a deployment group we would be needing the service role ARN.
1
2
3
4
shell复制代码aws iam get-role \
--role-name CodeDeployServiceRole \
--query "Role.Arn" \
--output text

The ARN should look something like arn:aws:iam::403593870368:role/CodeDeployServiceRole
6. Let’s go on to create a deployment group for the staging and production environments.

1
2
3
4
5
shell复制代码aws deploy create-deployment-group \
--application-name api-server \
--deployment-group-name staging \
--service-role-arn arn:aws:iam::403593870368:role/CodeDeployServiceRole \
--ec2-tag-filters Key=Name,Value=staging,Type=KEY_AND_VALUE
1
2
3
4
5
shell复制代码aws deploy create-deployment-group \
--application-name api-server \
--deployment-group-name production \
--service-role-arn arn:aws:iam::403593870368:role/CodeDeployServiceRole \
--ec2-tag-filters Key=Name,Value=production,Type=KEY_AND_VALUE

进入 Console -> Code Deploy 确认
aws-code-deploy-api-server
aws-code-deploy-api-server-staging

创建 S3 Bucket

创建一个名为 node-koa2-typescript 的 S3 Bucket

1
shell复制代码aws s3api create-bucket --bucket node-koa2-typescript --region ap-northeast-1

aws-s3-node-koa2-typescript

Create and Launch EC2 instance

完整的演示,应该创建 staging 和 production 两个 EC2 实例,为了节省资源,这里只创建一个实例

  1. 创建一个具有访问 S3 权限的角色 EC2RoleFetchS3
    aws-role-s3
  2. In this article, we will be selecting the Amazon Linux 2 AMI (HVM), SSD Volume Type.
    aws-ec2-amazon-linux-2-ami-ssd
  • 绑定上面创建的角色,并确认开启80/22/3001/3002几个端口
    aws-ec2-role-inbound
  • 添加 tag,key 是 Name,Value 是 production
    aws-ec2-tag
  • 导入用于 ssh 远程登入的公钥
    aws-ec2-public-key
  • 通过 ssh 远程登入 EC2 实例,安装 CodeDeploy Agent
    安装步骤详见 CodeDeploy Agent
  1. 通过 ssh 安装 Node.js 的运行环境
  • 通过 NVM 安装 Node.js
1
2
3
4
shell复制代码curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash
source ~/.bash_profile
nvm list-remote
nvm install 14
  • 安装 PM2 管理 node 的进程
1
shell复制代码npm i pm2 -g
  • 在项目的根目录下创建 ecosystem.config.js 文件
1
2
3
4
5
6
7
javascript复制代码module.exports = {
apps: [
{
script: './dist/index.js',
},
],
}
  1. 在 EC2 的实例 ec2-user 账号下设置环境变量, 编辑 ~/.bash_profile
1
2
bash复制代码export NODE_ENV=production
export SERVER=AWS

这里放置 NODE_ENV 和具有敏感信息的环境变量, 这里 SERVER=AWS 只是演示

创建用于 Github Actions 部署脚本的用户组和权限
  1. 在 IAM 中创建以一个 CodeDeployGroup 用户组,并赋予 AmazonS3FullAccess and AWSCodeDeployFullAccess 权限
  2. 在 CodeDeployGroup 添加一个 dev 用户,记录下 Access key ID 和 Secret access key
    aws-iam-dev
编写 Github Actions 脚本
  1. 在工程的根目录下创建 .github/workflows/deploy-ec2.yaml 文件
    deploy-ec2.yaml 的作用是,当修改的代码提交到 aws-staging 或 aws-production,触发编译,打包,并上传到 S3 的 node-koa2-typescript bucket, 然后再触发 CodeDeploy 完成后续的部署。所以这个 Github Action 是属于 CI 的角色,后面的 CodeDeploy 是 CD 的角色。
  2. 在 Github 该项目的设置中添加 Environment secrets, 将刚才 dev 用户的 Access key ID 和 Secret access key 添加进Environment secrets
    github-environment-secrets
添加 appspec.yml 及相关脚本

CodeDeploy 从 S3 node-koa2-typescript bucket 中获取最新的打包产物后,上传到 EC2 实例,解压到对应的目录下,这里我们指定的是 /home/ec2-user/api-server。CodeDeploy Agent 会找到该目录下的 appspec.yml 文件执行不同阶段的 Hook 脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
yml复制代码version: 0.0
os: linux
files:
- source: .
destination: /home/ec2-user/api-server
hooks:
AfterInstall:
- location: aws-ec2-deploy-scripts/after-install.sh
timeout: 300
runas: ec2-user
ApplicationStart:
- location: aws-ec2-deploy-scripts/application-start.sh
timeout: 300
runas: ec2-user

aws-ec2-deploy-scripts/application-start.sh 启动了 Node.js 的服务

1
2
3
4
5
shell复制代码#!/usr/bin/env bash
source /home/ec2-user/.bash_profile
cd /home/ec2-user/api-server/
pm2 delete $NODE_ENV
pm2 start ecosystem.config.js --name $NODE_ENV
在 EC2 实例下安装免费的域名证书,步骤详见Certificate automation: Let’s Encrypt with Certbot on Amazon Linux 2
  1. 去 Cloudflare 添加 A 记录指向这台 EC2 实例,指定二级域名是 aws-api
    cloudflare-aws-api
  2. 安装配置 Apache 服务器,用于证书认证
  3. Install and run Certbot
1
shell复制代码sudo certbot -d aws-api.magicefire.com

根据提示操作,最后证书生成在 /etc/letsencrypt/live/aws-api.magicefire.com/ 目录下
4. 因为我们启动 Node 服务的账号是 ec2-user, 而证书是 root 的权限创建的,所以去 /etc/letsencrypt 给 live 和 archive 两个目录添加其他用户的读取权限

1
2
3
4
shell复制代码sudo -i
cd /etc/letsencrypt
chmod -R 755 live/
chmod -R 755 archive/
  1. Configure automated certificate renewal
部署和验证
  1. 如果没有 aws-production 分支,先创建该分支,并切换到该分支下,合并修改的代码,推送到Github
1
2
3
shell复制代码git checkout -b aws-production
git merge main
git push orign
  1. 触发 Github Actions
    github-actions-build-node-api-server
  2. 编译,打包并上传到 S3 后,触发 CodeDeploy
    aws-code-deploy-ec2
  3. 完成后在浏览器里检查
    chrome-check-api-server
    或用 curl 在命令行下确认
1
shell复制代码curl https://aws-api.magicefire.com:3002/api/v1/health

curl-check-api-server

  1. 因为我们没有创建 EC2 的 staging 实例,如果推送到 aws-staging 分支,CodeDeploy 会提示以下错误
    aws-code-deploy-failed
过程回顾

aws-code-deploy-cicd

Postgres VPS 和 Heroku 部署[Server]

在 Heroku 上部署 Postgres

  1. Provisioning Heroku Postgres
1
2
shell复制代码heroku addons
heroku addons:create heroku-postgresql:hobby-dev
  1. Sharing Heroku Postgres between applications
1
shell复制代码heroku addons:attach my-originating-app::DATABASE --app staging-api-node-server
  1. 导入商品到线上 table
1
shell复制代码npm run import-goods-heroku-postgre

在 VPS 上部署 Postgres

编外

支持 Swagger

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

记一次上下文切换高排查 背景 相关知识点 工具 排查过程

发表于 2021-10-28

背景

  • 线上有N台机器配置一样,程序版本一样,但其中有一台机器会出现Content Switch上下文切换高的情况,其他机器并不会, 每台机器业务量差不多一致

相关知识点

上下文切换(context switch)

说明

  • Linux 是一个多任务操作系统,它支持远大于 CPU 数量的任务同时运行
  • 这些任务不是同时运行,而是系统在很短的时间内,将CPU轮流分配给它们,造成多任务同时运行的错觉
  • 在每个任务运行前,CPU都需要知道任务从哪里加载、又从哪里开始运行,需要系统事先帮它设置好CPU 寄存器和程序计数器(Program Counter,PC)
    • CPU 寄存器,是 CPU 内置的容量小、但速度极快的内存
    • 程序计数器,用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置
    • CPU 在运行任何任务前,必须的依赖环境,叫做 CPU 上下文
  • CPU 上下文切换就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来
    • 然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
    • 保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来
    • 这样能保证任务原来的状态不受影响,让任务看起来还是连续运行

场景

进程上下文切换

  • Linux 按照特权等级
    • 内核空间(Ring 0)具有最高权限,可以直接访问所有资源
    • 用户空间(Ring 3)只能访问受限资源,不能直接访问内存等硬件设备,必须通过系统调用陷入到内核中,才能访问这些特权资源。
  • 进程既可以在用户空间运行,也可以在内核空间中运行
    • 进程在用户空间运行时称为进程的用户态
    • 进程在内核空间运行时称为进程的内核态
    • 从用户态到内核态的转变,需要通过系统调用来完成
  • 进程切换时才需要切换上下文

线程上下文切换

  • 线程是调度的基本单位,而进程则是资源拥有的基本单位
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的
  • 线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的
  • 线程的上下文切换其实就可以分为两种情况:
    • 前后两个线程属于不同进程。因为资源不共享,所以切换过程就跟进程上下文切换是一样
    • 前后两个线程属于同一个进程。因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
    • 虽然同为上下文切换,但同进程内的线程切换,要比多进程间的切换消耗更少的资源

中断上下文切换

  • 为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件

工具

vmstat

  • vmstat 使用说明
    vmstat用于分析系统的内存使用情况,也可以分析 CPU 上下文切换和中断的次数, 正在运行和等待 CPU 的进程数过多,导致了大量的上下文切换,而上下文切换又导致了系统 CPU 的占用率升高

常用命令

1
2
3
4
5
bash复制代码# 每隔5秒输出1组数据
vmstat 5

# 格式化数据对齐
vmstat 1 3 | column -t

输出说明

  • cs(context switch)是每秒上下文切换的次数
  • in(interrupt)则是每秒中断的次数, 如果比较大可能有问题
  • r(Running or Runnable)是就绪队列的长度,也就是正在运行和等待 CPU 的进程数, 大于CPU数, 说明有大量竞争
  • b(Blocked)则是处于不可中断睡眠状态的进程数
  • us(user)用户进程执行时间百分比(user time)
  • sy(system)内核系统进程执行时间百分比(system time)

pidstat

  • pidstat 使用说明
    pidstat用于查看每个进程的详细情况
  • 安装
1
arduino复制代码apt-get install sysstat

常用命令

1
2
3
4
5
6
bash复制代码# 每隔5秒输出1组数据
pidstat -w -u 5

# 指定进程名
# -w参数表示输出进程切换指标,-u参数则表示输出CPU使用指标, -t参数输出线程的指标
pidstat -w -u -t -C "进程名" 3

输出说明

  • cswch表示每秒自愿上下文切换(voluntary context switches)的次数
    • 自愿上下文切换是指进程无法获取所需资源,导致的上下文切换
    • I/O、内存等系统资源不足时会发生自愿上下文切换
    • 自愿上下文切换变多了,说明进程都在等待资源,有可能发生了 I/O 等其他问题
  • nvcswch表示每秒非自愿上下文切换(non voluntary context switches)的次数。
    • 非自愿上下文切换是指进程由于时间片已到等原因,被系统强制调度,进而发生的上下文切换
    • 大量进程都在争抢 CPU 时就容易发生非自愿上下文切换
    • 非自愿上下文切换变多了,说明进程都在被强制调度,也就是都在争抢 CPU,说明 CPU 的确成了瓶颈;

中断 /proc/interrupts

  • /proc 是 Linux 的一个虚拟文件系统,用于内核空间与用户空间之间的通信
  • /proc/interrupts 是通信机制的一部分,提供了一个只读的中断使用情况
  • 中断次数变多说明 CPU 被中断处理程序占用,可以通过查看 /proc/interrupts 文件来分析具体的中断类型

查看命令

1
2
bash复制代码# -d 参数表示高亮显示变化的区域
watch -d cat /proc/interrupts

排查过程

  • 查找上下文切换高的程序
    • 使用vmstat命令观察系统in(中断),cs(上下文切换)情况,发现这两个数值比机器机器都要高一些
    • 猜测某一个程序有问题
    • 使用pidstat命令观察所有的进程情况,发现有问题机器的%usr比%system数值一般要大,而且持续时间比较长,正常的机器就不会
    • 猜测程序多数情况下不断地在用户空间和内核空间之间切换,导致上下文切换高
    • 查看上下文切换高的程序文档(业务关系这里忽略程序名称,程序是github上开源的),发现程序不能在内核空间上使用时会回退到用户空间
  • 解决方法
    • 查看linux系统当前加载的模块 lsmod, 发现有问题的机器并没有正确加载到程序的内核模块,而正常的机器有
    • 查看当前linux系统内核版本 uname -r, 发现系统内核版本和其他机器不一致
    • 查看当前下载过的内核 dpkg –list | grep linux-image, 发现系统内核被更新过,原来程序内核模块没有正常加载,导致使用用户空间
    • 最后禁用内核更新,重新编译程序安装

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

浅谈设计模式 - 中介者模式(十六) 前言 优缺点: 结构图

发表于 2021-10-28

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

前言

​ 中介者模式是一种行为设计模式, 他的目的和门面模式类似,他负责的是将所有的底层交互细节隐藏,提供统一的对外接口供外部调用。 该模式会限制对象之间的直接交互, 迫使它们通过一个中介者对象进行合作。

优缺点:

优点: 1、将对象的交互模式由多对一变为一对一。 2、可以实现多个类之间的强耦合。 3、此设计模式十分符合迪米特原则。

迪米特法则(Law of Demeter)又叫作最少知识原则(The Least Knowledge Principle),一个类对于其他类知道的越少越好,就是说一个对象应当对其他对象有尽可能少的了解,只和朋友通信,不和陌生人说话

**缺点:**中介者对象会逐渐庞大,如果组件过多会变得复杂难以维护。

使用场景

​ 1、在系统内部的对象之间通信紊乱的时候,按照设计模式单一职责的原则,通常会使用三方对象负责“请求”的转发,而中介的作用就是再次之上一个扩展,可以实现多个类之间的互相通信变为一对一的通信模式。

​ 2、想通过一个中间类来封装多个类中的行为,而又不想生成太多的子类。

​ 3、如果多个用户之间存在复杂的通信和耦合的情况时候,对外使用外观模式,对内则可以使用中介者模式。

**注意事项:**不应当在职责混乱的时候使用。

结构图

​ 下面是中介者模式的结构图,这里构建了多个具体的实现对象,他们需要实现统一的通信就需要

和门面模式的对比

​ 下面是门面模式的结构图,门面模式也叫做外观模式,可以看到和中介者的对象还有一定的相似性,外观提供,这两个模式的区别是外观主要提供对象的一致对外的接口,而中介模式实现的是让对象之间的通信由多对一变为一对一,我们简单的认为,中介者主要针对的对内多个复杂对象的统一,外观针对对外多个对象的方法统一。

实际案例

​ 下面我们用一段简短的代码快速介绍一下这个设计模式的代码, 其实从上面结构图基本可以快速的写出模板代码,下面按照一个租房的案例进行快速的讲解:

​ 首先是中介者的接口,中介者的接口需要由需要进行通信的子类实现,这里定义了租房的联络接口

1
2
3
4
JAVA复制代码public abstract class Mediator {
//申明一个联络方法
public abstract void constact(String message,Person person);
}

用户类,内部嵌入了联络的对象,使得子类可以和任意对象进行通信

1
2
3
4
5
6
7
8
9
10
java复制代码public abstract class Person {
protected String name;
protected Mediator mediator;

Person(String name,Mediator mediator){
this.name = name;
this.mediator = mediator;
}

}

房东类,继承Person类的同时,实现自己的通信方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public class HouseOwner extends Person{

HouseOwner(String name, Mediator mediator) {
super(name, mediator);
}

/**
* @desc 与中介者联系
* @param message
* @return void
*/
public void constact(String message){
mediator.constact(message, this);
}

/**
* @desc 获取信息
* @param message
* @return void
*/
public void getMessage(String message){
System.out.println("房主:" + name +",获得信息:" + message);
}
}

下面是另一个Person类,也就是中介者的对象,负责和房东进行通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public class Tenant extends Person{

Tenant(String name, Mediator mediator) {
super(name, mediator);
}

/**
* @desc 与中介者联系
* @param message
* @return void
*/
public void constact(String message){
mediator.constact(message, this);
}

/**
* @desc 获取信息
* @param message
* @return void
*/
public void getMessage(String message){
System.out.println("租房者:" + name +",获得信息:" + message);
}
}

下面是具体的中介对象,可以看到下面定义了房东的的信息以及租房者的对象,通过中介对象,我们实现了房东和房客之间的通信,让他们统一通过中介机构进行通信。

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
java复制代码public class MediatorStructure extends Mediator{
//首先中介结构必须知道所有房主和租房者的信息
private HouseOwner houseOwner;
private Tenant tenant;

public HouseOwner getHouseOwner() {
return houseOwner;
}

public void setHouseOwner(HouseOwner houseOwner) {
this.houseOwner = houseOwner;
}

public Tenant getTenant() {
return tenant;
}

public void setTenant(Tenant tenant) {
this.tenant = tenant;
}

public void constact(String message, Person person) {
if(person == houseOwner){ //如果是房主,则租房者获得信息
tenant.getMessage(message);
}
else{ //反正则是房主获得信息
houseOwner.getMessage(message);
}
}
}

最后是客户端的代码,可以看到通过中介的形式,可以实现中介和房东一对一,以及租房者和中介一对一的解耦合的形式.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public class Client {
public static void main(String[] args) {
//一个房主、一个租房者、一个中介机构
MediatorStructure mediator = new MediatorStructure();

//房主和租房者只需要知道中介机构即可
HouseOwner houseOwner = new HouseOwner("张三", mediator);
Tenant tenant = new Tenant("李四", mediator);

//中介结构要知道房主和租房者
mediator.setHouseOwner(houseOwner);
mediator.setTenant(tenant);

tenant.constact("听说你那里有三室的房主出租.....");
houseOwner.constact("是的!请问你需要租吗?");
}
}

总结

  1. 由于中介者需要将多个对象的行为进行连接通信,虽然各个对象的改动会改变通信的细节,但是中介者的改动却会影响整体通信行为,这是一把双刃剑,既让对象的通信简化同时,也让复杂耦合到了一处。这时候可以结合策略或者工厂将中介的通信行为实现动态的扩展
  2. 在中介者模式中通过引用中介者对象,将系统中有关的对象所引用的其他对象数目减少到最少。
  3. 中介的行为类似一种对象的闭包和统一通信,中介者模式按照房东,中介,房客的理解可以快速的了解到这个结构
  4. 其实我们可以把MVC看作一种中介的变种,Service层起到了承接的作用。

写在最后

​ 中介的行为经常在使用,但是使用中介者模式需要小心的考虑类的耦合和中介者类是否会变得复杂或者难以维护。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

造个‘’轮子‘’!只要掌握了这几点,你也可以撸一个写在简历上

发表于 2021-10-28

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

序言

最近公司的项目有点少,摸鱼的时间有点多,闲来无事就去程序员最大的交友论坛(Github)上看了看,发现上面好多轮子,简直可以说是车轮遍地爬!我也就好奇了?怎么那么多人不上班,每天忙着造人?啊不,造轮!

image.png

待我自己看了看,发现其实造轮子这件事也没有想象中的那么难,我也就尝试了一下在公司摸鱼的时间造了一个小小的独木轮。为什么叫他独木轮呢?因为我的想法很简单,就是要每一个模块甚至每一个类单独拎出来都可以直接测试,直接使用,用祖传的CV大法可以直接用在自己的项目中,不需要引入依赖,不需要搞花里胡哨的配置,最重要的是,官方文档,爷爷奶奶看了都能马上上手!

image.png

轮子简介

我给我的独木轮命名为:SweetCode,意味着你每天写代码就和吃Sugar一样甜蜜,哎呀妈呀,简直不要不要的。他的官方文档长这样,目前我就写了一个模块,都是自己在工作中常用到的一些小工具类,自己整理起来也是方便自己以后复用。

image.png

Arrays

我们来手撸一个Arrays的类,先来试试手。我目前常用到的对于数组的处理比较多的是:
  1. 显示字符串数组的内容,用,分隔.
  2. 取得数组的第一个元素.
  3. 把List转换成字符串数组.
  4. 判断字符串数组是否包含指定的字符串
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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
java复制代码package cn.linstudy.arrays;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

/**
* @Author XiaoLin
* @Date 2021/7/7 15:15
* @Description 数组工具类
*/
public class ArrayUtils {

/**
* 显示字符串数组的内容,用,分隔
* @param args 字符串数组
* @return 字符串数组的内容
*/
public static String toString(String[] args) {
return toString(args, ",");
}

/**
* 显示字符串数组的内容
* @param args 字符串数组
* @param separator 分隔符
* @return 字符串数组的内容
*/
public static String toString(String[] args, String separator) {
if (args == null || args.length == 0) {
return null;
}
StringBuilder buffer = new StringBuilder();
for (int i = 0; i < args.length; i++) {
if (i > 0) {
buffer.append(separator);
}
buffer.append(args[i]);
}
return buffer.toString();
}

/**
* 取得字符串数组的第一个元素
* @param stringArray 字符串数组
* @return 字符串数组的第一个元素
*/
public static String getFirst(String[] stringArray) {
if (stringArray == null || stringArray.length == 0) {
return null;
}
return stringArray[0];
}

/**
* 取得数组的第一个元素
* @param array 数组
* @return 数组的第一个元素
*/
public static Object getFirst(Object[] array) {
if (array == null || array.length == 0) {
return null;
}
return array[0];
}

/**
* 把List转换成字符串数组
* @param list 字符串List
* @return 字符串数组
*/
public static String[] toArray(List<String> list) {
return list.toArray(new String[list.size()]);
}

/**
* 把Set转换成字符串数组
* @param set 字符串Set
* @return 字符串数组
*/
public static String[] toArray(Set<String> set) {
return set.toArray(new String[set.size()]);
}

/**
* 判断字符串数组是否包含指定的字符串
* @param array 字符串数组
* @param str 指定的字符串
* @return 包含true,否则false
*/
public static boolean contains(String[] array, String str) {
if (array == null || array.length == 0) {
return false;
}

for (int i = 0; i < array.length; i++) {
if (array[i] == null && str == null) {
return true;
}
if (array[i].equals(str)) {
return true;
}
}
return false;
}

/**
* 判断字符串数组是否有不为Empty的值
* @param args 字符串数组
* @return 有true,否则false
*/
public static boolean hasValue(String[] args) {
if (args == null || args.length == 0 || (args.length == 1 && args[0] == null)) {
return false;
}
for (int i = 0, length = args.length; i < length; i++) {
if (args[i] != null || args[i].trim().length() > 0) {
return true;
}
}
return false;
}

/**
* 联合两个数组
* @param first 第一个数组
* @param last 另一个数组
* @return 内容合并后的数组
*/
public static Object[] combine(Object[] first, Object[] last) {
if (first.length == 0 && last.length == 0) {
return null;
}
Object[] result = new Object[first.length + last.length];
System.arraycopy(first, 0, result, 0, first.length);
System.arraycopy(last, 0, result, first.length, last.length);
return result;
}

/**
* 把数组转换成 列表,如果数组为 null,则会返回一个空列表。
* @param array 数组
* @return 列表对象
*/
public static List<Object> toList(Object[] array) {
ArrayList<Object> list = new ArrayList<Object>();
if (array == null) {
return list;
}

for (int i = 0; i < array.length; i++) {
list.add(array[i]);
}
return list;
}

/**
* 清除字符串数组中的null
* @param array 字符串数组
* @return 清除null后的字符串数组
*/
public static String[] clearNull(String[] array) {
ArrayList<String> list = new ArrayList<String>();
for (int i = 0; i < array.length; i++) {
if (array[i] != null) {
list.add(array[i]);
}
}
return toArray(list);
}

}
大概写完的话就这些几个方法,其实也很简单,最重要的是你需要在写方法的时候统筹全局,知道这个方法是干什么的,为什么要写他?写他会不会出现耦合性很强的问题,我写代码的时候,首先会想一下这段代码我写的目的是什么?然后先开始写注释,将这个方法的作用、入参、返回值一一定义清楚,让后面的人看了这个代码有一种醍醐灌顶的感觉,不会说一头雾水,接着就要开始去写方法了,写方法的话就涉及到了代码规范,我在总结的时候讲,**总之这个项目我会一直维护下去和写下去的**!

心得(代码规范)

其实说是写轮子,最重要的还是代码规范和方法的一个抽取,什么样的代码是好代码,什么样的代码是屎山,想必这个很多程序员都是不知道的,下面我就来说一下我眼中的代码规范!
  1. 方法要见名知意,看到方法的入参、返回值、方法名都要知道他的具体含义,而不是要进入方法看逻辑才知道。
  2. 方法的耦合性要尽量低,不要这个方法依赖下一个方法,下一个方法又调用其他的方法,如果在公司写代码方法要拆分的足够细,需要想好这个方法后面会不会复用,人如果复用的话需要抽取出共同的一些语句,防止重复写代码。
  3. 方法不要有太多的魔法值,如果有魔法值,可以用枚举或者常量去代替,降低代码耦合度。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JavaEE之多线程基础(1):进程、线程初识。创建线程的5

发表于 2021-10-28

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。 @[TOC]
在这里插入图片描述

🚩一、认识进程、线程

🥇1.1什么是进程

进程process/task.”进程”是计算机完成一个工作的”过程”

设备上一个正在运行的程序,就是一个进程。比如你打开的QQ就是一个进程,正在和别人聊天的微信也是一个进程。进程是系统进行资源分配的基本单位。

当我们打开任务管理器就可以看到,当前操作系统中正在运行的进程。
在这里插入图片描述
要想让一个进程真正的运行起来,就需要给这个进程分配一定的系统硬件资源。这些资源都包括:
CPU:例如我电脑中任务管理器占用了11%的CPU,QQ占用0.3%。
内存:任务管理器占用了45.7MB。Microsoft Edge占用了320.0MB
磁盘:qq使用的了0.2MB/秒。
网络带宽….

在举一个例子:我是班长,老师想让我组织一个活动,我要想组织这个活动,我就需要向老师申请一些活动经费、人员调用。这里我就相当于进程。老师就是CPU。我组织活动的时候向老师申请经费、人。就是在请求分配一些资源。有经费,有人才能把活动做好。

进程的管理
管理=描述(PCB)+组织
进程的组织:

使用一定的数据结构来组织。常见的做法是用一双向链表。 当你查看进程列表都有哪些进程时,本质上就是遍历操作系统内核中的这个组织进程的链表,再显示出每个进程的这些属性。创建一个进程,本质上就是创建了一个PCB对象,把这个对象加入到内核的链表中。销毁一个进程,本质上就是把这个PCB对象从内核链表中删除。

进程的描述:

PCB描述进程。这个PCB实际上是一个非常大的结构体,属性有很多,例如:PID(下表第二列)、内存指针、文件描述符表、进程的状态、上下文、优先级、记账信息等等。
PID: 一个进程的身份标识,一个机器同一时刻每个进程的PID是唯一的。
内存指针: 描述这个进程使用的内存空间是哪个范围
文件描述附表: 描述这个进程都打开了哪些文件

在这里插入图片描述

进程的调度

说到进程,就会涉及到进程的调度,刚才可以看到我电脑上的进程是非常多的,虽然应用那里只显示了5个,但是后台还是帮我运行了87个进程。相信大家的电脑一定没有这么多CPU吧。CPU数目是少于进程数目的,但是我又需要让那些进程“同时执行”。我们的系统是支持多任务的系统。而这个多任务系统其实就是基于进程调度这样的机制来完成的。

并发式执行

举个例子:假设有小张同学,他长的很好看,在学校里有很多的追求者。 按正常男人的标准,我同时只能和一个女生交往~
那小张同学有没有办法做到同时和多个女生交往呢? 小张同学前思后想,最终决定!安排一个时间表!!!
周一早上:和A女生一起吃早饭
周二下午:和B女生一起逛街
周三晚上:和C女生一起看电影
只要小张把时间表安排好,这三个女生就不会知道其他两个人的存在。
从宏观上来看,(一年)小张同学同时和三个女生交往。渣男
从微观上来看,(一天)小张同学同一时刻只是和一个女生交往。好男人。

换到电脑上操作系统就是这样管理进程的。
只不过现实中CPU运行速度太快,我们感受不到。我们觉得好像CPU是同时在运行这么多进程一样。

进程的优先级:安排时间表的时候优先给谁安排
进程的上下文:将寄存器的信息保存到内存中。记录上次运行到哪个指令,下次再调度的时候就可以很方便的继续从这个位置执行。可以理解为单机游戏的存档,读档。
进程的记账信息:记录这个进程在CPU上执行了多久,用来辅助决定这个进程是否要继续执行还是说要调度出去。

🥇1.2认识线程

为什么需要线程?

我们引入进程的目的,就是为了能够”并发编程”。为了同时运行多个程序,虽然多进程已经能够并发进程了,但是多进程还是有一定的提升空间。
创建进程、销毁进程、调度进程这些操作的开销有点太大了。 为此,引入了线程。

线程
Thread,在有些系统上也叫做”轻量级进程”。为什么说它轻量呢?
创建线程比创建进程更高效;
销毁线程比销毁进程更高效;
调度线程比调度进程更高效;
因为创建线程并没有申请资源,销毁线程也不需要释放资源。直接让线程产生在进程内部,公用之前的资源。
线程和进制是包含的关系。一个进程可以包含多个线程或者一个线程。当创建进程之后,就相当于把资源都分配好了,接着在这个进程里面创建线程,这样的线程就和之前的进程公用一样的资源了。

🥇1.3进程、线程之前的区别和联系(面试题)

1、进程是操作系统资源分配的基本单位,线程是操作系统调度执行的基本单位。
2、进程是包含线程的,一个进程可以含有多个线程,也可以含有一个线程。
3、每个进程都有独立的内存空间(虚拟地址空间),同一个进程的多个线程之间,公用这个虚拟地址空间。

创建线程的几种方式

1、创建自定义类继承Thread类重写run方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码/**
* Thread是Java标准库中的一个关于线程的类
* 常用的方式是自定义一个类继承Thread类,然后重写run方法
* 这里的run方法就是线程具体要执行的任务(代码)
*/
public class threadDemo1 {
public static void main (String[] args) {
Thread t=new Thread ();
//start方法就会在操作系统中创建一个线程出来。
t.start ();
}
}
class MyThread extends Thread{
@Override
public void run(){
System.out.println ("继承Thread类创建线程");
}

}

2、实现Runable接口,重写run方法

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public class threadDemo2 {
public static void main (String[] args) {
Thread T=new Thread (new myRunable ());
T.start ();
}
}
class myRunable implements Runnable{
@Override
public void run(){
System.out.println ("实现Runbale接口,重写run");
}
}

3、继承Thread类重写run方法,使用匿名内部类的方式

1
2
3
4
5
6
7
8
9
java复制代码   public static void main (String[] args) {
Thread t=new Thread (){
@Override
public void run(){
System.out.println ("匿名内部类");
}
};
t.start ();
}

4、实现Runable,重写run方法,使用匿名内部类

1
2
3
4
5
6
7
8
9
java复制代码    public static void main (String[] args) {
Thread t=new Thread (new Runnable () {
@Override
public void run () {
System.out.println ("实现Runable,重写run,使用匿名内部类");
}
});
t.start ();
}

5、使用lambda表达式

1
2
3
4
5
java复制代码//
Thread t3 = new Thread(() -> System.out.println("使用匿名类创建 Thread 子类对象"));
Thread t4 = new Thread(() -> {
System.out.println("使用匿名类创建 Thread 子类对象");
});

以上就是我们初识线程的内容啦。后面还会更新更深的内容哦。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

万字博文教你搞懂java源码的日期和时间相关用法 介绍 一:

发表于 2021-10-28

小知识,大挑战!本文正在参与“ 程序员必备小知识 ”创作活动

本文同时参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金

❤️作者简介:Java领域优质创作者🏆,CSDN博客专家认证🏆,华为云享专家认证🏆

❤️技术活,该赏

❤️点赞 👍 收藏 ⭐再看,养成习惯

介绍

本篇文章主要介绍java源码中提供了哪些日期和时间的类

日期和时间的两套API

java提供了两套处理日期和时间的API

1、旧的API,放在java.util 这个包下的:比较常用的有Date和Calendar等

2、新的API是java 8新引入的,放在java.time 这个包下的:LocalDateTime,ZonedDateTime,DateTimeFormatter和Instant等

为什么会有两套日期时间API,这个是有历史原因的,旧的API是jdk刚开始就提供的,随着版本的升级,逐渐发现原先的api不满足需要,暴露了一些问题,所以在java 8 这个版本中,重新引入新API。

这两套API都要了解,为什么呢?

因为java 8 发布时间是2014年,很多之前的系统还是沿用旧的API,所以这两套API都要了解,同时还要掌握两套API相互转化的技术。

一:Date

支持版本及以上

JDK1.0

介绍

Date类说明

Date类负责时间的表示,在计算机中,时间的表示是一个较大的概念,现有的系统基本都是利用从1970.1.1 00:00:00 到当前时间的毫秒数进行计时,这个时间称为epoch(时间戳)

1
2
3
4
5
6
7
8
9
10
java复制代码package java.util;

public class Date
implements java.io.Serializable, Cloneable, Comparable<Date>
{
...

private transient long fastTime;
....
}

java.util.Date是java提供表示日期和时间的类,类里有个long 类型的变量fastTime,它是用来存储以毫秒表示的时间戳。

date常用的用法

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
java复制代码import java.util.Date;
-----------------------------------------
//获取当前时间
Date date = new Date();
System.out.println("获取当前时间:"+date);
//获取时间戳
System.out.println("获取时间戳:"+date.getTime());

// date时间是否大于afterDate 等于也为false
Date afterDate = new Date(date.getTime()-3600*24*1000);
System.out.println("after:"+date.after(afterDate));
System.out.println("after:"+date.after(date));

// date时间是否小于afterDate 等于也为false
Date beforeDate = new Date(date.getTime()+3600*24*1000);
System.out.println("before:"+date.before(beforeDate));
System.out.println("before:"+date.before(date));

//两个日期比较
System.out.println("compareTo:"+date.compareTo(date));
System.out.println("compareTo:"+date.compareTo(afterDate));
System.out.println("compareTo:"+date.compareTo(beforeDate));

//转为字符串
System.out.println("转为字符串:"+date.toString());
//转为GMT时区 toGMTString() java8 中已废弃
System.out.println("转为GMT时区:"+date.toGMTString());
//转为本地时区 toLocaleString() java8 已废弃
System.out.println("转为本地时区:"+date.toLocaleString());

image-2021071385259183

image-202107135122012

自定义时间格式-SimpleDateFormat

date的toString方法转成字符串,不是我们想要的时间格式,如果要自定义时间格式,就要使用SimpleDateFormat

1
2
3
4
5
6
7
java复制代码		//获取当前时间
Date date = new Date();
System.out.println("获取当前时间:"+date);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println(simpleDateFormat.format(date));
SimpleDateFormat simpleDateFormat1 = new SimpleDateFormat("yyyy年MM月dd日 HH时mm分ss秒");
System.out.println(simpleDateFormat1.format(date));

image-20210713833564

SimpleDateFormat也可以方便的将字符串转成Date

1
2
3
4
5
6
7
8
java复制代码//获取当前时间
String str = "2021-07-13 23:48:23";
try {
Date date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(str);
System.out.println(date);
} catch (ParseException e) {
e.printStackTrace();
}

image-20210714224261

日期和时间格式化参数说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ini复制代码yyyy:年
MM:月
dd:日
hh:1~12小时制(1-12)
HH:24小时制(0-23)
mm:分
ss:秒
S:毫秒
E:星期几
D:一年中的第几天
F:一月中的第几个星期(会把这个月总共过的天数除以7)
w:一年中的第几个星期
W:一月中的第几星期(会根据实际情况来算)
a:上下午标识
k:和HH差不多,表示一天24小时制(1-24)。
K:和hh差不多,表示一天12小时制(0-11)。
z:表示时区

SimpleDateFormat线程不安全原因及解决方案

SimpleDateFormat线程为什么是线程不安全的呢?

来看看SimpleDateFormat的源码,先看format方法:

1
2
3
4
5
6
7
java复制代码// Called from Format after creating a FieldDelegate
private StringBuffer format(Date date, StringBuffer toAppendTo,
FieldDelegate delegate) {
// Convert input date to time field list
calendar.setTime(date);
...
}

问题就出在成员变量calendar,如果在使用SimpleDateFormat时,用static定义,那SimpleDateFormat变成了共享变量。那SimpleDateFormat中的calendar就可以被多个线程访问到。

SimpleDateFormat的parse方法也是线程不安全的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码 public Date parse(String text, ParsePosition pos)
{
...
Date parsedDate;
try {
parsedDate = calb.establish(calendar).getTime();
// If the year value is ambiguous,
// then the two-digit year == the default start year
if (ambiguousYear[0]) {
if (parsedDate.before(defaultCenturyStart)) {
parsedDate = calb.addYear(100).establish(calendar).getTime();
}
}
}
// An IllegalArgumentException will be thrown by Calendar.getTime()
// if any fields are out of range, e.g., MONTH == 17.
catch (IllegalArgumentException e) {
pos.errorIndex = start;
pos.index = oldStart;
return null;
}

return parsedDate;
}

由源码可知,最后是调用**parsedDate = calb.establish(calendar).getTime();**获取返回值。方法的参数是calendar,calendar可以被多个线程访问到,存在线程不安全问题。

我们再来看看**calb.establish(calendar)**的源码

image-20210805827464

calb.establish(calendar)方法先后调用了cal.clear()和cal.set(),先清理值,再设值。但是这两个操作并不是原子性的,也没有线程安全机制来保证,导致多线程并发时,可能会引起cal的值出现问题了。

验证SimpleDateFormat线程不安全

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
java复制代码public class SimpleDateFormatDemoTest {

private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args) {
//1、创建线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
//2、为线程池分配任务
ThreadPoolTest threadPoolTest = new ThreadPoolTest();
for (int i = 0; i < 10; i++) {
pool.submit(threadPoolTest);
}
//3、关闭线程池
pool.shutdown();
}


static class ThreadPoolTest implements Runnable{

@Override
public void run() {
String dateString = simpleDateFormat.format(new Date());
try {
Date parseDate = simpleDateFormat.parse(dateString);
String dateString2 = simpleDateFormat.format(parseDate);
System.out.println(Thread.currentThread().getName()+" 线程是否安全: "+dateString.equals(dateString2));
} catch (Exception e) {
System.out.println(Thread.currentThread().getName()+" 格式化失败 ");
}
}
}
}

image-20210805754416

出现了两次false,说明线程是不安全的。而且还抛异常,这个就严重了。

解决方案

这个是阿里巴巴 java开发手册中的规定:

img

1、不要定义为static变量,使用局部变量

2、加锁:synchronized锁和Lock锁

3、使用ThreadLocal方式

4、使用DateTimeFormatter代替SimpleDateFormat(DateTimeFormatter是线程安全的,java 8+支持)

5、使用FastDateFormat 替换SimpleDateFormat(FastDateFormat 是线程安全的,Apache Commons Lang包支持,不受限于java版本)

解决方案1:不要定义为static变量,使用局部变量

就是要使用SimpleDateFormat对象进行format或parse时,再定义为局部变量。就能保证线程安全。

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
java复制代码public class SimpleDateFormatDemoTest1 {

public static void main(String[] args) {
//1、创建线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
//2、为线程池分配任务
ThreadPoolTest threadPoolTest = new ThreadPoolTest();
for (int i = 0; i < 10; i++) {
pool.submit(threadPoolTest);
}
//3、关闭线程池
pool.shutdown();
}


static class ThreadPoolTest implements Runnable{

@Override
public void run() {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String dateString = simpleDateFormat.format(new Date());
try {
Date parseDate = simpleDateFormat.parse(dateString);
String dateString2 = simpleDateFormat.format(parseDate);
System.out.println(Thread.currentThread().getName()+" 线程是否安全: "+dateString.equals(dateString2));
} catch (Exception e) {
System.out.println(Thread.currentThread().getName()+" 格式化失败 ");
}
}
}
}

image-20210805936439

由图可知,已经保证了线程安全,但这种方案不建议在高并发场景下使用,因为会创建大量的SimpleDateFormat对象,影响性能。

解决方案2:加锁:synchronized锁和Lock锁

加synchronized锁:
SimpleDateFormat对象还是定义为全局变量,然后需要调用SimpleDateFormat进行格式化时间时,再用synchronized保证线程安全。

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
java复制代码public class SimpleDateFormatDemoTest2 {

private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args) {
//1、创建线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
//2、为线程池分配任务
ThreadPoolTest threadPoolTest = new ThreadPoolTest();
for (int i = 0; i < 10; i++) {
pool.submit(threadPoolTest);
}
//3、关闭线程池
pool.shutdown();
}

static class ThreadPoolTest implements Runnable{

@Override
public void run() {
try {
synchronized (simpleDateFormat){
String dateString = simpleDateFormat.format(new Date());
Date parseDate = simpleDateFormat.parse(dateString);
String dateString2 = simpleDateFormat.format(parseDate);
System.out.println(Thread.currentThread().getName()+" 线程是否安全: "+dateString.equals(dateString2));
}
} catch (Exception e) {
System.out.println(Thread.currentThread().getName()+" 格式化失败 ");
}
}
}
}

image-2021080591591
如图所示,线程是安全的。定义了全局变量SimpleDateFormat,减少了创建大量SimpleDateFormat对象的损耗。但是使用synchronized锁,
同一时刻只有一个线程能执行锁住的代码块,在高并发的情况下会影响性能。但这种方案不建议在高并发场景下使用
加Lock锁:
加Lock锁和synchronized锁原理是一样的,都是使用锁机制保证线程的安全。

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
java复制代码public class SimpleDateFormatDemoTest3 {

private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private static Lock lock = new ReentrantLock();
public static void main(String[] args) {
//1、创建线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
//2、为线程池分配任务
ThreadPoolTest threadPoolTest = new ThreadPoolTest();
for (int i = 0; i < 10; i++) {
pool.submit(threadPoolTest);
}
//3、关闭线程池
pool.shutdown();
}

static class ThreadPoolTest implements Runnable{

@Override
public void run() {
try {
lock.lock();
String dateString = simpleDateFormat.format(new Date());
Date parseDate = simpleDateFormat.parse(dateString);
String dateString2 = simpleDateFormat.format(parseDate);
System.out.println(Thread.currentThread().getName()+" 线程是否安全: "+dateString.equals(dateString2));
} catch (Exception e) {
System.out.println(Thread.currentThread().getName()+" 格式化失败 ");
}finally {
lock.unlock();
}
}
}
}

image-20210805940496
由结果可知,加Lock锁也能保证线程安全。要注意的是,最后一定要释放锁,代码里在finally里增加了lock.unlock();,保证释放锁。
在高并发的情况下会影响性能。这种方案不建议在高并发场景下使用

解决方案3:使用ThreadLocal方式

使用ThreadLocal保证每一个线程有SimpleDateFormat对象副本。这样就能保证线程的安全。

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
java复制代码public class SimpleDateFormatDemoTest4 {

private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>(){
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};
public static void main(String[] args) {
//1、创建线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
//2、为线程池分配任务
ThreadPoolTest threadPoolTest = new ThreadPoolTest();
for (int i = 0; i < 10; i++) {
pool.submit(threadPoolTest);
}
//3、关闭线程池
pool.shutdown();
}

static class ThreadPoolTest implements Runnable{

@Override
public void run() {
try {
String dateString = threadLocal.get().format(new Date());
Date parseDate = threadLocal.get().parse(dateString);
String dateString2 = threadLocal.get().format(parseDate);
System.out.println(Thread.currentThread().getName()+" 线程是否安全: "+dateString.equals(dateString2));
} catch (Exception e) {
System.out.println(Thread.currentThread().getName()+" 格式化失败 ");
}finally {
//避免内存泄漏,使用完threadLocal后要调用remove方法清除数据
threadLocal.remove();
}
}
}
}

image-202108059729

使用ThreadLocal能保证线程安全,且效率也是挺高的。适合高并发场景使用。

解决方案4:使用DateTimeFormatter代替SimpleDateFormat使用DateTimeFormatter代替SimpleDateFormat(DateTimeFormatter是线程安全的,java 8+支持)DateTimeFormatter介绍
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
java复制代码public class DateTimeFormatterDemoTest5 {
private static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args) {
//1、创建线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
//2、为线程池分配任务
ThreadPoolTest threadPoolTest = new ThreadPoolTest();
for (int i = 0; i < 10; i++) {
pool.submit(threadPoolTest);
}
//3、关闭线程池
pool.shutdown();
}


static class ThreadPoolTest implements Runnable{

@Override
public void run() {
try {
String dateString = dateTimeFormatter.format(LocalDateTime.now());
TemporalAccessor temporalAccessor = dateTimeFormatter.parse(dateString);
String dateString2 = dateTimeFormatter.format(temporalAccessor);
System.out.println(Thread.currentThread().getName()+" 线程是否安全: "+dateString.equals(dateString2));
} catch (Exception e) {
e.printStackTrace();
System.out.println(Thread.currentThread().getName()+" 格式化失败 ");
}
}
}
}

image-2021080591443373
使用DateTimeFormatter能保证线程安全,且效率也是挺高的。适合高并发场景使用。

解决方案5:使用FastDateFormat 替换SimpleDateFormat

使用FastDateFormat 替换SimpleDateFormat(FastDateFormat 是线程安全的,Apache Commons Lang包支持,不受限于java版本)

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
java复制代码public class FastDateFormatDemo6 {
private static FastDateFormat fastDateFormat = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args) {
//1、创建线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
//2、为线程池分配任务
ThreadPoolTest threadPoolTest = new ThreadPoolTest();
for (int i = 0; i < 10; i++) {
pool.submit(threadPoolTest);
}
//3、关闭线程池
pool.shutdown();
}


static class ThreadPoolTest implements Runnable{

@Override
public void run() {
try {
String dateString = fastDateFormat.format(new Date());
Date parseDate = fastDateFormat.parse(dateString);
String dateString2 = fastDateFormat.format(parseDate);
System.out.println(Thread.currentThread().getName()+" 线程是否安全: "+dateString.equals(dateString2));
} catch (Exception e) {
e.printStackTrace();
System.out.println(Thread.currentThread().getName()+" 格式化失败 ");
}
}
}
}

使用FastDateFormat能保证线程安全,且效率也是挺高的。适合高并发场景使用。

FastDateFormat源码分析
1
复制代码 Apache Commons Lang 3.5
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码//FastDateFormat
@Override
public String format(final Date date) {
return printer.format(date);
}

@Override
public String format(final Date date) {
final Calendar c = Calendar.getInstance(timeZone, locale);
c.setTime(date);
return applyRulesToString(c);
}

源码中 Calender 是在 format 方法里创建的,肯定不会出现 setTime 的线程安全问题。这样线程安全疑惑解决了。那还有性能问题要考虑?

我们来看下FastDateFormat是怎么获取的

1
2
java复制代码FastDateFormat.getInstance();
FastDateFormat.getInstance(CHINESE_DATE_TIME_PATTERN);

看下对应的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码/**
* 获得 FastDateFormat实例,使用默认格式和地区
*
* @return FastDateFormat
*/
public static FastDateFormat getInstance() {
return CACHE.getInstance();
}

/**
* 获得 FastDateFormat 实例,使用默认地区<br>
* 支持缓存
*
* @param pattern 使用{@link java.text.SimpleDateFormat} 相同的日期格式
* @return FastDateFormat
* @throws IllegalArgumentException 日期格式问题
*/
public static FastDateFormat getInstance(final String pattern) {
return CACHE.getInstance(pattern, null, null);
}

这里有用到一个CACHE,看来用了缓存,往下看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码private static final FormatCache<FastDateFormat> CACHE = new FormatCache<FastDateFormat>(){
@Override
protected FastDateFormat createInstance(final String pattern, final TimeZone timeZone, final Locale locale) {
return new FastDateFormat(pattern, timeZone, locale);
}
};

//
abstract class FormatCache<F extends Format> {
...
private final ConcurrentMap<Tuple, F> cInstanceCache = new ConcurrentHashMap<>(7);

private static final ConcurrentMap<Tuple, String> C_DATE_TIME_INSTANCE_CACHE = new ConcurrentHashMap<>(7);
...
}

image-20210728914309

在getInstance 方法中加了ConcurrentMap 做缓存,提高了性能。且我们知道ConcurrentMap 也是线程安全的。

实践
1
2
3
4
java复制代码/**
* 年月格式 {@link FastDateFormat}:yyyy-MM
*/
public static final FastDateFormat NORM_MONTH_FORMAT = FastDateFormat.getInstance(NORM_MONTH_PATTERN);

image-2021072895013629

1
2
3
4
java复制代码//FastDateFormat
public static FastDateFormat getInstance(final String pattern) {
return CACHE.getInstance(pattern, null, null);
}

image-20210728205104833

image-2021072895259113

如图可证,是使用了ConcurrentMap 做缓存。且key值是格式,时区和locale(语境)三者都相同为相同的key。

问题

1、tostring()输出时,总以系统的默认时区格式输出,不友好。

2、时区不能转换

3、日期和时间的计算不简便,例如计算加减,比较两个日期差几天等。

4、格式化日期和时间的SimpleDateFormat对象是线程不安全的

5、Date对象本身也是线程不安全的

1
2
3
4
5
java复制代码public class Date
implements java.io.Serializable, Cloneable, Comparable<Date>
{
...
}

二:Calendar

支持版本及以上

JDK1.1

介绍

Calendar类说明

Calendar类提供了获取或设置各种日历字段的各种方法,比Date类多了一个可以计算日期和时间的功能。

Calendar常用的用法

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码		// 获取当前时间:
Calendar c = Calendar.getInstance();
int y = c.get(Calendar.YEAR);
int m = 1 + c.get(Calendar.MONTH);
int d = c.get(Calendar.DAY_OF_MONTH);
int w = c.get(Calendar.DAY_OF_WEEK);
int hh = c.get(Calendar.HOUR_OF_DAY);
int mm = c.get(Calendar.MINUTE);
int ss = c.get(Calendar.SECOND);
int ms = c.get(Calendar.MILLISECOND);
System.out.println("返回的星期:"+w);
System.out.println(y + "-" + m + "-" + d + " " + " " + hh + ":" + mm + ":" + ss + "." + ms);

image-202107141981024

如上图所示,月份计算时,要+1;返回的星期是从周日开始计算,周日为1,1~7表示星期;

Calendar的跨年问题和解决方案

问题

背景:在使用Calendar 的api getWeekYear()读取年份,在跨年那周的时候,程序获取的年份可能不是我们想要的,例如在2019年30号时,要返回2019,结果是返回2020,是不是有毒

1
2
3
4
5
6
7
8
9
10
11
ini复制代码// 获取当前时间:
Calendar c = Calendar.getInstance();
c.clear();
String str = "2019-12-30";
try {
c.setTime(new SimpleDateFormat("yyyy-MM-dd").parse(str));
int y = c.getWeekYear();
System.out.println(y);
} catch (ParseException e) {
e.printStackTrace();
}

image-202107148203567

分析原因

老规矩,从源码入手

1
2
3
4
5
6
java复制代码Calendar类
-------------------------
//@since 1.7
public int getWeekYear() {
throw new UnsupportedOperationException();
}

这个源码有点奇怪,getWeekYear()方法是java 7引入的。它的实现怎么是抛出异常,但是执行时,又有结果返回。

断点跟进,通过Calendar.getInstance()获取的Calendar实例是GregorianCalendar

image-202107148065

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
java复制代码GregorianCalendar
--------------------------------------
public int getWeekYear() {
int year = get(YEAR); // implicitly calls complete()
if (internalGetEra() == BCE) {
year = 1 - year;
}

// Fast path for the Gregorian calendar years that are never
// affected by the Julian-Gregorian transition
if (year > gregorianCutoverYear + 1) {
int weekOfYear = internalGet(WEEK_OF_YEAR);
if (internalGet(MONTH) == JANUARY) {
if (weekOfYear >= 52) {
--year;
}
} else {
if (weekOfYear == 1) {
++year;
}
}
return year;
}
...
}

方法内获取的年份刚开始是正常的

image-20210714707548

image-20210714847423

在JDK中会把前一年末尾的几天判定为下一年的第一周,因此上面程序的结果是1

解决方案

使用Calendar类 get(Calendar.YEAR)获取年份

问题

1、读取月份时,要+1

2、返回的星期是从周日开始计算,周日为1,1~7表示星期

3、Calendar的跨年问题,获取年份要用c.get(Calendar.YEAR),不要用c.getWeekYear();

4、获取指定时间是一年中的第几周时,调用cl.get(Calendar.WEEK_OF_YEAR),要注意跨年问题,跨年的那一周,获取的值为1。离跨年最近的那周为52。

三:LocalDateTime

支持版本及以上

jdk8

介绍

LocalDateTime类说明

表示当前日期时间,相当于:yyyy-MM-ddTHH:mm:ss

LocalDateTime常用的用法

获取当前日期和时间

1
2
3
4
5
6
java复制代码		LocalDate d = LocalDate.now(); // 当前日期
LocalTime t = LocalTime.now(); // 当前时间
LocalDateTime dt = LocalDateTime.now(); // 当前日期和时间
System.out.println(d); // 严格按照ISO 8601格式打印
System.out.println(t); // 严格按照ISO 8601格式打印
System.out.println(dt); // 严格按照ISO 8601格式打印

image-20210714857780

由运行结果可行,本地日期时间通过now()获取到的总是以当前默认时区返回的

获取指定日期和时间

1
2
3
4
5
6
java复制代码		LocalDate d2 = LocalDate.of(2021, 07, 14); // 2021-07-14, 注意07=07月
LocalTime t2 = LocalTime.of(13, 14, 20); // 13:14:20
LocalDateTime dt2 = LocalDateTime.of(2021, 07, 14, 13, 14, 20);
LocalDateTime dt3 = LocalDateTime.of(d2, t2);
System.out.println("指定日期时间:"+dt2);
System.out.println("指定日期时间:"+dt3);

image-20210714803165

日期时间的加减法及修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码		LocalDateTime currentTime = LocalDateTime.now(); // 当前日期和时间
System.out.println("------------------时间的加减法及修改-----------------------");
//3.LocalDateTime的加减法包含了LocalDate和LocalTime的所有加减,上面说过,这里就只做简单介绍
System.out.println("3.当前时间:" + currentTime);
System.out.println("3.当前时间加5年:" + currentTime.plusYears(5));
System.out.println("3.当前时间加2个月:" + currentTime.plusMonths(2));
System.out.println("3.当前时间减2天:" + currentTime.minusDays(2));
System.out.println("3.当前时间减5个小时:" + currentTime.minusHours(5));
System.out.println("3.当前时间加5分钟:" + currentTime.plusMinutes(5));
System.out.println("3.当前时间加20秒:" + currentTime.plusSeconds(20));
//还可以灵活运用比如:向后加一年,向前减一天,向后加2个小时,向前减5分钟,可以进行连写
System.out.println("3.同时修改(向后加一年,向前减一天,向后加2个小时,向前减5分钟):" + currentTime.plusYears(1).minusDays(1).plusHours(2).minusMinutes(5));
System.out.println("3.修改年为2025年:" + currentTime.withYear(2025));
System.out.println("3.修改月为12月:" + currentTime.withMonth(12));
System.out.println("3.修改日为27日:" + currentTime.withDayOfMonth(27));
System.out.println("3.修改小时为12:" + currentTime.withHour(12));
System.out.println("3.修改分钟为12:" + currentTime.withMinute(12));
System.out.println("3.修改秒为12:" + currentTime.withSecond(12));

image-20210714941902

LocalDateTime和Date相互转化

Date转LocalDateTime

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码		System.out.println("------------------方法一:分步写-----------------------");
//实例化一个时间对象
Date date = new Date();
//返回表示时间轴上同一点的瞬间作为日期对象
Instant instant = date.toInstant();
//获取系统默认时区
ZoneId zoneId = ZoneId.systemDefault();
//根据时区获取带时区的日期和时间
ZonedDateTime zonedDateTime = instant.atZone(zoneId);
//转化为LocalDateTime
LocalDateTime localDateTime = zonedDateTime.toLocalDateTime();
System.out.println("方法一:原Date = " + date);
System.out.println("方法一:转化后的LocalDateTime = " + localDateTime);

System.out.println("------------------方法二:一步到位(推荐使用)-----------------------");
//实例化一个时间对象
Date todayDate = new Date();
//Instant.ofEpochMilli(long l)使用1970-01-01T00:00:00Z的纪元中的毫秒来获取Instant的实例
LocalDateTime ldt = Instant.ofEpochMilli(todayDate.getTime()).atZone(ZoneId.systemDefault()).toLocalDateTime();
System.out.println("方法二:原Date = " + todayDate);
System.out.println("方法二:转化后的LocalDateTime = " + ldt);

image-20210714210839339

LocalDateTime转Date

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码		System.out.println("------------------方法一:分步写-----------------------");
//获取LocalDateTime对象,当前时间
LocalDateTime localDateTime = LocalDateTime.now();
//获取系统默认时区
ZoneId zoneId = ZoneId.systemDefault();
//根据时区获取带时区的日期和时间
ZonedDateTime zonedDateTime = localDateTime.atZone(zoneId);
//返回表示时间轴上同一点的瞬间作为日期对象
Instant instant = zonedDateTime.toInstant();
//转化为Date
Date date = Date.from(instant);
System.out.println("方法一:原LocalDateTime = " + localDateTime);
System.out.println("方法一:转化后的Date = " + date);

System.out.println("------------------方法二:一步到位(推荐使用)-----------------------");
//实例化一个LocalDateTime对象
LocalDateTime now = LocalDateTime.now();
//转化为date
Date dateResult = Date.from(now.atZone(ZoneId.systemDefault()).toInstant());
System.out.println("方法二:原LocalDateTime = " + now);
System.out.println("方法二:转化后的Date = " + dateResult);

image-20210714211035080

线程安全

网上大家都在说JAVA 8提供的LocalDateTime是线程安全的,但是它是如何实现的呢

今天让我们来挖一挖

1
2
3
4
java复制代码public final class LocalDateTime
implements Temporal, TemporalAdjuster, ChronoLocalDateTime<LocalDate>, Serializable {
...
}

由上面的源码可知,LocalDateTime是不可变类。我们都知道一个Java并发编程规则:不可变对象永远是线程安全的。

对比下Date的源码 ,Date是可变类,所以是线程不安全的。

1
2
3
4
5
java复制代码public class Date
implements java.io.Serializable, Cloneable, Comparable<Date>
{
...
}

四:ZonedDateTime

支持版本及以上

jdk8

介绍

ZonedDateTime类说明

表示一个带时区的日期和时间,ZonedDateTime可以理解为LocalDateTime+ZoneId

从源码可以看出来,ZonedDateTime类中定义了LocalDateTime和ZoneId两个变量。

且ZonedDateTime类也是不可变类且是线程安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public final class ZonedDateTime
implements Temporal, ChronoZonedDateTime<LocalDate>, Serializable {

/**
* Serialization version.
*/
private static final long serialVersionUID = -6260982410461394882L;

/**
* The local date-time.
*/
private final LocalDateTime dateTime;
/**
* The time-zone.
*/
private final ZoneId zone;

...
}

ZonedDateTime常用的用法

获取当前时间+带时区+时区转换

1
2
3
4
5
6
7
8
9
java复制代码		// 默认时区获取当前时间
ZonedDateTime zonedDateTime = ZonedDateTime.now();
// 用指定时区获取当前时间,Asia/Shanghai为上海时区
ZonedDateTime zonedDateTime1 = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
//withZoneSameInstant为转换时区,参数为ZoneId
ZonedDateTime zonedDateTime2 = zonedDateTime.withZoneSameInstant(ZoneId.of("America/New_York"));
System.out.println(zonedDateTime);
System.out.println(zonedDateTime1);
System.out.println(zonedDateTime2);

image-202107205246938

LocalDateTime+ZoneId变ZonedDateTime

1
2
3
4
5
java复制代码		LocalDateTime localDateTime = LocalDateTime.now();
ZonedDateTime zonedDateTime1 = localDateTime.atZone(ZoneId.systemDefault());
ZonedDateTime zonedDateTime2 = localDateTime.atZone(ZoneId.of("America/New_York"));
System.out.println(zonedDateTime1);
System.out.println(zonedDateTime2);

image-2021072094003

上面的例子说明了,LocalDateTime是可以转成ZonedDateTime的。

五:DateTimeFormatter

支持版本及以上

jdk8

介绍

DateTimeFormatter类说明

DateTimeFormatter的作用是进行格式化显示,且DateTimeFormatter是不可变类且是线程安全的。

1
2
3
4
5
6
7
8
java复制代码public final class DateTimeFormatter {
...
}

```说到时间的格式化显示,就要说老朋友SimpleDateFormat了,之前格式化Date就要用上。但是我们知道SimpleDateFormat是线程不安全的,还不清楚的,[请看这里-->](#simpleDateFormat)


### DateTimeFormatter常用的用法

java复制代码 ZonedDateTime zonedDateTime = ZonedDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(“yyyy-MM-dd’T’HH:mm ZZZZ”);
System.out.println(formatter.format(zonedDateTime));

DateTimeFormatter usFormatter = DateTimeFormatter.ofPattern("E, MMMM/dd/yyyy HH:mm", Locale.US);
System.out.println(usFormatter.format(zonedDateTime));

DateTimeFormatter chinaFormatter = DateTimeFormatter.ofPattern("yyyy MMM dd EE HH:mm", Locale.CHINA);
System.out.println(chinaFormatter.format(zonedDateTime));
1
2
3
4
5
6
7
8
9

![image-202107209416958](https://gitee.com/songjianzaina/juejin_p8/raw/master/img/0bc5352e682975455cde6d694aed2e3e42a3360dbb5fc1e881783baf9b2439b1)


DateTimeFormatter的坑
-------------------


#### 1、在正常配置按照标准格式的字符串日期,是能够正常转换的。如果月,日,时,分,秒在不足两位的情况需要补0,否则的话会转换失败,抛出异常。

java复制代码 DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(“yyyy-MM-dd HH:mm:ss.SSS”);
LocalDateTime dt1 = LocalDateTime.parse(“2021-7-20 23:46:43.946”, DATE_TIME_FORMATTER);
System.out.println(dt1);

1
2
3
4
5

会报错:


![image-202107208183](https://gitee.com/songjianzaina/juejin_p8/raw/master/img/003428ee7a4c65204039945754ac4b1b6b86198a13c3c691eaa9492276b2604e)

java复制代码java.time.format.DateTimeParseException: Text ‘2021-7-20 23:46:43.946’ could not be parsed at index 5

1
2
3
4
5
6
7
8
9
10
11
12
13
14

分析原因:是格式字符串与实际的时间不匹配


"yyyy-MM-dd HH:mm:ss.SSS"


"2021-7-20 23:46:43.946"


中间的月份格式是MM,实际时间是7


解决方案:保持格式字符串与实际的时间匹配

java复制代码 DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(“yyyy-MM-dd HH:mm:ss.SSS”);
LocalDateTime dt1 = LocalDateTime.parse(“2021-07-20 23:46:43.946”, DATE_TIME_FORMATTER);
System.out.println(dt1);

1
2
3
4
5

![image-20210720504067](https://gitee.com/songjianzaina/juejin_p8/raw/master/img/1e1d72c55766953813b04ddaef480794368d6eb009d9c86cef76f2550524b556)


#### 2、YYYY和DD谨慎使用

java复制代码 LocalDate date = LocalDate.of(2020,12,31);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(“YYYYMM”);
// 结果是 202112
System.out.println( formatter.format(date));

1
2

![image-202107208183](https://gitee.com/songjianzaina/juejin_p8/raw/master/img/8cb3843c94bc1ac722c7062fd57eb0106bba76e68b0d286805073f34b66dee47)

java复制代码Java’s DateTimeFormatter pattern “YYYY” gives you the week-based-year, (by default, ISO-8601 standard) the year of the Thursday of that week.

1
2
3
4
5

YYYY是取的当前周所在的年份,week-based year 是 ISO 8601 规定的。2020年12月31号,周算年份,就是2021年


![image-2021072059555](https://gitee.com/songjianzaina/juejin_p8/raw/master/img/40cd1008f1697f556b3a4cb4f1bb34277dec67d4a73ae9a7e7e0233aae855e0f)

java复制代码 private static void tryit(int Y, int M, int D, String pat) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern(pat);
LocalDate dat = LocalDate.of(Y,M,D);
String str = fmt.format(dat);
System.out.printf(“Y=%04d M=%02d D=%02d “ +
“formatted with “ +
“"%s" -> %s\n”,Y,M,D,pat,str);
}
public static void main(String[] args){
tryit(2020,01,20,”MM/DD/YYYY”);
tryit(2020,01,21,”DD/MM/YYYY”);
tryit(2020,01,22,”YYYY-MM-DD”);
tryit(2020,03,17,”MM/DD/YYYY”);
tryit(2020,03,18,”DD/MM/YYYY”);
tryit(2020,03,19,”YYYY-MM-DD”);
}

1
2


ini复制代码Y=2020 M=01 D=20 formatted with “MM/DD/YYYY” -> 01/20/2020
Y=2020 M=01 D=21 formatted with “DD/MM/YYYY” -> 21/01/2020
Y=2020 M=01 D=22 formatted with “YYYY-MM-DD” -> 2020-01-22
Y=2020 M=03 D=17 formatted with “MM/DD/YYYY” -> 03/77/2020
Y=2020 M=03 D=18 formatted with “DD/MM/YYYY” -> 78/03/2020
Y=2020 M=03 D=19 formatted with “YYYY-MM-DD” -> 2020-03-79

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

最后三个日期是有问题的,因为大写的**DD**代表的是处于这一年中那一天,不是处于这个月的那一天,但是**dd**就没有问题。


例子参考于:[www.cnblogs.com/tonyY/p/121…](https://www.cnblogs.com/tonyY/p/12153335.html)


所以建议使用yyyy和dd。


六:Instant
=========


支持版本及以上
-------


jdk8


介绍
--


### Instant类说明

java复制代码public final class Instant
implements Temporal, TemporalAdjuster, Comparable, Serializable {
…
}

1
2
3
4
5

Instant也是不可变类且是线程安全的。其实**Java.time** 这个包是线程安全的。


**Instant**是java 8新增的特性,里面有两个核心的字段

java复制代码 …
private final long seconds;

private final int nanos;
...
1
2
3
4
5
6
7
8

一个是单位为秒的时间戳,另一个是单位为纳秒的时间戳。


是不是跟\*\*System.currentTimeMillis()\*\*返回的long时间戳很像,System.currentTimeMillis()返回的是毫秒级,Instant多了更精确的纳秒级时间戳。


### Instant常用的用法

java复制代码 Instant now = Instant.now();
System.out.println(“now:”+now);
System.out.println(now.getEpochSecond()); // 秒
System.out.println(now.toEpochMilli()); // 毫秒

1
2
3
4
5

![image-20210720905353](https://gitee.com/songjianzaina/juejin_p8/raw/master/img/562e7c387da105ff96f01d7fd61706519685e266d0be3c969e4ee7d21d8a1394)


#### Instant是没有时区的,但是Instant加上时区后,可以转化为ZonedDateTime

java复制代码 Instant ins = Instant.now();
ZonedDateTime zdt = ins.atZone(ZoneId.systemDefault());
System.out.println(zdt);

1
2
3
4
5
6
7
8

![image-202107205211996](https://gitee.com/songjianzaina/juejin_p8/raw/master/img/da46875858fb1aa7e30eaf8d61d6b54bebdd2ad708b9c76f11042956f1719b96)


#### long型时间戳转Instant


要注意long型时间戳的时间单位选择Instant对应的方法转化

java复制代码//1626796436 为秒级时间戳
Instant ins = Instant.ofEpochSecond(1626796436);
ZonedDateTime zdt = ins.atZone(ZoneId.systemDefault());
System.out.println(“秒级时间戳转化:”+zdt);
//1626796436111l 为秒级时间戳
Instant ins1 = Instant.ofEpochMilli(1626796436111l);
ZonedDateTime zdt1 = ins1.atZone(ZoneId.systemDefault());
System.out.println(“毫秒级时间戳转化:”+zdt1);

1
2
3
4
5
6
7
8

### Instant的坑


Instant.now()获取的时间与北京时间相差8个时区,这是一个细节,要避坑。


看源码,用的是UTC时间。

java复制代码public static Instant now() {
return Clock.systemUTC().instant();
}

1
2

解决方案:

java复制代码Instant now = Instant.now().plusMillis(TimeUnit.HOURS.toMillis(8));
System.out.println(“now:”+now);


![image-202107234326190](https://gitee.com/songjianzaina/juejin_p8/raw/master/img/af4a84dc9d208a38eee001cc0f92dcb7a782cf1e1748bfc9c00eca176ecf168e)


推荐相关文章
======


hutool日期时间系列文章
--------------


[1DateUtil(时间工具类)-当前时间和当前时间戳](https://blog.csdn.net/shi_hong_fei_hei/article/details/117254005)


[2DateUtil(时间工具类)-常用的时间类型Date,DateTime,Calendar和TemporalAccessor(LocalDateTime)转换](https://blog.csdn.net/shi_hong_fei_hei/article/details/117304856)


[3DateUtil(时间工具类)-获取日期的各种内容](https://blog.csdn.net/shi_hong_fei_hei/article/details/117305070)


[4DateUtil(时间工具类)-格式化时间](https://blog.csdn.net/shi_hong_fei_hei/article/details/117305616)


[5DateUtil(时间工具类)-解析被格式化的时间](https://blog.csdn.net/shi_hong_fei_hei/article/details/117305724)


[6DateUtil(时间工具类)-时间偏移量获取](https://blog.csdn.net/shi_hong_fei_hei/article/details/117430937)


[7DateUtil(时间工具类)-日期计算](https://blog.csdn.net/shi_hong_fei_hei/article/details/117431081)


[8ChineseDate(农历日期工具类)](https://blog.csdn.net/shi_hong_fei_hei/article/details/117431150)


[9LocalDateTimeUtil(JDK8+中的{@link LocalDateTime} 工具类封装)](https://blog.csdn.net/shi_hong_fei_hei/article/details/117431359)


[10TemporalAccessorUtil{@link TemporalAccessor} 工具类封装](https://blog.csdn.net/shi_hong_fei_hei/article/details/117431538)


其他
--


[要探索JDK的核心底层源码,那必须掌握native用法](https://blog.csdn.net/shi_hong_fei_hei/article/details/118658071)



**本文转载自:** [掘金](https://juejin.cn/post/7023961450859233293)

*[开发者博客 – 和开发相关的 这里全都有](https://dev.newban.cn/)*
1…460461462…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%