简单聊聊如何在Rails生态中高效研发第三方回调接口。

前言

无论用什么语言做Web开发,在项目发展到一定程度之后总是难免要基于第三方服务研发接口,而这些接口中就以回调接口最为麻烦。比方说,支付宝支付这个功能,支付完成之后支付结果一般不会立即返回,而是支付宝服务器会以回调的形式告诉平台最终的支付结果。这个接口是平台来研发,并提供给第三方服务调用的,只有在特定的场景才会触发,而这种回调类型的接口调试起来几乎都是噩梦般的存在。这篇文章简单聊聊,笔者是如何克服这个艰难的研发过程的。

麻烦之处

笔者在回流项目里面研发了不少的回调接口,总的来说他们大概都具备类似的特征

  1. 一般都是POST请求且免授权:因为第三方服务要随时调用该接口,不可能每次调用前都先登录。
  2. 触发麻烦:由于是第三方服务主动调用我们接口,有时候触发起来会相对麻烦。比如支付回调接口,就是每次支付完成后才触发,相当于每次调试都需要支付一笔订单。
  3. 幂等性:由于我们无法控制第三方平台调用回调接口的次数(只要没收到我方服务器的成功应答,回调接口会被重复调用多次),所以我们要保证接口的幂等性,以免重复修改资源。
  4. 验签:这个提供给第三方服务的接口是免授权,且一般都是文档公开的。也就是说所有人都能够效仿第三方平台往我们服务器发送类似的请求,因此接口就需要一个验签的过程,来保证只处理来自指定第三方服务的请求

以上是第三方回调接口的基本特征汇总,一句话总结就是调试麻烦。笔者在经过一顿洗礼之后总结了一套高效研发此类接口的方案。口诀为“测试先行,触发次之”。

测试先行

提供给第三方的回调接口文档都是公开的,坏处在于接口的安全性收到一定的威胁(容易有恶意请求来袭),而好处则在于文档肯定提供完整的请求参数以及响应参数。这为写接口测试带来一定的便利性。笔者目前的项目是用rswag来做接口方面的测试,每次写完测试它都可以通过指令

bundle exec rake rswag:specs:swaggerize

来生成接口文档,然而稍微难受的地方就是不写测试就没有接口文档,这下不写测试都不行了。我们拿快递100的快递信息推送API接口来举例。请求参数,以及响应内容在对应的规格文档里面已经描写得十分详细了。要触发该回调,首先要通过订阅接口进行订阅,订阅成功之后当物流信息有所变化的时候,快递100的服务器就会往我们提供的回调API里面推送数据,而我们想要实现的业务功能是当快递被签收的时候在数据库记录签收时间。

从文档可以得知,当推送的数据中state='3'的时候则表示该快递已经被签收了。而我们则需要在这个时候设置内置对象的received_at字段,来记录签收快递的时间。先写好测试代码

# frozen_string_literal: true

require 'swagger_helper'

RSpec.describe 'Logistic api' do
  path '/api/v1/logistics/receive_subscribed' do
    post '收货接口' do
      tags 'Logistics'
      consumes 'application/json'
      produces 'application/json'
      parameter name: :body, in: :body, schema: {
        type: :object,
        properties: {
          sign: { type: :string },
          param: { type: :object }
        }
      }, required: true

      response '200', 'receive message from kuaidi' do
        let(:param) do
          { status: 'polling',
            billstatus: 'got',
            message: '',
            autoCheck: '1',
            comOld: 'yuantong',
            comNew: logistic.company,
            lastResult: {
              message: 'ok',
              state: '3',
              status: '200',
              condition: 'F00',
              ischeck: '0',
              com: logistic.company,
              nu: logistic.no,
              data: [
                {
                  context: '上海分拨中心/装件入车扫描 ',
                  time: '2012-08-28 16:33:19',
                  ftime: '2012-08-28 16:33:19',
                  status: '在途',
                  areaCode: '310000000000',
                  areaName: '上海市'
                },
                {
                  context: '上海分拨中心/下车扫描 ',
                  time: '2012-08-27 23:22:42',
                  ftime: '2012-08-27 23:22:42',
                  status: '在途',
                  areaCode: '310000000000',
                  areaName: '上海市'
                }
              ]
            },
            destResult: {
              message: 'ok',
              state: '0',
              status: '200',
              condition: 'F00',
              ischeck: '0',
              com: logistic.company,
              nu: logistic.no,
              data: [
                {
                  context: '[01000]Final delivery Delivered to: SLOVESNOV',
                  time: '2016-05-24 14:00:00',
                  ftime: '2016-05-24 14:00:00',
                  status: '签收',
                  areaCode: nil,
                  areaName: nil
                }
              ]
            } }.to_json
        end
        let(:body) do
          {
            sign: 'SIGN',
            param: param
          }
        end

        let(:logistic) { create(:logistic) }
        let!(:backflow) { create(:backflow, status: :sent, confirmed_price: 200, stock: 3, logistic: logistic, sent_at: Time.current) }

        before do
          expect(backflow.received_at.nil?).to eq true # 请求之前是空的
          expect(Digest::MD5).to receive(:hexdigest).and_return('sign')
        end

        run_test! do
          backflow.reload
          expect(backflow.received_at.nil?).to eq false # 请求之后这个值被设置
          expect(json['message']).to eq('成功')
          expect(json['returnCode']).to eq('200')
          expect(json['result']).to eq(true)
        end
      end
    end
  end
