从 CSS 字符串到 AST(一)—— 词法分析器(Lexer)的实现

最近在实习的时候,遇到了一些需求,需要自己去实现 CSS 的解析、(伪)渲染流程。以之为契机,我学习了一下编译相关的知识,其中的第一环就是 Lexer。 本文中的代码均使用 Go 实现,成果已经作为 Go 库 go.baoshuo.dev/csslexer 发布。 GitHub 仓库:renbaoshuo/go-css-lexer 实现标准:CSS Syntax Module Level 3 (W3C Candidate Recommendation Draft; 24 December 2021) 建议在阅读本文前对 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、[]byte 和 io.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 方法拆分成了 Current 和 Shift 两个不同的方法,以便使用。 除此以外,在解析的时候还有需要在满足某一特定条件下一直 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。 u 或 U:这是一个特殊前缀。如果其后是 + 紧跟着十六进制数字或 ? (例如 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 生成。

2025/8/5
articleCard.readMore

向着璀璨的未来进发 —— 我的 2024 年度总结

即使是沉重的过去,也要接受它再继续向前迈进。 2024 年就这样过去了。上半年发生的一系列事情对我仿佛一场梦一样,而下半年我才回到真实的生活中。 前一阵子有读者来询问我的近况,同时进行了一个博客更新的催。其实我有写几篇草稿,不过中途废弃了一些,另一些还没有完成,所以一整年都没有产出。这篇总结中也会截取弃稿中一些完成度比较高的部分分享给各位读者。 高考 备战高考是我今年上半年的主旋律。从 2023 年 5 月 4 日正式回班,到 2024 年 6 月 6 日(高考前一天),刚好经历了整整四百天。 从起初的三百余分,到高考的 615 分,我实现了在旁人看来几乎不可能的飞跃。我没有任何理由不为我自己的巨大进步而感到自豪。我在刚出考场时的估分恰好就是 615 分,这么准确的估分我觉得也可以吹很久了。总的来说,我用自己的实力与汗水,狠狠地打了某些幻想看我过不了本科线的笑话的 “学生” “老师” 和 “家长” 的脸。 在实在学不下去的时候,由于没有任何的娱乐设施,我只能通过写文章来做一些与学习无关的事情。受精神状态影响,这些文章的行文思路可能不是很正常,表达的情感也会比较激烈,还望各位读者海涵。 《从零开始的异世界文化课生活》 在一些比较特殊的日子里,我会用日记的形式来记录近况。《从零开始的异世界文化课生活》是一篇日记选集,笔者从中再次截取了一部分比较有代表性的内容。 节选一 竞赛机房的灯熄灭了。我心中的火,也骤然灭了。笃行楼陷入了黑暗之中。我踩着黑暗,一步步地走向楼下。这段熟悉地不能再熟悉的路,我已经走了许多年。 在昏暗的夜色中,教学楼的灯光确实格外璀璨,无数学子正在其中奋笔疾书,追逐属于他们的梦想。可惜这梦想并不属于我,我的梦想已然破灭。 我背着书包,走向了教学楼,再也没有回头…… ▲ 从存真楼旧竞赛机房向远处望去。摄于 2022 年 6 月。 ▲ 从实验楼五楼向下望去。摄于 2022 年 2 月。 节选二 很多人都说竞赛生们个个智商超群,学习能力异于常人,可我却不这么认为。站在聚光灯下的人终究是那一小部分,从总体上来看,大部分人还是陪跑的选手。很不幸,我就在陪跑之列。竞赛招生时,教练们口中的「半个月逆袭登顶」的神话也不是人人都能书写的。 我终究还是要独自面对这冰冷残酷的现实。 回到班后,我发现教室内甚至已经没有了我的座位 —— 只有花名册上的名字证明着我属于这个班。甚至考试的时候都不会给我分配考场。我确实成为了一个透明人。一张张陌生的面孔,一个个陌生的名字,一阵阵陌生的声音…… 我虽然回到了我自己的班级,但却没有得到任何归属感。 节选三 虽然学习是一名高中生的本职工作,但是我仍然要指出,我并不喜欢囿于应试教育的牢笼之中。每天机械地刷题、考试,于宿舍、食堂和教室三点一线间往返,并非我所向往的生活。我希望我能够将青春的火焰燃烧在我所热爱的事业上,而不是在一摞摞试卷中消磨殆尽。 一次次的模拟考试,每次我的名字都会出现在成绩单的末尾。在一个几乎人人都能上 600 分的班级里面,我三四百分的成绩显得格外突兀。 相比于大多数同学们的包容,数任班主任和年级主任都有想过把我从省理科实验班里面给踢出去。好在到最后也没有成功。能够留在这个班里,算是不幸中的万幸。他们可否想过:如果我真的离开了这个班级,那我该怎么重新适应一个新的环境,该怎么重新接纳一批新老师的讲课风格,又该怎么与一批新的同学相处?我猜,他们并没有想过。 ▲ 高三时的教室。摄于暑假放假后,此时同学们都已把自己的书本搬回了家,准备接下来的线上学习。 节选四 高考前一百天左右,我的座位被固定了下来。 我坐在教室的倒数第二排,靠窗户的位置。有句话叫「后排靠窗,主角故乡」,希望能够在我身上应验。 另外,靠后的位置更有利于我自由发挥 —— 毕竟我的进度和班里面其他人的进度还是不太一样的,所以课上内容不能全听。 坐在这里学累了能够转向窗外看风景也是一件美事啊~ 节选五 我站在天台上,冷风直吹着我的脸庞。向下望去,大地突然显得十分亲切,仿佛在等待着我的造访。独处一会,我最终还是选择从楼梯走下去。我不知道我是在逃避什么,还是在寻找什么。我只知道,我不能就这样放弃。 ▲ 黄昏时的天台外景。我是从忘记锁门的维修通道溜上去的,那段时间学校在烫房顶。 《四百天的疯狂之后》(节选) 此文写于高考期间。节选中删去了一些琐碎的细节部分。 四百天很长,能让我从一名小白变成一名可以安稳面对高考的成熟学生。四百天也很短,回班的日子仿佛还是昨天。 明天就要高考了,而我还坐在这里写文章。心里是不是该有愧疚感呢?我不知道。不过我觉得再继续临时抱佛脚也只能够让自己乱了阵脚罢了。 尽人事,听天命。还有那不能缺席的:高考加油! 高考第一天。 上午的语文考试只能说是正常发挥,没有什么特别的感觉。因为还有四科考试,所以也没有和别人对答案。 下午的数学考试是题型改革后的第一次高考。前面的选择填空感觉还好,第一道大题是解三角形,也比较常规。但是到了第二道大题,炸裂的就来了 —— 圆锥曲线放到第二题考,打了个措手不及。要知道,我对自己的定位是丢弃掉导数和圆锥曲线这两类大题的。但是放到第二道大题的位置,理论上不应该不拿这分。所以我只能硬着头皮做了下去,结果没做出来。接下来的几道大题也并不顺利,一道做完的都没有(好像其他同学也差不多)。不过十九题做了两问,在同学们里面算做得比较多的了。 晚上由于大家的数学都很稀碎,班里面基本上没有多少动笔的声音,许多人都在发呆。班主任老师见状紧急开会平复大家的心情,以免影响次日的考试。 (对答案后补充:选择和填空加起来应该只扣了两分。第十九题写了的两问全部正确。感觉能 120+ 了) (出分后补充:真的考了 120 分欸) 高考第二天。 上午的物理答题意外地顺利,除了第一道大题卡了三分钟和本来就没想着做的最后一道大题以外,其他的题目都莫名其妙地顺利。出了考场以后我甚至跟老师自信地说能上 90 分(出分后补充:没上,只考了 72 分)。于是立马兴奋了起来,昨天数学带来的阴霾一扫而空。 下午的英语考试感觉自己彻底进入了状态。答完卷子以后发现还剩四五十分钟,遂捂住之前的答案再做了一遍。做完后发现有三处不一样的。仔细检查后修改了其中的两处。出考场后告知任课老师这个情况的时候她还安慰我,让我不要慌,这个是正常现象。(出分后补充:考了 137 分) 晚上又看了看化学。生物明天考完化学再看。 化学好难。不是说一年比一年简单的吗?怎么今年考完化竞生都落泪了。抱着老师哭的都出来了…… 考生物的时候,我的笔速愈发加快,花了不到一个小时就做完了。然后就是等待高考结束了。这个时候的我已经没有丝毫检查的欲望了,只想快点结束这该死的考试回家睡大觉。 出考场,大概估了一下,应该能考 615 分左右吧,就会这些了。 ▲ 高考结束后家委会送来的小蛋糕。 就这样吧。 志愿填报 在近一个星期紧张刺激的志愿填报过后,我根据自己的成绩、结合目标院校层次,筛选出了 96 个志愿 —— 卡着志愿填报的上限 —— 作为我的最终填报志愿。 根据我的期望,最终录取志愿会落在 40 ~ 60 序号的志愿上。等到录取的那天,面对迟迟没有动静的河北省教育考试院系统,我转向了各个大学的招生办的录取查询系统进行查询,最终在第 45 号志愿所对应的福州大学的查询系统上查询到自己被录取到了计算机科学与技术专业。 不久后(但是依然是焦急的等待),我就收到了录取通知书: ▲ 福州大学录取通知书外封套。从福州发出到石家庄签收全程只花了十几个小时。 虽然我知道,在这个人均 985 的时代,一张 211 的文凭已经不算什么了。但是我仍为我能通过自己的努力考上一所好学校而感到自豪。 大学 8 月 30 日,我从石家庄启程,经由南京转机,最后抵达了福建福州,准备开始我的大学生活。 24 年的下半年可以说是非常顺畅的: 考上了一所好大学。 遇到了一群好老师。 分到了几位好舍友。 此外,我还加入了福州大学西二在线工作室(校计算机协会),遇到了一群有着相同爱好的好朋友们。 下面是日常随拍分享时间: ▲ 福州大学铜盘校区教学楼。摄于 2024 年 9 月 5 日。 ▲ 福州福山郊野公园。 ▲ 福州西湖公园。 ▲ 落日。 ▲ 从鼓山上俯瞰福州城。摄于 2024 年 12 月 20 日。 ▲ 从鼓山上夜瞰福州城。摄于 2024 年 12 月 20 日。 ▲ 贵安欢乐世界一角。摄于 2024 年 12 月 31 日。 追番 看过 请问您今天要来点兔子吗?系列 请问您今天要来点兔子吗?(第一季) 请问您今天要来点兔子吗??(第二季) 请问您今天要来点兔子吗??~Sing for You~(剧场版) 不时轻声地以俄语遮羞的邻座艾莉同学 亚托莉 -我挚爱的时光- (剧场版)间谍过家家 代号:白 葬送的芙莉莲 想看 请问您今天要来点兔子吗?BLOOM (剧场版)请问您今天要来点兔子吗??~Dear My Sister~ 魔女之旅 业余无线电 在大学生活之余,我还完成了我中学时的一个愿望 —— 考取业余无线电台操作证,成为一名 HAM。 若要了解更多信息,请访问网址:https://baoshuo.ren/bd3rnw/ 后记 过去的 2024 年总体上来说还是比较顺利的。不过上了大学以后各种事情明显变多了,搞得年终总结都拖了好久😂。 最后,祝大家新的一年里身体健康,万事如意!

2025/1/28
articleCard.readMore

愿此去前路皆坦途 —— 我的 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,但尚未完工。 后记 在新的一年里,我会继续冲刺高考,争取考一所好大学。同时也在此感谢读者们对我的关心,不过由于我长期住校,故评论、邮件等可能不会及时回复,敬请谅解。 最后,祝大家新的一年里身体健康,万事如意!

2024/1/1
articleCard.readMore

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

在某些情况下,我们会遇到需要将网页打印出来的需求。但是,直接打印网页的效果往往不尽如人意,因为网页的排版和打印的排版是不同的。本文将介绍如何创建一个在打印时具有出色的质量和可读性的网页。 前置知识:@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 并尝试打印该页面。 ▲ 原网页 ▲ 打印效果(预览)

2023/5/28
articleCard.readMore

我的 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 得到与失去,只有时间会去评判;成功与失败,只有历史能去仲裁。 我不会永远成功,正如我不会永远失败一样。 —— 洪骥《……》 本文为原创文章,未经许可禁止任何形式的复制、摘抄与转载。

2023/4/2
articleCard.readMore

向 #define int long long 说不

TL;DR #define int long long 是一种未定义行为,尽量不要在代码中使用它。 前言 在算法竞赛社区中,经常能看见有人在代码中使用 #define int long long 来偷懒。我是一直极力反对这种做法的,因为这种做法会导致代码的可读性大大降低,并带来一些难以预料的问题。 C++ 标准 在 ISO/IEC 14882:2014(E) 的 17.6.4.3.1 Macro names 一节中,有这样一段描述: 翻译并整理一下,就是: 翻译单元不可 #define 或 #undef 词法上等同于下列部分的名称: C++ 中的关键字(表 4、表 5,在 2.12 节 Keywords [lex.key] 中给出); 有特殊含义的标识符(表 3,在 2.11 节 Identifiers [lex.name] 中给出); 任何标准属性记号(attribute-token,在 7.6 节 Attributes [dcl.attr] 中给出)。 也就是说,标准中 并不允许 #define int 这种操作。 编译器实现 GCC 在 GCC 的 C Preprocessor 文档中 给出了下面的说明: You may define any valid identifier as a macro, even if it is a C keyword. 也就是说,GCC 并没有严格按照标准来实现预处理器,而是稍微放宽了一些限制以允许通过这种方式来使得代码更加灵活,便于增强代码的向下兼容性。 Clang 相关文档中并未提及是否允许 define 关键字,但源代码中未见相关限制。 MSVC #define 指令 相关文档中并未提及。 后记 使用适当的数据类型来存储数据,有利于代码的可读性和稳定性,便于编写和调试。同时,正确设置变量类型也能提高程序的运行速度和效率。因此,我们应该做好正确的数据类型定义,而不是在编写代码时滥用 #define int long long。

2023/2/8
articleCard.readMore

再见,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/31
articleCard.readMore

我的新冠阳性日记

一直以为「感染新冠」这件事离我很远,像我这种居家上网课的人也不太可能与外面的病毒产生什么联系。直到 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 颗),因此无需囤积药品,够用就好。 最后,希望各位读者在疫情期间保护好自己,也祝大家身体健康,百毒不侵!

