掘金 后端 ( ) • 2024-04-15 16:18

前言

我所在的团队主营是供应链业务方向,但是降本增效也得帮销售域的同事写东西。因为公司在用飞书,所以飞书自建应用成了移动端小程序的首选。在写完第一个小程序的后端后,领导找到我让写一个脚手架,辅助同事开发。于是乎,素材来了,在写完SDK的SOP文档,我马不停蹄地更新了这篇文章。脚手架相对简单,我也是想着在后续开发过程中继续迭代的,主要是讲一下思路和SDK里面有意思的东西。明天会更新到我的小仓库里面,这几天光写文档了,忘了更新,可以关注一下,马上100Star了,https://gitee.com/cloudswzy/general-components

脚手架设计

背景

我介入的时候整个部门已经做了几个小应用了,我们团队刚开始做。不过因为架构组没有像传统WEB应用那样出一个统一的框架,所以领导让我去做一个脚手架共享给团队内部。如果是老读者的话,肯定知道博主之前就是做过类似的活,详见xxx。思考再三,决定还是使用模板项目+SDK的模式构建脚手架,接着就要考虑如何为飞书自建应用做特别的优化。

后端上架构逻辑维持和WEB应用相同的结构,即SpringBoot微服务+自建DevOps+公司基建(注册中心、网关、日志收集、监控等)。常规基建里只有登录认证这块需要重写,其他地方都可以复用。啰嗦一下背景,脚手架设计是在团队做第二个小应用之前提出的,当时给了我一天时间,真是蚌埠住了。在正式开启开发的时候,因为太赶了,飞书这边相关的一切都由我去写脚手架并提供,其他三个后端同事去赶业务部分的开发。最后当我花了三天设计并写完脚手架后,后端功能都写完了,绝活。

思路

思路上总体对历史的SDK做减法,大部分用不着的都给去掉,中间件能不用就不用,尤其是Redis、Seata和Elastic-Job之类需要外部依赖的。从一个简单基础的框架配置出发,需要什么能力

  1. 初始化默认参数和校验配置,比如服务编码、用到的中间件的必需配置之类的,做下判空或者格式校验,具体可以使用@PostConstruct和implements ApplicationContextInitializer之类的方式实现。
  2. 功能的拓展全局配置,比如jackson或者fastjson等日期或者数字的JSON格式化规范、HttpClient、RestTemplate和Feign之类的扩展改造(比如加Header)等等
  3. 规范类的配置,比如日志切面、全局异常监听、通用异常类、通用返回结果类等
  4. 基础组件的初始化和拓展,比如向注册中心、网关、监控等上报信息,给日志收集填充参数,单点登录的接入等等

针对飞书小应用,从业务角度可以在框架里添加和修改

  1. 登录认证授权鉴权按照飞书的规范重写
  2. 引入ORM框架Mybatis-Plus和配套的代码生成工具,并做一些自定义配置
  3. 封装飞书的方法,比如获取各类Token、消息通知等等

feishu-plus-sdk

亮点说明

  1. 提供比原始框架更友好的日志切面,出入参打印更细致,排除部分BUG
  2. 提供比原始框架更友好的全局异常监听,增加更多类型异常的监听处理
  3. 自带Mybatis-Plus最新版,并默认配置数据库方言和自定义扩展
  4. 自带完整的登录认证授权鉴权,配合模板项目无需二次开发,并默认提供获取当前用户和模拟登录功能
  5. 使用缓存并封装飞书大部分Token的获取,提供一键获取的方法
  6. 封装飞书的消息通知并保留其自带的日志打印、缓存等功能,非正式环境默认只发送给默认收件人

使用说明

异常使用com.xxx.framework.feishuplus.exception.BusinessException,确保异常被监听

转换工具使用com.xxx.framework.feishuplus.util.ModelConverterUtils,支持list转list,转page

飞书Token获取请使用com.xxx.framework.feishuplus.feishu.FeiShuPlusUtil

依赖解析

<dependencies>
        <!--        配置参数说明-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <version>2.3.12.RELEASE</version>
            <optional>true</optional>
        </dependency>
        <!--        httpclient工具,任选-->
        <dependency>
            <groupId>com.xxx.framework</groupId>
            <artifactId>xxx-framework-http</artifactId>
            <version>3.0.5-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.32</version>
            <scope>provided</scope>
        </dependency>
        <!--        springboot参数校验-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
            <version>2.3.12.RELEASE</version>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.5</version>
        </dependency>
        <!--        飞书sdk-->
        <dependency>
            <groupId>com.larksuite.oapi</groupId>
            <artifactId>oapi-sdk</artifactId>
            <version>2.2.2</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.46</version>
        </dependency>
    </dependencies>

