三天从零到一做飞书自建应用脚手架

前言

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

脚手架设计

背景

我介入的时候整个部门已经做了几个小应用了,我们团队刚开始做。不过因为架构组没有像传统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

依赖解析

xml 复制代码
<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工具

部分核心文件讲解

scss 复制代码
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源码的时候,真的感觉太细了,有的时候在写的时候能通过类名知道是干什么的,并且每个方法都有专属的枚举类,细啊,易用性拉满。

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。掘金文章都支持转载或者首发公众号,最近积极投稿中,部分已发布在个人公众号《神独自在的技术生活》,部分投稿给了其他公众号大佬,需要首发的可以私信我,欢迎欢迎!

相关推荐
一只叫煤球的猫1 分钟前
MySQL 8.0 SQL优化黑科技,面试官都不一定知道!
后端·sql·mysql
SoFlu软件机器人3 分钟前
智能生成完整 Java 后端架构,告别手动编写 ControllerServiceDao
java·开发语言·架构
写bug写bug1 小时前
如何正确地对接口进行防御式编程
java·后端·代码规范
Cyanto1 小时前
Java并发编程面试题
java·开发语言·面试
不超限1 小时前
Asp.net core 使用EntityFrame Work
后端·asp.net
在未来等你1 小时前
互联网大厂Java求职面试:AI大模型与云原生技术的深度融合
java·云原生·kubernetes·生成式ai·向量数据库·ai大模型·面试场景
豌豆花下猫1 小时前
Python 潮流周刊#105:Dify突破10万星、2025全栈开发的最佳实践
后端·python·ai
sss191s1 小时前
Java 集合面试题从数据结构到 HashMap 源码剖析详解及常见考点梳理
java·开发语言·数据结构
LI JS@你猜啊2 小时前
window安装docker
java·spring cloud·eureka