2022/12/23
articleCard.readMore

USTC Hackergame 2022 Write Up

签到 日常改参数。 猫咪问答喵 中国科学技术大学 NEBULA 战队(USTC NEBULA)是于何时成立的喵? 在这个页面中有这样一段介绍: 星云战队(Nebula) 中国科学技术大学“星云战队(Nebula)”成立于 2017 年 3 月,“星云”一词来自中国科学技术大学 BBS“瀚海星云”,代表同学们对科学技术的无限向往和追求。战队现领队为网络空间安全学院吴文涛老师,现任队长为网络空间安全学院李蔚林、童蒙和武汉。战队核心成员包括了来自网络空间安全学院、少年班学院、物理学院、计算机学院等各个院系的同学,充分体现了我校多学院共建网络空间安全一级学科的特点。战队以赛代练,以赛促学,在诸多赛事中获得佳绩。 可知答案为 2017-03。 22 年坚持,小 C 仍然使用着一台他从小用到大的 Windows 2000 计算机。那么,在不变更系统配置和程序代码的前提下,Firefox 浏览器能在 Windows 2000 下运行的最后一个大版本号是多少? Google 搜索 windows 2000 firefox 可以搜索到一个帖子:Last version of fireFox to work on Windows 2000?,可知答案为 12。 你知道 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 还是一如既往地稀烂……