主要是引入了MP和飞书原版SDK这两个比较重要的组件,其他都是基础的,比如参数校验和JSON工具

部分核心文件讲解

import com.lark.oapi.Client;
import com.lark.oapi.core.enums.BaseUrlEnum;
import com.lark.oapi.core.request.SelfBuiltTenantAccessTokenReq;
import com.lark.oapi.core.response.TenantAccessTokenResp;
import com.lark.oapi.service.contact.v3.model.BatchUserReq;
import com.lark.oapi.service.contact.v3.model.BatchUserResp;
import com.lark.oapi.service.contact.v3.model.User;
import com.lark.oapi.service.im.v1.enums.ReceiveIdTypeEnum;
import com.lark.oapi.service.im.v1.model.CreateMessageReq;
import com.lark.oapi.service.im.v1.model.CreateMessageReqBody;
import com.lark.oapi.service.im.v1.model.CreateMessageResp;
import com.xxx.framework.base.config.BaseEnvironmentConfigration;
import com.xxx.framework.feishuplus.exception.BusinessException;
import com.xxx.framework.feishuplus.pojo.MessageInDTO;
import com.xxx.framework.feishuplus.properties.CommonProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.Arrays;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;

@Slf4j
@Component
@EnableConfigurationProperties({CommonProperties.class})
public class FeiShuNoticeUtil {
    @Autowired
    private CommonProperties commonProperties;
    @Autowired
    private BaseEnvironmentConfigration baseEnv;
    private static Client client;

    /**
     * 延时生成客户端,全局配置Client
     */
    public static Client getClient(CommonProperties commonProperties) {
        if (client == null) {
            client = Client.newBuilder(commonProperties.getFeishuAppId(), commonProperties.getFeishuAppSecret())
                    .openBaseUrl(BaseUrlEnum.FeiShu) // 设置域名,默认为飞书
                    .logReqAtDebug(true) // 在 debug 模式下会打印 http 请求和响应的 headers,body 等信息。
                    .build();
        }
        return client;
    }

    /**
     * 发送消息
     */
    public void sendMessage(MessageInDTO message) {
        //先判断是否需要推送消息给真实用户
        if (!message.getNoticeReal()) {
            //如果不是生产环境,则使用默认配置
            if (!"pro".equals(baseEnv.getCurrentEnv())) {
                String noticeDefault = commonProperties.getNoticeDefault();
                if (StringUtils.hasText(noticeDefault)) {
                    String[] split = noticeDefault.split(",");
                    message.setUserIdList(Arrays.asList(split));
                } else {
                    message.setUserIdList(null);
                }
            }
        }
        if (message.getUserIdList() != null) {
            CompletableFuture.runAsync(() -> {
                for (String userId : message.getUserIdList()) {
                    CreateMessageReq req = CreateMessageReq.newBuilder()
                            .receiveIdType(StringUtils.hasText(message.getReceiveIdType()) ? message.getReceiveIdType()
                                    : ReceiveIdTypeEnum.USER_ID.getValue())
                            .createMessageReqBody(CreateMessageReqBody.newBuilder()
                                    .receiveId(userId)
                                    .msgType(message.getMsgType())
                                    .content(message.getContent())
                                    .uuid(UUID.randomUUID().toString())
                                    .build())
                            .build();

                    // 发起请求
                    CreateMessageResp resp = null;
                    try {
                        resp = getClient(commonProperties).im().message().create(req);
                        // 处理服务端错误
                        if (!resp.success()) {
                            log.warn("code:{},msg:{},reqId:{}", resp.getCode(), resp.getMsg(), resp.getRequestId());
                        }
                    } catch (Exception e) {
                        log.error("发送信息请求失败", e);
                    }
                }
            });
        }
    }
}


import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;


/**
 * @Description 业务包可配置参数
 **/
@Data
@ConfigurationProperties(prefix = "xxx.business")
public class CommonProperties {
    /**
     * 飞书APPID
     */
    private String feishuAppId;
    /**
     * 飞书APP密钥
     */
    private String feishuAppSecret;
}

为什么要延时获取Client?

首先说说为什么要设置一个类静态变量来初始化,原因是避免重复创建和销毁带来的资源浪费。相同的创建思路,比如我们会在框架中定义一个Redis操作工具类Bean交给Spring管理,默认就是单例的,还有Es、Kafka之类的,虽然官方例子为了举例方便都是创建一个新的,但是我们作为框架的二开是要做封装的。

