用工厂模式和模板方法统一封装所有第三方的Access Token

但凡好一点的程序员,写代码的时候都在想一件事:怎么把代码组织好,让它有结构。

代码一旦有了结构,就不容易被破坏。别人要新增功能的时候,是在这个结构上修改的,不是随便找个地方塞几行代码。好的代码结构,是对未来修改的一种约束。它不阻止修改,而是引导修改沿着正确的方向走。

如果你觉得某块业务的固定流程很明显,就大胆地把代码组织好。

AccessToken的获取就是这种场景。做过第三方系统对接的都知道,不管是钉钉、飞书还是企业微信,获取AccessToken的流程几乎一样:用应用凭证请求接口,拿到有时效的AccessToken,缓存起来,快过期了刷新。不同的只是每个平台的API地址、请求参数和返回格式。

流程骨架一样,细节不同。把骨架抽到基类里,子类只管自己平台那点差异。再配合工厂做分发,调用方连具体是哪个平台都不需要知道。下面看看怎么落地。

先看整体架构

展开细节之前,先看一下整体结构,有个全局认知后再看具体实现。

整个设计分四层:

ThirdPartyAccessTokenService是对外的统一入口。调用方只需要传一个平台枚举,就能拿到对应平台的AccessToken。它不包含任何获取AccessToken的具体逻辑,只负责把请求转交给下一层。

AccessTokenHandlerFactory是工厂。它在应用启动的时候,通过Spring自动注入收集所有的Handler实现类,按平台标识注册到一个Map里。拿到平台枚举后,从Map里取出对应的Handler,交给它去处理。

AbstractAccessTokenHandler是抽象基类,也是整个设计的核心。它定义了获取AccessToken的完整流程:查本地缓存、查Redis、加分布式锁、调第三方API、存缓存。这些步骤对所有平台都一样,全部在基类里实现。

DingDingAccessTokenHandlerWeComAccessTokenHandlerFeishuAccessTokenHandler是三个具体的子类。每个子类只需要实现两件事:怎么调自己平台的API,怎么把自己平台的返回值转成统一格式。

调用方不需要知道底层有几个平台、每个平台的API长什么样。新增平台的时候,调用方的代码一行都不用改。

不封装,会怎样

接过多个第三方系统的项目,如果不做统一封装,通常会发展成这样。

每个平台的AccessToken获取逻辑散落在各自的业务模块里。钉钉的在钉钉服务里,企业微信的在企业微信服务里,飞书的在飞书服务里。看起来各管各的挺整齐,问题是这几段代码的重复率能到70%以上。获取、缓存、过期判断、刷新,这些逻辑几乎一模一样,只是API地址和参数不同。

缓存策略也容易乱。有的同事用Redis缓存,有的用本地变量存,有的干脆不缓存每次都去请求第三方接口。在测试环境可能没什么感觉,到了生产环境请求量上来之后,频繁请求第三方接口很容易触发限流。

更麻烦的是并发场景。多个实例同时发现AccessToken过期,同时去请求第三方API刷新,拿回来的AccessToken互相覆盖。这种问题偶发且难复现,排查起来很耗时间。

每新接一个平台,就把上面的代码复制一遍改改参数。接了五六个平台之后,想统一调整缓存策略或者加个监控埋点,得改五六个地方。漏改一个就是线上问题。

模板方法:流程骨架放基类

不管是哪个平台,获取AccessToken的流程骨架是固定的:

  1. 先查本地缓存(Caffeine),有就直接返回
  2. 本地没有,查Redis
  3. Redis有,判断是否快过期需要刷新
  4. Redis也没有,加分布式锁,调第三方API拿一个新的
  5. 拿到之后存Redis,返回结果

这就是模板方法模式最典型的应用场景。把不变的流程骨架放在基类AbstractAccessTokenHandler里,把变化的部分(调哪个API、怎么解析返回值)留给子类实现。

AbstractAccessTokenHandler的核心代码:

Java 复制代码
package com.example.accesstoken.handler;