2022/10/30
articleCard.readMore

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__2col-img{display:flex;justify-content:space-between;width:100%;gap:.5rem}@media (max-width:768px){.oierdb-ng__2col-img{display:block}} ▲ 基础 / 高级搜索 ▲ 搜索页选手信息卡片 ▲ 地区信息学奥林匹克竞赛选手 / 学校排名 ▲ 学校 / 比赛详情页面 OIerDb NG 的不足之处 尽管 OIerDb NG 有了一个还算可以的开始,但仍然存在诸多不足之处。 例如,对于网络速度较慢的用户,加载数 MB 的数据可能仍需要十几秒甚至数十秒。并且,即使是一些小更新也需要重新从服务器拉取全量数据,对用户与服务器的流量都是一种浪费。 再比如一些用户可能需要指向性更强的查询条件,目前还没有找到一个比较好的办法来添加到用户界面中。 除了这些之外,还有一些其他的问题存在。这些问题由于团队内的各位开发者都在现实生活中有着自己的工作、学习任务,无法去逐一解决。笔者希望广大对信息学竞赛感兴趣的朋友们能或多或少地参与进 OIerDb NG 的开发,共同为信息学竞赛社区做出贡献。 后记 感谢 nocriz 建立的 OIerDb 网站,为国内的信息学竞赛社区做出了巨大贡献。 也感谢 yhx-12243、Menci 参与 OIerDb NG 的开发,完成了许多工作。 最后的最后,给 OIerDb-ng/OIerDb 求一波 Star~

