Cookie Cutter架构 - Janos Pasztor

19-01-09 banq
              

在业务应用程序方面,您需要一个可以很好地扩展的体系结构。这是我的看法,基于Uncle Bobs EBI。

尽管大多数人都认为我是DevOps人,但我经常在咨询项目期间使用业务应用程序,甚至在为DevOps企业编写管理软件时也是如此。在我这么多年的时间里,我意识到我编写代码的方式并不是非常有效。

首先,我开始使用一个框架,例如Symfony(拒绝抨击Symfony),我会以Symfony文档中举例说明的方式编写代码。但是,Symfony文档包含有关如何执行操作而不是黄金标准的简化示例。例如,它将直接在控制器中具有数据库(Doctrine)查询。

许多人试图将其理性化为简单,并且Doctrine是MVC中的模型,但是当我开始学习时,拥有扁平的架构并不能很好地扩展。随着需求的增长,控制器将变得越来越臃肿,并且不会分离出常见的代码部分。这是一个问题,但我知道没有解决方案。

几年前,我遇到过Uncle Bobs谈话架构 - 失落的岁月,但这太过于学术化,太过于理论化。尽管他提出的设置称为Entity-Boundary-Interactor有相当数量的文档,但我发现它太简单了。

然后,大约两年前,我从PHP切换到Java。不是因为我讨厌PHP,远非如此。我只是希望深入 静态类型,而PHP(现在仍然)缺乏这种类型。我第一次切换到Hacklang,这很棒,但当时缺乏任何合理的IDE支持。最后,我放弃并将所有代码移植到Java。

看看Java世界,我不太喜欢它。作为语言的新手,在我看来,我不喜欢古老的Servlet API,而且错过了PSR-7中提出的现代不可变HTTP表示形式 。

由于我没有时间压力,我做了一个商业环境中没有理智的人会做的事情,并且我自己也做了。我将PSR-7移植到Java,并为servlet API编写了一个映射器。我构建了一个围绕Jetty的抽象来充当嵌入式Web服务器,因此我摆脱了通常的Java架构的限制,可以自由地构建和试验我喜欢的任何系统。

去年夏天,另一个概念开始涌入我的观点,这严重影响了我构建系统的方式:单页应用程序。讨厌或喜欢React和它的好友,我开始考虑我的应用程序更像是一个API,而不是那些在会话中处理状态,存储表单数据和不存在的东西。

我还在很大程度上建立了依赖注入的概念,使用我自己的 依赖注入器。但最重要的是,我做了很多关于如何构建一个可以很好地维护的应用程序的思考。

架构

通过我的实验,我提出了一个我想出的架构。重点不在于编写最少量的代码。这也不是最快的写入代码。重点是可预测性。换句话说,我讨厌意外。当需求发生微小变化时,它不应该波及整个应用程序,搞乱一切。应该在一个地方捕获第三方API更改或错误,并且不应导致级联故障。

为了实现这一点,我的应用程序分为三层:API,业务逻辑和后端/存储。这些层中的每一层仅负责一件事,并且它们在它们之间传递实体。(基本上,半哑semi-dumb数据传输对象DTO,banq注:如果作者懂DDD,用领域模型替代DTO就跟棒了!)。

API

该API负责处理与输出介质。例如,如果API需要返回博客帖子,但又想获取所述博客帖子的作者,则需要调用相应的业务逻辑类来执行该操作。例如:

class BlogPostGetApi {
  private final BlogPostGetBusinessLogic blogPostGetBusinessLogic;
  private final AuthorGetBusinessLogic authorGetBusinessLogic;
  
  @Inject
  public BlogPostGetApi(
    BlogPostGetBusinessLogic blogPostGetBusinessLogic,
    AuthorGetBusinessLogic authorGetBusinessLogic
  ) {
    this.blogPostGetBusinessLogic = blogPostGetBusinessLogic;
    this.authorGetBusinessLogic = authorGetBusinessLogic;
  }
  
  @Route(
    method = "GET"
    path = "/blog/:id"
  )
  public Response get(String id) throws BlogPostNotFoundException {
    BlogPost blogPost = blogPostGetBusinessLogic.getById(id);
    Author author = authorGetBusinessLogic.getById(blogPost.getAuthorId());
  
    return new Response (
      blogPost,
      author
    )
  }
  
