适合中小型企业的出口入口网关微服务

1 阅读14分钟

23年入职现在这家公司,到岗后我做的第一件事不是接业务需求,而是先搭了快速开发框架和模版微服务工程,然后紧接着搭了这个出口入口网关微服务。

这个决定跟之前的经历有关。待过几家公司,每家都要对接不少第三方系统,钉钉、企业微信、金蝶、各种外卖平台、电子签章,零零散散加起来十几个。对接代码散落在各个微服务里,A服务对接了钉钉的用户接口,B服务也要用,又对接了一遍。接口一改,两边都得改。新人来了想找对接钉钉的代码,得翻好几个工程才能找全。

这个问题在中小型团队里特别明显。大厂有专门的开放平台团队来管这些事,中小型公司的IT部门没有这个编制,对接第三方的活是各个业务组自己干。干着干着,同一个第三方的对接代码就散落在了三四个微服务里。

所以入职后我判断这个网关是刚需,先把它搭起来,后面所有对接第三方的需求都走这个网关。事实证明这个决定是对的。网关上线到现在,除了有新的第三方系统要对接,几乎不用改代码。有时候几个月都不需要发版。

下面把这个网关的核心设计和关键代码都贴出来,可以直接拿去参考。

注意:文章涉及到策略模式、责任链模式、Dubbo泛化调用、Nacos配置监听这几个技术点,如果对这些概念不太熟,建议先了解一下再来看,不然部分代码可能会比较吃力。

整体概览

这个网关做两件事:

出口:内部微服务需要调用第三方系统的接口时,不直接调,而是通过网关去调。网关负责处理认证、签名、参数转换这些和第三方对接相关的脏活。

入口:第三方系统有回调通知时(比如钉钉审批状态变更回调、支付结果回调),统一由网关接收,再转发给内部对应的微服务处理。

工程上分成两个模块:

  • api模块:定义了RPC接口和请求对象,打成jar包给其他微服务引入。其他服务引入这个jar后,就能通过Dubbo RPC调用网关的接口,不需要关心网关内部怎么实现。
  • server模块:网关的核心实现,包含策略模式、责任链、Nacos配置管理、请求日志等所有逻辑。

没有统一网关时的问题

这不是一个理论推导出来的问题,是实际踩过的坑。

同一个第三方接口被对接了多次。 比如钉钉的获取用户信息接口,审批服务对接了一次,人事服务也对接了一次。两份代码逻辑差不多,但token的获取方式、异常处理的写法各有各的风格。钉钉接口升级时,得找到所有对接过的地方逐个改。

对接代码和业务代码混在一起。 调用第三方接口的HTTP请求构造、签名逻辑、token刷新这些代码,直接写在业务Service里。业务逻辑和对接逻辑搅在一起,改业务可能不小心碰到对接代码,改对接代码又怕影响业务。

回调接口没有统一管理。 每个服务各自暴露回调接口,URL格式不统一,安全校验(IP白名单、签名验证)各写各的。

做了统一网关之后,这些问题都不存在了。对接第三方这件事,做一次和做十次的成本应该是一样的。 第一次对接钉钉时在网关里写好策略类,后面任何服务想用钉钉的接口,引入SDK调一下网关的RPC接口就行,不用再写一行对接代码。

策略模式对接不同第三方

网关需要对接十几个第三方系统,每个系统的认证方式、接口协议、参数格式都不一样。钉钉用的是OAuth加AccessToken,金蝶是WebAPI加签名,有些系统直接是带AppKey的HTTP请求。

用策略模式来处理这个差异。定义一个连接策略接口,每个第三方系统对应一个策略实现类:

public interface ConnectStrategy {
    // 连接第三方系统,执行具体的API调用
    Object connect(RequestContext context);
}

钉钉的策略类大概长这样(以OA审批为例):

// routeKey用于路由,网关根据请求中的外部系统标识自动选择对应的策略类
@StrategyRoute(routeKey = "dingtalk")
public class DingTalkConnectStrategy implements ConnectStrategy {

    private final DingTalkProperties dingTalkProperties;

