halo 插件的开发虽然和一般的 Springboot 项目一样,但版本确是 springboot 3.x 的,Web 层不再使用 Servlet 技术,而是充分向异步和非阻塞的反应式编程靠拢,使用 Netty 作为 Web 服务器,使用 Reactor 作为异步编程框架,使用 R2DBC 作为数据库访问框架,使用 WebFlux 作为 Web 层框架。所以好多以前的开发习惯都已经不在适用 halo 插件的开发了,于是在没有专门学习这些新技术的前提下,去开发 halo 插件踩了很多坑,一些特殊语法都是参照已有的 halo 源码和其他插件源码以及 Springboot3.x 文档进行开发的,过程稍微有点一波三折,在此记录一些我开发过程中的踩坑记录

一、插件配置数据的获取

24 年七月初,halo 更新了 2.17.0 版本,对于内容管理方面进行了全面升级,随即导致好多插件不兼容,无法正常访问前台,主要的一个原因就是错误地在 Reactor 中调用 block 方法。而 halo 提供了两个对象获取插件的配置数据,分别是阻塞式的 SettingFetcher 和非阻塞式的 ReactiveSettingFetcher ,一个是同步获取,一个是异步获取。由于有些插件使用 SettingFetcher 获取插件配置数据,所以会导致程序阻塞。因为 Reactor 是基于非阻塞的响应式编程模型,而 block 方法会将异步操作转换为同步操作,从而破坏其非阻塞特性。为了避免这种情况,应该尽量避免在 WebFlux 中使用 block 方法,而是使用其他非阻塞的操作,如 flatMap、map,所以,获取插件配置数据使用 ReactiveSettingFetcher 最好,示例如下:

@Service
@RequiredArgsConstructor
public class SeoService {

    private final ReactiveSettingFetcher settingFetcher;

    public Mono<Void> getBaseConf() {
        return settingFetcher.fetch(SeoSetting.GROUP, SeoSetting.class)
                .doOnNext(baseConfig -> {
                    if (baseConfig.isEnable()) {
                        // do something
                    }
                })
                .then();
    }
}

如果需要将配置数据作为成员变量使用,可以通过订阅消费的方式去使用,也可以使用blockOptional 获取配置对象:

  • 订阅消费的方式使用

    @Service
    @RequiredArgsConstructor
    public class SeoService {
    
        private final ReactiveSettingFetcher settingFetcher;
    
        private BaseConifg baseConfig;
    
        public Mono<Void> getPluginConfig() {
            return settingFetcher.fetch(SeoSetting.GROUP, SeoSetting.class)
                    .subscribe(baseConf -> {
                         baseConfig = baseConf
                    });
        }
    }
    
  • block

    @RequiredArgsConstructor
    @Component
    public class ArtalkComment implements CommentWidget {
    
        private final SettingConfigGetter settingConfigGetter;
    
        @Override
        public void render(ITemplateContext context, IProcessableElementTag elementTag, IElementTagStructureHandler iElementTagStructureHandler) {
    
            var siteConfig = settingConfigGetter.getBasicConfig().blockOptional().orElseThrow();
    
            String siteTitle = String.valueOf(siteConfig.getSiteTitle());
            String artalkUrl = String.valueOf(siteConfig.getArtalkUrl());
    
            // do something 
        }
    }
    // 其中 SettingConfigGetter 是自己定义的抓去配置的方法
    @Component
    @RequiredArgsConstructor
    public class SettingConfigGetter {
    
        private final ReactiveSettingFetcher settingFetcher;
    
        public Mono<Settings> getBasicConfig() {
            return settingFetcher.fetch(Settings.GROUP, Settings.class)
                .defaultIfEmpty(new Settings());
        }
    }
    

建议通过 map、flatMap、doOnNext 等方法操作配置数据。这样以后维护插件的成本比较小。

二、WebClient 对象的使用

在 halo 插件开发中,如果后端需要通过 http 请求去获取一些数据,请不要使用 RestTemplate 去请求,否则在生产环境会导致程序阻塞,具体原因我目前还不是很确定,所以在之后的开发中,我都是使用了 Spring boot 3 中新增的 WebClient 对象模拟客户端进行 API 请求。

WebClient对象是一个非阻塞的、基于响应式编程的网络客户端,用于发送HTTP请求和接收HTTP响应。它是基于 Project Reactor 库的,可以与Spring WebFlux一起使用,以实现异步和非阻塞的网络通信。WebClient 提供了一种简洁的方式来构建 HTTP 请求,并支持多种请求方法(如GET、POST、PUT、DELETE等)。它还支持请求参数、请求头、请求体等的配置,以及响应的处理。下面是一个简单的示例,其中每个方法的作用请参考代码里的注释,其他方法和 API 请参考官方文档。

