LogoHydro
开发

使用 TypeScript 编写插件

Step0 为什么使用插件?

如果您参与过其他工程项目的开发,经常会遇到以下的痛点:

  • 若修改前端源代码,需要重新编译打包前端,编译通常消耗大量的内存与时间,且编译完成后需要重启服务,用户侧资源缓存也会全部失效;
  • 修改代码后,尝试更新系统时,自己的修改被新版本覆盖,需要手动合并(或是直接丢失了更改),极大幅度增加维护成本;
  • 第三方社区出现的大量修改分支无法直接按需求进行组合/拼装,或是功能间存在冲突;

基于以上痛点,Hydro 开创性的使用了插件系统,提供了一套完整的开发 API 供开发者使用,开发者基于提供的较稳定的 API 进行功能的编写,而无需过多关心内部实现,将功能拆分成多个最小的单元,允许用户根据需要进行自由组合,并在多个版本间达到一致性,同时提供热重载功能,提升开发效率。

此教程将以编写剪贴板插件为例进行插件开发的说明。

Step1 初始化项目

前置条件:NodeJS>=22

使用 hydrooj addon create 快速在 /root/addon 下初始化一个插件或是在一个空文件夹中运行 yarn init 并按照提示填写相关信息。

# 使用 yarn init 的样例
/workspace/hydro-plugin $ yarn init
yarn init v1.22.4
question name (hydro-plugin): @hydrooj/pastebin
question version (1.0.0): 0.0.1
question description: HydroOJ的剪贴板组件
question entry point (index.js): index.ts
question repository url: https://github.com/hydro-dev/pastebin.git
question author: undefined <i@undefined.moe>
question license (MIT): MIT
question private:
success Saved package.json

可选:在本机环境编写插件

有时我们希望使用本机的 IDE 编写插件上传到服务器(我们也推荐这么做,编辑器提供的代码补全可以很大程度简化开发流程),可以进行如下操作:

  1. 在本机安装 NodeJS 和 yarn 。
  2. 参照步骤 1 使用 yarn init 创建一个项目。
  3. 使用 VSCode 打开插件文件夹。
  4. 使用 yarn add hydrooj -D 安装相关开发组件。
  5. 参照下文进行插件开发工作
  6. 将本地的文件夹上传至服务器,并使用 hydrooj addon add 插件绝对路径 启用上传的插件。

Step2 准备编写组件

分析:剪贴板组件需要以下功能:

  • 与数据库交互来存储/检索相应文档。
  • 提供 /paste/create 路由以创建新文档。
  • 提供 /paste/show/:ID 来查看已创建的文档。
  • 根据用户ID进行鉴权,允许将文档设置为私密以防止他人查看。

在路由中定义所有的函数应均为异步函数,支持的函数有:prepare, get, post, post[Operation], cleanup
具体流程如下:

先执行 prepare(args) (如果存在)
args 为传入的参数集合(包括 QueryString, Body, Path)中的全部参数,
再执行 prepare(args) (如果存在)
检查请求类型:

为 GET ?
  -> 执行 get(args)
为 POST ?
  -> 执行 post(args)
  -> 含有 operation 字段?
       -> 执行 post[Operation]

执行 cleanup()

如果在 this.response.template 指定模板则渲染,否则直接返回 this.response.body 中的内容。

  • 在表单提交时的 operation 字段使用下划线,函数名使用驼峰命名。

<input type="hidden" name="operation" value="confirm_delete"> 对应 postConfirmDelete 函数。

应当提供 apply 函数,并与定义的 Handler 一同挂载到 global.Hydro.handler[模块名] 位置。 apply 函数将在初始化阶段被调用。

Step3 index.ts

// @filename: index.ts
import {
    , , , , , , , , ,
} from 'hydrooj';

const  = .('paste');

interface Paste {
    : string;
    : number;
    : string;
    : boolean;
}

declare module 'hydrooj' {
    interface Model {
        : typeof ;
    }
    interface Collections {
        : Paste; // 声明数据表类型
    }
}

async function (: number, : string, : boolean): <string> {
    const  = (16);
    // 使用 mongodb 为数据库驱动,相关操作参照其文档
    const  = await .({
        : ,
        : ,
        ,
        ,
    });
    return .; // 返回插入的文档ID
}

async function (: string): <Paste> {
    return await .({ :  });
}

// 暴露这些接口,使得 cli 也能够正常调用这些函数;
const  = { ,  };
... = ;

// 创建新路由
class  extends  {
    // Get请求时触发该函数
    async () {
        // 检查用户是否登录,此处为多余(因为底部注册路由时已声明所需权限)
        // 此方法适用于权限的动态检查
        // this.checkPriv(PRIV.PRIV_USER_PROFILE);
        this.. = 'paste_create.html'; // 返回此页面
    }

    // 使用 Types.Content 检查输入
    @('content', .)
    @('private', .)
    // 从用户提交的表单中取出content和private字段
    // domainId 为固定传入参数
    async (: string, : string,  = false) {
        // 在HTML表单提交的多选框中,选中值为 'on',未选中则为空,需要进行转换
        const  = await .(this.., , !!);
        // 将用户重定向到创建完成的url
        this.. = this.('paste_show', { :  });
        // 相应的,提供了 this.back() 方法用于将用户重定向至前一个地址(通常用于 Ajax 或是部分更新操作)
    }
}

class  extends  {
    @('id', .)
    async (: string, : string) {
        const  = await pastebin.get();
        if (!) throw new ();
        if (.isPrivate && this.. !== .owner) {
            throw new ();
        }
        this.. = {  };
        this.. = 'paste_show.html';
    }

    @('id', .)
    async (: string, : string) {
        // 当提交表单并存在 operation 值为 delete 时执行。
        // 本例中未实现删除功能,仅作为说明。
    }
}

// Hydro会在服务初始化完成后调用该函数。
export async function () {
    // 注册一个名为 paste_create 的路由,匹配 '/paste/create',
    // 使用 PasteCreateHandler 处理,访问该路由需要 PRIV.PRIV_USER_PROFILE 权限
    // 提示:路由匹配基于 path-to-regexp
    ctx.Route('paste_create', '/paste/create', , .);
    ctx.Route('paste_show', '/paste/show/:id', );
}

Step4 template

模板采用 nunjucks 语法。放置于 templates/ 文件夹下。
会在请求结束时根据 response.template 的值选择模板,并使用 response.body 的值进行渲染,存入 response.body 中。
response.template 为空或 request.headers['accept'] == 'application/json',则跳过渲染步骤。

Step5 locale

用于提供多国翻译。格式与 Hydro 的 locale 文件夹格式相同。

Step6 frontend

在 frontend 文件夹下编写前端代码。命名符合 [a-zA-Z0-9_]+.page.tsx? 的文件会被自动作为入口点加载。
paste 功能并不需要在前端有任何额外 js 驱动的交互,因此下方给出一个最基础的格式示例。

import './foo.css'; // 如果有额外的样式
import { addPage, NamedPage, AutoloadPage } from '@hydrooj/ui-default';

addPage(new NamedPage(['problem_detail'], () => {
  console.log('仅在题目详情页面执行');
}));

addPage(new AutoloadPage('my_page_name', () => {
  console.log('在所有页面均会执行');
}));