2022/8/9
articleCard.readMore

拥抱 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 的几大问题: 全局命名空间 依赖 无用代码消除 代码压缩 共享常量 非确定性(Non-Deterministic)解析 隔离 虽然 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.card 和 styles.profileCard 调换一下顺序,过滤之后的样式就变成了这样: color: #000000; 但 CSS 中有一些简写规则,如果只按照规则名称来处理显然是不行的。有的库强制开发者们不使用简写规则来避免这个问题,而另外的一些库则将这些简写规则展开成多条规则后再进行过滤,比如 margin: 10px 可以被拆成 margin-top: 10px、margin-right: 10px、margin-bottom: 10px、margin-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 之外还有 style9 和 styleQ。 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 }); }, 对 TaggedTemplateExpression 和 CallExpression 的处理,正好对应了文档中的两种不同调用方式: // 模板字符串 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 文件中,这个函数主要完成了以下操作: 合并现有的 className; 处理 css prop 中的样式; 生成 compiled 的 Atomic CSS-in-JS 组件。 这样就将组件的 css 参数拆成了两部分 —— 静态的样式和附加到原组件的 className 参数值。 其他 微软最近开源的 Griffel 既支持运行时模式,又支持预编译模式,同时拥有着更佳的 TypeScript 支持,不失为一个好的选择。这个库目前驱动了微软官方的 Fluent UI。 后记 以上就是本文要介绍关于 Atomic CSS 的全部内容了。 虽然 Atomic CSS-in-JS 是 React 生态系统中新涌起的一股潮流,但在使用前一定要三思 —— 这个方案到底符不符合项目的需求,而不是盲目地「为了使用而使用」,给将来的维护工作埋雷,但如果使用它能带来显而易见的好处,那么何乐而不为呢? 笔者才疏学浅,只是在前人的基础之上做了一些微小的工作而已,文章中如有错误欢迎在评论区指正。感谢 Sukka 大佬在本文编写过程中的指导。感谢 Byran Lee 指出本文中的错误。 参考资料 Atomic CSS-in-JS,Sébastien Lorber,2020 年 4 月 27 日。 聊聊原子类(Atomic CSS),Mongkii,2021 年 7 月 26 日。 值得参考的 CSS 理论:OOCSS、SMACSS 与 BEM,ACGTOFE,2014 年 9 月 30 日。 Utility-First Fundamentals,Tailwind CSS。