@Service
@Slf4j
public class ArtalkService {

    private WebClient webClient;

    private Settings pluginSetting;

    private static final String PAGE_COMMENT = "/api/v2/comments";

    private final SettingConfigGetter settingConfigGetter;

    public ArtalkService(SettingConfigGetter settingConfigGetter) {
        this.settingConfigGetter = settingConfigGetter;
        this.pluginSetting = this.settingConfigGetter.getBasicConfig().blockOptional().orElseThrow();   
         //初始化 webclient 对象,设置请求头,baseUrl
        this.webClient = WebClient.builder().baseUrl(pluginSetting.getArtalkUrl())
            .defaultHeaders( headers ->{
                    headers.set("Origin", this.pluginSetting.getAuthDomain());
                    headers.setBasicAuth("Content-Type","application/x-www-form-urlencoded");
            }).build();
    }


    /**
     * 获取指定页面的所有评论
     * @param pageKey 页面唯一标识
     * @return
     */
    public Mono<JsonNode> getSpecialPageComment(String pageKey){
        Mono<JsonNode> jsonNodeMono = webClient.get().uri(PAGE_COMMENT +
                    "?site_name={siteName}&page_key={page}&limit={size}",
                pluginSetting.getSiteTitle(), pageKey, 100) // get 请求参数传递方式
            .accept(MediaType.APPLICATION_JSON)
            .retrieve() //获取响应体
            .onStatus(HttpStatusCode::is4xxClientError, response ->
                response.bodyToMono(JsonNode.class).flatMap(error -> Mono.empty())) //处理客户端的错误
            .onStatus(HttpStatusCode::is5xxServerError, response ->
                response.bodyToMono(JsonNode.class).flatMap(error -> Mono.empty()))//处理服务端的500响应错误
            .onStatus(HttpStatusCode::is5xxServerError, response ->
                    response.bodyToMono(JsonNode.class).flatMap(error -> Mono.empty()))
            .bodyToMono(JsonNode.class); // 接受正确响应之后的数据
        return jsonNodeMono;
    }

}

三、表单定义

表单定义其实也没什么难度,就是根据 formkit 组件 将其转换为 yaml 语法进行使用,但是需要注意的是一些特殊的声明方式,否则就会造成转换异常,或者数据值无法正常映射的问题。

  • 动态显示隐藏相应的组件,使用 if: $get(changeVal).value 进行控制,比如下方的 customCss 字段是通过 enableCustomCss 进行显示隐藏的。

    - $formkit: radio
       name: enableCustomCss
       id: enableCustomCss
       key: enableCustomCss
       label: 是否自定css样式
       value: false
       options:
          - value: true
            label: 开启
          - value: false
            label: 关闭
    - $formkit: code
      name: customCss
      if: $get(enableCustomCss).value
      label: css样式
      value: a{display:inline-block;}
      language: css
      help: 支持css语法样式,无需包裹style标签
      height: 350px
    

$get(changeVal).value 也可以是字符串,比如 $get(changeVal).value == str 来显示隐藏,例如:

- $formkit: select
  name: enableLightDark
  id: enableLightDark
  key: enableLightDark
  label: 暗黑模式适配
  value: false
  options:
    - value: attribute
       label: 明暗切换属性选择器
    - value: single
      label: 独立切换
    - value: disable
      label: 禁用(明暗模式下的中性色,不是很美观,但内容清晰可见)
- $formkit: text
  if: $get(enableLightDark).value == attribute
  name: darkModeAttribute
  label: 选择器名称(严格按照说明的示例格式填写)
  value:
  • 如果要根据一个值的变化改变多组值的显示隐藏,请务必加上 key,负责会导致组件在切换的时候发生渲染错误和数据无法与正确映射的问题。例如这里需要根据 themeStyle 的值切换去渲染相应的组件,也就是 defaultTmplConfig,它下边有两个值,所以为了防止切换过程中和其他的组件发生渲染混乱的问题,要为其加上 key,标识其唯一性。注意不能在其 children 下边的值加上key,这样会导致数据无法正常更新!
     - $formkit: select
       name: themeStyle
       id: themeStyle
       key: themeStyle
       label: 跳转页面模版
       value: jumpGo
       options:
         - label: 默认展示页面
           value: jumpGo
         - label: 极客风格
           value: geekStyleGo
    
    - $formkit: group
      if: $get(themeStyle).value == jumpGo
      name: defaultTmplConfig
      key: defaultTmplConfig
      label: 默认模版参数配置
      value:
         staticBgImg: "https://files.codelife.cc/wallhaven/full/ey/wallhaven-eyzx5k.jpg"
         awaitTime: 6
      children:
         - $formkit: attachment
           name: staticBgImg
           label: 自定义背景
           value: https://files.codelife.cc/wallhaven/full/ey/wallhaven-eyzx5k.jpg
           help: 建议换成自己自建图床的图片url
         - $formkit: number
           name: awaitTime
           label: 倒计时时间,单位S
           value: 6
    