@Slf4j
public abstract class AbstractAccessTokenHandler {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private RedissonClient redissonClient;

    private LoadingCache<AccessTokenAppCredential, AccessTokenResult> localCache;

    private final Long localExpireTime;
    private final Long redisExpireTime;
    private final TimeUnit timeUnit;
    private static final String CACHE_KEY_PREFIX = "access_token";
    private static final String LOCK_KEY_PREFIX = "access_token_lock";

    public AbstractAccessTokenHandler(Long localExpireTime, Long redisExpireTime, TimeUnit timeUnit) {
        this.localExpireTime = localExpireTime;
        this.redisExpireTime = redisExpireTime;
        this.timeUnit = timeUnit;
    }

    @PostConstruct
    public void init() {
        // Caffeine本地缓存,过期后自动触发loadAccessToken加载
        localCache = Caffeine.newBuilder()
                .initialCapacity(2)
                .maximumSize(2)
                .expireAfterWrite(localExpireTime, timeUnit)
                .build(this::loadAccessToken);
    }

    private AccessTokenResult loadAccessToken(AccessTokenAppCredential credential) {
        String cacheKey = buildCacheKey(credential);
        String lockKey = buildLockKey(credential);

        // 查Redis
        String cached = stringRedisTemplate.opsForValue().get(cacheKey);
        if (StringUtils.isNotBlank(cached)) {
            AccessTokenResult result = JSON.parseObject(cached, AccessTokenResult.class);
            // 如果快过期了,走刷新逻辑
            if (needRefreshAccessToken(result)) {
                return doRefreshAndCache(result, cacheKey, lockKey);
            }
            return result;
        }

        // Redis也没有,加锁后调第三方API创建
        return doCreateAndCache(cacheKey, lockKey, credential);
    }

    private AccessTokenResult doCreateAndCache(String cacheKey, String lockKey,
                                                AccessTokenAppCredential credential) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            lock.lock();
            // 调用子类实现的方法,获取AccessToken
            AccessTokenResult result = doCreateAccessToken(credential);
            // 存入Redis
            stringRedisTemplate.opsForValue()
                    .set(cacheKey, JSON.toJSONString(result), redisExpireTime, timeUnit);
            return result;
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    // 子类必须实现:调第三方API获取AccessToken,并把响应转换成统一结构
    protected abstract AccessTokenResult doCreateAccessToken(AccessTokenAppCredential credential);

    // 子类必须实现:返回平台标识
    protected abstract String getPlatform();

    // 可选覆写:判断是否需要提前刷新
    protected boolean needRefreshAccessToken(AccessTokenResult result) {
        return false;
    }

    public AccessTokenResult getAccessToken() {
        return localCache.get(new AccessTokenAppCredential(null, null));
    }

    public AccessTokenResult getAccessToken(AccessTokenAppCredential credential) {
        return localCache.get(credential);
    }
}

这段代码里有几个设计决策值得注意。

Caffeine的build(this::loadAccessToken)把缓存的加载逻辑和缓存本身绑定在了一起。调用方只需要调localCache.get(credential),如果本地缓存没有命中,Caffeine会自动执行loadAccessToken方法去加载。这意味着调用方完全不需要关心缓存命中还是没命中,Caffeine帮你处理了这个分支。

doCreateAccessToken是子类必须实现的抽象方法。基类的流程走到需要调第三方API的时候,就调这个方法。子类在这个方法里做两件事:发HTTP请求拿到原始响应,把原始响应转换成统一的AccessTokenResult。基类拿到的永远是同一个类型,后续的缓存、过期判断全部统一处理。

needRefreshAccessToken是一个可选覆写的方法,默认返回false。如果某个平台需要在AccessToken即将过期前提前刷新,子类可以覆写这个方法,加上自己的判断逻辑。大部分平台不需要这个能力,所以基类给了一个默认实现,子类不覆写也能正常工作。

子类有多简洁

看一个具体的子类实现。钉钉的AccessTokenHandler:

Java 复制代码
package com.example.accesstoken.handler;

@Component
public class DingDingAccessTokenHandler extends AbstractAccessTokenHandler {

    @Resource
    private RestTemplate restTemplate;

    // 本地缓存3600秒,Redis缓存7000秒
    public DingDingAccessTokenHandler() {
        super(3600L, 7000L, TimeUnit.SECONDS);
    }

    @Override
    protected AccessTokenResult doCreateAccessToken(AccessTokenAppCredential credential) {
        // 构造请求
        String url = "https://oapi.dingtalk.com/gettoken?appkey="
                + credential.getAppId() + "&appsecret=" + credential.getAppSecret();
        JSONObject response = restTemplate.getForObject(url, JSONObject.class);

        // 把钉钉的响应格式转成统一结构
        AccessTokenCreateParam createParam = convertResponse(response);
        return AccessTokenResult.create(createParam);
    }

    @Override
    protected String getPlatform() {
        return AccessTokenPlatformEnum.DING_DING.getCode();
    }

    private AccessTokenCreateParam convertResponse(JSONObject response) {
        // 钉钉返回的字段名是access_token和expires_in
        return AccessTokenCreateParam.builder()
                .accessToken(response.getString("access_token"))
                .expiresIn(response.getLong("expires_in"))
                .build();
    }
}

整个子类就做了三件事:在构造函数里配置缓存时间,在doCreateAccessToken里调钉钉API并转换响应,在getPlatform里返回平台标识。其他所有事情,缓存查找、分布式锁、Redis存储,全部由基类处理,子类一行都不用写。

钉钉AccessToken的有效期是7200秒(2小时)。这里本地缓存设的是3600秒,Redis设的是7000秒。为什么不直接设7200秒?因为要留出余量,避免使用一个马上就要过期的AccessToken发起请求,请求还没返回AccessToken就失效了。这个提前量没有标准答案,根据业务对时效性的要求来定。

响应适配:不同的返回值,统一的结构

整个方案能落地的一个关键环节是响应适配。每个第三方平台获取AccessToken的API,返回的JSON结构、字段名都不一样。

钉钉的返回结构:

JSON 复制代码
{
  "errcode": 0,
  "errmsg": "ok",
  "access_token": "xxx",
  "expires_in": 7200
}

企业微信的返回结构:

JSON 复制代码
{
  "errcode": 0,
  "errmsg": "ok",
  "access_token": "xxx",
  "expires_in": 7200
}

飞书的返回结构:

JSON 复制代码
{
  "code": 0,
  "msg": "success",
  "tenant_access_token": "xxx",
  "expire": 7200
}

看起来钉钉和企业微信的结构很像,但和飞书一比差异就出来了。飞书的AccessToken字段叫tenant_access_token而不是access_token,过期时间字段叫expire而不是expires_in,错误码字段叫code而不是errcode。请求方式也不同,钉钉和企业微信用GET请求带查询参数,飞书用POST请求带JSON body。

如果基类直接处理这些原始响应,就得写一堆if-else判断当前是哪个平台、字段名是什么。这就又耦合了。

解决方案是让每个子类自己负责把平台特有的响应格式,转换成统一的AccessTokenCreateParam。基类拿到的永远是同一个结构,不管底层是钉钉、企业微信还是飞书。

看一下飞书子类的转换逻辑和钉钉的对比:

Java 复制代码
// 钉钉的转换:字段名是access_token和expires_in
private AccessTokenCreateParam convertResponse(JSONObject response) {
    return AccessTokenCreateParam.builder()
            .accessToken(response.getString("access_token"))
            .expiresIn(response.getLong("expires_in"))
            .build();
}

// 飞书的转换:字段名是tenant_access_token和expire
private AccessTokenCreateParam convertResponse(JSONObject response) {
    return AccessTokenCreateParam.builder()
            .accessToken(response.getString("tenant_access_token"))
            .expiresIn(response.getLong("expire"))
            .build();
}

