Eisen's Blog

© 2024. All rights reserved.

Spring Boot + Spring MVC + MyBatis 版本的 Real World 实现

2017 August-22

Real World 是由 thinkster 这样一个在线编程教育机构发起的一个前后端分离的项目规范。用以展示并作为教材教大家用 react、angular 等不同的前端框架或者 rails、django、spring boot 等不同的后端框架实现同一个项目时的实践是什么样子的。我觉得这个主意非常的好,它让大家对技术的讨论有了一个共同的主题,在采用不同的技术栈以及设计思路解决这个共同的问题的时候我们可以更确切的看到不同的方案之间的优劣,从而更切实的(而不是零散的代码和想象)了解不同框架、语言、设计思路在实现一个项目时的差异,从而帮助我们更好的选择项目的解决方案。当然,从单个技术栈来看,它提供了一个做出完整项目需要都需要哪些具体的知识点,可以当做某一个技术栈的入门小项目来学习和借鉴。

虽然这个项目叫做 real world,相比 todomvc 这样的 hello world 确实复杂了不少,但是很显然它的复杂度还仅仅是一个人几个小时就能完成的水平,当然不能全面的反映出一个框架的水平,仅做参考。

本篇对项目做一个整体的介绍,后续会有一些细节的介绍。文章中会涉及到一些 DDD(领域驱动设计) 相关的概念,想要更多的了解建议看看最下面相关材料中的链接。

项目功能

conduit 是 realworld 要实现的一个博客系统。具备一下的功能:

  1. 用户的注册和登录
  2. 用户可以发表、编辑文章
  3. 用户可以对文章添加评论、点赞
  4. 用户可以关注别的用户,关注的用户的文章会展示在用户的 feed 中

这是一个前后端分离的项目,其提供了后端 api 的规范。这里,我们不评论其 API 设计的好坏,要完全遵循其设计并实现它。当然,对于不同的语言和框架实现都有其 API 设计的偏好,既然这里定死了一种规范,那么在实现的过程中难免会有一些 tricky 的地方需要我们去克服。

目录结构

如标题所述,这里我提供了一个 spring boot + spring mvc + mybatis 的实现。其大概的结构如下:

.
├── JacksonCustomizations.java
├── MyBatisConfig.java
├── RealworldApplication.java
├── api
├── application
├── core
└── infrastructure
  1. api 是 web 层,实现了和 spring mvc 的 web 接口
  2. core 是业务层,包含了最关键的业务实体和领域服务以及其各个实体之间的交互
  3. application 是对外的服务层,由于这个项目本身的业务并不复杂,这里处理的基本都是各种信息的查询
  4. core 中定义的大量接口在 infrastructure 包含了其具体的实现,比如 data mapper 的实现,具体的密码加密的实现等
  5. 其他则是一些整体的配置类,如主类 RealworldApplication 数据库配置类 MyBatisConfig

六边形架构

六边形架构 或者说是 洋葱架构 其实不是一个什么新东西,因为分层架构会导致其最底层的实现是数据库,而之前很多的业务逻辑和数据库是揉在一起的(事实上很多项目也确实这样,大量的存储过程中包含着业务的逻辑,业务和数据库紧密的结合在了一起);但实际上,一个应用最核心的东西应该是业务逻辑,而业务逻辑是不应该和技术细节有强关联的,数据库实现和视图层一样,是某种技术细节,不应该和将其与业务逻辑绑定,所以应该用应该强调内部和外部:内部是我的业务逻辑,而外部与外界沟通的基础设施和技术细节,比如具体的数据库存储,比如 restful 的 api,再比如 html 的视图。

通过这样的思考方式,我们可以认为 mysql 数据库实现仅仅是众多数据库实现中的一个而已,我们可以在不同的环境中轻易的替换掉它,尤其是为对业务的测试提供了可能:我们可以采用内存数据库或者 mock 轻松的实现业务测试。

我所实现的这个 realworld 项目也基本遵循这个架构,首先在 core 中定义了我们的业务实体 User Article 已经各种 Repository 的接口。他们定义了这个项目核心实体的关系以及交互行为。其中具体的 Repository 的实现以及 web 接口的实现都与它无关。对于比较简单的数据创建等行为我们直接在 web 层中处理了,而相对比较麻烦的查询业务我们按照用例在 application 中提供。

在 api 层直接创建用户:

@RequestMapping(path = "/users", method = POST)
public ResponseEntity createUser(@Valid @RequestBody RegisterParam registerParam,
                                 BindingResult bindingResult) {
    checkInput(registerParam, bindingResult);

    User user = new User(
        registerParam.getEmail(),
        registerParam.getUsername(),
        encryptService.encrypt(registerParam.getPassword()),
        "",
        defaultImage);
    userRepository.save(user);
    UserData userData = userQueryService.findById(user.getId()).get();
    return ResponseEntity.status(201).body(
                userResponse(new UserWithToken(userData,
                                               jwtService.toToken(user))));
}

在 application 层创建一个 findRecentArticles 的服务,用于处理相对比较复杂的查询:

public ArticleDataList findRecentArticles(String tag, 
                                          String author, 
                                          String favoritedBy, 
                                          Page page, 
                                          User currentUser) {
    List<String> articleIds = articleReadService.queryArticles(tag, 
                                                               author, 
                                                               favoritedBy, 
                                                               page);
    int articleCount = articleReadService.countArticle(tag, author, favoritedBy);
    if (articleIds.size() == 0) {
        return new ArticleDataList(new ArrayList<>(), articleCount);
    } else {
        List<ArticleData> articles = articleReadService.findArticles(articleIds);
        fillExtraInfo(articles, currentUser);
        return new ArticleDataList(articles, articleCount);
    }
}

注意 这里之所以没有为创建数据的用例在 application 中创建 service 纯粹是因为它们比较简单,在面向复杂的场景时是可以提供的。

CQRS

在 DDD 中 Repository 主要负责数据的持久化:它的任务非常的简单:要么是将内存中的 aggregate 储到数据库中,要么是从数据库中将指定 id 的实体从数据库中重新在内存中构建起来。它实际上是不负责那种复杂的查询业务的,比如获取被喜爱最多的 50 篇文章。更多的内容可以看这篇文章

CQRS 全称 Command Query Responsibility Segregation,强调一个系统的读模型和写模型是分离的。其中 DDD 所实现的是读模型,保证了业务的实现以及数据的一致性。而读模型则纯粹是利用底层数据库的优势将用户需要的数据拼装起来,完全不涉及到实体。这样的好处在于我们可以完全实现界面所需要的数据模型和真正的业务模型的独立演进,不会因为一个界面上数据展示的变化而导致本身的业务模型出现变更。当然,这里所谓的界面就是我们的 json API 规格。对于查询业务,我们在 application/data 下提供了单独的 Data Transfer Object

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticleData {
    private String id;
    private String slug;
    private String title;
    private String description;
    private String body;
    private boolean favorited;
    private int favoritesCount;
    private DateTime createdAt;
    private DateTime updatedAt;
    private List<String> tagList;
    @JsonProperty("author")
    private ProfileData profileData;
}

可以看到 ArticleData 就是 DTO 或者说是 Presentation Model 它和 API 文档中对数据的格式要求完全对齐而不考虑 Article 和 Author 到底应不应当属于一个聚合。

而下面则是在 core 下的实体:

@Getter
@NoArgsConstructor
@EqualsAndHashCode(of = {"id"})
public class Article {
    private String userId;
    private String id;
    private String slug;
    private String title;
    private String description;
    private String body;
    private List<Tag> tags;
    private DateTime createdAt;
    private DateTime updatedAt;
	...
}

很显然,这里 Article 中并不包含 Author 的概念,因为它们并不属于一个聚合,Article 只能保存另外一个聚合的 Id (userId)。

具体的代码见 GitHub 欢迎 star、fork、报 bug、提供 PR。

相关资料

  1. Real World
  2. CQRS
  3. DDD Repository
  4. Some tips about DDD

有关 servlet 和 filter 的基础知识

2017 August-13

用惯了抽象级别比较高的 web 框架的人在看一些相关的资料的时候也会时不时看到 filter 和 servlet 这样的名词,也会听到一个容器的概念。如果你想真正的明白整个 java web 的开发体系,还是需要了解一些古老的知识,以便明白 java web 整个的体系是什么样子的。

servlet 的基础知识

这里只讲一些大概的概念,具体的操作以及最新的 servlet 注解的支持大多数人都不会使用,因此只要知道基本的体系即可,需要深究请看文档。

servlet

servlet 是 java web 得以实现的基础,任何 java web 的框架都是通过对 servlet 加以封装实现的。java 最基础的 servlet 接口只定义了一下的几个方法:

void init(ServletConfig config) throws ServletException
void service(ServletRequest request, ServletResponse response)
        throws ServletException, java.io.IOException
void destroy()
java.lang.String getServletInfo()
ServletConfig getServletConfig()

不过我们平时使用的是 HttpServlet 一个实现了 Http 处理并实现了 Servlet 接口的一个继承类。它针对 http 请求增加了 doGet doPost doDelete doPut 等方法。以用于处理不同类型的 http 请求。然后,通过一个 web.xml 定义 servlet 与请求路径之间的映射关系,以说明哪些请求被哪个 servlet 处理。这里我们展示一个例子:

public class TestServlet extends HttpServlet {
  public void doGet(HttpServletRequest request, 
                    HttpServletResponse response) throws IOException { // 1
    PrintWriter out = response.getWriter();
    out.println("Hello, Servlet");
  }
}
<?xml version=”1.0” encoding=”ISO-8851-1” ?>
<web-app ...>
    <servlet> // 2
        <servlet-name>Test Servlet</servlet-name>
        <servlet-class>TestServlet</servlet-class>
    </servlet>
    <servlet-mapping> // 3
        <servlet-name>Test Servlet</servlet-name>
        <url-pattern>/Serv1</url-pattern>
    </servlet-mapping>
</web-app>

可以看到,

  1. 实现了 doGet 方法,用于处理 GET 请求
  2. 在 web.xml 声明一个 servlet 元素,并定义一个新的名字 Test Servlet 与实际的 TestServlet 类之间的映射关系
  3. 定义 servlet-mapping 定义 Test Servlet 与具体的 url-pattern 之间的映射关系,这里定义到 /Serv1 路径的请求都由 Test Servlet 这个 servlet 处理

servlet container

在完成了 servlet 以及 web.xml 的编写之后需要使用一个叫做 servlet container 的东西才能让服务运行起来。著名的 servlet container 有 tomcat,jetty。它们主要负责维护其生命周期并按照 web.xml 的规则将 http 请求分发到指定的 servlet 进行处理。

filter

有的时候你需要一种机制来控制那些涵盖了多个 servlet 映射的请求,比如追踪每一次请求的执行时间,或者对一系列路径的访问做限制。java 有一个 Filter 的组件用于完成这样的工作。

它在将 request 传递给 servlet 前以及从 servlet 返回 response 后分别进行一系列的处理。并且 servlet 前后可以包含多个 filter。

2021 07 27 13 14 47

一个 filter 的例子如下:

public class MyFilter implements Filter { // 1
  public void doFilter(HttpServlet Request request, 
                       HttpServletResponse response, 
                       FilterChain chain) {
    // this is where request handling would go
    chain.doFilter(request, respoonse);
    // this is where response handling would go
  }
}
<web-app ...>
    <filter> // 2
        <filter-name>BeerRequest</filter-name>
        <filter-class>com.example.web.BeerRequestFilter</filter-class> 
    </filter>
    <filter-mapping> // 3
    	<filter-name>BeerRequest</filter-name>
        <url-pattern>*.do</url-pattern>
	</filter-mapping>
</web-app>

可以看到,

  1. java 同样已经提供了一个 Filter 的基类,并且每个 filter 都包含了处理 request 和处理 response 的双重任务:在 chain.doFilter 前的通常是处理请求的,当然你也可以根据一些情况直接返回异常结果而不允许请求继续传递下去;在 chain.doFilter 后的是 servlet 处理完之后返回的结果,这里可以对其进行进一步的加工。
  2. filter 同样需要在 web.xml 进行声明,指定其名字和类的映射关系
  3. filter 也需要对请求进行映射,和 servlet 类似

最后说明一些 filter 的顺序和其在 web.xml 的声明顺序有关。

spring mvc 与 servlet 的关系

前面提到过,java web 的各种框架都是建立在 servlet 体系之上的,SpringMVC 也不例外。和 structs 等老牌的 web 框架类似,它也提供了一个 DispatcherServlet 作为 Front Controller 来拦截所有的请求,并通过自己的请求分发机制将请求分发到 SpringMVC 下实际的 controller 之中。

2021 07 27 13 14 00