end

根据文档模拟了签收快递的时候快递100服务会往我们这边推送的数据,关键的是state='3'。并且在请求发送之前received_at值为空,请求处理完之后received_at的值不为空。并且在这种场景下,请求会响应官方要求的内容,表示我们已经收到了。

{
    "result":true,
    "returnCode":"200",
    "message":"成功"
}

快递100接收到这些内容之后就会就知道我们已经收到消息,不会再往这边继续推送内容。否则它会周期性地推送(不同的平台推送周期会有所区别)。测试写好了,接下来就可以写正式代码了。

# frozen_string_literal: true

module Api
  module V1
    class LogisticsController < ApplicationController
      skip_before_action :authenticate!
      before_action :validate, only: %i[receive_subscribed]

      def receive_subscribed
        params = permitted_subscribed_params
        data = JSON.parse params['param']
        nu = data['lastResult']['nu']
        com = data['lastResult']['com']
        if data['lastResult']['state'].to_s == '3'
          logistics = Logistic.where(no: nu, company: com)
          backflows = Backflow.where(logistic: logistics) # 查找物流信息相同的所有资源
          backflows.each { |b| b.update_column('received_at', Time.current)  } # 设置所有符合条件的资源的`received_at`字段
        end
        render json: { result: true, message: '成功', returnCode: '200' } # 返回官方要求的值
      end

      private

      def permitted_subscribed_params
        params.permit(:sign, :param)
      end

      def validate
        raise Exceptions::AuthenticationError, '请求来源未得到认证' unless Digest::MD5.hexdigest(permitted_subscribed_params['param'] + Kuaidi.customer).upcase == permitted_subscribed_params['sign']
      end
    end
  end
end

需要注意的是,请求动作received_subscribed执行之前会先调用validate方法,主要是用来验签,确保请求的发送者是快递100服务。为了让测试通过,我们可以稍微“欺骗一下”测试用例。

expect(Digest::MD5).to receive(:hexdigest).and_return('sign')

这样跑测试的时候Digest::MD5.hexdigest怎么都会返回字符串sign,再经过String#upcase处理则得到字符串SIGN跟请求参数body中的sign字段内容对应,这样validate方法无论如何都会返回true。这也规避了构造签名的麻烦,因为它并不是我们这个步骤的重点,目前我们的重点是在业务逻辑。测试结果也让人满意

Screen Shot 2022-04-28 at 08.29.39.png

到这一步骤为止,我已经对自己开发的回调接口有一定的信心了,然而还是没有十足的把握。毕竟没经过正式环境的调试依旧是让人难以安心,于是我们可以进行下一步,在正式环境中调试(触发回调)。

正式环境调试

有了测试用例那一步,我们对接口大概会怎么运作已经心里有数了,接下来就是在正式环境中进一步校验自己编写的接口是否符合生产要求。这个时候就需要接收真正来自第三方服务的请求了。比较传统的做法是直接把接口部署到测试环境/预生产环境,测试接口是否生效,然而这样出错成本就太高了,每次调试都要重新部署一个版本。我一般使用ngork这个工具来做本地调试。

假设本地的服务端口为3000,那么直接运行命令

ngrok http 3000

就可以把本地服务暴露到因特网,并且它提供了两个接口,分别是httphttps的,毕竟只是做调试,影响不会很大,然后再启动Rails服务即可

bin/rails s

Screen Shot 2022-04-26 at 08.11.36.png

接下来就可以在互联网通过它提供的URL直接访问服务了。

> curl http://8207-8-218-12-239.ngrok.io
> curl https://8207-8-218-12-239.ngrok.io

或者用浏览器也可以。那么我们的回调接口就会是http://8207-8-218-12-239.ngrok.io/api/v1/logistics/receive_subscribed。订阅该接口,并在received_subscribed方法打好断点。

module Api
  module V1
    class LogisticsController < ApplicationController
      # ....

      def receive_subscribed
        params = permitted_subscribed_params
        data = JSON.parse params['param']
        binding.pry

        # ...

        render json: { result: true, message: '成功', returnCode: '200' } # 返回官方要求的值
      end
    end
  end
end

每当回调接口被第三方服务触发,就会有如下景象。

Screen Shot 2022-04-26 at 08.29.22.png

可以在本地调试线上的回调参数。特别对于支付接口来说能够及时发现一些隐藏问题。一般调试个几次之后就会对该回调接口更有信心了,可以上测试/生产环境。

PS:回调接口要自己想办法触发,这里为了方便举例笔者使用的是快递100的物流信息回调,然而要触发这个回调会相对困难(订阅之后还需要现实场景的物流信息有变动),触发难度较大不太建议在本地调试。测试覆盖之后直接线上调试效果更佳。而支付回调接口一般触发比较容易(支付成功/失败一般都会触发),本地调试效果更佳。

尾声

编写第三方回调接口一直都是一件比较费劲的事情,不仅研究文档费劲,调试程序更是费劲。这篇文章简单总结了一下笔者日常是如何在Rails生态下研发这类接口的,私以为这种做法还算高效。其他语言应该也可以采取类似的做法。