两段代码结构一模一样,只是取值的字段名不同。经过这一层转换,到了基类那里看到的都是AccessTokenCreateParam,里面永远是accessToken和expiresIn这两个字段,和具体平台无关。

适配这一层看着简单,但它的价值在于把外部差异挡在了子类里,基类完全不需要知道外面的世界有多混乱。 这是模板方法模式能落地的关键:基类定义数据结构的契约,子类负责把外部的混乱适配成基类能处理的统一格式。

实际项目中还会遇到更复杂的情况。有些第三方平台返回的过期时间不是剩余秒数,而是毫秒级的绝对时间戳,需要用当前时间做减法再除以1000才能换算成剩余秒数。这类差异全部在子类的转换方法里处理,基类完全无感。

AccessTokenCreateParam的结构:

Java 复制代码
package com.example.accesstoken.factory;

@Data
@Builder
public class AccessTokenCreateParam {

    // AccessToken字符串
    private String accessToken;

    // 过期时间,单位秒
    private Long expiresIn;

    // 部分平台支持通过refreshToken刷新AccessToken
    private String refreshToken;

    // 提前过期时间,默认180秒
    private Integer advanceExpiresIn;
}

这个类就是基类和子类之间的契约。子类往里填值,基类从里面取值,双方通过这个结构解耦。

工厂模式:按平台分发

有了基类和子类,还需要一个工厂来做分发。调用方传一个平台标识进来,工厂返回对应的Handler。

AccessTokenHandlerFactory的实现:

Java 复制代码
package com.example.accesstoken.handler;

@Component
public class AccessTokenHandlerFactory {

    @Autowired
    private List<AbstractAccessTokenHandler> handlerList;

    private final Map<String, AbstractAccessTokenHandler> handlerMap = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        if (handlerList == null || handlerList.isEmpty()) {
            return;
        }
        // 自动注册所有Handler到Map
        for (AbstractAccessTokenHandler handler : handlerList) {
            String platform = handler.getPlatform();
            if (platform == null || platform.isEmpty()) {
                continue;
            }
            handlerMap.put(platform, handler);
        }
    }

    public AbstractAccessTokenHandler getHandler(String platform) {
        AbstractAccessTokenHandler handler = handlerMap.get(platform);
        if (handler == null) {
            throw new IllegalArgumentException("未找到平台对应的AccessTokenHandler: " + platform);
        }
        return handler;
    }
}

这个工厂的注册机制利用了Spring的一个特性:当你注入List<AbstractAccessTokenHandler>的时候,Spring会自动把容器里所有AbstractAccessTokenHandler的子类实例收集起来放到这个List里。在@PostConstruct阶段遍历这个List,按平台标识注册到ConcurrentHashMap,就完成了工厂的初始化。

用Spring自动注入加Map注册的方式做工厂,比写if-else或switch-case好在哪?新增平台时只需要新建一个Handler类加上@Component注解,工厂代码零修改。 这就是开闭原则在实际项目中最常见的落地形式。不是抽象的原则,而是具体的代码组织方式。新来一个同事要接入飞书,他只需要写一个FeishuAccessTokenHandler,工厂、基类、其他平台的代码都不需要动。

统一入口ThirdPartyAccessTokenService则更简单,它只是把工厂和Handler串起来:

Java 复制代码
package com.example.accesstoken.service;

@Service
@RequiredArgsConstructor
public class ThirdPartyAccessTokenService {

    private final AccessTokenHandlerFactory handlerFactory;

    public AccessTokenResult getAccessToken(AccessTokenAppCredential credential,
                                             AccessTokenPlatformEnum platform) {
        AbstractAccessTokenHandler handler = handlerFactory.getHandler(platform.getCode());
        if (credential == null || credential.getAppId() == null) {
            // 使用默认凭证
            return handler.getAccessToken();
        }
        return handler.getAccessToken(credential);
    }
}

两级缓存