2022/7/23
articleCard.readMore

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

对于一个有很多服务器的人来说,在不同服务器上同步 SSL 证书是一件麻烦事。笔者尝试过很多种方式,最后在 Menci 的推荐下选定了使用 GitHub Actions 来自动申请、续期 SSL 证书,并自动推送到各个服务器上。 本博客的证书也是使用这种方式进行签发、部署的,可以点击浏览器地址栏上的按钮查看证书。 申请证书 前期准备 首先请在本地(或自己的服务器上)成功使用 acme.sh 的 DNS-01 验证方式成功申请一次证书,如果不会操作的话可以参考 烧饼博客的教程 来进行。这个过程包括: 向 CA 注册 ACME 账户(如果使用 Let’s Encrypt 则会自动进行,详细步骤请参阅 acme.sh 的 Wiki)。 通过环境变量指定 DNS 提供商的凭据,用于添加/删除 ACME DNS-01 认证所需的 TXT 记录。 确认证书申请可以成功,为后续调试排除可能的问题。 第一次申请证书后,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 连接,还有两个方法可以将证书部署到内网服务器上: 将证书先部署到有部署条件的服务器上,然后再在内网服务器上使用 rsync 从部署好的服务器上拉取证书。 将证书上传到 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 完成了以下操作: 申请证书,并上传到仓库的 certs 分支。 在申请证书后将 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 按钮即可。 参考资料 使用 GitHub Actions 自动申请与部署 ACME SSL 证书,Menci,2022 年 5 月 11 日。对原文章内容的使用已经过作者同意。 使用 acme.sh 配置自动续签 SSL 证书,烧饼博客,2022 年 2 月 3 日。 文章头图由 Menci 制作,使用已经过授权,在此表示感谢。