    @Override
    public Object connect(RequestContext context) {
        RequestBizData bizData = context.getRequestDTO().getRequestBizData();
        Object data = bizData.getData();
        JSONObject json = parseRequestData(data);

        // 根据configId区分具体调用哪个钉钉接口
        if ("createApproval".equals(bizData.getConfigId())) {
            return createWorkFlow(json, context);
        }
        if ("getToken".equals(bizData.getConfigId())) {
            return getAccessToken(json, context);
        }
        if ("getUserInfo".equals(bizData.getConfigId())) {
            return getUserInfo(json, context);
        }

        // 如果configId不在已知列表里,回退到通用HTTP策略
        if (isCommonRequest(context)) {
            return commonStrategy.connect(context);
        }

        return context.getResponseResult();
    }
}

这里有个细节值得说一下。@StrategyRoute是一个自定义注解,网关启动时会扫描所有带这个注解的类,按routeKey建立映射关系。请求进来后,根据请求中携带的externalSystem字段,自动路由到对应的策略类。不需要写if-else来手动选择策略。

启动类上加一个注解就能开启这个能力:

@SpringBootApplication
// 扫描策略包,自动建立策略路由映射
@EnableStrategyRoute(scanBasePackages = "com.demo.gateway.server.core.chain.strategy")
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class, args);
    }
}

还有一个通用HTTP策略类,它是默认策略。如果某个第三方系统的接口就是标准的HTTP调用,没有复杂的认证和签名逻辑,不需要专门写策略类,直接在Nacos里配好URL和请求方式就能用:

// isDefault = true 表示这是默认策略,没有匹配到专用策略时走这个
@StrategyRoute(routeKey = "common", isDefault = true, interfaceClass = ConnectStrategy.class)
public class CommonHttpConnectStrategy implements ConnectStrategy {

    @Override
    public Object connect(RequestContext context) {
        // 从配置中读取URL、请求方式
        // 构建OkHttp请求
        // 发起调用并返回结果
    }
}

新增一个第三方系统的对接,只需要做一件事:写一个新的策略类,加上**@StrategyRoute**注解。 不需要改网关的任何已有代码。如果这个第三方系统只用标准HTTP调用,连策略类都不用写,配Nacos就行。

责任链模式处理请求

不管是出口请求还是入口请求,都需要经过一系列处理步骤:参数校验、配置加载、授权、调用、日志记录。把这些步骤拆成独立的节点,用责任链串起来。

上行链路(内部微服务 → 网关 → 第三方系统):

@Component
public class UpwardRequestNodeChain extends AbstractNodeHandlerChain {

    private final ParamCheckNode paramCheckNode;
    private final ParamHandleNode paramHandleNode;
    private final RequestAuthorizeNode requestAuthorizeNode;
    private final RequestConnectNode requestConnectNode;
    private final RequestTraceLogNode requestTraceLogNode;

    @Override
    protected List<NodeHandler> defineHandlerChain() {
        return List.of(
                paramCheckNode,
                paramHandleNode,
                requestAuthorizeNode,
                requestConnectNode,
                requestTraceLogNode
        );
    }
}

五个节点各管各的事:

节点职责
ParamCheckNode校验必填参数:externalSystem、configId、data,缺一个就拦住
ParamHandleNode根据externalSystem和configId从Nacos加载映射配置,绑定到上下文
RequestAuthorizeNode执行授权策略,比如获取AccessToken
RequestConnectNode调用对应的连接策略,真正发起对第三方的请求
RequestTraceLogNode记录请求日志,包括请求参数、响应结果、耗时

下行链路(第三方系统回调 → 网关 → 内部微服务):

@Component
public class DownwardRequestNodeChain extends AbstractNodeHandlerChain<AcceptContext, AcceptContext> {

    private final AdmittanceCheckNode admittanceCheckNode;
    private final RpcDispatchNode rpcDispatchNode;

    @Override
    protected List<NodeHandler<AcceptContext, AcceptContext>> defineHandlerChain() {
        return List.of(
                admittanceCheckNode,
                rpcDispatchNode
        );
    }
}

下行链路只有两个节点:

节点职责
AdmittanceCheckNode准入校验,检查请求来源IP是否在白名单内,黑名单直接拒绝
RpcDispatchNode用Dubbo泛化调用,把回调请求转发给内部对应的微服务

RpcDispatchNode是下行链路的核心。它用了Dubbo的泛化调用,不需要在网关里引入目标服务的接口依赖。调用哪个服务、哪个方法、传什么参数,全部从Nacos配置里读。这意味着新增一个回调接收场景,只需要在Nacos里配一条路由规则,网关代码不用动。

@Component
public class RpcDispatchNode extends AbstractNodeHandler<AcceptContext, AcceptContext> {

    @Override
    protected AcceptContext doHandle(ChainContext<AcceptContext> context) {
        AcceptContext acceptContext = context.getParam();
        GatewayMappingProperties props = acceptContext.getGatewayMappingProperties();
        RpcConfig rpcConfig = props.getRpcConfig();

        // 通过Dubbo泛化调用,不需要引入目标服务的接口依赖
        GenericService svc = getReferenceService(
            rpcConfig.getServiceInterface(),
            rpcConfig.getGroup(),
            rpcConfig.getVersion(),
            rpcConfig.getTimeout(),
            rpcConfig.getRetries()
        );

        // 执行调用
        Object result = svc.$invoke(
            rpcConfig.getMethodName(),
            parameterTypes,
            args
        );
        acceptContext.setRpcResult(result);
        return acceptContext;
    }
}

责任链的好处在于每个节点职责单一,改一个节点不影响其他节点。后面想加个限流节点,往链路里插一个就行。

Nacos配置中心实现动态路由

这是整个网关设计里最实用的一个点。对接第三方时,经常遇到这种情况:钉钉的OA审批接口已经对接好了,现在要加一个钉钉的用户查询接口。如果每加一个接口都要改代码、发版,那网关的价值就打了折扣。

网关的路由配置全部放在Nacos里,分成两层:

第一层:配置列表。 一个JSON数组,列出所有具体的配置文件ID。

Nacos的dataId是GatewayRouteConfigList,内容:

["dingtalk_config", "kingdee_config", "eleme_config", "meituan_config"]

第二层:具体的映射配置。 每个配置文件ID对应一组路由规则。

比如dingtalk_config的内容:

{
  "DingTalk_createApproval": {
    "url": "https://oapi.dingtalk.com/workflow/instance/create",
    "appName": "approval-service",
    "async": false,
    "timeout": 30000,
    "rpcConfig": {
      "serviceInterface": "com.demo.approval.api.WorkflowService",
      "methodName": "onApprovalCallback",
      "parameterTypes": ["java.lang.String"],
      "group": "default",
      "version": "1.0.0",
      "timeout": 5000,
      "retries": 2
    }
  },
  "DingTalk_getUserInfo": {
    "url": "https://oapi.dingtalk.com/topapi/v2/user/get",
    "appName": "user-service",
    "async": false,
    "timeout": 15000
  }
}

配置的key格式是{外部系统}_{接口标识},value里包含了调用URL、超时时间、是否异步、回调时转发的RPC配置等信息。

网关启动时,从Nacos加载这些配置并注册监听:

@Component
public class GatewayMappingConfig implements ApplicationContextAware {

    // 用ConcurrentHashMap存储所有映射配置,线程安全
    private final Map<String, GatewayMappingProperties> configMap = Maps.newConcurrentMap();
    private final Map<String, Listener> listenerMap = Maps.newConcurrentMap();

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        NacosConfigManager nacosConfigManager = ctx.getBean(NacosConfigManager.class);
        // 加载配置列表
        List<MappingConfigInfo> configList = loadConfigList(nacosConfigManager);
        // 对每个配置文件添加监听
        batchListenConfig(configList, nacosConfigManager);
    }

    // Nacos配置变更时的回调
    private void applyConfig(String configInfo) {
        JSONObject configJson = JSON.parseObject(configInfo);
        configJson.keySet().forEach(key -> {
            // 解析配置并存入内存Map
            GatewayMappingProperties properties = parseProperties(key, configJson);
            configMap.put(key, properties);
        });
    }
}