AccessToken不是每次请求都需要重新获取的,它有时效性(大部分平台是2小时),在有效期内可以反复使用。缓存策略的选择直接影响系统的性能和稳定性。

这里用的是两级缓存:Caffeine做本地缓存,Redis做分布式缓存。

为什么不只用Redis?如果每次获取AccessToken都走一趟Redis网络请求,在高频调用场景下网络开销不可忽略。Caffeine是本地内存缓存,命中的话直接从JVM堆内存返回,没有网络开销。大部分请求在本地缓存就命中了,只有本地缓存过期才会穿透到Redis。

为什么不只用Caffeine?因为多实例部署的时候,每个实例的本地缓存是隔离的。如果只用本地缓存,实例A创建了一个新的AccessToken,实例B不知道,它本地缓存过期后又去请求第三方API创建了另一个。Redis作为分布式缓存,让所有实例共享同一份AccessToken,一个实例创建了其他实例都能用。

缓存时间的设计有一个细节:本地缓存的过期时间要短于Redis,Redis的过期时间要短于AccessToken的真实有效期。 以钉钉为例,AccessToken真实有效期7200秒,Redis设7000秒,本地缓存设3600秒。本地缓存3600秒过期后,请求穿透到Redis,这时候Redis里的AccessToken还有3400秒有效期,直接返回。Redis缓存7000秒过期后,才需要重新调第三方API。200秒的余量是为了防止一个请求拿到AccessToken后还没来得及用它就过期了。

AccessTokenResult在创建的时候还会计算一个提前过期时间,默认提前180秒:

Java 复制代码
public static AccessTokenResult create(AccessTokenCreateParam createParam) {
    Date expiresTime = DateUtils.plusSeconds(new Date(), createParam.getExpiresIn());
    // 默认提前3分钟标记为即将过期
    Integer advanceSeconds = 180;
    if (createParam.getAdvanceExpiresIn() != null) {
        advanceSeconds = createParam.getAdvanceExpiresIn();
    }
    Date advanceExpiresTime = DateUtils.plusSeconds(
            new Date(), createParam.getExpiresIn() - advanceSeconds);

    return AccessTokenResult.builder()
            .accessToken(createParam.getAccessToken())
            .expiresIn(createParam.getExpiresIn())
            .refreshToken(createParam.getRefreshToken())
            .expiresTime(expiresTime)
            .advanceExpiresTime(advanceExpiresTime)
            .build();
}

这个提前过期时间是给needRefreshAccessToken用的。如果某个平台支持刷新AccessToken(不是重新创建,而是用refreshToken换一个新的),子类可以覆写needRefreshAccessToken方法,判断当前时间是否已经超过了提前过期时间。超过了就触发刷新,避免等到真正过期了才去处理。

分布式锁

多实例环境下,如果Redis缓存刚好过期,多个实例会同时发现缓存不存在,同时去请求第三方API。这会导致短时间内重复请求,浪费资源,某些平台可能因此触发限流。

解决方式是在调用第三方API之前加一把分布式锁,用Redisson实现。只有拿到锁的实例才去请求第三方API,其他实例等锁释放后直接从缓存取。

锁的粒度是按平台_appId维度的。也就是说,钉钉的AccessToken和企业微信的AccessToken用的是不同的锁,互不阻塞。同一个平台如果有多个应用(不同的appId),每个应用也有自己的锁。锁的key大概长这样:access_token_lock:DING_DING_your_app_id

这个粒度的选择是有考量的。如果所有平台共用一把锁,钉钉的AccessToken过期刷新会阻塞企业微信的请求,没必要。按平台加appId做维度,锁的粒度足够细,不会产生不必要的互斥。

各平台AccessToken对比速查表

接入新平台之前,通常需要查一下这个平台的AccessToken接口长什么样。下面这张表汇总了三个常见平台的关键信息,接入的时候可以直接对照。

