普通视图

发现新文章,点击刷新页面。
昨天以前宝硕博客

LLM 工程化在福 uu 中的落地实践 —— 假期调课的智能解析

作者 宝硕
2026年3月30日 14:55
LLM 工程化在福 uu 中的落地实践 —— 假期调课的智能解析

—— 在对话框以外,大模型又能发挥什么作用呢?

在编写新版福 uu 后端代码的过程中,我们已经实现了从教务系统上读取调课信息并应用、展示到课程表上的能力。但这个调课的展示仅限于教师录入好的单次调课,对于教务处发布的全校性假日调课通知则并没有去做解析和展示的逻辑。

究其原因,是教务处发出的调课通知都长下面这个样子:

1、10 月 1 日(星期三)至 10 月 8 日(星期三)放假,共 8 天,全校本科生课程(含通识教育选修课)停课。

2、9 月 28 日(星期日)补上 10 月 7 日(星期二)的课,10 月 11 日(星期六)补上 10 月 8 号(星期三)的课,原 9 月 28 日和 10 月 11 日的课程停课。

这种自然语言格式的描述难以被代码解析。短短几行字背后,隐含着十余条调课规则 —— 哪些日期的课取消了?哪些日期的课被挪到了另一天?原来那一天的课又怎么办?

在过去,这些调课信息需要用户自行阅读通知、对照课表、在脑中完成推理,这极大地增加了用户的心智负担,同时也增加了同学们在补课的日子出现意外旷课的可能性。

但是 LLM 的出现,使这种任务有了可能的解决方案,我们可以利用其强大的自然语言理解能力来从中提取结构化的调课信息,从而直接给用户展示时间调整后的课程表,极大地提升用户体验。为了实现这个目标,我们需要对 LLM 进行工程化的封装,使其能够具备像传统解析函数一样的表征,从而优雅地接入到现有的业务流程中。

Why this?

在介绍我们的新方法以前,有必要先解释一下为什么这类通知不能用正则表达式或者规则引擎来解析。

首先是格式不固定。每次调课通知都可能由不同的老师起草,措辞、结构、标点习惯都各不相同。有时候会说「补上……的课」,有时候会说「调至……上课」,有时候会直接用「顺延」「提前」这样的表达。这导致了通知的格式不可能完全一致。

其次是语义嵌套复杂,单条规则背后往往隐藏着多个子规则。上面例子中的第二条,实际上包含了下面的几条逻辑:9 月 28 日原有课程停课;9 月 28 日按 10 月 7 日课表上课;10 月 11 日原有课程停课;10 月 11 日按 10 月 8 日课表上课。

如果用纯代码形式的规则引擎实现的话,会由于一种种边界情况的出现而越写越复杂,导致维护成本会随着边界情况的积累呈指数级增长。正因如此,LLM 的强大语言理解能力就能够派上用场了。

LLM as Function

很多人对大模型的第一印象就是一个对话界面 —— 你问它答。但在福 uu 的这个场景中,我们需要的是一种完全不同的方案,输入一段教务处的通知,在合适的提示词引导下让其能够准确地输出一组结构化的调课规则。

在传统的业务流程中,要从某段数据中解析出一些结构化的数据,几乎都是编写一个函数来封装好解析能力。对于调用方而言,函数完全是一个黑盒,只需要按照约定的方式传参、返回结果,至于内部实现究竟如何是不需要关心的。

既然这样的话,我们完全可以把 LLM 调用封装成一个函数来实现一些功能,我们称其为「LLM Function」。没有多轮对话,没有用户交互,甚至没有前端界面。大模型在这里扮演的角色,更像是一个和传统的正则表达式、规则引擎等处于同一个生态位的解析引擎。但和传统的解析引擎相对比,利用大模型解析可以充分地理解自然语言,泛化性更强,不需要囿于固定的、一定能被代码描述的格式。

这种函数仍然具有可测试、可替换的特性,并不会对后续的开发维护流程产生什么特别的影响。

具体的实现请参阅仓库 renbaoshuo/llmfunc

实现思路

把一次 LLM 调用看作一个普通函数 f(T) → R。

定义 Function[T, R] 接收类型为 T 的业务输入,返回类型为 R 的业务输出。调用者完全不需要关心中间经历了消息组装、模型推理、工具调用、响应解析这些细节,只需要 fn.Run(ctx, &input) 就能拿到强类型的结果。

核心是一个泛型结构体:

// Function defines an LLM-backed function.
//
//   - T is the input type (must implement FunctionInputFormatter interface).
//   - R is the output type.
type Function[T any, R any] struct {
    client *Client
    output OutputHandler[T, R]
    config *FunctionConfig
}

它会完成下面三件事:

  1. 将输入 T 转换为 LLM 的 ChatCompletionMessage 列表;
  2. 携带配置(Model、Temperature、Instruction 等)发起 API 请求;
  3. 将 LLM 的原始响应通过 output 函数转换为类型安全的 R

其中,T 是业务输入类型,相当于函数参数,R 是业务输出类型,相当于函数返回值。对于二者的具体处理,可以参见下面的介绍。

输入侧转换

框架在设计的时候没有强制要求输入必须是某种固定格式,而是通过 FunctionInputFormatter 接口做了一层解耦。

type FunctionInput struct {
    Messages []openai.ChatCompletionMessage `json:"messages"`
}

type FunctionInputFormatter interface {
    FunctionInput() *FunctionInput
}

任何业务类型 T,只要实现了 FunctionInput() 方法,就能产出一个 FunctionInput 告诉框架「如何把我自己转换成 LLM 能理解的消息列表」。这样每种业务场景可以自行决定 prompt 的构造方式,比如一个「信息提取」和一个「翻译请求」可以有完全不同的消息组装逻辑,但都能被同一个 Function 框架驱动。

为什么不直接让 T 就是 FunctionInput

因为那样会迫使所有业务逻辑直接面对 openai.ChatCompletionMessage 这种底层结构,业务语义就被淹没了。

之后在调用的时候,用实现好的接口生成构造向 LLM 发送请求使用的 FunctionInput 结构体。

if formatter, hasFormatter := any(*in).(FunctionInputFormatter); hasFormatter {
    input = formatter.FunctionInput()
    ok = true
}

这里的 any(*in) 是为了强制使用值类型实现 FunctionInput 的 formatter,避免值和指针混写的情况发生。

输出侧转换

对于 LLM 输出的解析,框架允许调用方在创建 Function 时自定义符合 OutputHandler[T, R] 解析器,也提供了 BypassOutputUnmarshalOutput 两种内置解析器。

  • BypassOutput() 直接返回一个包含 LLM 的原始输出字符串的结构体作为函数返回值;
  • UnmarshalOutput[T any, R any]() 则将输出反序列化为一个类型为 R 的结构体来作为函数返回值。

在创建 Function 时给定的输出解析器,会在每次调用函数的时候被用来解析 LLM 的输出。

Structured Output

一般情况下,UnmarshalOutput 会配合 Structured Output 一起使用。如果启用了 Structured Output(前提是模型支持),那么则会使用以下代码自动生成提供给模型的 Output Schema:

if config.structuredOutput {
    schema, err := jsonschema.GenerateSchemaForType(*new(R))
    if err != nil {
        panic(err)
    }
    OutputSchema(schema).Apply(config)
}

这段代码在 NewFunction 构造阶段执行,做了这几件事:

  1. new(R) 创建一个 R 类型的零值实例;
  2. jsonschema.GenerateSchemaForType 通过反射从这个零值中提取出完整的 JSON Schema;
  3. schema 存入 config

在生成 schema 的流程中,如果出现 error 只可能是 R 的类型定义本身有问题(比如包含了不可序列化的字段),这属于开发期就能暴露的 bug,所以直接 panic 是很合理的。

然后在 Run 阶段:

req.ResponseFormat = &openai.ChatCompletionResponseFormat{
    Type: openai.ChatCompletionResponseFormatTypeJSONSchema,
    JSONSchema: &openai.ChatCompletionResponseFormatJSONSchema{
        Name:        f.config.name,
        Description: f.config.description,
        Schema:      f.config.outputSchema,
        Strict:      true,
    },
}

把 schema 注入到 OpenAI API 的请求中,强制 LLM 按照这个 schema 输出 JSON。

这里值得展开说说 Structured Output 对于这类业务场景的重要性。在没有 Structured Output 的时候,常见的做法是在 Prompt 里写上「请以 JSON 格式输出」,然后在代码里用正则或者字符串处理去提取 JSON 块。这种方式很脆弱 —— 模型可能会在 JSON 前后加上解释性的文字,可能会使用 Markdown 代码块,也可能因为输出截断导致 JSON 不完整。有了 Structured Output,模型在解码阶段就被约束在 schema 定义的范围内,输出的合法性由模型服务本身来保证,客户端代码可以直接 Unmarshal 而无需任何前处理。

总结一下,这样设计的好处是,在整个过程中,你只需要定义一个 Go struct,框架自动保证 LLM 输出能被安全地反序列化回这个 struct。

注:如果模型支持 Tool Calling 但不支持 Structured Output 的话,可以通过劫持 instruction 强制要求模型调用 callback tool 的方式来实现结构化的输出,但这样子会对 Prompt 造成污染,因此还是尽量使用 Structured Output 为佳。

Functional Options

FunctionConfig 通过经典的 Functional Options 模式暴露配置项:

llmfunc.Name("auto_adjust_course")               // 函数名称
llmfunc.Description("解析调课通知")               // 函数描述
llmfunc.Instruction("你是一名调课通知解析助手...") // System Prompt
llmfunc.Model("deepseek-ai/DeepSeek-V3.2")       // 模型选择
llmfunc.Temperature(0.2)                         // 温度参数
llmfunc.StructuredOutput(true)                   // 启用结构化输出

每个选项都是一个实现了 Option[*FunctionConfig] 接口的值,通过 Apply 方法修改配置。这种模式的好处是 API 简洁、可扩展 —— 未来增加新的参数只需要加一个新函数,不需要修改已有代码。

在调课解析这个场景中,Temperature 设置为较低值(如 0.2)是一个有意为之的选择。调课通知的解析是一个事实提取任务,而非创意生成任务。我们希望模型的输出是稳定且确定的,而不是在多次调用之间产生差异。低 Temperature 使模型更倾向于选择概率最高的 token,从而让同一份通知的解析结果具有良好的幂等性。

业务接入

输入和输出类型

输入类型 AutoAdjustCourseInput 包含通知的标题和正文两个字段,并实现了 FunctionInputFormatter 接口,将二者拼接为一条 User Message:

type AutoAdjustCourseInput struct {
    Title   string `json:"title"   description:"通知标题"`
    Content string `json:"content" description:"通知内容"`
}

func (i AutoAdjustCourseInput) FunctionInput() *llmfunc.FunctionInput {
    return &llmfunc.FunctionInput{
        Messages: []openai.ChatCompletionMessage{
            {
                Role:    openai.ChatMessageRoleUser,
                Content: fmt.Sprintf("#%s\n\n%s", i.Title, i.Content),
            },
        },
    }
}

输出类型的设计刻意保持简洁。每条调课记录只需要两个字段:from_date 表示原本上课的日期;to_date 表示调整后的实际上课日期,若课程取消则留空。不需要额外的字段,取消和换课的语义完全由 to_date 是否为空来区分。这样子可以让模型不在多个字段之间做复杂的联动判断,让模型专注于主要任务,以提升其准确度。

type AutoAdjustCourseItem struct {
    FromDate string `json:"from_date" description:"调整前课程本应上课的日期,格式为 YYYY-MM-DD"`
    ToDate   string `json:"to_date"   description:"调整后的实际上课日期,格式为 YYYY-MM-DD,如果课程取消则留空"`
}

type AutoAdjustCourseOutput struct {
    Items []AutoAdjustCourseItem `json:"items"`
}

其中 description 会生成到 schema 中来帮助模型理解输出格式。具体支持的结构体标签可以查看 jsonschema 库中 reflectSchemaObject 的实现作为参考。

编写 System Prompt

System Prompt 的质量直接决定了解析效果的上限。特别是对于「A 日补上 B 日的课,原 A 日课程停课」类型的表述,它们实际上各自对应着两条独立的记录,需要在 Prompt 中将这个隐含的拆分逻辑显式地写出来,避免模型不知道怎么处理这种情况而产生错误。

我们实际使用的 System Prompt 是由两部分组成的:

  1. 逐条列举的解析规则;
  2. 一次完整的解析输入、思考、输出示例。
你是一名调课通知解析助手,负责从调课通知的标题和内容中提取调课信息。调课信息包括调整前的日期和调整后的日期(如果当日课程取消则留空)。
请根据以下要求提取信息:
1. 输入的通知中可能包含多条调课信息,请提取所有的调课信息。
2. 日期格式为 YYYY-MM-DD。
3. 对于通知中提到放假的日期,如果没有提到其对应的补课日期则表示课程取消,产生一条调课信息,调整前的日期是放假的日期,调整后的日期留空。
4. 如果通知中提到日期X补上日期Y的课,则原来应该在日期X上课的课程取消,产生两条调课信息:一条是日期X的课程取消(调整后的日期留空),另一条是日期Y的课程调整(调整前的日期是X,调整后的日期是Y)。
5. 输出的结果应该是一个包含多个调课信息的列表,每条调课信息包含调整前的日期和调整后的日期(如果当日课程取消则留空)。
6. 如果无法提取到有效的调课信息,请返回一个空列表。

【示例输入】
# 关于2026年元旦放假课程调整的通知

各学院,各教学单位:

根据党政办有关2026年元旦放假通知的精神,现将放假期间的课程调整如下:
1、1月1日(周四)至1月3日(周六)放假,共3天,全校本科生课程(含通识教育选修课)停课。
2、1月4日(周日)补上1月2日(周五)的课,原1月4日的课程停课。
3、因停课受影响的教学内容,请任课老师自行调整安排。

请各学院、教学单位及时通知相关师生。

教务处
2025年12月29日

【解析结果】
第一条信息提示1月1日到1月3日放假,停课,得到一个1月1日、1月2日、1月3日均取消的记录列表;
第二条信息提示1月4日补上1月2日的课,原1月4日的课程停课,得到一个1月4日取消的记录,将1月2日的取消记录改为调整到1月4日。
综上,得到如示例输出所示的结果。

【示例输出】
[
  {
    "from_date": "2026-01-01",
    "to_date": ""
  },
  {
    "from_date": "2026-01-02",
    "to_date": "2026-01-04"
  },
  {
    "from_date": "2026-01-03",
    "to_date": ""
  },
  {
    "from_date": "2026-01-04",
    "to_date": ""
  }
]

Prompt 中附带了一个完整的示例,以元旦放假通知为输入,逐步推导出最终的 items 列表。这种 few-shot 风格的引导,可以让模型在面对真实通知时直接按照以往的思路进行推理,极大地提升了推理过程的确定性。

组装 LLM Function

项目在 pkg/ai/function.go 中二次封装了一个薄薄的 NewFunction 函数,统一从配置中读取 API Key 和 Endpoint,避免在每个 LLM Function 里重复做一些获取配置的 dirty work。

func NewFunction[T any, R any](
    handler llmfunc.OutputHandler[T, R],
    opts ...llmfunc.Option[*llmfunc.FunctionConfig],
) *llmfunc.Function[T, R] {
    client := llmfunc.NewClient(config.AI.Key, config.AI.Endpoint)
    return llmfunc.NewFunction[T, R](client, handler, opts...)
}

基于这个构造函数,AutoAdjustCourse 函数的完整实现如下:

func AutoAdjustCourse(ctx context.Context, input AutoAdjustCourseInput) (*AutoAdjustCourseOutput, error) {
    f := NewFunction(
        llmfunc.UnmarshalOutput[AutoAdjustCourseInput, AutoAdjustCourseOutput](),
        llmfunc.Name("auto_adjust_course"),
        llmfunc.Description("解析调课通知提取调课信息"),
        llmfunc.Instruction(AutoAdjustCourseInstruction),
        llmfunc.StructuredOutput(true),
        llmfunc.Model("deepseek-ai/DeepSeek-V3.2"),
        llmfunc.Temperature(0.2),
    )

    output, err := f.Run(ctx, &input)
    if err != nil {
        return nil, fmt.Errorf("failed to run auto adjust course function: %w", err)
    }

    return output, nil
}

对于调用方而言,AutoAdjustCourse 和任何普通的解析函数没有任何区别,传入标题和正文,拿回结构化的 items 列表。多么优雅!

定时爬取通知

AutoAdjustCourse 被接入在通知同步的定时任务里。系统在 cmd/common/main.go 中配置了一个定期爬取教务处通知的定时任务 syncNoticeTask,每次抓取到新通知时,会异步地触发调课解析流程:

go func(notice *jwch.NoticeInfo) {
    ctx := context.Background()
    if err := commonSvc.NewCommonService(ctx, clientSet, taskQueue).ProcessAutoAdjustCourseNotice(notice); err != nil {
        logger.Errorf("ProcessAutoAdjustCourseNotice failed, title=%s url=%s err=%v", notice.Title, notice.URL, err)
    }
}(row)

ProcessAutoAdjustCourseNotice 在内部处理时,首先按标题过滤,跳过标题中不包含「课程调整」的通知,如果需要处理,则拉取通知详情页的完整正文,然后解析规则:

// 获取通知内容
detail, err := jwch.NewStudent().GetNoticeDetail(&jwch.NoticeDetailReq{
    WbTreeId: info.WbTreeId,
    WbNewsId: info.WbNewsId,
})

// 调用 LLM 从通知标题和正文中提取结构化的课程调整条目
result, err := ai.AutoAdjustCourse(s.ctx, ai.AutoAdjustCourseInput{
    Title:   info.Title,
    Content: content,
})