2022/5/15
articleCard.readMore

强制卸载三星应用分身中的残留应用

前一阵子不小心手抖把手机里的 Chrome 卸载了,然后就装不上了。 ▲ 安装时的报错信息 这个迷惑的报错信息误导了我很长时间,再加上我在学校里拿不到手机,于是 3 月初的问题一直拖到了 4 月底才解决。 拿到手机之后我突然想看看手机里有什么不再需要的应用需要卸载,清理清理手机的存储空间。于是我翻到了这么一个应用: ▲ 应用图标右下角的橙色标志代表了这是一个安装在应用分身中的应用 但是我手机里的应用分身只能安装两个应用:微信和 QQ。所以问题应该就是出在了这里。我试着点击了下方的「卸载」按钮,但提示卸载成功并重启后,这个 Chrome 浏览器依然静静地呆在手机里,丝毫没有要离开的意思。 我在网上找到了一篇名为《技术 | One UI 不熔断,让应用分身双开任何应用》的文章,里面讲解了如何在应用分身中安装其他应用的方法,还提到了应用分身其实是以 Android 多用户的形式实现的,分身中的所有应用都安装在了名为 DUAL_APP 的用户下。知道了这些信息,事情就好办得多了,直接用 ADB 把它搞掉就行了(ADB 下载地址)。 在手机上开启开发者模式 后,在「开发者选项」中允许「USB 调试」,然后使用数据线将手机连接到电脑,这样就可以在电脑上通过 ADB 操控手机了。 使用下面的命令来查看手机中的用户列表: adb shell pm list users ▲ 图中被红色方框框住的就是应用分身对应的用户了 然后使用下面的命令来查看分身中的应用列表: adb shell pm list packages --user 95 ▲ 图中被红色方框框住的就是应用分身中的 Chrome 浏览器的包名了 那么就可以卸载分身中的 Chrome 浏览器了: adb uninstall --user 95 com.android.chrome 卸载完成后,就可以通过你喜欢的方式安装应用了。我这里同样使用了 ADB 来进行 Chrome 浏览器的安装操作: adb install "com.android.chrome_101.0.4951.41-495104123.apk" 最后不要忘记关掉「USB 调试」功能,以免产生安全风险。