这里延时获取是因为我们的参数是在配置文件CommonProperties中配置的,我在配置中定义了飞书ID和密钥,这个配置是动态的,所以不能一开始获取,就得新建一个getClient的方法去获取,类似于单例模式的懒加载。除了资源消耗,还有个好处就是这个ApiClient默认是内存管理Token的,详见飞书SDK-Java版文档的client.disableTokenCache()参数。如果有开启的话,内部会存储Token,使用相同的ApiClient时会从内存中直接获取对应的Token,而不用再次调用接口获取

聊聊飞书SDK

截图对应方法,com.lark.oapi.core.request.ReqTranslator#getToken,上面说的如果开启内存管理Token的话,在每一次使用ApiClient请求的时候都会走这个前置逻辑。判断内存管理是否开启,如果开启的话就从GlobalTokenManager.getTokenManager()中获取,没开启的话就调用对应API获取,这实际上是一个易用性的优化,很细节。

因为种种原因登录模块写得很拉垮,前后端约定俗成导致前期问题多多,为了赶工,我没有用ApiClient写登录模块,而是自己写了一下。因为通过接口返回Token里面带了过期时间,所以理所当然地需要缓存一下。所以一个需求产生了,需要一个带过期时间的缓存框架,首先想到的是Redis,但是服务资源紧张,而且大部分自建应用的体量也用不着,都是单点部署,所以我想着用本地缓存处理。想了想,Caffeine和Guava都不支持设置时间,于是考虑自己写一个,但是我突然想到ApiClient提到了默认是内存管理,所以他应该有实现,翻开源码果然就发现了com.lark.oapi.core.cache.LocalCache。他提供了com.lark.oapi.core.cache.ICache接口用作扩展,默认是使用LocalCache。

这里挺有意思的,用了本地缓存经典的ConcurrentMap<String, Value> CACHE作为基座,内部类封装了真正的值value并绑定了一个过期时间,在get/set方法做一下扩展处理就完成了带过期时间的本地缓存的需求。

翻阅oapi-sdk-java源码的时候,真的感觉太细了,有的时候在写的时候能通过类名知道是干什么的,并且每个方法都有专属的枚举类,细啊,易用性拉满。

https://open.feishu.cn/?lang=zh-CN,飞书开发者后台这个做的很不错,旁边这个示例代码简直顶呱呱,有的地方没有也能通过SDK的源码找出来。但让我头大的是,文档太细了,细到了没时间看的程度,后面这个SDK也会慢慢迭代,跟着公司的项目走,慢慢补充。

模板项目文件说明

包结构简析

├─java

│ └─com

│ └─xxx

│ └─template

│ │ TemplateApplication.java--默认启用Feign、Async和事务

│ ├─api

│ │ │ ErpApi.java--Feign样例

│ │ └─faliback

│ │ ErpApiFaliBack.java--Feign样例

│ ├─constants

│ │ CommonConstant.java--常量样例

│ ├─controller

│ │ CommonController.java--默认提供飞书登录、获取当前用户、获取文件服务器TOKEN和模拟登录

│ │ ExcludeController.java--开放接口

│ ├─mapper

│ ├─pojo

│ │ ├─dto

│ │ │ └─api

│ │ │ ErpPostHeader.java

│ │ │ ErpPreHeader.java

│ │ └─vo

│ │ └─view

│ │ CurUserVO.java--返回给前端的用户包装类,需要修改

│ ├─service

│ │ │ FeiShuPlusService.java

│ │ └─impl

│ │ FeiShuPlusServiceImpl.java--基于飞书PLUS-SDK的基础代码

│ └─util

│ MybatisPlusCodeGenerator.java--代码生成工具

└─resources

│ application-dev.properties--数据库配置,需填充飞书相关配置

│ application-pro.properties--数据库配置,需填充飞书相关配置

│ application-test.properties--数据库配置,需填充飞书相关配置

│ application-uat.properties--数据库配置,需填充飞书相关配置

│ application.properties--提供基础配置,需填充文件服务器配置

└─mapper

TestXml.xml

写在最后

好久没更新了,距离上一篇正经的技术文双剑破万法-递归加反射完成接口数据修改,过了45天了。emmm,不能说懈怠了,哈哈,毕竟还是很充实和忙碌。攒了三篇文章,还有篇数据库问题排查的这周出,Flink的刚弄完还没来得及回顾和写文档,估计放在下月跟另一篇文章放一块。还有个事,哥哥们求内推,博主来挑战一波,5年Java经验在职,意向北京和四川,给我发私信喔,谢啦谢啦!!☆⌒(*^-゜)v。掘金文章都支持转载或者首发公众号,最近积极投稿中,部分已发布在个人公众号《神独自在的技术生活》,部分投稿给了其他公众号大佬,需要首发的可以私信我,欢迎欢迎!