项目地址:https://github.com/wangpeng1994/springboot-club
项目功能包含:
- 首页: 展示所有作者设置到首页的博客列表
- 详情:展示博客详情
- 登录、注册: 用户登录注册
- 用户页面: 展示某个用户的所有博客列表
- 我的: 展示个人主页
- 编辑、删除、创建博客
后端主要技术栈:Java/SpringBoot/IDEA/Maven/Docker/MySQL/MyBatis/Jenkins
前端使用现成的静态打包文件:vue-cli/vue-router/vuex/axios/webpack/element-ui
1. 引入 Spring Boot
从零开始,从官方教程抄:https://spring.io/guides/gs/spring-boot/
Spring Boot 中声明 Bean 有两种方式:
https://www.springboottutorial.com/spring-boot-java-xml-context-configuration
- 通过 xml
- 通过注解(新项目推荐):@Configuration + @Bean,或者在 Bean 类上使用 @Service 或 @Component
注入 Bean:
@Autowired 和 @Resource 注入方式都不再推荐。
现在推荐 @Inject + 构造器进行依赖注入。
2. 使用 ORM 连接数据库
使用 mybatis-spring-boot-starter,在 application.properties
中配置 datasource,指定 mysql 相关,然后可以使用 @Mapper + interface 的方式创建 mybatis 的 mapper。
3. SpringBoot Controller入门与登录模块开发
从零开始,了解Controller的相关概念,处理第一个HTTP请求。
实现第一个后端接口:登录。
使用 Spring 的安全架构:https://spring.io/guides/gs/securing-web/
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
只要引入了该依赖就会全部开启登录鉴权,而实际上跟路径及登录相关的路径应该开放访问权限,因此按需配置即可。
Auth:
- authentication 鉴权(你是不是你自己声明/注册的自己)
- authorization 验权(root用户权限、普通用户权限、游客权限等)
4. 完善登录模块
实现接口 UserDetailsService,从而定制自己的 UserService。使用内存(Map)存储用户名和加密后的密码。
实现接口 UserDetailsService 只需实现接口唯一的方法 loadUserByUsername:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
- 一定要加密,不能存储明文密码
- 加密是不可逆的,只能 encode,不能 decode
- 加密必须是一致的
- 不要自己设计加密算法(比如想用 MD5、SHA-1,但其实常见弱口令的这些值都是广为人知的,很容易方向猜测出原文)
5. SpringBoot 登录状态维持与 Cookie 原理
注意 HTTP 是无状态协议,所以需要 cookie 作为服务端鉴定客户身份的依据,从而维持登录状态。
Set-Cookie: JSESSIONID=82841BFA358CA8F3436F58D449722306; Path=/; HttpOnly
Set-Cookie: JSESSIONID=82841BFA358CA8F3436F58D449722306; Path=/; HttpOnly
HttpOnly:
为避免跨域脚本 (XSS) 攻击,通过JavaScript的 Document.cookie API无法访问带有 HttpOnly 标记的Cookie,它们只应该发送给服务端。如果包含服务端 Session 信息的 Cookie 不想被客户端 JavaScript 脚本调用,那么就应该为其设置 HttpOnly 标记。
登录流程如下:
- 第一次访问,尚未登录,浏览器中尚未存储相关 cookie,请求
/auth
,后台根据 cookie 无法获取到username
,因此是"anonymousUser"
(假如 cookie 过期,那么后台同样也应获取不到想要的用户信息); - 接下来
/auth/login
登录后,后台鉴权通过后会存储用户信息并添加响应头 Set-Cookie; - 然后再次访问
/auth
会携带刚才种的 cookie(JSESSIONID),后台验证 cookie 后,获得了之前存储的用户信息,并作为本次请求的响应信息。
到此基本完成了 AuthController,即登录鉴权,只不过用户的注册信息是在内存中,接下来接入 MySQL 数据库进行替换。
6. 连接 MySQL 数据库并完善登录模块
使用真实的MySQL数据库替换内存存储。
使用 Mybatis 注解的 Mapper 形式,把 UserService 中对于 User 的存取改为通过 Mapper 从数据库中存取。
application.properties
中加一条配置,解决数据库字段到对象属性的下划线和驼峰映射问题:mybatis.configuration.map-underscore-to-camel-case=true
用户表:
create table user
(
id int primary key auto_increment unique not null,
username varchar(20) unique,
encrypted_password varchar(100),
avatar varchar(100),
created_at datetime,
updated_at datetime
)
SpringBoot 自带的 JSON 是 Jackson,可以使用相关的 Json 注解来改变序列化时的行为:
- @JsonIgnore // 序列化时忽略 encryptedPassword,从而不会返回给前台
到此完成了 /auth 相关的接口,包括是否登录、登录、注册、登出。具体参看 AuthController。
7. 持续集成与自动化测试
从长远的角度看,没有自动化测试的应用是无法维护的。
在这个任务中,我们会为应用添加自动化测试和持续集成。完成这些步骤之后,你的应用就和真实世界中、各个公司的项目几乎没有区别了。如果你能掌握这些技能,那么你就能够独当一面,具备真正的工作能力。
开始编写自动化测试
单元测试、黑盒测试(mock测试)
单元测试:简单快速
集成测试:
- 优点:安全可靠
- 缺点:复杂且慢
maven default 生命周期中:
- test 单元测试
- integration-test 集成测试
冒烟测试
回归测试:regression test
开始:
Junit4 2012
Junit5 2017
https://junit.org/junit5/docs/current/user-guide/
单元测试
对应插件:maven-surefire-plugin
不需要打包,也不需要部署,只是测试 Java 代码。
编写测试前,要梳理程序对外暴露的期望的行为。
测试代码就是纯 Java 代码,Java 看待一个类时是看包名,所以一般项目代码使用包级私有(默认即是),测试类只需要和其在一个包中便可访问。
待测试的项目类可能有很多依赖,这时测试类中往往需要 Mock 库,如 Mockito(见上面的 pom.xml)。
Mockito 基于继承待 Mock 的类进行 Mock。
JUnit5 + Mockito 写法演示如下:
package blog.service;
import blog.entity.User;
import blog.mapper.UserMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock // Mockito 基于继承进行Mock
BCryptPasswordEncoder mockEncoder;
@Mock
UserMapper mockMapper;
@InjectMocks // UserService 是真实的代码实现,其所依赖的东西需要注入Mock
UserService userService;
@Test
public void testSave() {
// 调用 userService,验证 userService 将请求转发给了 userMapper
// mock 出来的加密器太假了,需要进一步 mock 加密器中的方法
Mockito.when(mockEncoder.encode("myPassword")).thenReturn("myEncodedPassword");
// 调用方法
userService.save("myUsername", "myPassword");
// 验证刚才的调用转发给了 mockMapper.insertuser
// 这里是期望,看刚才的调用是否符合这里的期望
Mockito.verify(mockMapper).insertUser("myUsername", "myEncodedPassword");
}
@Test
public void testGetUserByUsername() {
userService.getUserByUsername("myUsername");
Mockito.verify(mockMapper).findUserByUsername("myUsername");
}
@Test
public void throwExceptionWhenUserNotfound() {
Mockito.when(mockMapper.findUserByUsername("myUsername")).thenReturn(null);
Assertions.assertThrows(UsernameNotFoundException.class,
() -> userService.loadUserByUsername("myUsername"));
}
@Test void returnUserDetailsWhenUserFound() {
Mockito.when(mockMapper.findUserByUsername("myUsername"))
.thenReturn(new User(1, "myUsername", "myEncodedPassword"));
UserDetails userDetails = userService.loadUserByUsername("myUsername");
Assertions.assertEquals("myUsername", userDetails.getUsername());
Assertions.assertEquals("myEncodedPassword", userDetails.getPassword());
}
}
为 Controller 编写单元测试
由于涉及 Spring,所以记得引入依赖 spring-boot-starter-test。
这样写单元测试,其实还是有些不放心,因为都是 mock 的。
因此接下来进行集成测试。
持续集成
对应插件:maven-failsafe-plugin
常见公有 CI 服务有(开源项目免费,私有项目收费):TravisCI、CircleCI、Appveyor
自己部署的有:Jenkins、Gitlab
集成测试是对外表现出来的行为,测试更稳。一般需要先打包和部署。
集成测试行为的本身一般比较昂贵,所以可能没有单元测试那么多。
默认情况下 maven-surefire-plugin 会进行所有的测试,因此需要 pom 中排除集成测试, 同时在 maven-failsafe-plugin 中包含集成测试:
<!-- 单元测试 -->
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<excludes>
<exclude>**/*IntegrationTest</exclude>
</excludes>
</configuration>
</plugin>
<!-- 集成测试 -->
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<includes>
<include>**/*IntegrationTest</include>
</includes>
</configuration>
</plugin>
看一下 maven 的 default 生命周期,推荐运行至 verify 阶段,以便出发单元测试和集成测试,即运行 mvn verify
。
引入 Flyway 数据库迁移工具
参照之前的笔记:项目实战 - 多线程网络爬虫与Elasticsearch新闻搜索引擎。
搭建和使用 jenkins
使用 docker 安装运行 jenkins,浏览器然后打开 localhost:8081 (你也可以映射到别的端口),然后选择自定义插件,这里可以只选择 git 和 pipeline,其他插件全先不选,不然可能会安装很久。
然后一路下一步,创建一个项目,然后源码管理选 git,然后填写当前博客项目的 github https 形式的仓库地址;
最后添加要执行的 shell 命令。
由于 jenkins 容器中并没有 mvn 命令,所以至少有两种办法:
- jenkins 容器启动后,再开个 shell 运行
docker exec -it <jenkins 容器ID> bash
,进入正在运行的 jenkins 容器,安装 mvn。 - 如截图所示,使用了 maven wrapper,当运行环境中没有 mvn 命令时会自动安装 mvn。只需项目目录下运行
mvn -N io.takari:maven:0.7.7:wrapper
,即可启用 maven wrapper,会发现根目录多了一些脚本。因此截图中使用了 mvnw 脚本代替原本的 mvn:$ docker run --restart=always --name my-jenkins -v `pwd`/jenkins-data:/var/jenkins_home -p 8081:8080 jenkins/jenkins
开始一条构建:
jenkins 自动拉取仓库代码,安装依赖,并执行 ./mvnw test
:
最终单元测试全部通过:
Maven 与集成测试
了解一下 maven 的生命周期,default 默认生命周期中在 package 阶段之后是 pre-integration-test、integration-test 和 post-integration-test 这三个阶段。在 pom.xml 中,在这三个阶段上绑定集成测试相关的插件目标,从而实现集成测试:
- pre-integration-test,在该阶段使用 docker 启动一个空的测试数据库,然后使用 flyway 灌入数据。
- integration-test,执行我们写的集成测试的代码,即启动一个随机端口的 SpringBootTest 容器,并连接已准备好的测试数据库,然后逐个执行 @Test 标记的测试用例。
- post-integration-test,在该阶段将 mysql 容器删除丢弃。
一个 maven 插件有若干个插件目标,这些插件目标既可以单独执行( mvn some-plugin:some-goal
sss),也可以和 maven 的生命周期进行绑定。
使用过 exec-maven-plugin 可以把外部命令和 maven 生命周期进行绑定,即在生命周期中触发我们自定义的命令,比如用 docker 启动 mysql,清除 mysql 等。
8. 敏捷实战 - 使用TDD方式开发博客模块
进行敏捷开发的一些实战 - 测试驱动开发(TDD Test-driven development),来完成最后的登录模块。
先写测试,并且刚开始测试肯定会失败,然后去写相应的实现,直到测试通过。然后继续写更多测试,开发更多功能。
Blog 模块和 User 做法大同小异,建表语句如下:
create table blog
(
id int primary key auto_increment unique not null,
user_id int,
title varchar(100),
description varchar(100),
content text,
created_at datetime,
updated_at datetime
)
BlogResult 使用工厂方法,而非直接调用构造器的好处如下:
- 有名字
- 进行复杂的逻辑
- 可以返回 null
进一步封装 Result,并使用 mybatis 的 xml mapper。
TODO:
可以继续添加测试,测试分页是否正确。
剩余接口,择期实现
- GET /blog/:blogId
- POST /blog
- PATCH /blog/:blogId
- DELETE /blog/:blogId
9. 自动化部署与发布
在服务器上部署以下服务:
- Jenkins
- Docker Registry
- APP
公钥和私钥
服务器上提前配置好自己本地机器的公钥,当连接服务器时,服务器会用你提供的公钥生成随机字符串,然后会把字符串返回给你,如果你用自己本地的私钥解开了服务器返回的字符串并通知服务器,则你获得了服务器的信任,你是真的你,而不是别人伪装成了你。
因为用你的公钥加密后的字符串,只能有你的私钥进行解密,此为非对称加密。因此任何情况下都不要暴露你的私钥,以免他人伪造你的身份。
搭建 Jenkins
使用内置了 docker 的 jenkins 镜像:weweave/jenkins-lts-docker
登录服务器,找个地方创建 jenkins-data、jenkins-m2 目录,第二个用于 maven 依赖包的缓存。
前台启动:
$ docker run -it -p 8081:8080 -v /var/run/docker.sock:/var/run/docker.sock:ro -v `pwd`/jenkins-data:/var/jenkins_home -v `pwd`/jenkins-m2:/var/jenkins_home/.m2 weweave/jenkins-lts-docker
或后台启动:
$ docker run --restart always --name my-jenkins -d -p 8081:8080 -v /var/run/docker.sock:/var/run/docker.sock:ro -v `pwd`/jenkins-data:/var/jenkins_home -v `pwd`/jenkins-m2:/var/jenkins_home/.m2 4oh4/jenkins-docker
(如果镜像有问题,可自行寻找靠谱的、内部集成了 docker 的 jenkins 镜像)
Jenkinsfile:
- 多项目共享配置
- jenkins 迁移、变更,配置持久化
自动测试、发布、回滚
jenkins 面板中创建新项目,选择 Multibranch Pipeline 多分支流水线,然后 Branch Sources 选择 Git 并填写 HTTPS 的 GitHub 仓库地址,然后点击保存。
具体的 jenkins 配置,由项目仓库下的 Jenkinsfile 控制。
初次构建之后,不做什么,只是将仓库中的 Jenkinsfile 中的配置应用到 jenkins 中,下次构建可以看到变成了参数化构建,可以指定构建类型,以及其他一些可能未提及的设定,都是在 Jenkinsfile 中配置的。
顺便,jenkins 意外重启后会继续未完成的构建。
Docker 私服与 tag
jenkins 本身是用 docker 容器搭建的,该容器内部有 jenkins + docker 环境,jenkins 调用 docker build 构建出应用镜像(我们的 blog 博客镜像),但 jenkins 和该镜像本身都是在 docker 内部,构建出来的 blog 镜像如何传递给外面的世界,以便进一步部署?
当然可以和宿主机进行数据卷的映射,但更好的方式是将镜像推送到私有的 docker 仓库中,从而共享而应用镜像。
在 docker pull/push 的时候,会自动识别镜像名称前面的仓库地址:
-v /var/run/docker.sock:/var/run/docker.sock:ro 把容器内对外通信的 docker.sock 映射到宿主机的 docker.sock,方能在容器内部连接到接下来要搭建的 docker 镜像仓库。而 ro
代表只读。
启动一个镜像仓库服务,对外提供 5000 端口:
docker run -d -p 5000:5000 --restart always --name registry registry:2
修改服务器的 /etc/docker/daemon.json:
Jenkins 自动化部署发布与回滚
Rollback 回滚
选择回滚,而不是正常构建:
本质就是重新部署指定的历史版本,更本质一点就是 Jenkinsfile 中这个函数,常规部署和回滚部署都调用该函数,常规部署会先经过 maven 打包和 docker build,而回滚则几乎是直接调用 deployVersion
传入历史版本号,从而启动之前已经推送到镜像仓库中的镜像容器即可:
def deployVersion(String version) {
sh "ssh root@192.168.31.83 'docker rm -f my-blog && docker run --name my-blog -d -p 8080:8080 192.168.31.83:5000/blog-springboot:${version}'"
}
总结一下:
- 常规发布:会自动定时轮询分支状态,有新的提交时进行打包、构建和部署。
- 版本回滚:在 Jenkins 管理界面手动发起回滚操作。
TODO
- 搬瓦工性能不足,遂采取本地演示,但之前的 jenkins 镜像内部不包含 docker 环境,因此需要更换 jenkins 镜像后重启运行。需要寻找一个靠谱的带 docker 的 jenkins 镜像。
- 完善遗留的几个blog接口