2022/4/30
articleCard.readMore

2022 年常中集训游记

这是我第一次出省参加外校集训。此行的目的地是江苏省常州高级中学,于 1907 年建校,截止本文写作时该校在「OIerDb 全国信息学竞赛学校排行榜」上位列第九。 启程 走前 2 天,也就是 2 月 18 日,我们才正式确定要动身去常中参加集训,当时火车票余票已经所剩无几了,索性直接买了一等座,省时省力,就是有点费钱。 一等座确实比二等座舒服了很多,还有免费的零食、饮料、矿泉水。不过,要是下次可以自己挑的话,我还是选二等座,经济实惠。 晚饭在火车上买了份「豚骨面」,花了 39 元,除了汤有点发酸(可能是醋倒多了)以外别的没啥毛病。 见闻 住宿 由于学校没有给我们提供宿舍,所以我们两人成团一起住宾馆,每间 160 元/晚。 环境还行,凑合着住。 食堂 ▲ 外景 ▲ 饭卡 食堂的餐食比假期的二南好多了,但我觉得赶不上开学时候的二南食堂。 蹭网 常中并没有给我们分配上网账号,所以要么用打开文件资源管理器都能死机的台式机上网,要么用自己的笔记本离线操作。 毛主席说过:「自己动手,丰衣足食。」所以我们把台式机的网线拔下来插到了自己的电脑上,并且手动配好了 IP 地址,成功解决了上网问题。 蹭网期间出一个小插曲:有人拿自己配好的网开热点给大家共享网络,结果被系统自动断网并且封掉设备了… 校园 进入校园。 标志性的「SCZ」标识。 校园一角。 教学楼外景。 小亭子。 学校旁边的 天宁宝塔。 返程 来的时候在火车上吃的是面条,于是返程的时候买了一盒「红烧牛肉饭」,花了 40 元,味道也不错。 花絮 回学校的最后一段路是坐公交回去的。 到站了,老师带着我们下了车,下车后才发现 zzq 还在车上,于是我们眼睁睁地看着公交车继续前行,而 zzq 还不知道他已经坐过站了… 后记 在校期间的学习、交流等活动均属保密范畴,在本文中不做叙述。 希望以后还能有这么好的机会参加外出集训。

2022/2/28
articleCard.readMore

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

