简单聊聊如何在 Rails 生态中高效研发第三方回调接口。原文链接:https://step-by-step.tech/posts/how-to-develop-callback-api
无论用什么语言做 Web 开发,在项目发展到一定程度之后总是难免要基于第三方服务研发接口,而这些接口中就以回调接口最为麻烦。比方说,支付宝支付这个功能,支付完成之后支付结果一般不会立即返回,而是支付宝服务器会以回调的形式告诉平台最终的支付结果。这个接口是平台来研发,并提供给第三方服务调用的,只有在特定的场景才会触发,而这种回调类型的接口调试起来几乎都是噩梦般的存在。这篇文章简单聊聊,笔者是如何克服这个艰难的研发过程的。
笔者在回流项目里面研发了不少的回调接口,总的来说他们大概都具备类似的特征
以上是第三方回调接口的基本特征汇总,一句话总结就是调试麻烦。笔者在经过一顿洗礼之后总结了一套高效研发此类接口的方案。口诀为“测试先行,触发次之”。
提供给第三方的回调接口文档都是公开的,坏处在于接口的安全性收到一定的威胁(容易有恶意请求来袭),而好处则在于文档肯定提供完整的请求参数以及响应参数。这为写接口测试带来一定的便利性。笔者目前的项目是用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。这也规避了构造签名的麻烦,因为它并不是我们这个步骤的重点,目前我们的重点是在业务逻辑。测试结果也让人满意
到这一步骤为止,我已经对自己开发的回调接口有一定的信心了,然而还是没有十足的把握。毕竟没经过正式环境的调试依旧是让人难以安心,于是我们可以进行下一步,在正式环境中调试(触发回调)。
有了测试用例那一步,我们对接口大概会怎么运作已经心里有数了,接下来就是在正式环境中进一步校验自己编写的接口是否符合生产要求。这个时候就需要接收真正来自第三方服务的请求了。比较传统的做法是直接把接口部署到测试环境/预生产环境,测试接口是否生效,然而这样出错成本就太高了,每次调试都要重新部署一个版本。我一般使用ngork这个工具来做本地调试。
假设本地的服务端口为 3000,那么直接运行命令
ngrok http 3000
就可以把本地服务暴露到因特网,并且它提供了两个接口,分别是http
跟https
的,毕竟只是做调试,影响不会很大,然后再启动 Rails 服务即可
bin/rails s
接下来就可以在互联网通过它提供的 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
每当回调接口被第三方服务触发,就会有如下景象。
可以在本地调试线上的回调参数。特别对于支付接口来说能够及时发现一些隐藏问题。一般调试个几次之后就会对该回调接口更有信心了,可以上测试/生产环境。
PS:回调接口要自己想办法触发,这里为了方便举例笔者使用的是快递 100 的物流信息回调,然而要触发这个回调会相对困难(订阅之后还需要现实场景的物流信息有变动),触发难度较大不太建议在本地调试。测试覆盖之后直接线上调试效果更佳。而支付回调接口一般触发比较容易(支付成功/失败一般都会触发),本地调试效果更佳。
编写第三方回调接口一直都是一件比较费劲的事情,不仅研究文档费劲,调试程序更是费劲。这篇文章简单总结了一下笔者日常是如何在 Rails 生态下研发这类接口的,私以为这种做法还算高效。其他语言应该也可以采取类似的做法。