关键在于configMapConcurrentHashMap。Nacos推送配置变更时,回调方法更新Map里的值,后续请求读到的就是新配置。整个过程不需要重启服务。

举个实际场景。 钉钉的OA审批接口已经对接好了,跑了半年没问题。现在产品说要加一个钉钉的部门查询接口。我需要做什么?

在Nacos的dingtalk_config里加一条配置:

{
  "DingTalk_getDeptInfo": {
    "url": "https://oapi.dingtalk.com/topapi/v2/department/get",
    "appName": "hr-service",
    "async": false,
    "timeout": 15000
  }
}

保存。网关不需要重启,不需要发版。业务服务通过SDK调用时,传入externalSystem=DingTalkconfigId=getDeptInfo,网关就能找到这条配置并发起调用。

如果是接收回调的场景,配置里加上rpcConfig,指定转发到哪个服务的哪个方法就行。

配置属性的完整结构:

属性类型说明
urlString第三方接口地址
appNameString关联的内部服务名
asyncBoolean是否异步处理,异步时立即返回成功标识
timeoutLong连接超时时间,单位毫秒
useUrlParamBoolean是否使用URL参数
ignoreTraceLogBoolean是否跳过日志记录
successFlagObject成功时的返回格式
failFlagObject失败时的返回格式
rpcConfigObject回调转发的Dubbo配置:接口名、方法名、参数类型、分组、版本、超时、重试
extParamMap扩展参数,用于策略类读取自定义配置

SDK让其他微服务一行代码调网关

api模块就干一件事:定义一个RPC接口和请求对象,打成jar包。

public interface GatewayFacade {
    Result<String> request(GatewayRequestDTO gatewayRequestDTO);
}

请求对象的结构:

@Data
public class GatewayRequestDTO implements Serializable {
    // 业务数据
    private RequestBizData requestBizData;
    // 自定义请求头
    private List<RequestHeader> requestHeaders;
    // 请求路径(可选,某些场景需要指定)
    private String url;
    // 请求方法,默认POST
    private String method;

    @Data
    public static class RequestBizData implements Serializable {
        // 外部系统标识,比如DingTalk、Kingdee
        private String externalSystem;
        // 配置ID,比如createApproval、getUserInfo
        private String configId;
        // 请求数据
        private Object data;
        // 业务ID(可选,用于日志追踪)
        private String bizId;
        // 业务类型(可选)
        private String bizType;
    }
}

其他微服务引入SDK后的使用方式:

@Service
public class ApprovalService {

    // 引入SDK后,通过Dubbo注入网关接口
    @DubboReference(version = "1.0.0")
    private GatewayFacade gatewayFacade;

    public String createDingTalkApproval(ApprovalParam param) {
        GatewayRequestDTO request = new GatewayRequestDTO();
        RequestBizData bizData = new RequestBizData();
        // 指定外部系统和接口标识
        bizData.setExternalSystem("DingTalk");
        bizData.setConfigId("createApproval");
        bizData.setData(param);
        request.setRequestBizData(bizData);

        Result<String> result = gatewayFacade.request(request);
        return result.getData();
    }
}

调用方不需要关心钉钉的token怎么获取、接口签名怎么算、HTTP请求怎么构造。传入外部系统名称、接口标识、业务数据,剩下的事情网关全包了。

入口方向,网关暴露了一个统一的HTTP接口来接收第三方回调:

@RestController
@RequestMapping("openapi")
public class UnifiedAcceptController {

    @RequestMapping("accept/{externalSystem}/{configKey}")
    public Object accept(
            @PathVariable("externalSystem") String externalSystem,
            @PathVariable("configKey") String configKey) {

        // 根据路径参数构建上下文
        AcceptContext ctx = new AcceptContext(getMappingKey(externalSystem, configKey));
        // 解析请求参数
        settingRequestParam(ctx);
        // 走下行责任链处理
        return gatewayService.accept(ctx);
    }
}

回调URL的格式是/openapi/accept/{外部系统}/{配置标识}。给钉钉配回调地址时填https://你的域名/openapi/accept/DingTalk/approvalCallback,网关收到请求后根据路径参数找到Nacos里的路由配置,通过Dubbo泛化调用转发给内部服务。