四、使用扩展点的注意事项

如果需要使用 halo 提供的扩展点,比如 TemplateHeadProcessorTemplateFooterProcessor 时,注入一些插件中获取的数据的时候,请先处理一下空数据,否则会有空指针异常导致的前台模版无法渲染问题,一般我都会在插件配置数据中提供一个默认值,或者在程序中进行判断。

对于评论区的扩展,在 2.17.0 版本之后,请声明一下自定义模型文件,否则用户在使用的时候是无法在后台的插件扩展点配置中选择评论系统,其他扩展点亦是如此。例如,声明一个类 ArtalkComment 对评论进行扩展,则需要下边的 yaml 文件进行声明。

@RequiredArgsConstructor
@Component
public class ArtalkComment implements CommentWidget {

    private final SettingConfigGetter settingConfigGetter;

    @Override
    public void render(ITemplateContext context, IProcessableElementTag elementTag, IElementTagStructureHandler iElementTagStructureHandler) {

        var siteConfig = settingConfigGetter.getBasicConfig().blockOptional().orElseThrow();

        String siteTitle = String.valueOf(siteConfig.getSiteTitle());
        String artalkUrl = String.valueOf(siteConfig.getArtalkUrl());

        iElementTagStructureHandler.replaceWith(moderateTemplateResolve(siteTitle, artalkUrl), false);
            }
        }
    }
}

扩展点声明文件:

apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
  name: artalk-comment
spec:
  className: xin.wenjing.halo.artalk.ArtalkComment
  extensionPointName: comment-widget
  displayName: "artalk评论组件"
  description: "由作者 dreamChaser的小屋 提供的第三方评论组件"

五、插件内置模版路由的使用

首先,你需要在插件的 resources 目录下创建一个 templates 目录,然后在 templates 目录下提供你的模板,这个模版不仅可以使用你在渲染时提供的数据,而且可以使用全局变量 sitetheme ,参考文档:https://docs.halo.run/developer-guide/theme/global-variables#theme。例如,下边是一个更具配置参数动态切换模版文件和动态注入变量的一个示例:

@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class LinkSecurityDetectRouter {

    private final TemplateNameResolver templateNameResolver;

    private final ReactiveSettingFetcher settingFetcher;

    private final PluginContext pluginContext;

    @Bean
    RouterFunction<ServerResponse> momentRouterFunction() {
        return RouterFunctions.route().GET("/jumpGo",this::renderTermialPage).build();
    }

    Mono<ServerResponse> renderTermialPage(ServerRequest request) {
        var model = new HashMap<String, Object>();
        Settings.getTipMsgConfig(this.settingFetcher).subscribe((tipMsgConfig)-> model.put("tipMessage", tipMsgConfig) );
        return this.settingFetcher.fetch(Settings.BaseConfig.GROUP, Settings.BaseConfig.class).flatMap( baseConfig -> {
            String themeStyle = baseConfig.getThemeStyle();
            model.put("version", pluginContext.getVersion());
            // 各个模版的独有变量注入
            if("jumpGo".equals(themeStyle)){
                model.put("backgroundImage", baseConfig.getDefaultTmplConfig().getStaticBgImg());
            }else if("wave".equals(themeStyle)){
                System.out.println(baseConfig.getWaveTmplConfig().getColorValOne()+ "=======" + baseConfig.getWaveTmplConfig().getColorValTwo());
                model.put("colorOne", baseConfig.getWaveTmplConfig().getColorValOne());
                model.put("colorTwo", baseConfig.getWaveTmplConfig().getColorValTwo());
            }
            model.put("browserTitle", baseConfig.getBroswerTitle());

            return templateNameResolver.resolveTemplateNameOrDefault(request.exchange(), themeStyle)
                .flatMap(templateName -> ServerResponse.ok().render(templateName, model));
        });
    }

}

暂时就记录这么多吧,还有一些平时踩过的坑忘了记录了,后续我会慢慢的在进行一个补充完善,希望可以帮助到初入 halo 插件的开发者们。