拿到 items 列表后,后续处理完全回归到确定性的代码逻辑 —— 将每条记录的日期字符串映射到所在学期,并计算出对应的周次和星期,最终构造出 model.AutoAdjustCourse 准备存入数据库:

// 根据 from_date 查找所属学期
term, found := utils.FindTermByDate(calendar.Terms, fromDate)

// 计算 from_date 在该学期中对应的周次和星期几
fromWeek, fromWeekday, err := utils.GetWeekdayByDate(term.StartDate, item.FromDate)

// 构造最终数据
adjustCourse := &model.AutoAdjustCourse{
    Year:        year,
    FromDate:    item.FromDate,
    ToDate:      toDate,
    Term:        term.Term,
    FromWeek:    int64(fromWeek),
    ToWeek:      toWeekPtr,
    FromWeekday: int64(fromWeekday),
    ToWeekday:   toWeekdayPtr,
    Enabled:     false, // 默认禁用,等待人工审核后再启用
}

所有记录写入完成后,统一刷新涉及学期的缓存,确保后续的课表查询能立即感知到新的调课记录。

应用调课规则

调课记录写入数据库并启用之后,在学生请求课表时,系统在拿到原始课程数据后,紧接着读取当前学期所有已启用的调课记录,并通过 getAdjustRules 生成匹配的调课规则,生成最终应用到课程上的 CourseAdjustRule 列表:

autoAdjustCourses, err := s.GetAutoAdjustCourseList(req.Term)

for _, c := range courses {
    adjustRules := getAdjustRules(c.ScheduleRules, autoAdjustCourses)
    c.ScheduleRules = jwch.ApplyAdjustRules(
        jwch.ApplyAdjustRules(c.ScheduleRules, c.AdjustRules),
        adjustRules,
    )
}

注意这里有两层 ApplyAdjustRules,第一层应用的是教师在教务系统中录入的单次调课(jwch 库解析出来的 c.AdjustRules),第二层才是我们新增的全校性假期调课(前面生成的 adjustRules)。

getAdjustRules 的核心逻辑是对于每条调课记录,遍历该课程的所有排课规则,找出「周次和星期与调课记录的 from_week / from_weekday 匹配」的那些排课,为其生成一条 CourseAdjustRule。这里就正好和前面的日期到周次和星期几的转换遥相呼应,通过预处理减少了在后续阶段的计算量。详细代码请参见 internal/course/service/get_course_list.go,这里就不再单独贴出来了。

多级缓存的设计

GetCourseList 的实现里,有一个特别的设计 —— 数据库和缓存存储的课表内容是不一样的。

// 写入数据库存储
originalCourses := pack.BuildCourse(courses)
s.taskQueue.Add(fmt.Sprintf("putCourse:%s", stuId), taskqueue.QueueTask{Execute: func() error {
    return s.putCourseToDatabase(stuId, req.Term, originalCourses)
}})

// ... 应用调课规则 ...

// 写入缓存存储
s.taskQueue.Add(courseKey, taskqueue.QueueTask{Execute: func() error {
    return cache.SetSliceCache(s.cache, s.ctx, courseKey, courses,
        constants.CourseTermsKeyExpire, "Course.SetCourseCache")
}})

数据库存储原始数据是为了保留重新计算的能力。调课规则是随时可能变化的 —— 管理员可能启用新规则,也可能修改或禁用已有规则。如果数据库里存的是某个时间点的处理结果,那么每次规则变更后都需要遍历所有用户的历史数据重新修改并写入,代价极高。而存储原始课表,规则变更后只需要让缓存失效,下次访问时自然会用最新的规则重新计算并回写缓存,做到了数据和规则的解耦。

缓存存储调课后的数据是为了让 hot path 足够快。对于活跃用户而言,每次打开 App 查看课表都应该是毫秒级响应。如果缓存里存的是原始数据,那么每次命中缓存后还要再走一遍调课信息的匹配逻辑,而调课信息通常是不会频繁变化的,导致白白增加计算开销。所以把已经处理好的结果直接放进缓存,命中时直接返回即可。

实际效果

以文章开头的国庆调课通知为例,来完整地走一遍我们设计的流程。

教务处通知发出后,syncNoticeTask 在下一个同步周期抓取到到新通知后,发现标题中含「课程调整」的关键词,随即在后台异步拉取通知正文并调用 AutoAdjustCourse 解析,解析后得到的结构化输出如下:

{
  "items": [
    { "from_date": "2025-10-01", "to_date": "" },
    { "from_date": "2025-10-02", "to_date": "" },
    { "from_date": "2025-10-03", "to_date": "" },
    { "from_date": "2025-10-04", "to_date": "" },
    { "from_date": "2025-10-05", "to_date": "" },
    { "from_date": "2025-10-06", "to_date": "" },
    { "from_date": "2025-10-07", "to_date": "2025-09-28" },
    { "from_date": "2025-10-08", "to_date": "2025-10-11" },
    { "from_date": "2025-09-28", "to_date": "" },
    { "from_date": "2025-10-11", "to_date": "" }
  ]
}
  • 10 月 1 日至 10 月 6 日连续停课;
  • 10 月 7 日的课调到了 9 月 28 日;
  • 10 月 8 日的课调到了 10 月 11 日;
  • 而 9 月 28 日和 10 月 11 日原有的课程则各自产生了一条取消记录。

这 10 条记录会先以未启用的状态存进数据库里,等待管理员审核后启用。启用后,所有查询该学期课表的请求都会经过 getAdjustRules 匹配,将受影响的那几节课从课表里移除,或者把它们调整到对应的补课日。用户打开 App,看到的已经是一张应用了全部调课规则的最终课表,不需要再对照通知自行推算。

整个过程对用户完全无感,LLM 解析发生在后台,与用户请求课表的链路没有任何交集;从用户视角来看,这和以往的课表体验没有任何不同,只是课表变「聪明」了。

后记

回顾整个流程,LLM 只做了它最擅长的那一件事 —— 自然语言理解,把教务处的通知翻译成结构化的课程调整信息列表。之后的日期到周次的换算、学期的归属判断、与每门课排课规则的匹配、缓存的刷新,全部由确定性的代码完成。两种技术各司其职,边界清晰。

这种架构在工程上带来了一个令人满意的性质:整个系统没有任何一个环节是只有 LLM 能做但却又无法被验证的黑盒。AI 层的输出可以被单元测试固定下来验证,业务层的每一条分支都可以通过 mock 独立覆盖,规则应用层是没有副作用的纯函数,数据库存储的是原始事实而非中间结论。把 LLM 当作函数来使用,意味着它必须接受和普通函数一样的工程约束 —— 可测试、可替换、职责单一 —— 而这些约束反过来也让整个系统的可靠性有了更坚实的保证。

这次实践中由 LLM 承担的部分,从代码量上看并不大,甚至可以说很小。但它完成的是在此之前我们一直没有好办法处理的格式不固定的自然语言通知的解析。这类通知隐含着复杂语义嵌套的调课逻辑,纯靠代码很难完成解析。过去这些信息只能靠用户自己去查询记录、专门留意,而现在这件事由我们的系统替用户完成了。

这次实践虽然由 LLM 实现的部分并不是很大,但它让我们看到了大模型并不只是聊天窗口里那个无所不知的助手,它同样可以是系统架构图上一个朴素而可靠的函数节点,能够和普通的代码有机结合。


相关代码:

本文所述业务接入部分的实现,是和正在参与西二在线 Go 组考核、福州大学 2025 级软件工程专业的吴佳伟同学一同完成的,在此表示感谢。感谢王智壹同学审阅本文并给出修改建议。

实现一个 CSS 词法分析器(Lexer)

作者 宝硕
2025年8月5日 23:18
实现一个 CSS 词法分析器(Lexer)

最近在实习的时候,遇到了一些需求,需要自己去实现 CSS 的解析、(伪)渲染流程。以之为契机,我学习了一下编译相关的知识,其中的第一环就是 Lexer。

本文中的代码均使用 Go 实现,成果已经作为 Go 库 go.baoshuo.dev/csslexer 发布。

建议在阅读本文前对 CSS 标准内容有一定理解。

词法分析

词法分析(lexical analysis)是计算机科学中将字符序列转换为记号(token,也有译为标记或词元)序列的过程。进行词法分析的程序或者函数叫作词法分析器(lexical analyzer,简称 lexer),也叫扫描器(scanner)。词法分析器一般以函数的形式存在,供语法分析器调用。

——维基百科

词法分析是编译中的第一个步骤。它读入组成源码的字符流,并将他们组织成一个个的词素(lexeme)。有了词素以后,识别并标注它的类型,就可以生成一个 <token-name, attribute-value> 形式的词法单元(token)。这个单元会被传送给下一个步骤 —— 语法分析 —— 进行后续的处理。

在进行词法分析之前,首先要设定好到底有多少种 token 类型,然后再确定每个 token 类型的判断条件和解析方式。

Token 的分类

由 CSS Syntax Module Level 3 中的 4. Tokenization 一节可以得到 CSS 的 token 有以下几种类型:

<ident-token>
<function-token>
<at-keyword-token>
<hash-token>
<string-token>
<bad-string-token>
<url-token>
<bad-url-token>
<delim-token>
<number-token>
<percentage-token>
<dimension-token>
<whitespace-token>
<CDO-token>
<CDC-token>
<colon-token>
<semicolon-token>
<comma-token>
<[-token>
<]-token>
<(-token>
<)-token>
<{-token>
<}-token>

为了解析方便,我们又在标准的 token 类型外拓展了几个 token 类型,得到了下面的 token 表:

<ident-token>         IdentToken
<function-token>      FunctionToken            foo()
<at-keyword-token>    AtKeywordToken           @foo
<hash-token>          HashToken                #foo
<string-token>        StringToken
<bad-string-token>    BadStringToken
<url-token>           UrlToken                 url()
<bad-url-token>       BadUrlToken
<delim-token>         DelimiterToken
<number-token>        NumberToken              3
<percentage-token>    PercentageToken          3%
<dimension-token>     DimensionToken           3em
<whitespace-token>    WhitespaceToken
<CDO-token>           CDOToken                 <!--
<CDC-token>           CDCToken                 -->
<colon-token>         ColonToken               :
<semicolon-token>     SemicolonToken           ;
<comma-token>         CommaToken               ,
<(-token>             LeftParenthesisToken     (
<)-token>             RightParenthesisToken    )
<[-token>             LeftBracketToken         [
<]-token>             RightBracketToken        ]
<{-token>             LeftBraceToken           {
<}-token>             RightBraceToken          }
<EOF-token>           EOFToken

CommentToken          /* ... */
IncludeMatchToken     ~=
DashMatchToken        |=
PrefixMatchToken      ^=
SuffixMatchToken      $=
SubstringMatchToken   *=
ColumnToken           ||
UnicodeRangeToken

于是乎,我们就有了词法分析的期望目标产物 —— 由这 33 种类型的 token 组成的 token 流。

输入流

工欲善其事,必先利其器。

在实现真正的词法分析流程以前,我们需要编写一套输入流来辅助我们完成读入的操作。

首先,我们给出输入流的定义:

// Input represents a stream of runes read from a source.
type Input struct {
    runes []rune // The runes in the input stream.
    pos   int    // The current position in the input stream.
    start int    // The start position of the current token being read.
    err   error  // Any error encountered while reading the input.
}

这个结构封装了对一个 rune 切片的访问,并维护了当前扫描的位置(pos)和当前正在扫描的 token 的起始位置(start)。

需要注意的是,我们使用 rune 而不是 byte 来存储内容,这样做的原因是为了便于处理代码中包含的 Emoji 等 Unicode 字符。

为了使用方便,这个输入流可以从 string[]rune[]byteio.Reader 初始化。实现细节可以查看仓库中的 input.go,各个函数签名如下:

  • NewInput(input string) *Input
  • NewInputRunes(runes []rune) *Input
  • NewInputBytes(input []byte) *Input
  • NewInputReader(r io.Reader) *Input

接下来,我们需要设计一系列合理的方法,使得这个输入流的使用能够在满足我们的实际需求的同时,还保持简洁的风格。

在 4.2 节的一系列定义中,通过观察不难发现,在解析过程中会不断地出现 consume 和 reconsume 的操作,也就是说,在输入流的末尾会不断地进行 pop_back 和 push_back 的操作。那么我们可以将这些操作转化为「预读」和「后移指针」的操作,以此来减少频繁在流末尾进行的弹出和插入操作。

于是,我们就有了以下两个方法:

  • func (z *Input) Peek(n int) rune

    预读输入流中 pos+n 位置的字符。

  • func (z *Input) Move(n int)

    将当前输入流的指针后移 n 位。

经过阅读规范以后,不难发现一个 token 可以由几个不同类别的字符序列组成,比如 16px 就是一个 16 (number sequence) 和一个 px (ident sequence) 共同组成的 dimension-token。所以我们在解析一个 token 的时候可能会调用多个解析函数,那么就需要在 token 级别做一个固定的输出模式。

于是,我们定义 func (z *Input) Shift() []rune 来弹出当前 token,并更新 Input 实例中的 start 值,以开始下一 token 的解析。

不过后续在解析 url-token 的时候遇到了需要读取当前已经 consume 的内容的情况,于是将 Shift 方法拆分成了 CurrentShift 两个不同的方法,以便使用。

除此以外,在解析的时候还有需要在满足某一特定条件下一直 consume 的能力需求,因此又设计了较为通用的 func (z *Input) MoveWhilePredicate(pred func(rune) bool) 方法,来实现这一能力。

加上错误处理逻辑以后,整个 Input 的方法如下:

func (z *Input) PeekErr(pos int) error
func (z *Input) Err() error
func (z *Input) Peek(n int) rune
func (z *Input) Move(n int)
func (z *Input) Current() []rune
func (z *Input) Shift() []rune
func (z *Input) MoveWhilePredicate(pred func(rune) bool)

接下来,我们就可以正式开始 lexer 的编写了。

词法分析器

其实 Lexer 的方法框架设计就相对简单了,下面直接给出定义:

type Lexer struct {
    r *Input // The input stream of runes to be lexed.
}

func (l *Lexer) Err() error
func (l *Lexer) Next() (TokenType, []rune)

Next 方法中有一个巨大的 switch-case 语句,这里面包含了 4.3.1. Consume a token 中所描述的所有在 token 开始时的情形。我们将会根据一个 token 开始的几个字符(小于等于 3 个)来确定这个 token 的后续部分应该如何解析。

Token 开始处的分类讨论

开始解析 token 的时候一定是在文件流的开头或者上一个 token 刚刚解析完毕的时候,那么此时我们只需要根据对应规则判断 token 类型即可。