时光飞逝,2021 年就这么过完了。在这一年,我经历了很多,也学到了很多。现在,让我坐在电脑前,用文字的方式将这一切都记录下来… 本文内容按照发生时间先后排序。 大事记 封校的日子 2021 年 1 月 3 日,石家庄疫情 爆发,石家庄市进入战时状态(新华网)。 次日清晨,学校里传开了一条爆炸性新闻 —— 封校了! 中午下课后,大家纷纷奔向学校超市,开始抢购商品。由于初三下课最晚,等我们奔向超市的时候,超市空得连价签都没了。我并没有去抢,因为我在元旦假期返校的时候带了不少东西,足够我吃了。 然而超市一连好几天无货可卖,这使得我们都开始担心 —— 万一哪天没饭了怎么办?而这时学校终于传来了好消息:学校和政府终于为超市协调来了货源,可以继续上架面包、牛奶和生活必需品了。 不过我去食堂的时候倒是发现了一个以前从未出现的奇妙现象,那就是肉给的比菜多。我举一个典型事例:我去食堂买板面吃,食堂阿姨给我盛了半勺子肉,然后从旁边的菜盆里夹了三根很小的菠菜叶放到了碗里。除此之外,免费续米饭的量也变得越来越少。不过有的吃我就知足了,没必要那么挑。 周末学校还给放了电影,周日按照正常留宿作息多睡了会。封校的第一周就这么过去了。 谁也没想到封校会持续那么久,于是大家都在学校呆着,在学习的同时等待外界的好消息。 有一次教育处的尹主任值我们班晚自习,我正好坐第一排,作业写完了没事干,又去不了高中部,便和老师聊了起来。我向老师抱怨初三的学生觉不够睡,而初一的学生休息时间太多以至于睡不着觉,希望能多给初三的学生点休息时间,不然上课得困死。听完以后,老师给年级主任发了条微信。没想到,第二节晚自习下课,年级就广播说以后每周六都让初三的多睡一小时。多亏了尹主任的反馈,我终于能补个好觉了,上课也终于不用再半睡半醒了。 就这样又过了大概二十天,外面终于传来了好消息 —— 解封了! 听到解封的消息时,我的心情很复杂 —— 我的健康码是黄色的。这代表即使学校解封了,我也回不了家。 好在解封的第二天,我的健康码变回了绿色,当天下午我被接回了家,一切都结束了。 中考 6 月 21 日,我迎来了人生中的第一次大考 —— 中考。 说实话,头进考场我还是有一点点发慌的,因为我的知识大多都是退役以后现补的。物理的力学还没补,语文的古诗文刚背完原文没背课注和赏析,政治看都没看… 总之没好好复习就是了。就这样硬着头皮进了考场,考啥算啥吧。 考完以后便是漫长的等待。7 月 3 日 0 时,终于出分了: 中考试卷的难易度还算适中,考得不算特别差,全市三千多名。 由于疫情的原因,石家庄并没有举行体育和实验的考试,所以中考总分只有 610 分。这对于体育不是很好的我来说是一个救命稻草,要不然我得比别人低至少 10 分,就没学可上了。 中考成绩出来以后,我向我的竞赛教练咨询了一下我的成绩,教练告诉我没啥问题,能上二南。 居然混进了一个省重点高中… 失学儿童有学上了! 上了高中以后因为学习竞赛,被分到了省理科竞赛实验班,大概是全校最好的班级了吧。只不过因为班里其他人都是各地状元,所以我的文化课被同学们吊起来暴锤。 总体还行。只是没想到因为这些事就被别人称为了「别人家的孩子」,当不起当不起,毕竟我是真的菜… 竞赛 今年的竞赛成绩并不理想。 CSP 拿了个省二,再多考 19 分就有省一了。 NOIP 打算冲省一,结果考试的时候电脑蓝屏了… 以后再也不用画图当草稿纸了! 不过和自己的好朋友们在一起努力还是很开心的~ 丢人的练习详情可以在 GitHub 上查看:Compare 2020…2021 - renbaoshuo/OI-codes。 一些零碎 GPG 今年年初,我开始使用 GPG 来对我的消息和提交签名。 我的 GPG 公钥可以在 GitHub 上找到。 HUSTOJ 年初的时候给 HUSTOJ 贡献了两个主题:SYZOJ 和 MDUI。 因为 SYZOJ 主题被设置为了默认主题,所以年末的时候还被教练找上门问 HUSTOJ 相关的问题来了。 计算机网络 从寒假开始我开始接触计算机网络相关的知识,并且在 DN42 和公网上进行了相关实践。 Pure 主题 这个主题移植自 imhanjie/gridea-theme-pure。 源代码公开在 GitHub 上。 我的 Yubikey 体验良好,于是年末又入了个 CanoKey 作为备用 Key。 OIerDb NG 这是个从 12 月份刚刚开始搞的项目,目前正处于开发期,由我和 Menci 共同维护。 计划中 OIerDb NG 将会最终替代现在的 OIerDb。 OIerDb NG 的前端缓存式查询方法在提高查询速度的同时还省去了后端服务器的处理。 代码开源在 GitHub 上,欢迎 Star ~ 其他 其实里面有好多都是贡献给自己的私有仓库了。 可以直接去 我的 GitHub 主页 上看公开的贡献详情,懒得再多说了。 后记 2021 年一转眼就过完了,有很多新收获,同时也有很多遗憾,就不再多说了。 希望自己 2022 年会过得更好吧,也祝读者们新年快乐。 本文封面图片来自美国驻华使领馆官方推特账号,其他图片均为本人所拍摄或截取。

2021/12/31
articleCard.readMore