  //Inner class that contains a structured response
  public class Response {
    //...
  }
}

正如您所看到的,业务逻辑层不必处理它要获取对象有关的复杂性,这是API层要处理的复杂性。API处理任何潜在的权限问题也很重要,例如:

if (!blogPost.isPublished()) {
  throw new BlogPostNotFoundException();
}

它变得更复杂,但你更容易都懂代码且明白。

业务逻辑

业务逻辑是负责怎么做的问题,所以你可以有像这样的类 UserCreateBusinessLogic。此类仅负责导致用户创建的业务流程。因任何原因需要创建用户的API都可以依赖于UserCreateBusinessLogic确保用户创建仅在一个地方完成。

毋庸置疑,业务流程可能变得复杂,因此他们当然可以相互调用。例如,如果您有一个创建组织对象的业务流程,并且有一个用户,那么您可以拥有一个UserOrganizationRegisterBusinessLogic,它将同时调用UserCreateBusinessLogic和OrganizationCreateBusinessLogic。也许还有付款创建和其他几个。

后端/存储

我们应用程序中的最后一层负责处理我们应用程序之外的任何讨厌部分,例如第三方API,数据库以及我们认为不可靠的所有其他内容。

等一下......我刚才说数据库不可靠吗?Yepp,我刚才是这么说。数据库在网络上,并且开发人员希望它与其他人一样,而网络是不可靠的(banq注:FLP定理)。他们可以损坏,他们可能很慢,他们可以丢包。因此,我对数据库的处理与处理第三方API的方式相同。

回到API ......通常我们必须处理第三方API,而我们并不是很清楚这些API内部。要么它没有被文档描述,要么它只是有一些我们还没有遇到过的怪癖。这些将不可避免地导致我们的系统迟早要处理的问题,例如捕获错误,例如,让业务逻辑现在我无法做到这一点。

例如,数据结构可能很奇怪而且不符合我们的喜好,在这种情况下,后端层作业将其转换为我们可以工作的对象。

实体

注意:本文不区分实体和DTO。出于本文的目的,实体是您希望在应用程序的各个部分之间传递的结构化数据集。如果您想根据目的或用途拆分它们,请选择它。

正如我提到的,不同的层使用实体进行通信。这些不是您期望从ORM系统获得的实体。它们不包含加载子对象的魔术函数,例如blogPost.getAuthor()。这些是哑数据传输对象(banq注:可以用DDD实体或值对象实现),例如:

class BlogPost {
  private final String id;
  private final String authorId;
  private final String title;
  
  public BlogPost(
    String id,
    String authorId,
    String title
  ) {
    this.id = id;
    this.authorId = authorId;
    this.title = title;
  }
  
  public String getId() {
    return this.id;
  }
  
  //...
}

想要获取属于此博客帖子的作者?自己做。在我看来,它应该在您的业务逻辑中明确。可读代码,用于记录发生的情况,而不是依赖于ORM的内部行为。

您可能还注意到上面的实体是不可变的。如果要修改标题,则必须在副本中执行此操作。为此,实体可以包含辅助函数:

public BlogPost withTitle(
  String title
) {
  return new BlogPost(
    this.id,
    this.authorId,
    title
  );
}

就是这样!除了可能验证之外,实体中没有更多内容。毕竟,甚至不应该创建具有无效数据的实体,应该尽早发生故障。

处理一致性

还记得我们上面的组织和用户注册示例吗?一个注册过程涉及创建多个对象,而这些对象又使用多个存储类将数据保存到数据库。

你如何确保一致性?换句话说,您如何确保创建全部或全部?

这就是Java真正开始闪耀的地方。有一种称为Java Transaction API的东西,它允许创建分布式事务,甚至可以跨多个数据库。

我只是Transaction在执行需要它的操作时在我的API层中请求一个对象,然后将它通过我的应用程序传递到存储层。然后,存储层可以使用它来确保一致性,甚至可以跨多个对象创建/更新。

处理权限检查