首先预读 1 个字符,记为 next,然后对这个字符进行分类讨论。

  • EOF:直接返回 EOF-token。

  • \t, \n, \r, \f, :根据标准需要将此字符及后续的所有 whitespace 组合成一个 whitespace-token。

  • /:如果是 /* 则一直读取到 */ 或者 EOF 作为 comment-token。

  • ' (单引号), "(双引号):遇到这两种引号,会调用字符串解析函数 consumeStringToken()。该函数会持续读取字符,直到遇到与之匹配的结束引号。在此过程中,它会处理转义字符(如 \")。如果在中途遇到换行符或文件末尾,则会生成一个 bad-string-token,否则生成一个 string-token。

  • 0 ~ 9 的数字字符:如果以数字开头,确定无疑是数字类型,调用数字解析函数 consumeNumericToken()

  • (, ), [, ], {, }:生成对应的括号字符。function-token 或者 url-token 的情况会在处理 ident-like 的时候另行考虑。

  • +, .:这两个字符,再加上 -,都比较特殊。不过 - 需要包含一些额外的判断,因此归属于另外一条规则处理。

    • 解析器会向后预读,通过 nextCharsAreNumber() 判断后续字符是否能构成一个合法的数字(例如 +1.5, .5)。
    • 如果可以,则调用 consumeNumericToken() 将其完整解析为一个 numeric-token。
    • 如果不构成数字,则 +. 会被当作 delimiter-token。
  • -:除了像 + 一样判断是否有可能进入数字的处理逻辑以外,还需要考虑作为 --> (CDC-token) 和 ident-like 的情况。如果都不是才会被当做 delimiter-token。

    if l.nextCharsAreNumber() {
        return l.consumeNumericToken()
    }
    if l.r.Peek(1) == '-' && l.r.Peek(2) == '>' {
        l.r.Move(3) // consume "-->"
        return CDCToken, l.r.Shift()
    }
    if l.nextCharsAreIdentifier() {
        return l.consumeIdentLikeToken()
    }
    l.r.Move(1)
    return DelimiterToken, l.r.Shift()
  • <:如果能构成 <!--,解析为一个 CDO-token,否则解析为 delimiter-token。

  • *, ^, $, |, ~: 这些是属性选择器中的匹配符。

    • 如果它们后面紧跟 =,则会组合成一个专有 token:
      • *= → substring-match-token
      • ^= → prefix-match-token
      • $= → suffix-match-token
      • ~= → include-match-token
      • |= → dash-match-token
    • 特别地,对于 |,如果能够组成 ||,则会成为 column-token。
    • 如果没有,则单独作为 delimiter-token。
  • @:如果后续的字符能够组成一个 identifier,那么解析为 at-keyword-token,否则解析为 delimiter-token。

  • , (逗号):直接生成 comma-token。

  • : (冒号):直接生成 colon-token。

  • ; (分号):直接生成 semicolon-token。

  • uU:这是一个特殊前缀。如果其后是 + 紧跟着十六进制数字或 ? (例如 U+26 或 u+A?),则调用 consumeUnicodeRangeToken() 解析为一个 urange-token。否则,按标识符处理。

    • 这里有一个坑点,需要在编写 parser 的时候注意,比如 u+a 既是一个合法的 unicode-range,也是一个合法的 selector,需要根据上下文来判定。
  • 1 <= c <= 31, !, %, &, =, >, ?, `, 127:解析为 delimiter-token。

  • 其余字符:尝试解析为 ident-like。

整个流程在 lexer.go 的 24-198 行,由于篇幅原因此处就不贴完整代码了。

Token 解析

为了方便,我们为几种逻辑复杂 / 需要重用的 token 解析逻辑进行了封装,产生了如下函数:

  • consumeNumericToken()

    • 先 consume 一个数字;
    • 如果后续跟一个合法的 name,则 consume 这个 name 作为它的单位,组合为 dimension-token;
    • 如果后续跟一个 %,consume 掉这个 %,产生一个 percentage-token;
    • 否则产生一个 number-token。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-numeric-token
    func (l *Lexer) consumeNumericToken() (TokenType, []rune) {
        l.consumeNumber()
    
        if l.nextCharsAreIdentifier() {
            l.consumeName()
            return DimensionToken, l.r.Shift()
        } else if l.r.Peek(0) == '%' {
            l.r.Move(1) // consume '%'
            return PercentageToken, l.r.Shift()
        }
    
        return NumberToken, l.r.Shift()
    }
  • consumeUnicodeRangeToken()

    • 有以下几种情况:
      • U+0000FF+ 后面可以跟 1 ~ 6 个 16 进制数字;
      • U+0000??+ 后面先跟 16 进制数字再跟 ?(通配符),总数不超过 6 个;
      • U+0001-0002- 两侧可以有 1 ~ 6 个 16 进制数字。
    • 这些情况需要各自分类讨论,最后产生一个 urange-token。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#urange
    func (l *Lexer) consumeUnicodeRangeToken() (TokenType, []rune) {
        // range start
        start_length_remaining := 6
        for next := l.r.Peek(0); start_length_remaining > 0 && next != EOF && isASCIIHexDigit(next); next = l.r.Peek(0) {
            l.r.Move(1) // consume the hex digit
            start_length_remaining--
        }
    
        if start_length_remaining > 0 && l.r.Peek(0) == '?' { // wildcard range
            for start_length_remaining > 0 && l.r.Peek(0) == '?' {
                l.r.Move(1) // consume the '?'
                start_length_remaining--
            }
        } else if l.r.Peek(0) == '-' && isASCIIHexDigit(l.r.Peek(1)) { // range end
            l.r.Move(1) // consume the '-'
    
            end_length_remaining := 6
            for next := l.r.Peek(0); end_length_remaining > 0 && next != EOF && isASCIIHexDigit(next); next = l.r.Peek(0) {
                l.r.Move(1) // consume the hex digit
                end_length_remaining--
            }
        }
    
        return UnicodeRangeToken, l.r.Shift()
    }
  • consumeIdentLikeToken()

    • 先 consume 一个合法的 name;
    • 然后判断是否为一个函数的开始,如果是,再判断是否是 url-token,转入特定的解析流程。
      • 需要额外注意的是,如果 url 函数的参数是使用单 / 双引号包裹的字符串,那么按照普通函数参数解析即可。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-ident-like-token
    func (l *Lexer) consumeIdentLikeToken() (TokenType, []rune) {
        l.consumeName()
    
        if l.r.Peek(0) == '(' {
            l.r.Move(1) // consume the opening parenthesis
            if equalIgnoringASCIICase(l.r.Current(), urlRunes) {
                // The spec is slightly different so as to avoid dropping whitespace
                // tokens, but they wouldn't be used and this is easier.
                l.consumeWhitespace()
    
                next := l.r.Peek(0)
                if next != '"' && next != '\'' {
                    return l.consumeURLToken()
                }
            }
    
            return FunctionToken, l.r.Shift()
        }
    
        return IdentToken, l.r.Shift()
    }

    注意这里的实现其实会在含转义的 URL-token 上出现问题,后续通过修改 consumeName 函数的实现,通过返回值判断解决了此问题。

  • consumeStringToken()

    • 简而言之,就是从开始的引号的位置一直匹配到相对应的结束引号位置或者文件末尾;
    • 特别地,如果遇到没有转义的换行,那么此时就需要作为 bad-string-token 返回了。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-string-token
    func (l *Lexer) consumeStringToken() (TokenType, []rune) {
        until := l.r.Peek(0) // the opening quote, already checked valid by the caller
        l.r.Move(1)
    
        for {
            next := l.r.Peek(0)
    
            if next == until {
                l.r.Move(1)
                return StringToken, l.r.Shift()
            }
    
            if next == EOF {
                return StringToken, l.r.Shift()
            }
    
            if isCSSNewline(next) {
                return BadStringToken, l.r.Shift()
            }
    
            if next == '\\' {
                next_next := l.r.Peek(1)
    
                if next_next == EOF {
                    l.r.Move(1) // consume the backslash
                    continue
                }
    
                if isCSSNewline(next_next) {
                    l.r.Move(1)
                    l.consumeSingleWhitespace()
                } else if twoCharsAreValidEscape(next, next_next) {
                    l.r.Move(1) // consume the backslash
                    l.consumeEscape()
                } else {
                    l.r.Move(1)
                }
            } else {
                l.r.Move(1) // consume the current rune
            }
        }
    }
  • consumeURLToken()

    • 需要按照规范特别注意 bad-url-token 的情况。
    • 但此处的实现和规范不同,在 consumeIdentLikeToken() 中我们把 URL 的前导空格全部 consume 掉了,但如果遇到使用引号包裹的 URL 时,这段空格理应单独作为一个 whitespace-token,不过无伤大雅,这样解析也可以,不影响后续的 parse 流程。
    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-url-token
    func (l *Lexer) consumeURLToken() (TokenType, []rune) {
        for {
            next := l.r.Peek(0)
    
            if next == ')' {
                l.r.Move(1)
                return UrlToken, l.r.Shift()
            }
    
            if next == EOF {
                return UrlToken, l.r.Shift()
            }
    
            if isHTMLWhitespace(next) {
                l.consumeWhitespace()
    
                next_next := l.r.Peek(0)
                if next_next == ')' {
                    l.r.Move(1) // consume the closing parenthesis
                    return UrlToken, l.r.Shift()
                }
                if next_next == EOF {
                    return UrlToken, l.r.Shift()
                }
    
                // If the next character is not a closing parenthesis, there's an error and we should mark it as a bad URL token.
                break
            }
    
            if next == '"' || next == '\'' || isNonPrintableCodePoint(next) {
                l.r.Move(1) // consume the invalid character
                break
            }
    
            if next == '\\' {
                if twoCharsAreValidEscape(next, l.r.Peek(1)) {
                    l.r.Move(1) // consume the backslash
                    l.consumeEscape()
                    continue
                } else {
                    break
                }
            }
    
            l.r.Move(1) // consume the current rune
        }
    
        l.consumeBadUrlRemnants()
        return BadUrlToken, l.r.Shift()
    }

特定类型字符片段解析

一共有以下几个片段解析的函数:

  • consumeUntilCommentEnd():一直读取到注释结束。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-comment
    func (l *Lexer) consumeUntilCommentEnd() {
        for {
            next := l.r.Peek(0)
    
            if next == EOF {
                break
            }
    
            if next == '*' && l.r.Peek(1) == '/' {
                l.r.Move(2) // consume '*/'
                return
            }
    
            l.r.Move(1) // consume the current rune
        }
    }
  • consumeEscape():解析一个转义字符。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-escaped-code-point
    func (l *Lexer) consumeEscape() rune {
        var res rune = 0
    
        next := l.r.Peek(0)
    
        if isASCIIHexDigit(next) {
            l.r.Move(1)
            res = hexDigitToValue(next)
    
            for i := 1; i < 6; i++ {
                c := l.r.Peek(0)
                if isASCIIHexDigit(c) {
                    l.r.Move(1)
                    res = res*16 + hexDigitToValue(c)
                } else {
                    break
                }
            }
    
            if !isValidCodePoint(res) {
                res = '\uFFFD' // U+FFFD REPLACEMENT CHARACTER
            }
    
            // If the next input code point is whitespace, consume it as well.
            l.consumeSingleWhitespace()
        } else if next != EOF {
            l.r.Move(1) // consume the escape character
            res = next
        } else {
            res = '\uFFFD' // U+FFFD REPLACEMENT CHARACTER for EOF
        }
    
        return res
    }
  • consumeName():读取一个 name。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-name
    func (l *Lexer) consumeName() {
        for {
            next := l.r.Peek(0)
    
            if isNameCodePoint(next) {
                l.r.Move(1)
            } else if twoCharsAreValidEscape(next, l.r.Peek(1)) {
                l.r.Move(1) // consume the backslash
                l.consumeEscape()
            } else {
                break
            }
        }
    }
  • consumeNumber():读取一个数字。需要特别注意对科学计数法的处理,以及与调用侧配合正确解析 .7 +.7 等 case。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-number
    func (l *Lexer) consumeNumber() {
        next := l.r.Peek(0)
    
        // If the next rune is '+' or '-', consume it as part of the number.
        if next == '+' || next == '-' {
            l.r.Move(1)
        }
    
        // consume the integer part of the number
        l.r.MoveWhilePredicate(isASCIIDigit)
    
        // float
        next = l.r.Peek(0)
        if next == '.' && isASCIIDigit(l.r.Peek(1)) {
            l.r.Move(1) // consume the '.'
            l.r.MoveWhilePredicate(isASCIIDigit)
        }
    
        // scientific notation
        next = l.r.Peek(0)
        if next == 'e' || next == 'E' {
            next_next := l.r.Peek(1)
    
            if isASCIIDigit(next_next) {
                l.r.Move(1) // consume 'e' or 'E'
                l.r.MoveWhilePredicate(isASCIIDigit)
            } else if (next_next == '+' || next_next == '-') && isASCIIDigit(l.r.Peek(2)) {
                l.r.Move(2) // consume 'e' or 'E' and the sign
                l.r.MoveWhilePredicate(isASCIIDigit)
            }
        }
    }
  • consumeSingleWhitespace():读取一个空格。

    func (l *Lexer) consumeSingleWhitespace() {
        next := l.r.Peek(0)
        if next == '\r' && l.r.Peek(1) == '\n' {
            l.r.Move(2) // consume CRLF
        } else if isHTMLWhitespace(next) {
            l.r.Move(1) // consume the whitespace character
        }
    }
  • consumeWhitespace():读取多个空格。

    func (l *Lexer) consumeWhitespace() {
        for {
            next := l.r.Peek(0)
    
            if isHTMLWhitespace(next) {
                l.consumeSingleWhitespace()
            } else if next == EOF {
                return
            } else {
                break
            }
        }
    }
  • consumeBadUrlRemnants():读取 bad-url-token 的剩余部分。

    // https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-the-remnants-of-a-bad-url
    func (l *Lexer) consumeBadUrlRemnants() {
        for {
            next := l.r.Peek(0)
    
            if next == ')' {
                l.r.Move(1)
                return
            }
            if next == EOF {
                return
            }
    
            if twoCharsAreValidEscape(next, l.r.Peek(1)) {
                l.r.Move(1) // consume the backslash
                l.consumeEscape()
                continue
            }
    
            l.r.Move(1)
        }
    }

Identifier 和 Number 的鉴别逻辑

对于 identifier,我们根据以下标准判断接下来的字符是否可能开始一个 identifier 的序列:

  • 第一位是 NameStartCodePoint(以英文字母、下划线或非 ASCII 字母开始);或
  • 第一位和第二位组合起来可以开始一段转义序列;或
  • - 开始的 identifier(再走一遍上面两点的识别流程,同时注意 -- 的情况)。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#would-start-an-identifier
func (l *Lexer) nextCharsAreIdentifier() bool {
    first := l.r.Peek(0)

    if isNameStartCodePoint(first) {
        return true
    }

    second := l.r.Peek(1)

    if twoCharsAreValidEscape(first, second) {
        return true
    }

    if first == '-' {
        return isNameStartCodePoint(second) || second == '-' ||
            twoCharsAreValidEscape(second, l.r.Peek(2))
    }

    return false
}

对于 number,当符合以下条件的时候可以开始一个 number 的序列:

  • 第一位是数字;
  • 第一位是正负号,第二位是数字;
  • 第一位是正负号,第二位是小数点,第三位是数字;
  • 第一位是小数点,第二位是数字。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#starts-with-a-number
func (l *Lexer) nextCharsAreNumber() bool {
    first := l.r.Peek(0)

    if isASCIIDigit(first) {
        return true
    }

    second := l.r.Peek(1)

    if first == '+' || first == '-' {
        if isASCIIDigit(second) {
            return true
        }

        if second == '.' {
            third := l.r.Peek(2)

            if isASCIIDigit(third) {
                return true
            }
        }
    }

    if first == '.' {
        return isASCIIDigit(second)
    }

    return false
}

小结

让我们来总结一下 lexer 工作流程:在 lexer 读取到某个 token 的起始点的时候,lexer 预读起始的几个字符,然后辨别 token 的类型。对于大致分类好的 token,根据其更具体的特征预读并消耗掉对应的字符,直到这个 token 结束。

大致的类型辨别是通过 Next() 函数中的那个巨大的 switch-case 语句来完成的。而对于精细的 token 类型的判断,则是 case 中的语句和 consume_token.go 定义的一系列函数来共同完成的。至于 token 内部的字符段的解析,则是 consume.go 中的一系列函数完成的。由此组合,整个 token 的解析过程得以良好运转。

除了文中提到的相关方法以外,在 util.go 中还有一系列的工具函数:

  • func isASCII(c rune) bool
  • func isASCIIAlpha(c rune) bool
  • func isASCIIDigit(c rune) bool
  • func isASCIIHexDigit(c rune) bool
  • func isCSSNewline(c rune) bool
  • func isNameStartCodePoint(r rune) bool
  • func isNameCodePoint(r rune) bool
  • func isNonPrintableCodePoint(r rune) bool
  • func twoCharsAreValidEscape(first, second rune) bool
  • func isHTMLSpecialWhitespace(c rune) bool
  • func isHTMLWhitespace(c rune) bool

这些函数的作用可以很容易地由它们的名字得知,故此处不再赘述。

测试

为了验证 lexer 的实现正确性,我们引入了 romainmenke/css-tokenizer-tests 的测试用例来对 lexer 进行测试。具体的测试流程可以参考 lexer_test.go 中的实现。

根据测试结果来看,出现的问题主要集中在与转义字符相关的处理,对于大部分情况已经能够正常解析。截止编写本文之时,测试通过率为 96.53% (167/173),个人认为已经处于可用水平。

后记

文中所述的 lexer 的具体实现已经开源在 renbaoshuo/go-css-lexer,欢迎大家 Star!

搓这个 lexer 花了半个周末的时间,修修补补又消耗了一些时间。也算是在工作之余充实自己的大脑了。后续还可能会针对预读相关的内存访问进行优化(不知道读者有没有发现最多会预读三个字符),以提升处理效率。

文章题图由 Gemini 2.5 Pro Imagen 生成。

愿此去前路皆坦途 —— 我的 2023 年度总结

作者 宝硕
2024年1月1日 13:10
愿此去前路皆坦途 —— 我的 2023 年度总结

2023 年就这样在恍恍惚惚间过去了,在这一年中发生了许多事情,就让我挑一些大家可能感兴趣的事情来讲讲吧。

回归文化课

如我在《我的 OI 生涯 —— 一名退役竞赛生的回忆录》中所述,我在竞赛失利后,已经选择了回归文化课道路。在回班以后,不时有好友、读者向我私信或者邮件询问我的近况。由于寄宿制学校放假时间极短,未能一一详尽回答,所以我将在此介绍一下我的近况,以回应各位热心读者的关切。

博君一笑

首先先给大家看点好笑的:

在班里感觉如何?

截止到现在,我在回班以后主要分为了以下几个阶段:

  • 心态恢复期(4 月 ~ 5 月)
  • 一轮复习前期(5 月 ~ 7 月上旬)
  • 暑假(7 月下旬 ~ 8 月)
  • 一轮复习后期(9 月以后)

随着时间的推移,我从最开始几个月的 “听天书” 到现在已经逐渐适应了班内的学习节奏。虽然由于高一高二的长时间停课导致现在的成绩不太理想,但我相信通过一轮复习,我的知识水平会得到很大的提升。虽然今后还有一段很艰苦的道路要走,但我坚信只要努力就能克服路途上的艰难险阻,到达成功的彼岸。

想上什么大学?

暂时还没想好。考哪算哪,不强求。

考虑出国吗?

暂时不考虑。原因有三:

  • 语言:我的英语水平不算很高,出国后可能会存在沟通障碍;
  • 耗财:出国留学需要不少费用,我更希望将家里的钱花到一个合适的地方去,而不是浪费在我身上;
  • 思乡:我希望能有多一些的陪伴家人的时间,在出国以后回家的机会可能会大大减少,这于我来说不太能接受。

考虑复读吗?

高考之日未到,现在谈复读与否其实有点早。我个人以及我家长的意见都倾向于不复读。

复读,意味着又要承受一年高三的巨大压力,这对于一个人的身体和心理都是一个巨大的挑战,而我的身体较为羸弱,恐怕很难再扛得住一年这样的压力。除此之外,复读还使我在一条我不喜欢的且充满不确定性的道路上多耗费了整整一年的光阴,这样做真的值得吗?我不太好回答这个问题。

竞赛对你的高考有什么帮助吗?

在强基计划公布以后,除非取得国家级的奖项,否则竞赛对高考已经没有了什么实质性的帮助,省一等奖最多也就给三四十分的优惠,所以最后还得看文化课的水平到底如何。

我学竞赛并无太多功利因素,更多的是怀揣着一份对计算机的热爱,这也是支撑着我度过这四年有余的竞赛生涯的最关键因素。此外,我也没见过几个一心为了功利还能取得好成绩的竞赛生。毕竟竞赛的学习过程并不轻松,且其对文化课的影响常常是显著的,所以从功利的角度来看,学习竞赛显然是不划算的。

不过,如果再给我一次机会,我还会选择学习竞赛。正如我在《我的 OI 生涯 —— 一名退役竞赛生的回忆录》中所述,竞赛带给我的并不仅仅是那几张薄薄的证书,更多的是思维方式的蜕变,这将在我今后的人生中产生深远影响。

有什么想对学弟学妹们说的吗?

高一的时候一定要打牢文化课基础,不然等到省选前有你紧张到哭的时候。我就是一个很好的反面教材,高一停课停早了导致文化课约等于没学,结果最后几场比赛前就非常害怕退役回去学文化课,于是就整夜整夜的失眠。

追番情况

动漫于我的意义并不只是看个 “动画”,一段精彩的作画、一段感人的故事、一段轻松的日常,都能以其积极向上的乐观主义精神,将我从低谷中拉出来,使我能够更加乐观地面对今后的人生道路。

《别当欧尼酱了!》(2023 年 1 月)

  • 评分:6 分(还行)
  • 短评:剧情还可以,但不算完全合我口味。

《我推的孩子》(2023 年 4 月)

  • 评分:6 分(还行)
  • 短评:有点玄幻?看个乐呵也挺好的。

《孤独摇滚》(2022 年 10 月)

  • 评分:8 分(佳作)
  • 短评:在上映近一年之后才抽出时间来看这部番,看完以后为波奇的改变而欣慰,同时也非常喜欢活泼开朗的虹夏。是一部非常值得去看的好番。

《间谍过家家(第二季)》(2023 年 10 月)

  • 评分:7 分(可以)
  • 短评:一如既往的家庭喜剧,欢乐多彩的家庭日常。

(二刷)《莉可丽丝》(2022 年 7 月)

  • 评分:8 分(佳作)
  • 短评:喜欢千束的活泼开朗以及面对困难时的积极向上,同时也非常羡慕千束和泷奈之间真挚的感情。

▲ 我宿舍内悬挂的《莉可丽丝》海报

(三刷)《干物妹!小埋》系列

  • 评分:9 分(神作)
  • 短评:最能打动我的一部番。轻松愉快的日常、真挚热烈的友情,无不令我心驰神往。同时也从大平的身上看到了自己的影子。

(二刷)《天使降临到我身边》系列

  • 评分:8 分(佳作)
  • 短评:欢乐而充实的孩童日常。喜欢可爱的孩子们。

一些照片

▲ 故地重游(参见:USTC Hackergame 2021 旅行照片

▲ 燕山大学

▲ 二南随拍

GitHub 活动概况

由于学业因素,在过去的一年里我用来写代码的时间大大减少。不出意外的话,在高考结束以前我都会保持这种低频活动状态。

个人主页

对整体布局进行了一些重新设计。此外我还计划将其迁移至 Next.js 13 App Router,但尚未完工。

后记

在新的一年里,我会继续冲刺高考,争取考一所好大学。同时也在此感谢读者们对我的关心,不过由于我长期住校,故评论、邮件等可能不会及时回复,敬请谅解。

最后,祝大家新的一年里身体健康,万事如意!

如何创建一个打印友好型的网页

作者 宝硕
2023年5月28日 11:31
如何创建一个打印友好型的网页

在某些情况下,我们会遇到需要将网页打印出来的需求。但是,直接打印网页的效果往往不尽如人意,因为网页的排版和打印的排版是不同的。本文将介绍如何创建一个在打印时具有出色的质量和可读性的网页。

前置知识:@media print 媒体查询

经常编写 CSS 的读者应该对 @media 媒体查询是比较熟悉的了。这个语句在创建响应式网页时是非常有用的,经常被大家用来调整不同屏幕宽度的设备间的样式。而 @media print 媒体查询则是专门用来调整打印时的样式的。

@media print 媒体查询的语法如下:

@media print {
  /* 在这里定义打印时应用的样式 */
  body {
    font-size: 12pt;
  }

  .header,
  .footer {
    display: none;
  }

  /* 更多样式规则... */
}

这些样式只会在打印时应用,而不会在屏幕上显示。了解了 @media print 媒体查询的基本语法后,我们就可以开始创建打印友好型的网页了。

优化内容和布局

隐藏不必要的页面元素、样式

在打印时,页面上的一些与正文无关的元素需要被隐藏掉。

比如在《二分图学习笔记》页面中(本文在后续部分中将会一直以本页面作为示例),顶部的导航栏以及右侧的侧边栏与正文信息并没有什么关联,因此可以在打印输出中隐去。

确保信息的整齐和清晰可读性

▲ IT 之家某篇文章的打印版截图。

从这张截图中可以看出这个页面似乎并没有对打印机进行适配,并且侧边栏还遮挡到了正文中的文字。不过由于笔者并没有找到更好的遮挡示例,因此只能给出这么一个有点勉强的例子 —— 侧边栏按照上一节中的建议是应该要被隐藏掉的。

对于这种情况,需要在设计、编写页面布局的时候下功夫,以避免遮挡到正文。

除了文字被遮挡的问题,截图下部的超链接在纸质媒介上显然是不能被点击的。

以此处的超链接为例,可以通过特殊处理来在纸上显示出链接的实际指向 URL:

@media print {
  a:not([href^='#'])::after {
    content: ' (' attr(href) ')';
    font-size: 80%;
    color: var(--color-fg-muted);
  }
}

效果如图:

除此之外,如果需要,还要对字体及其大小进行一些调整。

笔者认为在相当一部分情况下,使用衬线字体在打印后的观感要比使用非衬线字体时好很多。(PS:在此对通篇使用微软雅黑出试卷的老师表示强烈谴责)

多媒体内容的处理

有时网页上会包含一些音频、视频等多媒体内容,这些内容在纸质媒介上与超链接类似,无法与读者交互。

此时可以考虑提供一些替代文本来对其内容进行描述,并提供指向相关资源的链接、二维码等辅助工具来帮助读者获取多媒体资源中的信息。

编写适合打印的样式

单位制

在网络世界中,我们的常用单位诸如像素(px)、百分比(%)、相对大小(em、rem) 等。然而在现实世界中,我们常用的单位则为物理单位,如厘米(cm)、点(pt)等。这导致了在打印输出时需要额外注意单位制相关的问题。虽然现代浏览器对这些问题的处理已经比较优秀了,但在部分情况下仍然会导致页面排版布局出现错乱。

CSS 优先级

经常写 CSS 的读者应该对 CSS 中的样式优先级不陌生了。笔者建议在编写 CSS 时将打印相关样式置于靠下的位置以免产生冲突,同时也可以适当地使用 !important 来强制覆盖一些样式。

测试和调整

可以使用 DevTools 来模拟打印环境进行调试(按 Ctrl + Shift + P 组合键唤出菜单;对于中文版浏览器,请搜索「打印」关键字)。

演示

感兴趣的读者可以访问 oi.baoshuo.ren/bi-graph 并尝试打印该页面。

▲ 原网页

▲ 打印效果(预览)

我的 OI 生涯 —— 一名退役竞赛生的回忆录

作者 宝硕
2023年4月2日 17:33
我的 OI 生涯 —— 一名退役竞赛生的回忆录

在经历了四年半的不算短也不算长的时光后,我的 OI 生涯画上了一个并不算圆满的句号。

是的,我退役了。


写回忆录的本质是自己给自己整理遗容。

—— 郑渊洁《舒克和贝塔历险记》

谨以此文纪念我与 OI 一同逝去的青春。

OI 之路:我的成长历程

我第一次接触信息学竞赛时在初一上学期(2018 年)。当时学校与旁边的高中部合作开设了「信息贯通」课程,使得我在信息老师的帮助下了解到了信息学竞赛这个东西。这便是一切的开端了。

在学 OI 之前,我已经具有了一定的 Python 基础,并且还掌握了一些网页开发相关技能。不过这些东西和 OI 并没有什么关系,如果硬要说有的话,那么这些东西对我的帮助就是使得我的 C++ 语法入门过程并没有那么痛苦,促使了我留下来继续深入学习 OI 知识。

我对计算机有着与众不同的兴趣 —— 别的同龄人用电脑基本上都是打游戏,而我用电脑则是折腾软硬件、写写代码等等。在接触 OI 之后,我找到了有着相同兴趣的一群小伙伴,我们可以在一起交流很多计算机相关的东西 —— 大多是算法相关的内容 —— 我们都为代码可以实现的无限可能性着迷。这让我对 OI 的喜爱更甚 —— 又能学知识,还能结交好友。

▲ 初中开设的「信息贯通」课程正在授课。来源于学校微信公众号。本人跟随高中部学习,因此不在照片中。

不过与此同时,我在班级里并不是很合群,因为我不打游戏。当时流行的游戏叫做《王者荣耀》,同学们周末都会废寝忘食的去玩它,然后在返校后的课余时间交流上周末打游戏的心得,以及规划下次放假的游戏时间。而我因为对游戏没有兴趣,所以很难插上话。这使得我与班级的主体渐行渐远,转而更加亲近我们这个小圈子,在这个圈子里我能获得更多的认同感和归属感。

我初中的 OI 生涯到初三下学期(2021 年)告一段落。初三下学期是一段比较痛苦的日子 —— 我需要补习文化课,来应对即将到来的中考。我和我在学习 OI 时认识的邻班的好伙伴赵泽峰同学一起互帮互助(其实还是我向他取经比较多),共同学习。那段时间几乎每天我们两个都是最后回宿舍睡觉的人。最后的结果很令人振奋,我们都考上了我们理想的高中 —— 石家庄二中实验学校,也就是前文中提到的高中部,这所重点高中有着专业的教练团队和竞赛培养体系,是学习竞赛的好去处。

▲ 二南日落。本人在 2022 年 6 月摄于石家庄二中实验学校存真楼上。

进入高中后,我有更多的时间学习 OI,但相应地,学习文化课的时间减少了。我最初被分入了竞赛班,但我的成绩排在很靠后的位置,这是因为我不仅文化课考不了高分,而且不能兼顾竞赛和文化课的学习。这招来了文化课老师的不满 —— 学竞赛不拿金牌最后还得学文化课,而且文化课成绩太差会拉低班级平均分,这显然是他们所不想看到的。好在我高一下学期被编入了另外一个省理科实验班,这个班的班主任是上一届带竞赛班的班主任(我先前在竞赛班时班主任从没有接触过竞赛生),所以相比之下高一下学期时来自文化课班的压力要减轻许多。

高一下学期的期中考试结束后,我停课了。这给了我充足的时间去研究一些较为困难的知识点,这对我来说是一大收获。

▲ 我在存真楼上旧信息中心 NOI 教室 3 中的机位。由本人在 2022 年 6 月拍摄。

然后我就进入了高二,每天都被模拟赛压得喘不过气来。当时基本上每天的规划都是上午模拟赛,下午改题,晚上隔三岔五的还会有南校自己办的「基础模拟赛」—— 专练第一、二题难度,防止挂分(虽然该挂的还得挂)。

直到快要退役的时候,才能真正体会到往届学长们的痛楚。我送走了好几届学长,这次终于要成为了被送走的那一批。CSP-S 2022 拿了个一等,全省二十多名,这应该就是我能够达到的最好的成绩了吧。NOIP 2022 被取消了,没有考成。春季赛和省选又给我强行续了几个月的命,但于事无补。

▲ 我在 CSP-S 2022 中获得的获奖证书。

我的 OI 之旅到这里就结束了。退役之后特别喜欢学长们常说的一句话:菜是原罪。如果我的实力能够再强一些的话,我肯定不用担心退役这件事情。但即使最终的结局必然是退役,我也无悔竞赛。

收获

思维方式的转变

在学习竞赛的过程中,我收获了许多宝贵的经验和知识。其中最重要的收获之一就是我的思维方式进行了深刻的转变。

竞赛知识点的数量很大,并且通常都比较深入、复杂、抽象。这要求我们必须具备良好的理科思维和创新思维,能够将问题进行深入研究,并将其与实际问题相结合,产生新的想法和解决方案,从而在比赛中熟练运用它们。

知识面的拓展

OI 中所涉及的知识非常广泛,仅在《NOI 大纲》中列出的知识点就已经能够涉及到好几摞半人高的书堆了。此外,在日常训练的过程中还需要接触到各类国内外的在线资料,这同时需要良好的外语水平。等等。

对我而言,在学习 OI 之余,我还略微了解了一些软件工程相关的知识,写了一些小玩具出来。

社交关系与合作学习

我结识了许多友好的同学,他们都非常优秀。在竞赛学习的过程中,我们经常会相互帮助,互相学习。这种友好的关系使得我们的竞赛旅途更加愉快。

向优秀选手学习

俗话说得好,「人外有人,天外有天」。在学习竞赛的过程中,我时常有机会接触到并认识来自全国乃至全世界的优秀选手。

比自己更强的选手不一定只是对手,更可以成为我们的老师和榜样。从他们身上可以学习到很多独特的思维方式和优秀的解题方法,而这些在自己日常独自训练时是很难接触到的。所以要学会欣赏和学习优秀选手的思路和方法,并从中受益、成长。只有这样,我们才能不断提高自己的水平,成为更好的自己。

竞赛与文化课

平衡与挑战

对于大部分人,竞赛和文化课是不可兼顾的。既然要抽出时间来学习竞赛,那么就必须压缩一些干其他的事情的时间,比如学习文化课。这会导致文化课的学习效果受到影响,然后成绩就不可避免地下滑了。

考试成绩下降之后,班主任和任课老师们自然会有意见。竞赛不是一条捷径,我们学校每年只有那么几个人能够进入省队并在国赛中取得奖牌,其他人则会慢慢地被淘汰下来,这是不可避免的。老师们自然希望我们的文化课成绩要好一些,所以会鼓动甚至要求我们放弃学习竞赛,毕竟相比之下,竞赛的容错率和回报率太低了。

那么如何在竞赛和文化课之间取得一个较好的平衡就成了一个棘手的问题,这个问题各路人马争论至今也没有一个定论,我觉得以后也不会有一个定论,毕竟人和人是不一样的。

重回文化课

在春季赛后,我休息了半天便准备考虑回归文化课学习的事宜。

我先回班找到了各科的任课老师们,向她们说明了我的实际情况。她们表示理解,希望我能够尽快找回状态,回归文化课的学习,因为我已经落下了很多课程的学习进度。

一些能听懂的科目自然也是要回班听一听的,网课讲得显然不如老师好。不能听懂的科目就只能自己看书听网课,一轮复习再回班跟了。

刚退役的时候还是很失落的,也不能专注到文化课的学习上,不过经过后来的慢慢调整,现在情况有转好的迹象。再慢慢观察吧。

感谢

不论结果如何,我能坚持学习竞赛到今天,都少不了来自家长、教练和同学们的鼓励与支持。

我想感谢我的父母,没有他们的支持和鼓励,我不可能坚持到今天。

我想感谢我的教练任亮老师和聂文彬老师,没有他们的指导和帮助,我不可能取得今天的成绩(虽然并不是很出类拔萃)。

我想感谢我的同学们,没有他们的陪伴和帮助,我不可能从竞赛学习中收获如此多的东西。

后记

0

虽然退役了,但是我应该还会经常回来 OI 圈子看一看,没准还会参加一些比赛呢。

一切皆有可能,接下来的日子里,我会继续努力,不断提高自己的水平,成为更好的自己。

1

竞赛不是火,却能点亮一生。

这是石家庄二中实验学校旧信息中心旁的信息学竞赛教室墙外贴的一句话。

这句话的意思是,学习竞赛虽然不会像火焰燃烧那样为当下带来光明与温暖,但是它能够在一个人的一生中产生持久的影响。竞赛可以激发人的竞争精神,并培养毅力和耐力等品质。这些优点不仅在竞赛过程中得到锻炼,而且会伴随一个人的一生,对其产生长远、积极的影响。

上初中时第一次看到这句话时,我便对其留下了深刻的印象。随着时间的推移以及心境的不同,每次看到这句话,我都会对其有不同的理解。直到我的 OI 之旅走到尽头之时,我才明白了这句话之中的深意。

2

在退役之前的一个晚上,我走出实验楼的机房,向旁边的教学楼望了过去。灯火通明的教学楼与人烟稀少的实验楼形成了鲜明的对比 —— 这使得我莫名地产生了一种怅然若失的感觉 —— 我的竞赛之旅即将结束,我将要离开这个我已经熟悉的环境,去面对一个陌生的未来。

我想起了小时候读过的一首诗歌中的内容:

也许多少年后在某个地方,

我将轻声叹息把往事回顾,

一片树林里分出两条路,

而我选择了人迹更少的一条,

因此走出了这迥异的旅途。

– The Road Not Taken, Robert Frost.

我选择了竞赛,一个小众的发展方向,而这个选择决定了我今后的人生道路。竞赛决不是捷径,它只是另一种艰辛的生活方式。我不知道未来的路会怎么走,但我知道,我会一直一步一步脚踏实地地走下去。即使不再参加与竞赛相关的活动,竞赛带给我的思维方式也将伴我一生。

3

【心态乐观】

有人说,“生命中,我们都接到不同的剧本。平淡或浓烈,欢笑或眼泪,我们总要演好,直至落幕。”

心态好,一切都好。积极乐观的心态,是幸福生活的钥匙。

不管发生什么事,记得告诉自己,一切都会过去,好事自会发生。

—— 摘抄:人民日报夜读《善待自己,过张弛有度的生活》,2023 年 02 月 25 日。

4

大家都说,高考是千军万马过独木桥,不容易。

可是又有几个「大家」知道,竞赛是一个人摸黑走路,盲人骑瞎马,半夜临深池?

在无数个孤独清冷的深夜,无数次羡慕已经安然入梦的同学们。

我们都是行走在镜面边缘的人。

低下头看到的,是半个迷茫的自己,和半个不见底的深渊。

到哪里,会不会跌倒,是到终点还是滑进深渊,都不知道。

唯一确定的是,自己只有一个人。

—— 《行走在镜面的边缘》

5

得到与失去,只有时间会去评判;成功与失败,只有历史能去仲裁。

我不会永远成功,正如我不会永远失败一样。

—— 洪骥《……》


本文为原创文章,未经许可禁止任何形式的复制、摘抄与转载。

再见,2022 —— 我的 2022 年度总结

作者 宝硕
2022年12月31日 22:28
再见,2022 —— 我的 2022 年度总结

又一年过去了。由于学业繁忙,这一年中发生的能写出来公之于众的事情并没有多少,但做一些微小的记录总是值得的,所以就有了这篇年度总结。

大事记

竞赛生涯的续命

先向大家报告一个好消息,我在 CSP-S 2022 中取得了省一等奖(省排第 30),这么多年的竞赛算是没有白学。

不过,按照正常的进度,到 NOIP 2022 结束之时也就是我的退役之日了,毕竟河北省只有 15 个省队。

由于疫情影响,河北省取消了 NOIP 2022,并将在 2023 年 3 月举办春季赛,以此作为省选成绩的参考,这也就意味着我可以继续冲刺省选了(虽然进省队的希望不大,但仍然可以一试)。

但是,现在也是时候考虑如何补习文化课的事情了。

网课的日子

在得知 NOIP 2022 取消后,我们便返回家中,开始了上网课的日子。

跟班上网课貌似不太现实,所以只好跟着竞赛一起上网课。竞赛这边没有早读,早上上课时间比较晚,可以多睡会。

在网课期间既要补习文化课,又要兼顾竞赛的进度,实属一个难题。

疫情的终结

此部分内容没有文字描述。关于我感染之后的情况记录,可以查看《我的新冠阳性日记

一些零碎

我的 IPv4 地址段

感谢炮总相助,今年 3 月我终于有了一段属于自己的 IPv4 地址段 —— 174.136.239.0/24。不过因为线路问题,这段地址并没有开展大规模应用。

关于 AS141776 的更多信息,请访问 baoshuo.ren/network

新的个人主页

基于 Vite + React + Primer Design 的新个人主页上线了!

新的个人主页主要分为了个人简介、项目介绍、友情链接、项目单页等几个板块,并可以方便地在后期增删页面及其内容。

请访问 baoshuo.ren 了解更多信息。

OIerDb NG 正式上线

又经过了半年多的开发,OIerDb NG 正式上线了。

详细介绍可以查看文章《OIerDb NG —— 新一代的 OIerDb》,在此不作过多叙述。

附上今年最后一个季度的访问量数据,平均日访客数也能保持在 900 人左右。

欢迎体验:oier.baoshuo.dev

加入 Hexo Core Team

今年上半年折腾了折腾自己的 OI 博客,在折腾的过程中顺手给 Hexo 发了一些 PR,并在 Sukka 大佬的引荐下加入了 Hexo Core Team。

S2OJ v3.0

今年下半年正式接手了学校的 在线测评系统,并进行了 一些大改(当心过大的 diff 导致浏览器卡死)。

▲ 旧版界面

▲ 新版界面

除了界面更新之外,还增加了许多新功能,并修复了一些问题。在开发的过程中,也向上游 UOJ 官网版、UOJ 社区版发送了一些 Pull Request,算是为后人栽树了。

GitHub 上的贡献

又是碌碌无为的一年呢!

后记

2022 年就这样在疫情阴霾退散的过程中结束了。希望在走出这一困难之后,2023 年能够是一个更加美好的年份,让我们一起期待着明天的希望,共同迎接更加美好的未来。

我的新冠阳性日记

作者 宝硕
2022年12月23日 17:07

一直以为「感染新冠」这件事离我很远,像我这种居家上网课的人也不太可能与外面的病毒产生什么联系。直到 12 月 13 日午休之后,我居然发烧了,拿出抗原一测,发现自己阳了。

至前几日,笔者已经基本康复,于是决定写下这篇文章,记录下这一个多星期的别样的体验。

Day 1(12 月 13 日)

上午正常上课。

午饭后开始感觉略有不适,开始发烧(38.5℃ 左右),遂请假休息。下午烧到 39℃ 后服用一包布洛芬,体温略有下降,但并未退烧。抗原测试呈弱阳性。

半夜继续高烧(39.4℃),服用一包布洛芬,体温略有下降,但仍未退烧。

Day 2(12 月 14 日)

全天请假休息。

上午高烧(39.5℃),服用一包布洛芬后于午饭前退烧。

下午重新开始低烧(37.5℃),一直烧到晚上睡觉。

Day 3(12 月 15 日)

继续全天请假休息。

早上感觉身体已经适应了高烧的状态,没有前几日那么蔫,但体温仍在 39.5℃ 附近徘徊。

下午排便后转为低烧,并未服用退烧药。

Day 4(12 月 16 日)

正常上课。

不再发烧,有轻微咳嗽和流鼻涕的症状。

Day 5(12 月 17 日)

正常上课。

咳嗽和流鼻涕的症状加重,但没有太大影响。

Day 6(12 月 18 日)

正常上课。

咳嗽和流鼻涕导致头昏脑胀。晚上因咳嗽久久无法入眠。

Day 7(12 月 19 日)

正常上课。症状与前一天相似。

Day 8(12 月 20 日)

正常上课。症状开始转轻。

Day 9(12 月 21 日)

正常上课。抗原测试基本转阴。仍有些许咳嗽。

Day 10(12 月 22 日)

症状基本消失,食欲恢复。

后记

人民日报发布的「新冠发病 7 日典型症状过程」还是比较准确的,值得参考。

由于我去年在家中准备了一盒布洛芬颗粒,因此并没有陷入到「一药难求」的境地。从发病到痊愈,只消耗了半盒布洛芬颗粒(即 5 包)和两盒连花清瘟胶囊(48 颗),因此无需囤积药品,够用就好。

最后,希望各位读者在疫情期间保护好自己,也祝大家身体健康,百毒不侵!

USTC Hackergame 2022 Write Up

作者 宝硕
2022年10月30日 07:54

签到

日常改参数。

猫咪问答喵

  1. 中国科学技术大学 NEBULA 战队(USTC NEBULA)是于何时成立的喵?

    在这个页面中有这样一段介绍:

    星云战队(Nebula)

    中国科学技术大学“星云战队(Nebula)”成立于 2017 年 3 月,“星云”一词来自中国科学技术大学 BBS“瀚海星云”,代表同学们对科学技术的无限向往和追求。战队现领队为网络空间安全学院吴文涛老师,现任队长为网络空间安全学院李蔚林、童蒙和武汉。战队核心成员包括了来自网络空间安全学院、少年班学院、物理学院、计算机学院等各个院系的同学,充分体现了我校多学院共建网络空间安全一级学科的特点。战队以赛代练,以赛促学,在诸多赛事中获得佳绩。

    可知答案为 2017-03

  1. 22 年坚持,小 C 仍然使用着一台他从小用到大的 Windows 2000 计算机。那么,在不变更系统配置和程序代码的前提下,Firefox 浏览器能在 Windows 2000 下运行的最后一个大版本号是多少?

    Google 搜索 windows 2000 firefox 可以搜索到一个帖子:Last version of fireFox to work on Windows 2000?,可知答案为 12。

  2. 你知道 PwnKit(CVE-2021-4034)喵?据可靠谣传,出题组的某位同学本来想出这样一道类似的题,但是发现 Linux 内核更新之后居然不再允许 argc 为 0 了喵!那么,请找出在 Linux 内核 master 分支(torvalds/linux.git)下,首个变动此行为的 commit 的 hash 吧喵!

    关于这部分的限制在 fs/exec.c 文件下,那么 git blame 可知这部分是在 dcd46d8 中被修改的。

家目录里的秘密

VS Code 里的 flag

全局搜索 flag{ 可得:

rclone 里的 flag

rclone.conf 里可以找到一个密码,通过在 Google 上搜索可以找到 一份现成的解密代码

package main

import (
    "crypto/aes"
    "crypto/cipher"
    "crypto/rand"
    "encoding/base64"
    "errors"
    "fmt"
    "log"
)

// crypt internals
var (
    cryptKey = []byte{
        0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
        0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xa9, 0x2b,
        0xd3, 0x90, 0x19, 0x8e, 0xb8, 0x12, 0x8a, 0xfb,
        0xf4, 0xde, 0x16, 0x2b, 0x8b, 0x95, 0xf6, 0x38,
    }
    cryptBlock cipher.Block
    cryptRand  = rand.Reader
)

// crypt transforms in to out using iv under AES-CTR.
//
// in and out may be the same buffer.
//
// Note encryption and decryption are the same operation
func crypt(out, in, iv []byte) error {
    if cryptBlock == nil {
        var err error
        cryptBlock, err = aes.NewCipher(cryptKey)
        if err != nil {
            return err
        }
    }
    stream := cipher.NewCTR(cryptBlock, iv)
    stream.XORKeyStream(out, in)
    return nil
}

// Reveal an obscured value
func Reveal(x string) (string, error) {
    ciphertext, err := base64.RawURLEncoding.DecodeString(x)
    if err != nil {
        return "", fmt.Errorf("base64 decode failed when revealing password - is it obscured? %w", err)
    }
    if len(ciphertext) < aes.BlockSize {
        return "", errors.New("input too short when revealing password - is it obscured?")
    }
    buf := ciphertext[aes.BlockSize:]
    iv := ciphertext[:aes.BlockSize]
    if err := crypt(buf, buf, iv); err != nil {
        return "", fmt.Errorf("decrypt failed when revealing password - is it obscured? %w", err)
    }
    return string(buf), nil
}

// MustReveal reveals an obscured value, exiting with a fatal error if it failed
func MustReveal(x string) string {
    out, err := Reveal(x)
    if err != nil {
        log.Fatalf("Reveal failed: %v", err)
    }
    return out
}

func main() {
    fmt.Println(MustReveal("tqqTq4tmQRDZ0sT_leJr7-WtCiHVXSMrVN49dWELPH1uce-5DPiuDtjBUN3EI38zvewgN5JaZqAirNnLlsQ"))
}

跑一遍就出来了:

HeiLang

打开 VSCode,将 \| ([\d]+)\] = ([\d]+) 递归替换为 ] = $2\na[$1] = $2,然后执行脚本即可得到 flag:

Xcaptcha

在打开验证码页面的一瞬间将这段 JS 脚本塞进控制台里即可。

for (let i = 1; i <= 3; i++) {
  let raw_str = document.querySelector(`label[for="captcha${i}"]`).innerHTML;
  let match = /([\d]+)\+([\d]+)/.exec(raw_str);
  let sum = BigInt(match[1]) + BigInt(match[2]);
  document.getElementById('captcha' + i).value = sum.toString();
}

document.getElementById('submit').click();

旅行照片 2.0

照片分析

exiftool 一把梭。

LaTeX 机器人

纯文本

众所周知,LaTeX 有一个 \input 指令:

Flag 到手:flag{becAr3fu11dUd3c5a1b17ffa}

安全的在线测评

无法 AC 的题目

最开始我打算在程序里读文件,结果不知道为什么写挂了……

于是就立马想到了用汇编读文件,代码和动态数据差不多。

动态数据

查看评测机的源码可以发现 n = 5,那么可以用汇编在编译的时候读文件:

#include <stdio.h>
#include <string.h>

asm("staticInput: .incbin \"data/static.in\"");
asm("staticAnswer: .incbin \"data/static.out\"");
asm("dynamicInput0: .incbin \"data/dynamic0.in\"");
asm("dynamicInput1: .incbin \"data/dynamic1.in\"");
asm("dynamicInput2: .incbin \"data/dynamic2.in\"");
asm("dynamicInput3: .incbin \"data/dynamic3.in\"");
asm("dynamicInput4: .incbin \"data/dynamic4.in\"");
asm("dynamicAnswer0: .incbin \"data/dynamic0.out\"");
asm("dynamicAnswer1: .incbin \"data/dynamic1.out\"");
asm("dynamicAnswer2: .incbin \"data/dynamic2.out\"");
asm("dynamicAnswer3: .incbin \"data/dynamic3.out\"");
asm("dynamicAnswer4: .incbin \"data/dynamic4.out\"");

extern char staticInput[];
extern char staticAnswer[];
extern char dynamicInput0[];
extern char dynamicInput1[];
extern char dynamicInput2[];
extern char dynamicInput3[];
extern char dynamicInput4[];
extern char dynamicAnswer0[];
extern char dynamicAnswer1[];
extern char dynamicAnswer2[];
extern char dynamicAnswer3[];
extern char dynamicAnswer4[];

char n[1000000], a[6][1000000], p[1000000], q[1000000];

int main(int argc, char **argv[]) {
    scanf("%s\n", &n);

    sscanf(staticInput, "%s", a[0]);
    sscanf(dynamicInput0, "%s", a[1]);
    sscanf(dynamicInput1, "%s", a[2]);
    sscanf(dynamicInput2, "%s", a[3]);
    sscanf(dynamicInput3, "%s", a[4]);
    sscanf(dynamicInput4, "%s", a[5]);

    if (!strcmp(n, a[0])) {
        sscanf(staticAnswer, "%s\n%s", p, q);
        printf("%s\n%s\n", p, q);
    } else if (!strcmp(n, a[1])) {
        sscanf(dynamicAnswer0, "%s\n%s", p, q);
        printf("%s\n%s\n", p, q);
    } else if (!strcmp(n, a[2])) {
        sscanf(dynamicAnswer1, "%s\n%s", p, q);
        printf("%s\n%s\n", p, q);
    } else if (!strcmp(n, a[3])) {
        sscanf(dynamicAnswer2, "%s\n%s", p, q);
        printf("%s\n%s\n", p, q);
    } else if (!strcmp(n, a[4])) {
        sscanf(dynamicAnswer3, "%s\n%s", p, q);
        printf("%s\n%s\n", p, q);
    } else if (!strcmp(n, a[5])) {
        sscanf(dynamicAnswer4, "%s\n%s", p, q);
        printf("%s\n%s\n", p, q);
    }

    return 0;
}

企鹅拼盘

这么简单我闭眼都可以!

手动一个一个试就好啦~

后记

今年是我打 Hackergame 的第三年了,由于近期学业繁忙(甚至我刚从 CSP-S 考场出来就回来写 Write Up),所以并没有能抽出足够的时间来享受这场比赛了,只能用两天的零碎时间水一点签到题了事,binary 还是一如既往地稀烂……

OIerDb NG —— 新一代的 OIerDb

作者 宝硕
2022年8月9日 19:54
OIerDb NG —— 新一代的 OIerDb

笔者有幸能与 OI 巨神虞皓翔等人合作来共同参与 OIerDb NG 的开发,经过数个月的不断改进,目前项目已经初具雏形,特此写下本篇文章对其进行简要介绍。

笔者主要参与了前端用户界面的开发工作,而数据处理部分则主要由虞皓翔完成。

OIerDb NG 的新特性

纯前端处理 —— 效率更高、可离线使用

相比于老版 OIerDb,OIerDb NG 摈弃了传统的「客户端发送查询请求 -> 服务器响应查询请求」的模式,而是采用了纯前端的处理方式 —— 将数据库存储在浏览器的 indexedDB 中,这样在用户查询时无需向服务器发送请求,直接在浏览器端即可处理。这样带来的好处是显而易见的 —— 我们拥有了更快的查询响应速度,同时也减轻了对服务器的压力。至于缺点嘛… 在首次访问网页的时候会下载几 MB 的数据,从统计数据来看,这个过程在大部分情况下最多需要消耗 5 秒左右的时间。

在下载好所有页面的代码、数据加载完成后,即使断开网络连接也能正常使用 OIerDb NG 的基础功能 ,可以在断网打模拟赛 AK 之后找点东西看

查询更灵活 —— 满足用户的不同需求

老版的 OIerDb 只能应对两种类型的查询 —— 以选手或者学校为中心的查询。而新版的 OIerDb 在设计之初就希望具备应对更灵活的查询请求的能力,比如「查询『NOIP 2021』中『河北省』的获奖情况」(如上图所示),更进一步的话还可以「查询『NOIP 2021』中位于『河北省』的『石家庄市第二中学』的获奖情况」(不过这种查询目前还没有在用户界面中实现)。

开发回忆录

在 2021 年 12 月初的一天中午,笔者在 OIerDb 的页面底部发现了 nocriz 的《呼吁广大选手积极参与开发下一代 OIerDb》文章,恰巧笔者在课余时间学习了一些在现在看来非常浅薄的前端技术,于是跃跃欲试地在 12 月 12 号的那天创建了一个新的 GitHub 仓库,并使用模板来提交了 第一个 commit

接下来的几天,笔者利用自己的空余时间来编写代码,终于在 12 月 19 日完成了第一版的 OIerDb NG,并部署到了 Netlify 上。

▲ OIerDb NG 的第一版界面。

当时笔者初学 React,许多知识仍有待学习,再加之笔者忙于完成学业,因此开发进度异常缓慢,网站的功能也有很多欠缺。

第一版完成后没多久,笔者找到了精通 React 的好友 Menci 来帮忙 review 代码。在这个过程中,Menci 提出了许多富有建设性的意见,同时对项目整体进行了一番调整,使其更加现代化、工程化。笔者也从中学到了很多知识。

之后笔者边实践边学习,还从 LibreOJ 的前端中抄来了一些代码,比如手机端的导航栏。

慢慢地,OIerDb NG 上线了 nocriz 的文章中提到的大部分功能(点击图片可以前往对应页面):

▲ 基础 / 高级搜索

▲ 搜索页选手信息卡片

▲ 地区信息学奥林匹克竞赛选手 / 学校排名

▲ 学校 / 比赛详情页面

OIerDb NG 的不足之处

尽管 OIerDb NG 有了一个还算可以的开始,但仍然存在诸多不足之处。

例如,对于网络速度较慢的用户,加载数 MB 的数据可能仍需要十几秒甚至数十秒。并且,即使是一些小更新也需要重新从服务器拉取全量数据,对用户与服务器的流量都是一种浪费。

再比如一些用户可能需要指向性更强的查询条件,目前还没有找到一个比较好的办法来添加到用户界面中。

除了这些之外,还有一些其他的问题存在。这些问题由于团队内的各位开发者都在现实生活中有着自己的工作、学习任务,无法去逐一解决。笔者希望广大对信息学竞赛感兴趣的朋友们能或多或少地参与进 OIerDb NG 的开发,共同为信息学竞赛社区做出贡献。

后记

感谢 nocriz 建立的 OIerDb 网站,为国内的信息学竞赛社区做出了巨大贡献。

也感谢 yhx-12243Menci 参与 OIerDb NG 的开发,完成了许多工作。

最后的最后,给 OIerDb-ng/OIerDb 求一波 Star~

拥抱 Atomic CSS-in-JS

作者 宝硕
2022年7月23日 17:30
拥抱 Atomic CSS-in-JS

当下,Atomic CSS 愈发受到人们的关注。相比于传统 CSS 编写方法中每个组件对应一个 CSS 类,使用了 Atomic CSS 以后,每一个 CSS 类都唯一对应了一条独立的 CSS 规则,随着组件数量逐渐增加、能复用的 CSS 规则越来越多,最终的 CSS 产物体积也会下降许多,使得网页的加载速度能够产生一个质的飞跃。

CSS 编写方法的发展历程

在介绍 Atomic CSS 之前,让我们先来回顾一下 CSS 编写方法的发展历程。

SMACSS

SMACSS(Scalable & Modular Architecture for CSS),是由 Jonathan Snook 提出的 CSS 理论。其主要原则有 3 条:

  • Categorizing CSS Rules(为 CSS 分类)
  • Naming Rules(命名规则)
  • Minimizing the Depth of Applicability(最小化适配深度)

规则分类

SMACSS 将规则分为了五类:Base(基础)、Layout(布局)、Module(模块)、State(状态)、Theme(主题)。

基础(Base) 规则里放置默认样式。这些默认样式基本上都是元素选择器,不过也可以包含属性选择器,伪类选择器,孩子选择器,兄弟选择器。本质上来说,一个基础样式定义了元素在页面的任何位置应该是怎么样的。

布局(Layout) 规则将页面拆分成几个部分,每个部分都可能有一到多个模块。顾名思义,这个分类主要用来做页面的整体或其中一块区域的布局。

模块(Modules) 是我们的设计当中可重用,可模块化的部分。插图,侧边栏,文章列表等等都属于模块。

状态(State) 规则定义了我们的模块或者布局在特殊的状态下应该呈现怎样的效果。它可能定义模块、布局在不同显示屏上应该如何显示。也可能定义一个模块在不同页面(例如主页和内页)中可能呈现怎么样的效果。

主题(Theme) 规则和状态规则类似,定义模块或者布局的外观。很多网站的「深色模式」「换肤」等等功能就是这样实现的。

命名规则

将规则分成五类之后,还需要命名规范。命名规范能够使得我们立刻了解到某个样式属于哪一类,以及它在整个页面中起到的作用。在一个大型项目中,我们可能会将一个样式分割成几个文件,这个时候命名约定能够使得我们更容易知道这个样式属于哪个文件。

推荐使用前缀来区分布局、模块和状态等等规则。比如对布局规则使用 layout- 前缀,对状态规则使用 is- 前缀就是一个不错的选择。

最小化适配深度

尽量不要依赖文档树的结构来编写样式。这样可以让我们的样式更加灵活,并且容易维护。

BEM

BEM( Block Element Modifier)是由 Yandex 团队提出的一种前端 CSS 命名方法论。它是一个简单又非常有用的命名约定。让前端代码更容易阅读和理解,更容易协作,更容易控制,更加健壮和明确,而且更加严密。

BEM 命名约定的模式是:

.block {
}

.block__element {
}

.block--modifier {
}
  • block 代表了「块」,用于组件本体。
  • element 代表了「块」中的某个「元素」(也可以叫做「子组件」),是块构成的主要成员。
  • modifier 代表了「块」的修饰符,表示不同的状态和版本。使用 -- 做区分,适用于「块」和「元素」,分别称之为「块修饰符」和「元素修饰符」。

命名的不同部分之间之所以使用 __-- 分割,是因为如果某部分中如果出现了多个单词需要使用 - 分隔,这样可以避免造成混淆。

CSS Modules

随着时代的发展,一个大型前端工程中的 CSS 类名越来越多,此时难免会出现类名冲突的情况,此时 CSS Modules 应运而生 —— 它通过为 CSS 类名添加 Hash 等方式来产生唯一的名称来防止冲突的产生。

CSS Modules 并不是 CSS 官方的标准,也不是浏览器的特性,而是使用一些构建工具,比如 Webpack,对 CSS 类名和选择器限定作用域的一种方式。

Utility-First CSS

当传统大型项目使用的 CSS 方法论还都大多是上方提到的 OOCSS、SMACSS、BEM 等等主要聚焦在「关注点分离」的「语义化 CSS」方案的时候,Utility-First 的 CSS 概念脱颖而出、逐渐受到社区的关注。而这之中最为被人熟知的、也最典型的就是 Tailwind CSS 了。

Utility-First CSS 不像 Semantic CSS 那样将组件样式放在一个类中,而是为我们提供一个由不同功能类组成的工具箱,我们可以将它们混合在一起应用在页面元素上。这样有几个好处:

  • 不用纠结于类名的命名;
  • 功能越简单的类,复用率越高,可以减小最终的打包大小;
  • 不存在全局样式污染问题;
  • 等等。

但也存在一些不足:

  • class 属性的内容过长;
  • 存在 CSS 规则插入顺序相关的问题;
  • 不能通过语义化类名得知组件的作用;
  • 不压缩的话构建产物体积过大。

新时代,来临了 —— Atomic CSS-in-JS

在前文介绍的 Utility-First CSS 的基础之上更进一步,Atomic CSS 便映入了人们的眼帘。

Atomic CSS 背后的思想与以往的「关注点分离」的思想可以称得上是背道而驰了。使用 Atomic CSS 时实际上将结构层和样式层耦合在了一起,这样的方式在现代 CSS-in-JS 的代码库中基本上得到了广泛认可,下文将会进行进一步的介绍。

Atomic CSS 可以看作是 Utility-First CSS 的极致抽象版本,每一个 CSS 类都对应一条单一的 CSS 规则。可面对如此繁复的 CSS 规则,手写 Atomic CSS 的类名并不是一个好的办法。于是 Atomic CSS-in-JS 应运而生,它可以看作是「自动化的 Atomic CSS」:

  • 无需手动设计 CSS 类名;
  • 能够提取页面的关键 CSS,并进行代码拆分;
  • 可以解决经典的 CSS 规则插入顺序的问题。

传统 CSS 编写方式的缺点

Christopher Chedeau 一直致力于推广 React 生态系统中 CSS-in-JS 理念。在很多次演讲中,他都解释了 CSS 的几大问题:

  1. 全局命名空间
  2. 依赖
  3. 无用代码消除
  4. 代码压缩
  5. 共享常量
  6. 非确定性(Non-Deterministic)解析
  7. 隔离

虽然 Utility-First CSS 和 Atomic CSS 也解决了其中的一些问题,但它们无法解决所有问题(特别是样式的非确定性解析)。

举个例子:Tailwind CSS 会在生成时生成出来许多无用代码,导致样式文件体积的增长,看看下面这份代码:

<div class="before:bg-white before:p-4">content</div>

生成出来的样式文件长这个样子:

.before\:bg-white::before {
  content: var(--tw-content);
  --tw-bg-opacity: 1;
  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}

.before\:p-4::before {
  content: var(--tw-content);
  padding: 1rem;
}

可以看到这份文件中包括了许多的无用代码,比如重复的 content: var(--tw-content)

更小的构建产物

传统的 CSS 编写方法无法复用组件间重复的 CSS 规则,比如下图中高亮的几条规则各自躺在它们对应的 CSS 类中:

这样会导致 CSS 产物大小与项目的复杂程度和组件数量线性正相关。

但使用 Atomic CSS 之后,这些规则被提取出来进行复用:

随着后期组件数量逐渐增加、能复用的 CSS 规则越来越多、最终 CSS 产物大小与项目复杂程度呈对数关系:

Facebook 分享了他们的数据:在旧网站上,仅登录页就需要加载 413 KiB 的样式文件,而在使用 Atomic CSS-in-JS 重写后,整个站点只有 74 KiB 的样式文件,还包括了深色模式。

虽然在使用 Atomic CSS 之后,HTML 的体积会显著增大,不过由于这些类名的高冗余度,可以利用 gzip 来压缩掉很大一部分体积。

处理 CSS 规则的插入顺序

让我们再来回顾一遍这个经典的 CSS 规则插入顺序的问题:

我们都知道,最后生效的样式不是最后一个类名对应的规则,而是样式表中最后插入的规则。

那么,如何在 CSS-in-JS 中处理这个问题呢?通用的做法是在生成阶段就将冲突的规则过滤掉,以避免产生冲突。比如下面这个组件:

const styles = style9.create({
  card: {
    color: '#000000',
  },
  profileCard: {
    color: '#ffffff',
  },
});

const Component = () => (
  <div className={style9(styles.card, styles.profileCard)} />
);

过滤后组件的实际样式如下:

color: #ffffff;

而如果将组件样式中的 styles.cardstyles.profileCard 调换一下顺序,过滤之后的样式就变成了这样:

color: #000000;

但 CSS 中有一些简写规则,如果只按照规则名称来处理显然是不行的。有的库强制开发者们不使用简写规则来避免这个问题,而另外的一些库则将这些简写规则展开成多条规则后再进行过滤,比如 margin: 10px 可以被拆成 margin-top: 10pxmargin-right: 10pxmargin-bottom: 10pxmargin-left: 10px 四条独立的规则。

经典实现

Atomic CSS-in-JS 实现有运行时(Runtime)和预编译(Pre-Compile)两种。运行时(Runtime)的优点在于可以动态生成样式,相比于下文中采用预编译方法的库来说灵活度高了不止一点半点。其缺点则在于 Vendor Prefix 等操作需要在 Runtime 执行,因此 Bundle 中必须携带相关依赖导致体积增大。预编译(Pre-Compile)的优点则在于无需将相关依赖打包发送给客户端,改善了性能。而缺点则是预编译的过程高度依赖静态代码分析,所以难以实现动态样式生成与组合。

Styletron

Styletron 是 Uber 公司开发的一个较为典型的运行时 Atomic CSS-in-JS 库,驱动了 Uber 的官网和 H5 页面。

Styletron 还提供了一套 Styled Components 的实现,可以通过下面的方式使用:

import { styled } from 'styletron-react';

const Component = styled('div', {
  marginTop: '10px',
  marginBottom: '10px',
});

<Component />;

还可以根据 prop 的值来动态生成样式:

const Component = styled('div', (props) => {
  return { color: props.$fraction < 0.5 ? 'red' : 'green' };
});

<Component $fraction={Math.random()} />;

Fela

与 Styletron 同为运行时 Atomic CSS-in-JS 库的还有沃尔沃汽车前技术主管开发的 Fela,驱动了沃尔沃汽车官网,Cloudflare Dashboard 和 Medium 等众多网站。

vanilla-extract

Stylex 是 Meta(原 Facebook)的一个尚未开源的预编译 Atomic CSS-in-JS 库。不过由于 Meta 迟迟不开源 stylex,社区中已经涌现出了数个基于其思想的开源实现,其中以 vanilla-extract 最为知名。

style9

基于 stylex 思想的预编译 Atomic CSS-in-JS 库除了 vanilla-extract 之外还有 style9styleQ

compiled

将视线从 stylex 系列中转移开来,Atlassian 还编写了一个名为 compiled 的预编译 Atomic CSS-in-JS 库,但在笔者的实际使用过程中坑点较多,可能会导致样式的重复生成,并且其对 TypeScript 的支持也不尽人意,不过其代码实现中的许多技巧还是有借鉴价值的。

Styled Components

compiled 依靠一个 babel transformer 来对代码进行转换以插入样式。

packages/react/src/styled/index.tsx 文件中可以看到,@compiled/react 包含了一个导出了一个名为 styled 的对象,这个对象一旦被访问就会立刻抛出错误,提示 transformer 没有正常工作:

export const styled: StyledComponentInstantiator = new Proxy(
  {},
  {
    get() {
      return () => {
        // Blow up if the transformer isn't turned on.
        // This code won't ever be executed when setup correctly.
        throw createSetupError();
      };
    },
  }
) as any;

那么可以看出,styled 会被 transformer 替换掉,对应的入口逻辑在 packages/babel-plugin/src/babel-plugin.tsx 文件中:

ImportDeclaration(path, state) {
  // 不是从 @compiled/react 导入的包不处理
  if (path.node.source.value !== '@compiled/react') {
    return;
  }

  // 记录导入的模块
  state.compiledImports = {};

  // 遍历导入数组中的所有元素
  path.get('specifiers').forEach((specifier) => {
    if (!state.compiledImports || !specifier.isImportSpecifier()) {
      return;
    }

    (['styled', 'ClassNames', 'css', 'keyframes'] as const).forEach((apiName) => {
      if (
        state.compiledImports &&
        t.isIdentifier(specifier.node?.imported) &&
        specifier.node?.imported.name === apiName
      ) {
        // 记录下导入后 API 的名称
        state.compiledImports[apiName] = specifier.node.local.name;
      }
    });
  });

  // 导入 @compiled/react/runtime 中的 API
  appendRuntimeImports(path);

  path.remove();
},

这段代码记录了 @compiled/react 的引入情况,为下方的处理提供了便利。

TaggedTemplateExpression(path, state) {
  if (t.isIdentifier(path.node.tag) && path.node.tag.name === state.compiledImports?.css) {
    state.pathsToCleanup.push({ path, action: 'replace' });
    return;
  }

  if (
    t.isIdentifier(path.node.tag) &&
    path.node.tag.name === state.compiledImports?.keyframes
  ) {
    state.pathsToCleanup.push({ path, action: 'replace' });
    return;
  }

  if (!state.compiledImports?.styled) {
    return;
  }

  // 处理 styled component
  visitStyledPath(path, { context: 'root', state, parentPath: path });
},
CallExpression(path, state) {
  if (!state.compiledImports) {
    return;
  }

  if (
    t.isIdentifier(path.node.callee) &&
    (path.node.callee.name === state.compiledImports?.css ||
      path.node.callee.name === state.compiledImports?.keyframes)
  ) {
    state.pathsToCleanup.push({ path, action: 'replace' });
    return;
  }

  // 处理 styled component
  visitStyledPath(path, { context: 'root', state, parentPath: path });
},

TaggedTemplateExpressionCallExpression 的处理,正好对应了文档中的两种不同调用方式:

// 模板字符串
styled.a`
  color: blue;