维度 钉钉 企业微信 飞书
AccessToken有效期 7200秒 7200秒 7200秒
建议本地缓存时间 3600秒 6600秒 6600秒
建议Redis缓存时间 7000秒 6600秒 6600秒
请求方式 GET GET POST
认证参数 appKey + appSecret corpId + corpSecret app_id + app_secret
AccessToken字段名 access_token access_token tenant_access_token
过期时间字段名 expires_in(秒) expires_in(秒) expire(秒)
错误码字段名 errcode errcode code
需要特殊适配 字段名与其他平台不同

三个平台的AccessToken有效期都是7200秒,但字段命名差异不小。钉钉和企业微信的返回结构比较接近,飞书的字段名完全不一样。这也是为什么响应适配这一层不能省掉的原因。

新增一个平台的步骤

现在如果要接入一个新的第三方平台,整个过程只有5步:

  1. 在AccessTokenPlatformEnum新增一个枚举值
  2. 新建一个类继承AbstractAccessTokenHandler
  3. 实现doCreateAccessToken()方法:发HTTP请求,把响应转成AccessTokenCreateParam
  4. 实现getPlatform()方法:返回枚举值的code
  5. 在类上加@Component注解

工厂代码不用改,基类代码不用改,其他平台的代码不用改。Spring启动的时候自动把新的Handler注册到工厂里,调用方传新的平台枚举就能拿到AccessToken。如果哪天这个平台不用了,删掉这一个类就行。

小结

设计模式在实际项目里的价值,不在于你能说出它的名字,而在于你能在合适的时候想到用它。模板方法解决的问题很具体:多个流程的骨架一样,但细节不同。当你看到这种特征的时候,不需要犹豫,把骨架抽出来就对了。工厂模式也一样,当调用方不应该关心具体实现是哪个的时候,加一层工厂做分发。

有人可能会觉得,就三个平台,写三个类各管各的也能跑,干嘛要搞这么多抽象。如果永远只有三个平台,各管各的确实没什么大问题。但实际项目里,第三方平台的数量是会变的。半年前只有钉钉,现在加了企业微信和飞书,下个季度可能还要接入某个行业系统。你不封装的话,每加一个平台,缓存、锁、过期判断的逻辑都得再写一遍。三个平台的时候还能忍,到了七八个平台想统一改个缓存策略,改七八个地方,漏一个就是事故。

判断要不要做封装,不是看当前有几个实现,而是看新增一个实现需要改多少地方。 如果答案是只需要新建一个类,那说明结构已经到位了。如果答案是要改好几个文件、复制好几段逻辑,那就该考虑封装了。

AccessToken管理看起来是个小事,做好了能省掉大量排查问题的时间。AccessToken过期没刷新、并发重复请求、缓存不一致,踩过这些坑的人都知道,每一个都能让你排查半天。把这块做扎实,后面不管接多少个第三方系统,这些问题都不会再出现。

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


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

  • 老码头的技术浮生录

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

我的知乎账号:

  • SamDeepThinking
相关推荐
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题】【Java基础篇】第17题:HashMap的加载因子为什么是0.75而不是1或0.5
java·开发语言·算法·哈希算法·散列表
CodeSheep2 小时前
DeepSeek的最新招人标准,太讽刺了。
前端·后端·程序员
huipeng9262 小时前
GateWay使用详解
java·spring boot·spring cloud·微服务·gateway
AKA__Zas2 小时前
初识多线程(初初识)
java·服务器·开发语言·学习方法
TE-茶叶蛋2 小时前
微服务下 DTO 设计核心原则
微服务·云原生·架构
夏沫的梦2 小时前
DeepSeek V4-Vllm部署:高效长上下文推理的实现
人工智能·后端
blasit2 小时前
Qt C++ http服务器安全登录token生成管理
c++·后端·qt
薛定猫AI2 小时前
【深度解析】Claude Code 本地代理架构:用 Free Cloud Code 降低 Agentic Coding 成本
microsoft·架构
2501_933329552 小时前
AI 赋能媒介宣发新范式:Infoseek 重构企业品牌传播效率
大数据·人工智能·自然语言处理·架构