前端拆分_如何在消费者驱动的合同测试的帮助下拆分前端和后端的部署

前端拆分

Consumer driven contract testing is a great way to improve the reliability of interconnected systems. Integration testing becomes way easier and more self contained. It opens the door for independent deployments, and leads to faster iterations and more granular feedback. Unlike your insurance, it doesn't have any fine print. This article is about setting it up in a delivery pipeline, in the context of doing continuous delivery.

消费者驱动的合同测试是提高互连系统可靠性的好方法。 集成测试变得更容易,更独立。 它为独立部署打开了大门,并导致更快的迭代和更细致的反馈。 与您的保险不同,它没有任何精美的文字。 本文是关于在连续交付的情况下在交付管道中进行设置的。

I want to show how Contract Tests help split the deployment of the front end and the back end of a small application. I have a React client and a Spring Boot backend written in Kotlin.

我想展示合同测试如何帮助拆分小型应用程序的前端和后端部署。 我有一个用Kotlin编写的React客户端和一个Spring Boot后端。

什么是合同测试? (What is a Contract Test?)

I am not talking about smart contracts. There is no blockchain whatsoever in this article. Sorry for that (Contract Tests for Smart Contracts sounds like a conference talk that the world badly needs, though!).

我不是在谈论智能合约 。 本文中没有任何区块链。 对此感到抱歉(智能合约的合同测试听起来像是世界急需的会议演讲!)。

In a nutshell, a Contract Test is a specification of the interactions between a consumer and a provider. In our case, the communication happens using REST. The consumer defines the actions sent to the provider and the responses that will be returned. In our case, the frontend is the consumer and the backend is the provider. A contract is generated. Both sides test against this contract.

简而言之,合同测试是消费者与提供者之间交互的规范。 在我们的案例中,通信是使用REST进行的。 使用者定义发送给提供者的动作以及将返回的响应。 在我们的例子中,前端是消费者,后端是提供者。 合同已生成。 双方都对该合同进行测试。

It is not really about any particular technology. There are a bunch of different frameworks, but some simple scripts could do the trick.

它并不是真的与任何特定技术有关。 有很多不同的框架,但是一些简单的脚本可以解决问题。

为什么将其作为交付渠道的一部分? (Why have it as part of the delivery pipeline?)

First of all, running these tests continuously ensures that they keep working at all times. The big benefit, however, is that we can separate the deployment of the front end and back end. If both sides are fulfilling the contract, it is likely that they work together correctly. Thus, we can consider avoiding expensive integrated tests. They tend to work pretty badly anyways.

首先,连续运行这些测试可确保它们始终保持工作状态。 但是,最大的好处是我们可以将前端和后端的部署分开。 如果双方都在履行合同,则很可能他们可以正常合作。 因此,我们可以考虑避免昂贵的集成测试。 无论如何,它们往往表现很差。

建立一些合同 (Setting up some contracts)

There are two sides to set up, consumer and provider. The tests will run in the pipelines that build the front end and the back end, respectively. We are going to use the Pact framework for our examples, which is the tool that I am most familiar with. Because of that, I tend to use pact and contract interchangeably. Our pipelines are written for CircleCI, but they should be fairly easy to port to other CI Tools.

设置有两个方面,即消费者和提供者。 这些测试将分别在构建前端和后端的管道中运行。 我们将在示例中使用Pact框架 ,这是我最熟悉的工具。 因此,我倾向于互换使用契约和契约。 我们的管道是为CircleCI编写的,但是它们应该很容易移植到其他CI工具。

消费方 (The consumer side)

As mentioned, the consumer leads the creation of the contract. Having the client driving this might sound counterintuitive. Often, APIs are created before the clients that will use them. Flipping it around is a nice habit to get into. It forces you to really think in terms of what the client will actually do, instead of bikeshedding a super generic API that will never need most of its features. You should give it a try!

