如何使用WebHook的架构设计来扩展和开放你的业务系统

598 阅读5分钟

需求来源

我们开发了一个B端的业务管理系统,目前基础功能已经基本开发完毕并发布上线,客户体验都还挺不错。 但作妖的产品经理希望,我们很多的数据变更希望能同步到他们的其他第三方系统中,但每个客户对于数据变更的场景、第三方目标系统都不一样。。。

经过我们技术团队内部开会讨论了两分钟,于是计划上一个 WebHook 服务来解决掉这个问题。

需求分析

根据产品经理的要求,我们梳理了下面的需求:

  1. 系统需要支持常用的办公软件的通知,比如 企业微信 钉钉 飞书 等;
  2. 系统需要支持邮件通知,将变更的数据内容发送到指定的邮箱;
  3. 系统需要支持主动将变更数据使用 http 请求发送到第三方系统;
  4. 系统发送 http 请求时可携带身份令牌等验证信息;
  5. 系统需要支持自定义配置通知场景、通知目标、通知类型、令牌等;

需求时序图

sequenceDiagram
    用户->>系统: 创建WebHook(场景、类型、第三方目标、令牌)
    系统--)用户: 创建成功
    
    用户->>系统: 创建供应商
    系统--)用户: 创建成功
    系统->>系统: 查询配置的创建供应商场景的WebHook列表
    系统--)第三方(1): WebHook 创建供应商
    系统--)邮件: 发送创建供应商邮件
    系统--)企业微信: 机器人通知创建了供应商

需求类图

  • WebHook 实体

classDiagram

class WebHookScene{
  <<enumeration>>
  SUPPLIER_ADD
  SUPPLIER_UPDATE
  MORE_SCENES...
}



class WebHookType{
  <<enumeration>>
  WORK_WECHAT
  FEI_SHU
  DING_TALK
  EMAIL
  WEB_HOOK
}


class WebHook{
  + Long id
  + WebHookType type
  + WebHookScene scene
  + String url
  + String token
  + String remark
}


WebHook ..> WebHookType : Enum
WebHook ..> WebHookScene : Enum
  • 通知工厂

classDiagram

class AbstractWebHookFactory{
  # String getWebHookContent(WebHookEntity webHook)*
  + void request()
  - void doRequest(Object data, WebHookEntity webHook)
  # String getWorkWechatMarkDown(WebHookEntity webHook) 
  # String getFeiShuMarkDown(WebHookEntity webHook)
  # String getDingTalkMarkDown(WebHookEntity webHook)
  # String getEmailBody(WebHookEntity webHook)
  # String getEmailBody(WebHookEntity webHook)
  # Object prepareWebHookData(WebHookEntity webHook)
}

class SupplierAddEvent {
  # String getWebHookContent(WebHookEntity webHook)
  # Object prepareWebHookData(WebHookEntity webHook)
}

SupplierAddEvent ..|> AbstractWebHookFactory : extends
  • 通知服务

classDiagram

class WebHookService{
  - Map<WebHookScene, AbstractWebHookFactory<?>> factoryMap
  - ThreadPoolExecutor EXECUTOR$
  + <E> void sendHook(WebHookScene scene, E entity)
  - void doRequest(Object data, WebHookEntity webHook)
  - <E, F extends AbstractWebHookFactory<E>> F getFactory(WebHookScene scene, E entity) 
}

需求实现

  • 声明枚举类

@Getter
@AllArgsConstructor
public enum WebHookScene implements IDictionary {
    SUPPLIER_ADD(2, "新建供应商"),
    SUPPLIER_UPDATE(3, "修改供应商"),
    ;

    private final int key;
    private final String label;
}

@Getter
@AllArgsConstructor
public enum WebHookType implements IDictionary {
    WORK_WECHAT(1, "企业微信"),
    FEI_SHU(2, "飞书"),
    DING_TALK(3, "钉钉"),
    EMAIL(4, "邮件"),
    WEB_HOOK(5, "WebHook");

    private final int key;
    private final String label;
}
  • WebHook实体

我们使用的是 JPA + MySQL8,如下是实体类的声明

@Entity
@Data
@Description("通知钩子")
public class WebHookEntity extends BaseEntity<WebHookEntity> {
    @Description("类型")
    @Dictionary(WebHookType.class)
    @Column(columnDefinition = "tinyint UNSIGNED default 1 comment '类型'")
    @Search(Search.Mode.EQUALS)
    private Integer type;

    @Description("场景")
    @Dictionary(WebHookType.class)
    @Column(columnDefinition = "tinyint UNSIGNED default 1 comment '场景'")
    @Search(Search.Mode.EQUALS)
    private Integer scene;

    @Description("网址")
    @Column(columnDefinition = "varchar(255) default '' comment '网址'")
    private String url;