<web-app>
    <servlet>
        <servlet-name>example</servlet-name>
        <servlet-class>
            org.springframework.web.servlet.DispatcherServlet
        </servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>example</servlet-name>
        <url-pattern>/example/*</url-pattern>
    </servlet-mapping>
</web-app>

spring security web 与 filter 的关系

spring security web 都是基于 servlet filter 建立起来的。其使用的方式和 SpringMVC 的 DispatcherServlet 非常类似,有一个 DelegatingFilterProxy 作为全局的 Filter 对所有的请求进行过滤,并在其层次之下实现 Spring Security 所提供的特有的 Filter(当然不再是继承自 java Filter 的类了)层次。

<filter>
    <filter-name>myFilter</filter-name>
    <filter-class>
        org.springframework.web.filter.DelegatingFilterProxy
    </filter-class>
    </filter>

    <filter-mapping>
    <filter-name>myFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

相关资料

  1. SpringMVC
  2. Head First Servlets and JSP
  3. Spring Security: Web Application Security

Drone 一个原生支持 docker 的 CI

2017 August-04

大概是一年前发现了这样一个叫做 Drone 的开源 ci,在逐渐的尝试过程中发现它的功能非常的强大,其 pipeline as code + docker + backing service 支持的体系基本和我理想中的 ci 一模一样...这里就介绍一下我看到的 drone 的一些非常出彩的地方以及日常使用时一些非常有用的使用方式。

本地安装

可以看到 drone 的界面非常的简洁,和其他 ci 一样它通过和 github gitlab 或者是 gogs 这样的 git repository 链接并绑定 web hook 在用户提交新的 commit 的时候出发 ci 的执行。drone 作为一个开源的 ci 其支持 docker 方式的安装,非常的简单:

version: '2'

services:
  drone-server:
    image: drone/drone:0.7
    ports:
      - 80:8000
    volumes:
      - /var/lib/drone:/var/lib/drone/
    restart: always
    environment:
      - DRONE_OPEN=true
      - DRONE_HOST=${DRONE_HOST}
      - DRONE_GITHUB=true
      - DRONE_GITHUB_CLIENT=${DRONE_GITHUB_CLIENT}
      - DRONE_GITHUB_SECRET=${DRONE_GITHUB_SECRET}
      - DRONE_SECRET=${DRONE_SECRET}

  drone-agent:
    image: drone/drone:0.7
    command: agent
    restart: always
    depends_on:
      - drone-server
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - DRONE_SERVER=ws://drone-server:8000/ws/broker
      - DRONE_SECRET=${DRONE_SECRET}

通过这样的 docker-compose 文件就可以在本地启动一个 droner server 和一个 drone-agent(其概念和 gocd 类似)。

drone 的亮点

pipeline as code

首先 drone 支持 pipeline as code 其通过一个简单的 yaml 文件就包含了一个项目 ci 的所有内容了(当然,到底简不简单要看你构建流程的复杂程度以及你对项目的封装程度)。举一个例子:

pipeline:
  build:
    image: node:6.10.2-alpine
    commands:
      - yarn install
      - yarn run build
  publish:
    image: plugins/docker
    repo: eisenxu/realtopper-app
    secrets: [ docker_username, docker_password ]
    tags:
      - latest
      - ${DRONE_COMMIT_SHA:0:8}

这是一个采用 create-react-app 创建的一个前端单页应用项目。这里定义了 buildpublish 两个阶段。第一个阶段 yarn installyarn run build 分别下载依赖和编译但也应用。第二阶段 publish 采用项目中的 Dockerfile 构建一个 docker image 并发布到 hub.docker.com,具体两个步骤如何进行的细节我们在后面会慢慢介绍。

原生支持 docker

上面的 yaml 中每个阶段都有一个 image 的字段,这个 image 就是指一个 docker image 也就是说 drone 下每一个阶段都是在一个你所指定的 docker container 下执行的。这样当然就集成了 docker 所引入的一系列的好处:环境隔离、标准化镜像。并且,它是后面插件扩展以及 backing service 可以被轻而易举的实现的基础。

简单易用的插件扩展

还是在看上面的那个例子,第一个阶段我们在 node:6.10.2-alpine 下构建了一个单页应用。然后,我们采用了 plugins/docker 镜像构建并发布了我们的镜像到 hub.docker.com。而这里的 plugins/docker 就是 drone 为我们提供的一个插件了。

虽说是一个插件,但实际上用起来就和其他的 image 一样,这个插件的功能就是帮我们利用项目中的 Dockerfile 构建一个新的 docker image 并提交。除此之外还有一些其他的官方插件可供使用,详情在这里

当然,自己做一个插件也是非常简单的,在插件被执行的时候,当前目录就是项目的根目录,然后 drone 会暴露一系列的环境变量给用户使用,我们可以采用之前的步骤所产生的数据或者环境变量中的内容实现一个特定功能的插件。

支持 backing service

我们跑 ci 的时候难免会有一些外部的依赖,比如跑单元测试的时候可能会用到外部的数据库。比如跑前端界面测试的时候我们会需要 selenium。drone 对这种场景提供了支持。

pipeline:
  test:
    image: golang
    commands:
      - go get
      - go test

services:
  database:
    image: postgres
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_DB=test

这是官方所提供的一个数据库的例子。通过定义一个 services 字段,我们可以提供一个或者多个外部服务。

最佳实践

对 docker image 的验收测试

我们采用 docker 容器进行部署的时候通常是在 docker 容器中跑单元测试然后打包镜像并推到 registry。但是这样的流程并不能保证我们打包好的镜像是工作的,可能我们的 Dockerfile 写的有问题导致服务没办法被使用。所以,其实还可以最这种最终构件好的镜像做一个验收测试。

backing service 的机制可以让我们这么做:

pipeline:

  build:
    image: node:6.10.2-alpine
    commands:
      - npm build

  publish_for_test:
    image: plugins/docker
    repo: test/bar
    tags: [ 1.0.0, 1.0, latest ]

  run_server:
    image: test/bar:latest
    detach: true
  
  verify:
    image: blueimp/chromedriver
    environment:
      - VNC_ENABLED=true
      - EXPOSE_X11=true
    commands:
      - nightwatch

  publish:
    image: plugins/docker
    repo: production/bar
    tags: [ 1.0.0, 1.0, latest ]
  1. 首先,我们在 publish_for_test 中构建一个 test/bar:latest 的镜像。
  2. 然后我们采用 detach 的字段表明我们在这里把我们刚刚创建好的镜像运行起来。
  3. verify 阶段,我们采用 nightwatch 对已经运行起来的 test/bar:latest 服务执行验收测试,也就是说,这时候我们把刚刚创建的应用当做我们的 backing service
  4. 如果测试通过了,我们再构建一个新的镜像并 push 到生产环境 registry

当然,这里的 publish_for_test 其实最好的办法是只构建镜像而不提交镜像,然后在本地启动这个镜像。不过这种使用本地镜像的方式并没有使用过,而且也没有那种只构建不提交或者只提交已经存在的镜像的插件,以后可以自己进一步做一些优化。

用 drone 部署多个阶段的环境

虽然 drone 没有 GoCD 里的 deployment pipeline 的概念,但是它可以通过指定特殊的 deployment 的事件实现手动激活的多环境部署。

pipeline:
  build:
    image: golang
    commands:
      - go build
      - go test

  publish:
    image: plugins/docker
    registry: registry.heroku.com
    repo: registry.heroku.com/my-staging-app/web
    when:
+     event: deployment
+     environment: staging

  publish_to_prod:
    image: plugins/docker
    registry: registry.heroku.com
    repo: registry.heroku.com/my-production-app/web
    when:
+     event: deployment
+     environment: production

可以看到通过指定 eventenvironment 可以指定两个不同的环境:staging 和 production。然后,通过 drone 所提供的命令行可以实现手工部署到不同的环境。

drone deploy octocat/hello-world 24 staging

这里是将构建 24 号部署到 staging 环境。

使用插件为构建增加缓存

每次构建都从远端获取依赖真是非常费流量费时间,最好可以不要重复下载。drone 就以插件的方式支持了这样的功能。

pipeline:
  restore-cache:
    image: drillster/drone-volume-cache
    restore: true
    mount:
      - ./node_modules
    volumes:
      - /tmp/cache:/cache

  build:
    image: node
    commands:
      - npm install

  rebuild-cache:
    image: drillster/drone-volume-cache
    rebuild: true
    mount:
      - ./node_modules
    volumes:
      - /tmp/cache:/cache

第一阶段 restore-cache/tmp/cache 下该项目的缓存拷贝到 ./node_modules。第三阶段将 ./node_modules 的内容拷贝会 /tmp/cache 详细的内容见缓存

当前的状态

目前 drone 刚刚开始了商业化之路,并且在疯狂的更新中,整体社区非常的活跃 star 也已经过万了,非常期待它未来的发展。

相关资料