如前所述,由消费者主导合同的创建。 让客户开车可能听起来违反直觉。 通常,会在使用API​​的客户端之前创建API。 翻转它是一个很好的习惯。 它迫使您根据客户端的实际用途进行真正的思考,而不是放弃一个将不需要其大多数功能的超级通用API。 您应该尝试一下!

The pact is defined through interactions specified in unit tests. We specify what we expect to be sent to the back end, and then use the client code to trigger requests. Why? We can compare expectations against actual requests, and fail the tests if they don't match.

通过在单元测试中指定的交互来定义契约。 我们指定希望发送到后端的内容,然后使用客户端代码触发请求。 为什么? 我们可以将期望与实际请求进行比较,如果不匹配,则测试失败。

Let's have a look at an example. We are using Jest to run the tests. We'll start with some initialization code:

让我们看一个例子。 我们正在使用Jest运行测试。 我们将从一些初始化代码开始:

import path from 'path'
import Pact from 'pact'

const provider = () =>
  Pact({
    port: 8990,
    log: path.resolve(process.cwd(), 'logs', 'pact.log'),
    dir: path.resolve(process.cwd(), 'pacts'),
    spec: 2,
    consumer: 'frontend',
    provider: 'backend'
  })

export default provider

Then we have the code for an actual test. The test consists of two parts. First we define the expected interaction. This is not very different from mocking an http library, with something like axios. It specifies the request that we will send (URL, headers, body and so forth), and the response that we will get.

然后,我们有了用于实际测试的代码。 该测试包括两个部分。 首先,我们定义预期的交互。 这与使用axios这样的模拟 http库没有太大区别。 它指定了我们将发送的请求(URL,标头,正文等)以及我们将得到的响应。

const interaction: InteractionObject = {
  state: 'i have a list of recipes',
  uponReceiving: 'a request to get recipes',
  withRequest: {
    method: 'GET',
    path: '/rest/recipes',
    headers: {
      Accept: 'application/json',
      'X-Requested-With': 'XMLHttpRequest'
    }
  },
  willRespondWith: {
    status: 200,
    headers: { 'Content-Type': 'application/json; charset=utf-8' },
    body: [
      {
        id: 1,
        name: 'pasta carbonara',
        servings: 4,
        duration: 35
      }
    ]
  }
}

Then we have the test itself, where we call the actual client code that will trigger the request. I like to encapsulate these requests in services that convert the raw response into the domain model that will be used by the rest of the app. Through some assertions, we make sure that the data that we are delivering from the service is exactly what we expect.

然后,我们进行测试,在其中调用将触发请求的实际客户端代码。 我喜欢将这些请求封装在将原始响应转换为将由该应用程序的其余部分使用的域模型的服务中。 通过一些断言,我们可以确保从服务中传递的数据正是我们所期望的。

it('works', async () => {
  const response = await recipeList()

  expect(response.data.length).toBeGreaterThan(0)
  expect(response.data[0]).toEqual({
    id: 1,
    name: 'pasta carbonara',
    servings: 4,
    duration: 35
  })
})

Note that even if recipeList is properly typed with TypeScript, that won't help us here. Types disappear at runtime, so if the method is returning an invalid Recipe we won't realize it, unless we explicitly test for it.

请注意,即使使用TypeScript正确输入了recipeList ,这也对我们没有帮助。 类型在运行时消失,因此,如果该方法返回无效的Recipe ,则除非我们明确对其进行测试,否则我们将不会意识到。

Finally we need to define some extra methods that will ensure that the interactions are verified. If there are interactions missing, or they don't look like they should, the test will fail here. After that, all that remains is writing the pact to disk.

最后,我们需要定义一些额外的方法,以确保对交互进行验证。 如果缺少交互,或者看起来不应该交互,则测试将在此处失败。 之后,剩下的就是将协定写入磁盘。

beforeAll(() => provider.setup())
afterEach(() => provider.verify())
afterAll(() => provider.finalize())

In the end, the pact gets generated as a JSON file, reflecting all the interactions that we have defined throughout all our tests.

最后,该协定以JSON文件的形式生成,反映了我们在所有测试中定义的所有交互。

灵活匹配 (Flexible matching)