    @Description("令牌")
    @Column(columnDefinition = "varchar(255) default '' comment '令牌'")
    private String token;
}

  • 通知工厂

我们使用通知工厂来统一处理请求的发起、各种目标数据结构的构建等。

@Slf4j
@Data
@Accessors(chain = true)
public abstract class AbstractWebHookFactory<E> {
    /**
     * <h2>当前数据实体</h2>
     */
    private E entity;

    /**
     * <h2>场景</h2>
     */
    @Getter(AccessLevel.PRIVATE)
    private WebHookScene scene;

    /**
     * <h2>发起请求</h2>
     */
    public final void request() {
        DictionaryUtil dictionaryUtil = Utils.getDictionaryUtil();

        // 查询指定场景的Hook列表
        List<WebHookEntity> notifyHookList = Services.getWebHookService().filter(
                new WebHookEntity()
                        .setScene(scene.getKey())
        );
        notifyHookList.forEach(notifyHook -> {
            // 获取通知类型
            WebHookType webHookType = dictionaryUtil.getDictionary(WebHookType.class, notifyHook.getType());

            // 获取各个类型的通知内容(POST结构)
            Object object = switch (webHookType) {
                case WORK_WECHAT -> getWorkWechatMarkDown(notifyHook);
                case FEI_SHU -> getFeishuMarkDown(notifyHook);
                case DING_TALK -> getDingTalkMarkDown(notifyHook);
                case EMAIL -> getEmailBody(notifyHook);
                case WEB_HOOK -> getWebHookBody(notifyHook);
            };

            // 发起通知
            doRequest(object, notifyHook);
        });
    }

    /**
     * <h2>获取通知内容</h2>
     *
     * @param webHook 通知钩子
     * @return 准备的数据
     */
    protected abstract String getWebHookContent(WebHookEntity webHook);

    /**
     * <h2>请求</h2>
     *
     * @param data       数据
     * @param webHook 通知钩子
     */
    private void doRequest(@NotNull Object data, @NotNull WebHookEntity webHook) {
        DictionaryUtil dictionaryUtil = Utils.getDictionaryUtil();
        WebHookType webHookType = dictionaryUtil.getDictionary(WebHookType.class, webHook.getType());
        if (webHookType == WebHookType.EMAIL) {
            // 如果是邮箱通知 直接发送邮件
            try {
                Utils.getEmailUtil().sendEmail(webHook.getUrl(), scene.getLabel(), data.toString());
            } catch (MessagingException e) {
                log.error(e.getMessage(), e);
            }
            return;
        }

        // 其他通知 发起网络请求
        Utils.getHttpUtil().setUrl(webHook.getUrl()).post(data.toString());
    }

    /**
     * <h2>获取企业微信MarkDown格式</h2>
     *
     * @param webHook 通知钩子
     * @return 企业微信MarkDown
     */
    protected final String getWorkWechatMarkDown(WebHookEntity webHook) {
        return Json.toString(Map.of(
                "msgtype", "markdown",
                "markdown", Map.of(
                        "content", String.format("# %s\n\n%s", scene.getLabel(), getWebHookContent(webHook))
                )
        ));
    }

    /**
     * <h2>获取钉钉MarkDown格式</h2>
     *
     * @param webHook 通知钩子
     * @return 钉钉MarkDown
     */
    protected final String getDingTalkMarkDown(WebHookEntity webHook) {
        return Json.toString(Map.of(
                "msgtype", "markdown",
                "markdown", Map.of(
                        "text", String.format("# %s\n\n%s", scene.getLabel(), getWebHookContent(webHook)),
                        "title", scene.getLabel()
                )
        ));
    }

    /**
     * <h2>获取飞书MarkDown格式</h2>
     *
     * @param webHook 通知钩子
     * @return 飞书MarkDown
     */
    protected final String getFeishuMarkDown(WebHookEntity webHook) {
        List<Map<String, Object>> elements = new ArrayList<>();
        elements.add(Map.of(
                "tag", "div",
                "text", Map.of(
                        "tag", "lark_md",
                        "content", String.format("# %s\n\n%s", scene.getLabel(), getWebHookContent(webHook))
                )
        ));
        return Json.toString(Map.of(
                "msg_type", "interactive",
                "card", Map.of(
                        "elements", elements
                ),
                "header", Map.of(
                        "title", Map.of(
                                "tag", "plain_text",
                                "content", scene.getLabel()
                        )
                )
        ));
    }

    /**
     * <h2>获取邮件内容</h2>
     *
     * @param webHook 通知钩子
     * @return 邮件内容
     */
    protected final @NotNull String getEmailBody(WebHookEntity webHook) {
        return getWebHookContent(webHook).replaceAll("\n", "<br/>");
    }