`;

// 函数调用
styled.a({
  color: 'blue',
});

跟随着 visitStyledPath 函数的定义,可以找到 packages/babel-plugin/src/styled/index.tsx 文件。

export const visitStyledPath = (
  path: NodePath<t.TaggedTemplateExpression> | NodePath<t.CallExpression>,
  meta: Metadata
): void => {
  // 判断是否是支持的操作
  if (
    t.isTaggedTemplateExpression(path.node) &&
    hasInValidExpression(path.node)
  ) {
    throw buildCodeFrameError(
      `A logical expression contains an invalid CSS declaration. 
      Compiled doesn't support CSS properties that are defined with a conditional rule that doesn't specify a default value.
      Eg. font-weight: \${(props) => (props.isPrimary && props.isMaybe) && 'bold'}; is invalid.
      Use \${(props) => props.isPrimary && props.isMaybe && ({ 'font-weight': 'bold' })}; instead`,
      path.node,
      meta.parentPath
    );
  }

  // 提取样式信息
  const styledData = extractStyledDataFromNode(path.node, meta);
  if (!styledData) {
    // 没有样式信息
    return;
  }

  // 生成 CSS
  const cssOutput = buildCss(styledData.cssNode, meta);

  // 构建并替换节点
  path.replaceWith(buildStyledComponent(styledData.tag, cssOutput, meta));

  const parentVariableDeclaration = path.findParent((x) =>
    x.isVariableDeclaration()
  );
  if (
    parentVariableDeclaration &&
    t.isVariableDeclaration(parentVariableDeclaration.node)
  ) {
    const variableDeclarator = parentVariableDeclaration.node.declarations[0];
    if (t.isIdentifier(variableDeclarator.id)) {
      const variableName = variableDeclarator.id.name;
      parentVariableDeclaration.insertAfter(buildDisplayName(variableName));
    }
  }
};

再来看提取样式信息的函数 extractStyledDataFromNode,这个函数根据不同情况使用不同的方法提取样式信息:

const extractStyledDataFromNode = (
  node: t.TaggedTemplateExpression | t.CallExpression,
  meta: Metadata
) => {
  // 使用模板字符串
  if (t.isTaggedTemplateExpression(node)) {
    return extractStyledDataFromTemplateLiteral(node, meta);
  }

  // 使用函数调用
  if (t.isCallExpression(node)) {
    return extractStyledDataFromObjectLiteral(node, meta);
  }

  // 提取不到信息
  return undefined;
};

构建新节点的函数被定义在 packages/babel-plugin/src/utils/ast-builders.tsx 文件中:

export const buildStyledComponent = (
  tag: Tag,
  cssOutput: CSSOutput,
  meta: Metadata
): t.Node => {
  const unconditionalCss: string[] = [];
  const logicalCss: CssItem[] = [];

  cssOutput.css.forEach((item) => {
    if (item.type === 'logical') {
      logicalCss.push(item);
    } else {
      unconditionalCss.push(getItemCss(item));
    }
  });

  // 去重,只保留最后一个
  const uniqueUnconditionalCssOutput = transformCss(unconditionalCss.join(''));

  const logicalCssOutput = transformItemCss({
    css: logicalCss,
    variables: cssOutput.variables,
  });

  const sheets = [
    ...uniqueUnconditionalCssOutput.sheets,
    ...logicalCssOutput.sheets,
  ];

  const classNames = [
    ...[t.stringLiteral(uniqueUnconditionalCssOutput.classNames.join(' '))],
    ...logicalCssOutput.classNames,
  ];

  // 返回构建好的节点
  return styledTemplate(
    {
      classNames,
      tag,
      sheets,
      variables: cssOutput.variables,
    },
    meta
  );
};

至于构建节点的操作,则是较为简单的字符串拼接:

const styledTemplate = (opts: StyledTemplateOpts, meta: Metadata): t.Node => {
  const nonceAttribute = meta.state.opts.nonce
    ? `nonce={${meta.state.opts.nonce}}`
    : '';
  const propsToDestructure: string[] = [];

  // 提取样式
  const styleProp = opts.variables.length
    ? styledStyleProp(opts.variables, (node) => {
        const nestedArrowFunctionExpressionVisitor = {
          noScope: true,
          MemberExpression(path: NodePath<t.MemberExpression>) {
            const propsToDestructureFromMemberExpression =
              handleMemberExpressionInStyledInterpolation(path);

            propsToDestructure.push(...propsToDestructureFromMemberExpression);
          },
          Identifier(path: NodePath<t.Identifier>) {
            const propsToDestructureFromIdentifier =
              handleDestructuringInStyledInterpolation(path);

            propsToDestructure.push(...propsToDestructureFromIdentifier);
          },
        };

        if (t.isArrowFunctionExpression(node)) {
          return traverseStyledArrowFunctionExpression(
            node,
            nestedArrowFunctionExpressionVisitor
          );
        }

        if (t.isBinaryExpression(node)) {
          return traverseStyledBinaryExpression(
            node,
            nestedArrowFunctionExpressionVisitor
          );
        }

        return node;
      })
    : t.identifier('style');

  let unconditionalClassNames = '',
    logicalClassNames = '';

  opts.classNames.forEach((item) => {
    if (t.isStringLiteral(item)) {
      unconditionalClassNames += `${item.value} `;
    } else if (t.isLogicalExpression(item)) {
      logicalClassNames += `${generate(item).code}, `;
    }
  });

  // classNames 为生成好的类名
  const classNames = `"${unconditionalClassNames.trim()}", ${logicalClassNames}`;

  // 此处的 <CC />, <CS /> 是上文中处理 import 时从 @compiled/react/runtime 中导入的组件
  return template(
    `
  forwardRef(({
    as: C = ${buildComponentTag(opts.tag)},
    style,
    ${unique(propsToDestructure)
      .map((prop) => prop + ',')
      .join('')}
    ...${PROPS_IDENTIFIER_NAME}
  }, ref) => (
    <CC>
      <CS ${nonceAttribute}>{%%cssNode%%}</CS>
      <C
        {...${PROPS_IDENTIFIER_NAME}}
        style={%%styleProp%%}
        ref={ref}
        className={ax([${classNames} ${PROPS_IDENTIFIER_NAME}.className])}
      />
    </CC>
  ));