Our pact thus far is specifying the exact values that it will get from the backend. That won't be maintainable in the long run. Certain things are inherently harder to pin down to exact values (for example, dates).

到目前为止,我们的约定是指定将从后端获取的确切值。 从长远来看,这将是无法维持的。 本质上,某些事情很难确定确切的值(例如,日期)。

A pact that breaks constantly will lead to frustration. We are going through this whole process to make our life easier, not harder. We'll avoid that by using matchers. We can be more flexible and define how things will look like, without having to provide exact values. Let's rewrite our previous body:

经常中断的协议会导致挫败感。 我们正在经历整个过程,以使我们的生活更轻松,而不是更艰难。 我们将通过使用Matchers避免这种情况。 我们可以更灵活地定义事物的外观,而不必提供确切的值。 让我们重写我们之前的内容:

willRespondWith: {
  status: 200,
  headers: { 'Content-Type': 'application/json; charset=utf-8' },
  body: Matchers.eachLike({
    id: Matchers.somethingLike(1),
    name: Matchers.somethingLike('pasta carbonara'),
    servings: Matchers.somethingLike(4),
    duration: Matchers.somethingLike(35)
  })
}

You can be more specific. You can set the expected length of a list, use regexes and a bunch of other things.

您可以更具体。 您可以设置列表的预期长度,使用正则表达式和其他方法。

将其集成到管道中 (Integrating it in the pipeline)

The pact tests rely on an external process, and having multiple tests hitting it can lead to non deterministic behavior. One solution is to run all the tests sequentially:

契约测试依赖于外部过程,并且多次击中它可能导致不确定的行为。 一种解决方案是按顺序运行所有测试:

npm test --coverage --runInBand

If you want to run the pact tests independently, we can build our own task to run them separately:

如果要独立运行契约测试,我们可以构建自己的任务来分别运行它们:

"scripts": {
  "pact": "jest --transform '{\"^.+\\\\.ts$\": \"ts-jest\"}' --testRegex '.test.pact.ts$' --runInBand"
}

Which will become an extra step in our pipeline:

这将成为我们产品线中的额外步骤:

jobs:
  check:
    working_directory: ~/app

    docker:
      - image: circleci/node:12.4

    steps:
      - checkout
      - run: npm
      - run: npm run linter:js
      - run: npm test --coverage --runInBand
      - run: npm pact
储存条约 (Storing the pact)

Our pact is a json file that we are going to commit directly in the frontend repository, after running the tests locally. I've found that this tends to work well enough. Making the pipeline itself commit the pact to git does not seem to be necessary.We'll get to extending the pact and in a second.

我们的协定是一个json文件,在本地运行测试后,我们将直接在前端存储库中提交。 我发现这往往效果很好。 使管道本身将协议提交给git似乎没有必要,我们将在稍后扩展协议。

提供方 (The provider side)

At this point we have a working pact, that is being verified by the consumer. But that is only half of the equation. Without a verification from the provider side, we haven't accomplished anything. Maybe even less than that, because we might get a false sense of security!

在这一点上,我们有一个有效的协议,正在由消费者进行验证。 但这只是等式的一半。 没有提供者的验证,我们什么都做不了。 甚至比这还少,因为我们可能会得到错误的安全感!

To do this, we are going to start the back end as a development server and run the pact against it. There is a gradle provider that takes care of this. We need to configure it and provide a way of finding the pact (which is stored in the front end repository). You can fetch the pact from the internet, or from a local file, whichever is more convenient.

为此,我们将启动后端作为开发服务器,并对它运行协议。 有gradle提供商来解决这个问题。 我们需要对其进行配置,并提供一种查找协定的方式(该协定存储在前端存储库中)。 您可以从Internet或本地文件中获取协定,以较方便的为准。

buildscript {
    dependencies {
        classpath 'au.com.dius:pact-jvm-provider-gradle_2.12:3.6.14'
    }
}

apply plugin: 'au.com.dius.pact'