我很难在很长一段时间内实施更复杂的权限检查。API层本身不适合实现广泛的权限检查,因为可能需要跨多个API重用这些权限。

让我们举一个非常简单的例子:登录的每个用户都获得一个访问令牌,然后你在API中为每个需要权限的请求请求访问令牌,如下所示:

public Response update(
  @RequestHeader(name = "Authorization", prefix = "Bearer") 
  String accessToken,
  String blogPostId,
  String title,
  //...
) {
  //...
}

在此示例中,单页应用程序将在Authorization标头中发送访问令牌,如下所示:

Authorization: Bearer your-access-token-here

但是,您的API需要确定使用所述访问令牌登录的用户是否有权更新此博客帖子。我们可以在这里使用一个小技巧:我们在业务逻辑之前添加一个额外的安全层,例如:

public Response update(
  @RequestHeader(name = "Authorization", prefix = "Bearer") 
  String accessToken,
  String blogPostId,
  String title,
  //...
) throws AccessDeniedException, BlogPostNotFoundException {
  newBlogPost = blogPostUpdateSecurity.update(
    blogPostId,
    accessToken,
    title,
    //...
  );
  return new Response(
    newBlogPost
  );
}

安全层检查用户是否具有适当的权限,并将请求传递给博客帖子的实际更新业务逻辑,并返回响应。当然,在内部,它需要从数据库中获取访问令牌,检索用户,如果需要可能涉及缓存层,但这不需要涉及API。

传统的Web应用程序

到目前为止,我们只谈到了一个特定于单页应用程序的架构,其中的东西很简单。实际上,您的应用程序不必包含任何状态。它只能传递管道中的任何请求并返回结果。从本质上讲,您的应用程序基本上是一组函数,通常是纯粹的或至少是无状态的,具有依赖注入。(向JavaScript大家们致敬,我们要感谢近年来函数式编程的兴起!)

但是,当谈到传统的Web应用程序时,事情会变得混乱。他们希望在会话中存储临时表单数据和一堆其他内容。虽然我不提倡使用会话,但我们必须处理API无需处理的许多事情。

所以,这里有一个想法:为什么我们不在API之上再添加一层?毕竟,权限检查和所有其他事情已经处理完毕,因此Web层应该只处理传统Web应用程序特有的内容!

总结

Michael Cullum称这是Cookie Cutter方法,所以我正式称之为。基本思路如下:

  • 将您的应用程序拆分为服务和实体。
  • 实体应该是不可变的,并且只包含验证代码并创建自身的更改副本。(banq注:正是DDD值对象)
  • 除了注入的依赖项之外,服务应该没有内部状态。
  • 服务应该具有非常少量的公共方法,理想情况下只有一种,通常是纯粹的或至少是无状态的 功能。(如果需要,可以添加私有方法以便于阅读,但通常最好分割整个类。)
  • 服务应该尽可能少地处理。尽量让它们低于〜150行代码。
  • 应将服务分组为多个层,每个层负责一组任务。

额外的事实:这不是我的定制、疯狂gou屁框架特有的。您可以在支持依赖项注入的任何现代Web框架中实现此功能。您只需要愿意放弃在应用程序的所有部分中使用该框架。

好处

您可能已经意识到,这种架构要求您编写大量代码,尤其是最初的代码。它至少不适用于任何类似于快速原型制作方法的东西。

不可否认,我致力于维护周期很长的应用程序,并且经常会收到客户更改请求。你的情况可能会有所不同,也许你把网站交给你再也见不到的客户,但是让我问你:你最后一次走捷径的时又是什么时候再次困扰了你?

对我而言,这是最可怕的感受之一,看到客户提出了一个相对简单的变更请求,然后导致整个团队多周头痛。

这种架构已经证明是一致的。不快,但是一致。我们知道开发某个功能需要多长时间。系统中没有任何意外,但它带来的缺点是我们必须自己编写很多代码。

此外,由于此设置不依赖于Java,因此我设法聘请了一位经验不足的PHP开发人员,并在IDE的帮助下,让他们在大约3天内提供生产就绪代码。

此外,由于一切都非常好并且切割得很好,因此对单个零件进行单元测试非常容易。它使维护起来非常舒适。

              

1