`,
    {
      plugins: ['jsx'],
    }
  )({
    styleProp,
    cssNode: t.arrayExpression(
      unique(opts.sheets).map((sheet) => hoistSheet(sheet, meta))
    ),
  }) as t.Node;
};

这样兜兜转转一圈下来,就将使用了 styled 方法生成的组件的样式抽离出来,变成了一个 compiled 的 Atomic CSS-in-JS 组件。

css Prop

compiled 首先 增加了 css prop 的 TypeScript 定义,然后和 styled component 一样在 babel transform 的时候对这个 prop 进行特殊处理:

JSXOpeningElement(path, state) {
  if (!state.compiledImports) {
    return;
  }

  // 处理 css prop
  visitCssPropPath(path, { context: 'root', state, parentPath: path });
},

相比于 styled component 繁复的处理方式,css prop 的处理看起来简洁了许多:

export const visitCssPropPath = (
  path: NodePath<t.JSXOpeningElement>,
  meta: Metadata
): void => {
  let cssPropIndex = -1;
  const cssProp = path.node.attributes.find(
    (attr, index): attr is t.JSXAttribute => {
      if (t.isJSXAttribute(attr) && attr.name.name === 'css') {
        cssPropIndex = index;
        return true;
      }

      return false;
    }
  );

  // 不存在 css prop 就不进行处理了
  if (!cssProp || !cssProp.value) {
    return;
  }

  // 从 css props 中提取样式信息
  const cssOutput = buildCss(getJsxAttributeExpression(cssProp), meta);

  // 删除 css prop
  path.node.attributes.splice(cssPropIndex, 1);

  // 没有样式信息
  if (!cssOutput.css.length) {
    return;
  }

  // 构建并替换节点
  path.parentPath.replaceWith(
    buildCompiledComponent(
      path.parentPath.node as t.JSXElement,
      cssOutput,
      meta
    )
  );
};

构建新节点的 buildCompiledComponent 函数被定义在 packages/babel-plugin/src/utils/ast-builders.tsx 文件中,这个函数主要完成了以下操作:

  1. 合并现有的 className
  2. 处理 css prop 中的样式;
  3. 生成 compiled 的 Atomic CSS-in-JS 组件。

这样就将组件的 css 参数拆成了两部分 —— 静态的样式和附加到原组件的 className 参数值。

其他

微软最近开源的 Griffel 既支持运行时模式,又支持预编译模式,同时拥有着更佳的 TypeScript 支持,不失为一个好的选择。这个库目前驱动了微软官方的 Fluent UI

后记

以上就是本文要介绍关于 Atomic CSS 的全部内容了。

虽然 Atomic CSS-in-JS 是 React 生态系统中新涌起的一股潮流,但在使用前一定要三思 —— 这个方案到底符不符合项目的需求,而不是盲目地「为了使用而使用」,给将来的维护工作埋雷,但如果使用它能带来显而易见的好处,那么何乐而不为呢?

笔者才疏学浅,只是在前人的基础之上做了一些微小的工作而已,文章中如有错误欢迎在评论区指正。感谢 Sukka 大佬在本文编写过程中的指导。感谢 Byran Lee 指出本文中的错误。

参考资料

  1. Atomic CSS-in-JS,Sébastien Lorber,2020 年 4 月 27 日。
  2. 聊聊原子类(Atomic CSS),Mongkii,2021 年 7 月 26 日。
  3. 值得参考的 CSS 理论:OOCSS、SMACSS 与 BEM,ACGTOFE,2014 年 9 月 30 日。
  4. Utility-First Fundamentals,Tailwind CSS。

使用 GitHub Actions 自动申请与部署 SSL 证书

作者 宝硕
2022年5月15日 16:47
使用 GitHub Actions 自动申请与部署 SSL 证书

对于一个有很多服务器的人来说,在不同服务器上同步 SSL 证书是一件麻烦事。笔者尝试过很多种方式,最后在 Menci 的推荐下选定了使用 GitHub Actions 来自动申请、续期 SSL 证书,并自动推送到各个服务器上。

本博客的证书也是使用这种方式进行签发、部署的,可以点击浏览器地址栏上的按钮查看证书。

申请证书

前期准备

首先请在本地(或自己的服务器上)成功使用 acme.shDNS-01 验证方式成功申请一次证书,如果不会操作的话可以参考 烧饼博客的教程 来进行。这个过程包括:

  1. 向 CA 注册 ACME 账户(如果使用 Let’s Encrypt 则会自动进行,详细步骤请参阅 acme.sh 的 Wiki)。
  2. 通过环境变量指定 DNS 提供商的凭据,用于添加/删除 ACME DNS-01 认证所需的 TXT 记录。
  3. 确认证书申请可以成功,为后续调试排除可能的问题。

第一次申请证书后,CA 的 ACME 账户凭据将被存储到 ~/.acme.sh/ca 中,DNS 提供商的凭据将被存储到 ~/.acme.sh/account.conf 中。将它们打包并使用 Base64 编码存储,以备在 GitHub Actions 中使用:

cd ~/.acme.sh
tar cz ca account.conf | base64 -w0

将输出内容添加到 GitHub 仓库的 Secrets 中。注意不要复制输出中的多余信息。

自动化

如果没有特殊需求,可以使用 Menci/acme 来简单地申请证书:

# 全局环境变量
env:
  # Checkout 到的目录
  CERTS_OUTPUT_BASE: certs
  # 证书输出目录
  CERTS_OUTPUT_DIRECTORY: example.com
  # 证书文件名
  FILE_FULLCHAIN: fullchain.pem
  # 私钥文件名
  FILE_KEY: privatekey.key

jobs:
  issue-ssl-certificate:
    name: Issue SSL certificate
    runs-on: ubuntu-latest
    steps:
      - uses: Menci/acme@v2
        with:
          # 指定 acme.sh 的版本
          version: 3.0.2

          # 上方保存的以 Base64 编码存储的凭据
          account-tar: ${{ secrets.ACME_SH_ACCOUNT_TAR }}

          # 域名列表,以空格分隔
          domains: example.com example.net example.org example.edu
          # 是否申请通配符
          append-wildcard: true

          # 传递给 acme.sh 的额外参数
          arguments: --dns dns_cf --challenge-alias example.com

          # 导出的证书路径
          output-fullchain: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
          output-key: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}

如果需要高度自定义 acme.sh 的参数,比如为不同的域名设置不同的 DNS 提供商,可以使用下面的方式手动编写命令来执行:

# 全局环境变量
env:
  # Checkout 到的目录
  CERTS_OUTPUT_BASE: certs
  # 证书输出目录
  CERTS_OUTPUT_DIRECTORY: example.com
  # 证书文件名
  FILE_FULLCHAIN: fullchain.pem
  # 私钥文件名
  FILE_KEY: privatekey.key

jobs:
  issue-ssl-certificate:
    name: Issue SSL certificate
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          ref: master

      - name: Checkout output branch
        uses: actions/checkout@v2
        with:
          ref: certs
          path: ${{ env.CERTS_OUTPUT_BASE }}

      # 安装 acme.sh
      - name: Install acme.sh
        shell: bash
        run: curl -s https://get.acme.sh | sh

      # 解压 acme.sh 配置信息
      - name: Extract account files for acme.sh
        shell: bash
        run: |
          echo "$ACME_SH_ACCOUNT_TAR" | base64 -d | tar -C ~/.acme.sh -xz
        env:
          # Base64 编码的 acme.sh 配置信息
          ACME_SH_ACCOUNT_TAR: ${{ secrets.ACME_SH_ACCOUNT_TAR }}

      # 申请证书
      - name: Issue SSL certificates
        shell: bash
        run: |
          ~/.acme.sh/acme.sh --issue        \
            -d "example.com"   --dns dns_cf \
            -d "*.example.com" --dns dns_cf \
            -d "example.net"   --dns dns_dp \
            -d "*.example.net" --dns dns_dp \
            --server letsencrypt

      # 导出证书
      - name: Copy certificate to output paths
        shell: bash
        run: |
          ACME_SH_TEMP_DIR="$(mktemp -d)"
          ACME_SH_TEMP_FILE_FULLCHAIN="$ACME_SH_TEMP_DIR/fullchain.pem"
          ACME_SH_TEMP_FILE_KEY="$ACME_SH_TEMP_DIR/key.pem"

          ~/.acme.sh/acme.sh --install-cert -d "$ACME_SH_FIRST_DOMAIN" --fullchain-file "$ACME_SH_TEMP_FILE_FULLCHAIN" --key-file "$ACME_SH_TEMP_FILE_KEY"

          [[ -z "$ACME_SH_OUTPUT_FULLCHAIN" ]] || (mkdir -p "$(dirname "$ACME_SH_OUTPUT_FULLCHAIN")" && cp "$ACME_SH_TEMP_FILE_FULLCHAIN" "$ACME_SH_OUTPUT_FULLCHAIN")
          [[ -z "$ACME_SH_OUTPUT_KEY" ]] || (mkdir -p "$(dirname "$ACME_SH_OUTPUT_KEY")" && cp "$ACME_SH_TEMP_FILE_KEY" "$ACME_SH_OUTPUT_KEY")

          rm -rf "$ACME_SH_TEMP_DIR"
        env:
          # 修改此处的 example.com 为申请时填写的第一个域名
          ACME_SH_FIRST_DOMAIN: example.com
          ACME_SH_OUTPUT_FULLCHAIN: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
          ACME_SH_OUTPUT_KEY: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}

上传证书至仓库

# 上传证书
- name: Push to GitHub
  run: |
    git config --global user.name "BaoshuoBot"
    git config --global user.email "79077260+BaoshuoBot@users.noreply.github.com"

    cd "$CERTS_DIRECTORY"

    git add "$FILE_FULLCHAIN" "$FILE_KEY"
    git commit -m "Upload certificates on $(date '+%Y-%m-%d %H:%M:%S')"
    git push
  env:
    TZ: Asia/Shanghai
    CERTS_DIRECTORY: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}

部署证书

在申请证书的 Job 执行完成后,可以执行一系列其他的 Job 来将证书部署到各个服务器或云服务。

服务器

可以使用 easingthemes/ssh-deploy 来使用 rsync 将证书同步到服务器上。同步完成后再使用 appleboy/ssh-action 远程执行命令重载 Nginx / Apache。

# 部署到服务器
deploy-to-server:
  name: Deploy Certificate to Server
  runs-on: ubuntu-latest
  needs: issue-ssl-certificate

  strategy:
    matrix:
      host:
        - 174.136.239.1 # Server 1
        - 174.136.239.2 # Server 2
        # ...
        - 174.136.239.254 # Server N

  steps:
    - name: Checkout
      uses: actions/checkout@v2
      with:
        ref: certs

    # 上传证书
    - name: Upload certificate to server
      uses: easingthemes/ssh-deploy@v2.1.5
      env:
        SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
        ARGS: '-avz --delete'
        REMOTE_HOST: ${{ matrix.host }}
        REMOTE_USER: ${{ secrets.REMOTE_USER }}
        SOURCE: ${{ env.CERTS_OUTPUT_DIRECTORY }}/
        TARGET: /path/to/ssl/certs/${{ env.CERTS_OUTPUT_DIRECTORY }}/

    # 重载 Nginx
    - name: Force-reload nginx
      uses: appleboy/ssh-action@v0.1.4
      with:
        host: ${{ matrix.host }}
        username: ${{ secrets.REMOTE_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        script: |
          sudo /opt/hooks/reload-nginx.sh

需要注意的是,重载 Nginx / Apache 的命令需要 root 权限才能执行,可以采用只允许部署用户以 root 权限执行重载脚本的方式来避免出现安全问题。

/opt/hooks 目录下新建一个文件 reload-nginx.sh,内容如下:

#!/bin/bash
sudo systemctl force-reload nginx

然后新建一个名为 actions-cert 的用户,然后在 /etc/sudoers 文件中添加以下内容:

actions-cert ALL=(ALL) NOPASSWD: /opt/hooks/reload-nginx.sh

这个配置可以使 actions-cert 用户免密码以 root 用户的权限执行 /opt/hooks/reload-nginx.sh

最后使用 chmod 755 /opt/hooks/reload-nginx.sh 命令将 reload-nginx.sh 文件设置为可执行,同时禁止非所有者对其进行写入操作。

如果服务器位于 NAT 后,或者禁止了 SSH 连接,还有两个方法可以将证书部署到内网服务器上:

  1. 将证书先部署到有部署条件的服务器上,然后再在内网服务器上使用 rsync 从部署好的服务器上拉取证书。
  2. 将证书上传到 Azure Key Vault 等托管服务中,再在服务器上按照 Menci 的文章 中的教程拉取即可。

阿里云

阿里云的 SSL 证书服务 支持上传自定义证书,该证书可以用于 阿里云 CDN。阿里云暂未提供将证书部署至 OSS 的 API,建议 OSS 用户使用 CDN 回源 OSS 来代替。

使用 Menci/deploy-certificate-to-aliyun 将证书部署到阿里云:

# 部署到阿里云
deploy-to-aliyun:
  name: Deploy Certificate to Aliyun
  runs-on: ubuntu-latest
  needs: issue-ssl-certificate

  steps:
    # 拉取证书存储分支
    - name: Checkout
      uses: actions/checkout@v2
      with:
        ref: certs

    # 上传证书
    - name: Deploy certificate to aliyun
      uses: Menci/deploy-certificate-to-aliyun@beta-v1
      with:
        access-key-id: ${{ secrets.ALIYUN_ACCESS_KEY_ID }}
        access-key-secret: ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }}
        fullchain-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
        key-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}
        certificate-name: example.com
        cdn-domains: |
          example.com
          example.net

其中 certificate-name 指定上传的证书在证书服务中的名称(将自动替换旧版本),cdn-domains 指定需要将该证书部署到的 CDN 域名列表(用空白字符隔开)。

建议使用子账户 Access Key,为其赋予以下权限(并按需使用资源组隔离):

  • AliyunYundunCertFullAccess
  • AliyunCDNFullAccess
  • AliyunPCDNFullAccess
  • AliyunSCDNFullAccess
  • AliyunDCDNFullAccess

腾讯云

使用 renbaoshuo/deploy-certificate-to-tencentcloud 将证书部署至腾讯云 CDN:

deploy-to-qcloud-cdn:
  name: Deploy certificate to Tencent Cloud CDN
  runs-on: ubuntu-latest
  needs: issue-ssl-certificate

  steps:
    - name: Check out
      uses: actions/checkout@v2
      with:
        # If you just commited and pushed your newly issued certificate to this repo in a previous job,
        # use `ref` to make sure checking out the newest commit in this job
        ref: ${{ github.ref }}

    - uses: renbaoshuo/deploy-certificate-to-tencentcloud@v1
      with:
        # Use Access Key
        secret-id: ${{ secrets.QCLOUD_SECRET_ID }}
        secret-key: ${{ secrets.QCLOUD_SECRET_KEY }}

        # Specify PEM fullchain file
        fullchain-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
        # Specify PEM private key file
        key-file: ${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}

        # Deploy to CDN
        cdn-domains: |
          cdn1.example.com
          cdn2.example.com

其中 cdn-domains 指定需要将该证书部署到的 CDN 域名列表(用空白字符隔开)。

建议使用子账户 API 密钥,为其赋予以下权限(并按需使用资源组隔离):

  • QcloudCDNFullAccess

自建 GoEdge CDN

使用 renbaoshuo/deploy-certificate-to-goedge 将证书部署至自建的 GoEdge CDN:

deploy-to-goedge-cdn:
  name: Deploy certificate to GoEdge CDN
  runs-on: ubuntu-latest
  steps:
    - name: Check out
      uses: actions/checkout@v2
      with:
        # If you just commited and pushed your newly issued certificate to this repo in a previous job,
        # use `ref` to make sure checking out the newest commit in this job
        ref: ${{ github.ref }}
    - uses: renbaoshuo/deploy-certificate-to-goedge@beta-v1
      with:
        # GoEdge API endpoint
        api-endpoint: https://cdn.api.baoshuo.dev

        # Use Access Key
        access-key-type: user
        access-key-id: ${{ secrets.GOEDGE_ACCESS_KEY_ID }}
        access-key: ${{ secrets.GOEDGE_ACCESS_KEY }}

        # GoEdge certificate ID
        cert-id: ${{ secrets.GOEDGE_CERT_ID }}

        # Specify PEM fullchain file
        fullchain-file: ${{ env.FILE_FULLCHAIN }}
        # Specify PEM private key file
        key-file: ${{ env.FILE_KEY }}

注:在部署前需要手动上传一次证书以便获取证书 ID。证书 ID 可以在「证书文件下载」处的 URL 参数中找到。

完整例子

这个 Action 完成了以下操作:

  1. 申请证书,并上传到仓库的 certs 分支。
  2. 在申请证书后将 certs 分支中的证书部署到服务器上。
# 名称
name: Issue SSL Certificates

# 触发条件
on:
  # 手动运行
  workflow_dispatch:
  # 定时运行
  schedule:
    # 每两个月运行一次
    - cron: '0 0 1 */2 *'

# 全局环境变量
env:
  # Checkout 到的目录
  CERTS_OUTPUT_BASE: certs
  # 证书输出目录
  CERTS_OUTPUT_DIRECTORY: example.com
  # 证书文件名
  FILE_FULLCHAIN: fullchain.pem
  # 私钥文件名
  FILE_KEY: privatekey.key

jobs:
  issue-ssl-certificate:
    # 申请证书并 push 到 certs 分支
    name: Issue SSL certificate
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          ref: master

      - name: Checkout output branch
        uses: actions/checkout@v2
        with:
          ref: certs
          path: ${{ env.CERTS_OUTPUT_BASE }}

      # 安装 acme.sh
      - name: Install acme.sh
        shell: bash
        run: curl -s https://get.acme.sh | sh

      # 解压 acme.sh 配置信息
      - name: Extract account files for acme.sh
        shell: bash
        run: |
          echo "$ACME_SH_ACCOUNT_TAR" | base64 -d | tar -C ~/.acme.sh -xz
        env:
          # Base64 编码的 acme.sh 配置信息
          ACME_SH_ACCOUNT_TAR: ${{ secrets.ACME_SH_ACCOUNT_TAR }}

      # 申请证书
      - name: Issue SSL certificates
        shell: bash
        run: |
          ~/.acme.sh/acme.sh --issue            \
            -d "example.com" -d "*.example.com" \
            --dns dns_cf --server letsencrypt

      # 导出证书
      - name: Copy certificate to output paths
        shell: bash
        run: |
          ACME_SH_TEMP_DIR="$(mktemp -d)"
          ACME_SH_TEMP_FILE_FULLCHAIN="$ACME_SH_TEMP_DIR/fullchain.pem"
          ACME_SH_TEMP_FILE_KEY="$ACME_SH_TEMP_DIR/key.pem"

          # 不要忘记修改这里的 -d 参数值为上方的第一个域名
          ~/.acme.sh/acme.sh --install-cert -d "example.com" --fullchain-file "$ACME_SH_TEMP_FILE_FULLCHAIN" --key-file "$ACME_SH_TEMP_FILE_KEY"

          [[ -z "$ACME_SH_OUTPUT_FULLCHAIN" ]] || (mkdir -p "$(dirname "$ACME_SH_OUTPUT_FULLCHAIN")" && cp "$ACME_SH_TEMP_FILE_FULLCHAIN" "$ACME_SH_OUTPUT_FULLCHAIN")
          [[ -z "$ACME_SH_OUTPUT_KEY" ]] || (mkdir -p "$(dirname "$ACME_SH_OUTPUT_KEY")" && cp "$ACME_SH_TEMP_FILE_KEY" "$ACME_SH_OUTPUT_KEY")

          rm -rf "$ACME_SH_TEMP_DIR"
        env:
          ACME_SH_OUTPUT_FULLCHAIN: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_FULLCHAIN }}
          ACME_SH_OUTPUT_KEY: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}/${{ env.FILE_KEY }}

      # 上传证书
      - name: Push to GitHub
        run: |
          git config --global user.name "BaoshuoBot"
          git config --global user.email "79077260+BaoshuoBot@users.noreply.github.com"

          cd "$CERTS_DIRECTORY"

          git add "$FILE_FULLCHAIN" "$FILE_KEY"
          git commit -m "Upload certificates on $(date '+%Y-%m-%d %H:%M:%S')"
          git push
        env:
          TZ: Asia/Shanghai
          CERTS_DIRECTORY: ${{ env.CERTS_OUTPUT_BASE }}/${{ env.CERTS_OUTPUT_DIRECTORY }}

  # 部署证书到服务器
  deploy-to-server:
    name: Deploy Certificate to Server
    runs-on: ubuntu-latest
    needs: issue-ssl-certificate

    strategy:
      matrix:
        host:
          - 174.136.239.1 # Server 1
          - 174.136.239.2 # Server 2
          # ...
          - 174.136.239.254 # Server N

    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          ref: certs

      # 上传证书
      - name: Upload certificate to server
        uses: easingthemes/ssh-deploy@v2.1.5
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          ARGS: '-avz --delete'
          REMOTE_HOST: ${{ matrix.host }}
          REMOTE_USER: ${{ secrets.REMOTE_USER }}
          SOURCE: ${{ env.CERTS_OUTPUT_DIRECTORY }}/
          TARGET: /path/to/ssl/certs/${{ env.CERTS_OUTPUT_DIRECTORY }}/

      # 重载 Nginx
      - name: Force-reload nginx
        uses: appleboy/ssh-action@v0.1.4
        with:
          host: ${{ matrix.host }}
          username: ${{ secrets.REMOTE_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            sudo /opt/hooks/reload-nginx.sh

杂项

部分情况下,GitHub Actions 中的 GITHUB_TOKEN 只有 Read repository contents permission,而本文中的 Actions 要求这个 Token 具有 Read and write permissions,那么需要在仓库的 Settings > Actions > General 页面的底部赋予其写入权限,如图所示:

设置好后点击 Save 按钮即可。

参考资料

  1. 使用 GitHub Actions 自动申请与部署 ACME SSL 证书,Menci,2022 年 5 月 11 日。对原文章内容的使用已经过作者同意。
  2. 使用 acme.sh 配置自动续签 SSL 证书,烧饼博客,2022 年 2 月 3 日。

文章头图由 Menci 制作,使用已经过授权,在此表示感谢。

2022 年常中集训游记

作者 宝硕
2022年2月28日 17:03
2022 年常中集训游记

这是我第一次出省参加外校集训。此行的目的地是江苏省常州高级中学,于 1907 年建校,截止本文写作时该校在「OIerDb 全国信息学竞赛学校排行榜」上位列第九。

启程

走前 2 天,也就是 2 月 18 日,我们才正式确定要动身去常中参加集训,当时火车票余票已经所剩无几了,索性直接买了一等座,省时省力,就是有点费钱。

一等座确实比二等座舒服了很多,还有免费的零食、饮料、矿泉水。不过,要是下次可以自己挑的话,我还是选二等座,经济实惠。

晚饭在火车上买了份「豚骨面」,花了 39 元,除了汤有点发酸(可能是醋倒多了)以外别的没啥毛病。

见闻

住宿

由于学校没有给我们提供宿舍,所以我们两人成团一起住宾馆,每间 160 元/晚。

环境还行,凑合着住。

食堂

▲ 外景

▲ 饭卡

食堂的餐食比假期的二南好多了,但我觉得赶不上开学时候的二南食堂。

蹭网

常中并没有给我们分配上网账号,所以要么用打开文件资源管理器都能死机的台式机上网,要么用自己的笔记本离线操作。

毛主席说过:「自己动手,丰衣足食。」所以我们把台式机的网线拔下来插到了自己的电脑上,并且手动配好了 IP 地址,成功解决了上网问题。

蹭网期间出一个小插曲:有人拿自己配好的网开热点给大家共享网络,结果被系统自动断网并且封掉设备了…

校园

进入校园。

标志性的「SCZ」标识。

校园一角。

教学楼外景。

小亭子。

学校旁边的 天宁宝塔

返程

来的时候在火车上吃的是面条,于是返程的时候买了一盒「红烧牛肉饭」,花了 40 元,味道也不错。

花絮

回学校的最后一段路是坐公交回去的。

到站了,老师带着我们下了车,下车后才发现 zzq 还在车上,于是我们眼睁睁地看着公交车继续前行,而 zzq 还不知道他已经坐过站了…

后记

在校期间的学习、交流等活动均属保密范畴,在本文中不做叙述。

希望以后还能有这么好的机会参加外出集训。

浅谈跨域资源共享(CORS)

作者 宝硕
2021年10月2日 22:24
浅谈跨域资源共享(CORS)

跨域资源共享(CORS)是一种基于 HTTP 头来让网页的受限资源能够被其他域名的页面访问的一种机制。通过该机制,页面能够自由地使用不同源(cross-origin)的图片、样式、脚本、iframes 以及视频。

在通常情况下,一些跨域的请求(特别是 ajax)会被同源策略(same-origin policy)禁止。而 CORS 定义了一种方式,可以允许 Web 应用服务器进行跨源访问控制,从而使得跨源数据传输得以安全进行。


目前几乎所有现代浏览器都支持 CORS ,可以在 MDN 上找到有关浏览器兼容性的信息。

浏览器将 CORS 请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。这两个术语并不属于 Fetch 规范。

简单请求

某些请求不会触发 CORS 预检请求。本文中称这样的请求为「简单请求」。

定义

若请求满足所有下述条件则该请求可被视为「简单请求」:

  1. 请求方法为 HEADGETPOST
  2. 除了被用户代理自动设置的字段以及在 Fetch 规范中被定义为 禁用头名称 的字段之外,HTTP 头信息只允许包含 Fetch 规范定义的 对 CORS 安全的首部字段集合
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type 仅限于三个值:application/x-www-form-urlencodedmultipart/form-datatext/plain
  3. 请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
  4. 请求中没有使用 ReadableStream 对象。

简单请求的设计是为了兼容表单(form),因为历史上表单就一直可以发出跨域请求。

基本流程

对于简单请求,浏览器会直接发出 CORS 请求。具体来说,就是增加一个名为 Origin 的字段到 HTTP 头中。

GET /cors HTTP/1.1
Origin: http://foo.example
Host: foo.example
Accept-Language: zh-CN
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin 字段用来说明本次请求来自哪个源,服务器端根据这个值决定是否同意这个请求。

如果 Origin 指定的源在许可范围内,服务器返回的响应头会添加以下几个字段:

  • Access-Control-Allow-Origin 字段表明服务器允许的请求源,其值要么为请求时 Origin 字段的值,要么为 *
  • Access-Control-Allow-Credentials 字段表明服务器是否允许发送凭据信息,该字段是可选的,默认情况下不允许发送凭据信息。
  • Access-Control-Expose-Headers 字段表明服务器指定的允许获取的 HTTP 头字段,该字段是可选的。

如果 Origin 指定的源不在许可范围内,服务器会返回一个不带 Access-Control-Allow-Origin 字段的正常的 HTTP 回应。当浏览器发现没有包含这个字段就知道请求出错了,会抛出一个异常。需要注意的是,这种错误的 HTTP 响应码有可能是 200 或 204 ,因此无法通过状态码识别。

代码示例

fetch('https://baoshuo.ren', {
  mode: 'no-cors',
});

非简单请求 —— 预检请求

上面提到,CORS 请求除了简单请求外还有非简单请求。简单来说,非简单请求时对服务器有特殊要求的请求,比如请求方法是 PUTDELETE ,或者 HTTP 头中 Content-Type 字段的值不是上文所述的那三个「对 CORS 安全的 Content-type 字段值」。

基本流程

非简单请求的 CORS 请求,会在正式通信之前增加一次称为「预检」(preflight)的 HTTP 查询请求。

从上面的报文中可以看到,浏览器先发送了一个使用 OPTIONS 方法的「预检请求」。OPTIONS 是 HTTP/1.1 协议中定义的方法,用以从服务器获取更多信息。该方法不会对服务器资源产生影响。预检请求中同时携带了下面两个首部字段:

Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
  • Access-Control-Request-Method 字段将告知服务器实际请求将要使用的方法。
  • Access-Control-Request-Headers 字段将告知服务器实际请求将要携带的自定义请求首部字段。

服务器将据此决定是否允许实际请求,并返回相应的响应。

Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
  • Access-Control-Allow-Origin 字段与简单请求时并无差异。
  • Access-Control-Allow-Methods 字段表明服务器允许哪些方法发起请求。
  • Access-Control-Allow-Headers 字段表明服务器允许请求头中携带的额外字段。
  • Access-Control-Allow-Credentials 字段与简单请求时并无差异。
  • Access-Control-Max-Age 字段表明该响应的有效时间,在有效时间内浏览器无须为同一请求再次发起预检请求。需要注意的是浏览器自身维护了一个最大有效时间,如果该字段的值超出了浏览器维护的最大有效时间则不会生效。

如果服务器「否定」了一个预检请求,也会返回一个正常的 HTTP 回应,但不包含任何与 CORS 相关的 HTTP 头信息字段。此时浏览器就会认定服务器不同意预检请求,并抛出一个错误。

一旦通过了预检请求,接下来的步骤就都和简单请求一样了,此处不过多赘述。

代码示例

fetch('https://baoshuo.ren', {
  mode: 'cors',
});

附带身份凭证的 CORS 请求

上文中提到,CORS 请求默认不发送凭据信息(Cookie 和 HTTP 认证信息),如果要向服务器发送凭据,不仅需要服务器指定 HTTP 头的 Access-Control-Allow-Credentials 字段,还需要在请求时指明是否发送凭据信息。

代码示例

使用 XmlHttpRequest 向服务器发起 CORS 请求时,需要将 withCredentials 标志设置为 true

var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://baoshuo.ren', true);
xhr.withCredentials = true;
xhr.onreadystatechange = handler; // 此处的 Handler 需要自行定义
xhr.send();

使用 fetch 进行请求时则需要设置 credentialsinclude 才能使浏览器向跨域源发送包含凭据的请求。

fetch('https://baoshuo.ren', {
  credentials: 'include',
});

与 JSONP 的比较

CORS 与 JSONP 的使用目的是相同的,但是 CORS 比 JSONP 更强大。

JSONP 的缺点是只支持 GET 请求,而 CORS 则支持所有类型的 HTTP 请求。如果网站需要兼容老式浏览器或者需要向不支持 CORS 的网站请求数据仍然需要使用 JSONP 。

参考资料

  1. 跨源资源共享(CORS),MDN Web Docs,2021 年 8 月 8 日。
  2. 跨域资源共享 CORS 详解,阮一峰的网络日志,2016 年 4 月 12 日。
  3. 跨域资源共享,维基百科,2021 年 5 月 3 日。
  4. 3.2. CORS protocol,Fetch Standard,2021 年 9 月 30 日。
  5. 参数,WorkerOrGlobalScope.fetch(),MDN Web Docs,2021 年 9 月 1 日。
❌
❌