pact {
    serviceProviders {
        api {
            port = 4003

            hasPactWith('frontend') {
                pactSource = url('https://path-to-the-pact/frontend-backend.json')
                stateChangeUrl = url("http://localhost:$port/pact")
            }
        }
    }
}

What remains is starting the server and running the pact against it, which we do with a small script:

剩下的就是启动服务器并针对它运行协定,我们使用一个小脚本来完成:

goal_test-pact() {
  trap "stop_server" EXIT

  goal_build
  start_server

  ./gradlew pactVerify
}

start_server() {
  artifact=app.jar
  port=4003

  if lsof -i -P -n | grep LISTEN | grep :$port > /dev/null ; then
    echo "Port[${port}] is busy. Server won't be able to start"
    exit 1
  fi

  nohup java -Dspring.profiles.active=pact -jar ./build/libs/${artifact} >/dev/null 2>&1 &

  # Wait for server to answer requests
  until curl --output /dev/null --silent --fail http://localhost:$port/actuator/health; do
    printf '.'
    sleep 3
  done
}

stop_server() {
  pkill -f 'java -Dspring.profiles.active=pact -jar'
}
治具 (Fixtures)

If you are running your back end in development mode, it will have to deliver some data, so that the contract is fulfilled. Even if we are not using exact matching, we have to return something, otherwise it won't be possible to verify it.

如果您在开发模式下运行后端,则必须交付一些数据,以便履行合同。 即使我们不使用精确匹配,我们也必须返回某些内容,否则将无法进行验证。

You can use mocks, but I've found that avoiding them as much as possible leads to more trustworthy results. Your app is closer to what will happen in production. So what other options are there? Remember that when we were defining interactions, we had a state. That's the cue for the provider. One way of using it is the stateChangeUrl. We can provide a special controller to initialize our back end based on the state:

您可以使用模拟,但是我发现尽可能避免使用模拟会带来更可信赖的结果。 您的应用程序更接近生产环境。 那么还有哪些其他选择呢? 请记住,当我们定义交互时,我们有一个state 。 这就是提供者的提示。 使用它的一种方法是stateChangeUrl 。 我们可以提供一个特殊的控制器来根据state初始化后端:

private const val PATH = "/pact"

data class Pact(val state: String)

@RestController
@RequestMapping(PATH, consumes = [MediaType.APPLICATION_JSON_VALUE])
@ConditionalOnExpression("\${pact.enabled:true}")
class PactController(val repository: RecipeRepository) {
    @PostMapping
    fun setup(@RequestBody body: Pact): ResponseEntity<Map<String,String>> {
        when(body.state) {
            "i have a list of recipes" -> initialRecipes()
            else -> doNothing()
        }

        return ResponseEntity.ok(mapOf())
    }
}

Note that this controller is only active for a specific profile, and won't exist outside of it.

请注意,此控制器仅对特定配置文件有效,在其外部将不存在。

将其集成到管道中 (Integrating it in the pipeline)

As with the provider, we will run the check as part of our pipeline

与提供者一样,我们将在管道中运行检查

version: 2
jobs:
  build:

    working_directory: ~/app

    docker:
      - image: circleci/openjdk:8-jdk

    steps:

      - checkout
      - run: ./go linter-kt
      - run: ./go test-unit
      - run: ./go test-pact

There is a slight difference, though. Our contract gets generated by the consumer. That means that a change in the front end could lead to a pact that does not verify properly anymore, even though no code was changed in the back end. So ideally, a change in the pact should trigger the back end pipeline as well. I haven't found a way to represent this elegantly in CircleCI, unlike say ConcourseCI.

不过,两者之间存在细微差别。 我们的合同是由消费者产生的。 这意味着,即使后端未更改任何代码,前端的更改也可能导致不再正确验证的协议。 因此,理想情况下,协议的变更也应触发后端管道。 与ConcourseCI不同,我还没有找到一种在CircleCI中优雅地表达这一点的方法。

合约如何影响前端与后端之间的关系 (How the contract influences the relationship between front end and back end)