网关服务层还做了同步和异步两种处理模式。如果Nacos配置里async=true,网关收到回调后立即返回成功标识给第三方(避免第三方超时重试),然后异步转发给内部服务:

@Service
public class GatewayServiceImpl implements GatewayService {

    private final Executor executor = new ThreadPoolExecutor(
        10, 20, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(5000));

    @Override
    public Object accept(AcceptContext ctx) {
        bindGatewayMappingConfig(ctx);
        GatewayMappingProperties props = ctx.getGatewayMappingProperties();

        Long traceLogId = saveLog(ctx);

        if (Boolean.TRUE.equals(props.getAsync())) {
            // 异步处理:立即返回,后台线程执行
            CompletableFuture.supplyAsync(
                () -> doInvokeChain(traceLogId, ctx), executor);
            return props.extractReturnFlagMsg("success");
        } else {
            // 同步处理:等待执行完成后返回
            return doInvokeChain(traceLogId, ctx);
        }
    }
}

对接新系统的标准流程

以对接一个新的第三方系统为例(假设叫「某签章平台」),从0到上线的步骤:

如果这个系统需要特殊的认证或签名逻辑:

  • 在策略包下新建SignPlatformConnectStrategy类,实现ConnectStrategy接口
  • 加上@StrategyRoute(routeKey = "signPlatform")注解
  • 在connect方法里实现认证、签名、接口调用的逻辑
  • 如果有独立的配置项(如AppKey),在Nacos的ThirdPartyAppConfig里加上对应的配置
  • 在Nacos的GatewayRouteConfigList里加上新的配置文件ID
  • 在新的配置文件里写好路由规则
  • 网关发版(因为加了新的策略类)

如果这个系统只需要标准HTTP调用:

  • 在Nacos的GatewayRouteConfigList里加上新的配置文件ID
  • 在新的配置文件里写好路由规则(URL、请求方式、超时等)
  • 不需要写代码,不需要网关发版

业务服务如何使用:

  • pom.xml里引入网关的api模块依赖
  • @DubboReference注入GatewayFacade
  • 构建GatewayRequestDTO,传入externalSystem和configId
  • 调用gatewayFacade.request(dto)

如果需要接收回调:

  • 在Nacos的路由配置里,给对应的configKey配上rpcConfig(指定转发到哪个服务的哪个方法)
  • 把回调URL https://域名/openapi/accept/signPlatform/{configKey} 配到第三方平台
  • 如果需要IP白名单,在Nacos的准入配置里加上对方的IP

小结

做了这么多年开发,我越来越觉得,中小型团队做技术选型,最重要的不是技术有多先进,而是维护成本有多低。一个技术方案如果需要频繁发版、频繁调试、频繁修改,那它再优雅也没用。真正好用的方案是:搭一次,跑很久,偶尔加点配置就能适应新需求。

这个网关的技术含量不高,策略模式、责任链、Nacos配置监听,都是常见的设计模式和中间件用法。它的价值不在于技术有多深,而在于把对接第三方这件零碎的事情收拢到了一个地方,用配置化的方式降低了后续的维护成本。

有人可能会说,对接的第三方不多的话,有必要单独搭一个网关服务吗?我的判断标准是:如果你的团队有3个以上的微服务,并且已经对接了或者即将对接2个以上的第三方系统,这个网关就值得搭。前期投入两三天,后面省的时间远不止这些。

如果当前只有1个外部系统要对接,而且短期内看不到增长趋势,那直接在业务服务里写对接代码就行,不用过度设计。

希望这篇内容可以帮到你。


最近在知乎出了秒杀专栏,感兴趣的可以订阅一下。至于知识星球的,可以搜:

  • 老码头的技术浮生录

它是一个能实际帮你解决难题的星球。有问题的,找知心的Sam哥,支持无限次语音一对一解决你遇到的难题。另外后续我新写的所有对外的付费专栏,在星球内都是免费的,且可以拿到所有源代码。

我的知乎账号:

  • SamDeepThinking