    /**
     * <h2>获取WebHook请求包体</h2>
     *
     * @param webHook 通知钩子
     * @return WebHook内容
     */
    protected final String getWebHookBody(@NotNull WebHookEntity webHook) {
        return Json.toString(Map.of(
                "scene", scene.name(),
                "remark", webHook.getRemark(),
                "token", webHook.getToken(),
                "data", prepareWebHookData(webHook)));
    }

    /**
     * <h2>获取WebHook通知的数据</h2>
     *
     * @param webHook 通知钩子
     * @return 通知数据
     */
    protected Object prepareWebHookData(WebHookEntity webHook) {
        return entity;
    }
}

WebHookService

此类实现了工厂的获取、发送WebHook通知的方法、获取工厂的方法。

@Slf4j
@Service
public class WebHookService extends BaseService<WebHookEntity, WebHookRepository> {
    /**
     * <h2>工厂列表</h2>
     */
    private final Map<WebHookScene, AbstractWebHookFactory<?>> factoryMap = Map.of(
            WebHookScene.APP_SECRET_RESET, new AppSecretResetEvent(),
            WebHookScene.SUPPLIER_ADD, new SupplierAddEvent(),
            WebHookScene.SUPPLIER_UPDATE, new SupplierUpdateEvent()
    );

    /**
     * <h2>线程池</h2>
     */
    private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(
            5,
            20,
            3600L,
            TimeUnit.SECONDS,
            new LinkedBlockingQueue<>()
    );

    /**
     * <h2>发送通知</h2>
     *
     * @param scene  场景
     * @param entity 实体
     */
    public <E> void sendHook(WebHookScene scene, E entity) {
        try {
            EXECUTOR.submit(() -> getFactory(scene, entity).request());
        } catch (Exception exception) {
            log.error(exception.getMessage(), exception);
        }
    }

    /**
     * <h2>获取工厂</h2>
     *
     * @param scene 场景
     * @return 工厂
     */
    @SuppressWarnings("unchecked")
    private <E, F extends AbstractWebHookFactory<E>> @NotNull F getFactory(@NotNull WebHookScene scene, E entity) {
        try {
            F factory = (F) factoryMap.get(scene);
            factory.setEntity(entity).setScene(scene);
            return factory;
        } catch (Exception exception) {
            throw new ServiceException("没有找到对应的通知工厂");
        }
    }
}

场景事件准备

因为不同的场景的通内容是不一样的,所以需要根据场景来准备不同的通知内容以及通知数据的脱敏等操作:

public class SupplierUpdateEvent extends AbstractWebHookFactory<SupplierEntity> {
    /**
     * <h2>获取通知内容</h2>
     *
     * @param webHook 通知钩子
     * @return 准备的数据
     */
    @Override
    protected String getWebHookContent(@NotNull WebHookEntity webHook) {
        return String.format("创建了一个新的供应商 (%s)",
                getEntity().getName()
        );
    }

    @Override
    protected Object prepareWebHookData(WebHookEntity webHook) {
        // 需要脱敏请在此操作
        return getEntity();
    }
}


调用通知

@Service
public class SupplierService extends BaseService<SupplierEntity, SupplierRepository> {
    @Autowired
    private WebHookService webHookService;

    @Override
    protected void afterAdd(long id, @NotNull SupplierEntity supplierEntity) {
        webHookService.sendHook(WebHookScene.SUPPLIER_ADD, get(id));
    }

    @Override
    protected void afterUpdate(long id, @NotNull SupplierEntity supplierEntity) {
        webHookService.sendHook(WebHookScene.SUPPLIER_UPDATE, get(id));
    }
}

截图

  • 创建WebHook

QQ_1720775838931.png

  • WebHook包体示例

{
    "data": {
        "id": 1,
        "remark": "",
        "isDisabled": false,
        "createTime": 1720775924582,
        "createUserId": 1,
        "updateUserId": 0,
        "updateTime": 1720775924593,
        "name": "苹果中国",
        "code": "SUP0001",
        "phone": "17666666666"
    },
    "token": "passcode",
    "remark": "供应商创建后的通知",
    "scene": "SUPPLIER_ADD"
}
  • 企业微信通知示例

QQ_1720776046521.png

总结

通过这套设计,我们实现了管理员可在后台自定义配置通知,然后通过通知钩子来触发通知,通知钩子支持多种通知方式,包括邮件、企业微信、钉钉、飞书、WebHook等,如果第三方有开发能力,可通过WebHook来开发后续的业务处理,再配合我们开放能力提供的第三方API,就能实现各种其他业务的扩展了。

开源仓库:

Github: github.com/HammCn/AirP…

Gitee: gitee.com/air-power/A…

Bye

下一篇,我们来讲讲开放能力的设计。 如何使用OpenAPI来实现一个标准的开放应用服务架构

今天水的文章就这样,拜拜了各位。