It's nice that we got this set up. Never touch a running system, right? Well, we might! After all, quick change is why we invest in all this tooling. How would you introduce a change that requires extending the API?

很高兴我们进行了此设置。 永远不要触摸正在运行的系统 ,对吗? 好吧,我们可能会! 毕竟,快速更改是我们对所有这些工具进行投资的原因。 您将如何引入需要扩展API的更改?

  1. We start with the client. We want to define what the client will get that is not there yet. As we learned, we do that through a test in the front end that defines the expectation for the new route, or the new fields. That will create a new version of the pact.

    我们从客户开始。 我们想定义客户将获得的东西还不存在。 据我们了解,我们通过在前端进行测试来做到这一点,该测试定义了对新路线或新字段的期望。 这将创建该条约的新版本。
  2. Note that at this point the back end does not fulfill the pact. A new deployment of the back end will fail. But also, the existing backend does not fulfill the pact either right now. The change you introduced has to be backwards compatible. The front end should not be relying on the changes, either.

    请注意,此时后端无法实现协议。 后端的新部署将失败。 但是, 现有的后端也无法立即达成协议。 您引入的更改必须向后兼容。 前端也不应该依赖于更改。

  3. Now it's time to fulfill the new pact from the back end side. If this takes a long time, you will block your deployment process, which is not good. Consider doing smaller increments in that case. Anyways, you've got to implement the new functionality. The pact test will verify that your change is what's actually expected.

    现在是时候从后端实现新协议了。 如果这需要很长时间,则将阻止您的部署过程,这是不好的。 在这种情况下,考虑进行较小的增量。 无论如何,您必须实现新功能。 契约测试将验证您所做的更改是否是实际期望的。
  4. Now that the back end is providing the new functionality, you can freely integrate it in your front end.

    现在,后端提供了新功能,您可以将其自由地集成到前端中。

This flow can get a bit awkward in the beginning. It is really important to work with the smallest quantum of functionality. You don't want to block your deployment process.

开始时,此流程可能会有些尴尬。 使用最小的功能范围确实很重要。 您不想阻止您的部署过程。

下一步 (Next steps)

For the integration between your own front end and back end I have found this setup to be sufficient in practice. However, as complexity grows, versioning will become important. You'll want to help multiple teams collaborate more easily. For that, we can use a broker. This is a lot harder to implement, so you should ask yourself if you really need it. Don't fix problems that you don't have yet.

对于您自己的前端和后端之间的集成,我发现此设置实际上已足够。 但是,随着复杂性的增加,版本控制将变得很重要。 您将希望帮助多个团队更轻松地协作。 为此,我们可以使用Broker 。 这很难实现,因此您应该问自己是否真的需要它。 不要修复您尚未遇到的问题。

结论 (Conclusion)

To summarize, this is the setup we arrived at:

总而言之,这是我们到达的设置:

Think about all the time you have spent writing tests to check that your back end is sending the right data. That is a lot more convenient to do with a contract. Moreover, releasing front end and back end independently means being faster, releasing smaller pieces of functionality. It might feel scary at first, but you will realize that you actually are much more aware of what's going out that way.

想一想您花在编写测试以检查后端是否发送正确数据上的所有时间。 签订合同要方便得多。 此外,独立释放前端和后端意味着更快,释放更小的功能。 刚开始时可能会感到恐惧,但您会意识到实际上您更了解这种情况。

Once you have adopted this for one service, there is no reason not to do it for all of them. I really don't miss running costly end to end test suites just to verify that my back end is working. Here is the code that I used in the examples for the front end and the back end. It is a full running (albeit small) application. Good luck with your contracts!

一旦为一项服务采用了此功能,就没有理由不对所有服务都这样做。 我真的不会错过运行昂贵的端到端测试套件,只是为了验证我的后端是否正常工作。 这是在示例中用于前端后端的代码 。 它是一个正在运行的(尽管很小)的应用程序。 祝您合同顺利!

翻译自: https://www.freecodecamp.org/news/split-frontend-backend-deployment-with-cdcs/

前端拆分

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值