项目地址:https://github.com/wangpeng1994/springboot-club

项目功能包含:

  • 首页: 展示所有作者设置到首页的博客列表
  • 详情:展示博客详情
  • 登录、注册: 用户登录注册
  • 用户页面: 展示某个用户的所有博客列表
  • 我的: 展示个人主页
  • 编辑、删除、创建博客

后端主要技术栈:Java/SpringBoot/IDEA/Maven/Docker/MySQL/MyBatis/Jenkins
前端使用现成的静态打包文件:vue-cli/vue-router/vuex/axios/webpack/element-ui

多人协作平台接口文档.md

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/

  1. <dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-security</artifactId>
  4. </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 后,获得了之前存储的用户信息,并作为本次请求的响应信息。

image.png
到此基本完成了 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测试)

  1. 单元测试:简单快速

  2. 集成测试:

  • 优点:安全可靠
  • 缺点:复杂且慢

maven default 生命周期中:

  • test 单元测试
  • integration-test 集成测试
  1. 冒烟测试

  2. 回归测试: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
    
    image.png
    image.png
    image.png
    开始一条构建:
    image.png
    jenkins 自动拉取仓库代码,安装依赖,并执行 ./mvnw test
    image.png
    image.png
    最终单元测试全部通过:
    image.png

image.png

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 中配置的。
image.png
顺便,jenkins 意外重启后会继续未完成的构建。

Docker 私服与 tag

jenkins 本身是用 docker 容器搭建的,该容器内部有 jenkins + docker 环境,jenkins 调用 docker build 构建出应用镜像(我们的 blog 博客镜像),但 jenkins 和该镜像本身都是在 docker 内部,构建出来的 blog 镜像如何传递给外面的世界,以便进一步部署?

当然可以和宿主机进行数据卷的映射,但更好的方式是将镜像推送到私有的 docker 仓库中,从而共享而应用镜像。

在 docker pull/push 的时候,会自动识别镜像名称前面的仓库地址:image.png
-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:
image.png
image.png

Jenkins 自动化部署发布与回滚

从镜像仓库 pull 刚才的镜像:
image.png
image.png
image.pngimage.png

Rollback 回滚

选择回滚,而不是正常构建:
image.png
image.png
本质就是重新部署指定的历史版本,更本质一点就是 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接口