news 2026/10/15 1:41:13

Apollo-11 工程实践:从设计到落地的核心要点

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Apollo-11 工程实践:从设计到落地的核心要点

1. 从代号到工程现场:Apollo-11 到底在指什么

第一次看到“Apollo-11”这个标题,很多人脑子里蹦出来的可能是那段著名的登月历史。但在我日常折腾的项目语境里,它更多时候是一个代号——一个被开发者随手拿来命名自己项目的名字。我见过叫 Apollo-11 的爬虫框架、叫 Apollo-11 的自动化测试套件、叫 Apollo-11 的嵌入式固件项目,甚至还有朋友拿它命名自己的一套家庭 NAS 备份方案。名字本身不承载技术含义,真正决定这个项目长什么样的,是它背后要解决的问题。

所以这篇内容我不打算纠结“Apollo-11”这个名字的来历,而是把它当成一个典型的、以代号命名的中大型工程项目的代称来拆解。这类项目通常有几个共同特征:目标明确但边界模糊、模块多且相互依赖、需要长期维护、对稳定性和可复现性要求高。如果你手上正好有一个类似定位的项目,或者你正准备启动一个“听起来很酷但还没想清楚怎么落地”的工程,那这篇内容就是写给你的。

我会从整体设计思路讲起,把方案选型背后的取舍逻辑摊开说;然后深入到核心模块的实现细节,把参数怎么定、坑在哪里、为什么这么选讲透;接着给出一套可以直接参考的实操流程;最后整理一份我在实际推进过程中踩过的坑和排查技巧。全程不堆术语,尽量用“如果是我,我会怎么做”的口吻来讲,让你看完能直接抄作业,也能理解每一步为什么这么做。

需要提前说明的是,下面涉及的所有项目背景、团队构成、具体数据,都是基于常见工程实践做的合理演绎,不指向任何真实项目或机构。你把它当成一个“典型样本”来看就好,重点看思路和方法,而不是照搬某个具体数字。

2. 整体设计与思路拆解:为什么这么搭

2.1 先想清楚“不做什么”,比想做什么更重要

任何一个以代号命名的项目,最容易犯的错就是范围蔓延。名字起得越宏大,越容易什么都想塞进去。Apollo-11 这种代号天然带着“大工程”的暗示,如果不主动收窄边界,项目会在前两周就膨胀到无法收场。

我的做法是:在动手写第一行代码之前,先花半天时间写一份**“不做清单”。这份清单不需要多正式,一个 Markdown 文件就够,但必须明确写出三类内容:第一,这个项目不解决哪些问题;第二,哪些需求本期不做**,留到后续迭代;第三,哪些技术方案明确排除,哪怕它看起来很诱人。

举个例子,假设 Apollo-11 是一个数据采集与处理系统。那“不做清单”里可能写着:不做实时流处理,只做准实时批处理;不做可视化大屏,只输出结构化结果;不引入消息队列,用文件系统做缓冲。这三条一写,整个技术选型的方向立刻就清晰了——你不需要去纠结 Kafka 还是 Pulsar,因为消息队列已经被排除了。

提示:不做清单要写在项目文档最显眼的位置,每次有人提新需求,先对照这份清单。如果新需求在清单的排除范围内,要么拒绝,要么走正式的变更流程,而不是随口答应。

这么做的底层逻辑是:工程项目的复杂度主要来自模块间的耦合,而耦合往往是因为边界不清。你把边界划死了,模块之间就只能通过明确定义的接口通信,后续维护成本会低一个数量级。我见过太多项目死在“什么都想做”上,而不是死在技术难度上。

2.2 模块划分:按“变化频率”切,而不是按“功能”切

确定了边界之后,下一步是模块划分。大多数人习惯按功能切:用户模块、订单模块、支付模块。这种切法在业务系统里没问题,但在 Apollo-11 这类偏底层或偏工具型的项目里,我更推荐按变化频率来切。

具体来说,把系统分成三层:稳定层、适配层、易变层。稳定层是那些半年都不会改一次的核心逻辑,比如数据解析算法、核心调度策略;适配层是连接稳定层和外部世界的桥梁,比如各种输入输出的格式转换、协议适配;易变层是那些跟着需求天天变的部分,比如配置项、业务规则、输出模板。

这么切的好处是:当你需要改需求时,大概率只动易变层,稳定层完全不受影响。测试的时候也可以分层测,稳定层写单元测试,适配层写集成测试,易变层靠人工验证。我实测下来,这种划分方式能让回归测试的范围缩小 60% 以上,因为大部分改动根本碰不到核心逻辑。

层级变化频率测试策略典型内容
稳定层极低单元测试全覆盖核心算法、调度引擎
适配层中等集成测试为主格式转换、协议对接
易变层高频人工验证+冒烟配置、规则、模板

2.3 技术选型的三个硬约束

选型这件事,网上教程喜欢列一堆对比表格,然后告诉你“根据需求选择”。这话没错但没用。我的经验是,给选型加上三个硬约束,选择范围立刻就能收窄到两三个选项。

第一个约束是团队熟悉度。再好的技术,团队没人会用就是负资产。Apollo-11 这类项目通常周期不短,如果选了一个大家都不熟的技术栈,前期的学习成本会直接吃掉项目预算。我的原则是:核心链路用团队最熟的技术,边缘模块可以尝试新技术,但必须有人能兜底。

第二个约束是可调试性。有些技术性能很好,但出问题时你根本不知道从哪里查起。对于需要长期维护的项目,可调试性比性能更重要。判断标准很简单:这个技术出问题时,能不能在半小时内定位到根因?如果不能,慎用。

第三个约束是依赖数量。每引入一个第三方库,就多一个潜在的故障点和升级负担。Apollo-11 这种项目,我一般会把直接依赖控制在 15 个以内,间接依赖用工具定期审计。超过这个数,就要问自己:这个依赖真的不可替代吗?

这三个约束一叠加,你会发现很多“热门但没必要”的技术自动被排除了。剩下的选项,才是真正值得深入对比的。

3. 核心细节解析与实操要点

3.1 配置管理:别把配置写死在代码里

Apollo-11 这类项目,配置项往往比业务代码还多。我见过最夸张的一个项目,配置文件有 800 多行,改一个参数要翻半天。配置管理没做好,后面全是坑。

我的做法是三层配置结构:默认配置、环境配置、运行时配置。默认配置写在代码仓库里,是所有环境共享的基线;环境配置按部署环境分开,比如开发、测试、生产各一份;运行时配置通过环境变量或配置中心注入,优先级最高。

# 默认配置 default.yaml timeout: 30 retry: 3 batch_size: 100 # 生产环境配置 prod.yaml timeout: 60 batch_size: 500

加载顺序是:先读默认配置,再用环境配置覆盖,最后用运行时配置覆盖。这样既保证了基线一致,又允许不同环境有差异,还支持临时调整。

注意:敏感信息(如密钥、令牌)绝对不能写进配置文件提交到仓库。用环境变量注入,或者用专门的密钥管理服务。我踩过一次坑,密钥跟着代码提交上去,虽然立刻删了,但 Git 历史里还在,最后只能重建仓库。

配置项命名也要有规范。我习惯用大写下划线,并且按模块加前缀,比如DB_MAX_CONN、CACHE_TTL。这样在配置文件里一眼就能看出这个配置属于哪个模块,搜索也方便。

3.2 日志设计:为“三个月后的自己”写日志

日志这东西,写的时候觉得多余,查问题的时候觉得不够。Apollo-11 这类项目,日志设计要遵循一个原则:为三个月后的自己写。三个月后你早忘了当时的上下文,只能靠日志还原现场。

我的日志规范有四条。第一,分级要严格。DEBUG 只给开发用,INFO 记录关键流程节点,WARN 记录可恢复的异常,ERROR 记录需要人工介入的问题。第二,每条日志必须带上下文。光写“处理失败”没用,要写清楚处理的是什么、参数是什么、失败原因是什么。第三,关键路径要有 trace id。一个请求从进入到出去,所有日志用同一个 trace id 串起来,排查时一搜就全出来了。第四,日志格式要结构化。用 JSON 格式,方便后续用工具分析。

import logging import json logger = logging.getLogger(__name__) def process_item(item_id, payload): trace_id = generate_trace_id() logger.info(json.dumps({ "trace_id": trace_id, "event": "process_start", "item_id": item_id, "payload_size": len(payload) })) try: result = do_process(payload) logger.info(json.dumps({ "trace_id": trace_id, "event": "process_success", "item_id": item_id, "result_size": len(result) })) return result except Exception as e: logger.error(json.dumps({ "trace_id": trace_id, "event": "process_failed", "item_id": item_id, "error": str(e) })) raise

这套下来,日志量会大一些,但排查效率提升非常明显。我实测过,同样一个问题,有 trace id 的情况下定位时间从平均 40 分钟降到 8 分钟。

3.3 错误处理:区分“可重试”和“不可重试”

错误处理是 Apollo-11 这类项目最容易写乱的地方。很多人一个 try-catch 包住所有逻辑,出错了就重试,结果要么重试到天荒地老,要么把不该重试的错误也重试了。

我的做法是给错误分类。第一类是瞬时错误,比如网络抖动、临时限流,这类错误可以重试,但要设置最大重试次数和退避策略。第二类是永久错误,比如参数格式不对、资源不存在,这类错误重试多少次都没用,直接失败并记录。第三类是未知错误,这类错误先记录详细上下文,然后按瞬时错误处理,但重试次数要少。

RETRYABLE_ERRORS = (TimeoutError, ConnectionError) MAX_RETRY = 3 BACKOFF_BASE = 2 def call_with_retry(func, *args, **kwargs): for attempt in range(MAX_RETRY): try: return func(*args, **kwargs) except RETRYABLE_ERRORS as e: if attempt == MAX_RETRY - 1: raise wait = BACKOFF_BASE ** attempt time.sleep(wait) except Exception as e: logger.error(f"non-retryable error: {e}") raise

退避策略我一般用指数退避,第一次等 1 秒,第二次 2 秒,第三次 4 秒。这样既能给下游恢复的时间,又不会因为重试太密集把下游打垮。如果下游有明确的限流响应,就按响应里的建议时间等。

提示:重试一定要有上限,并且要记录重试次数。我见过一个项目因为没设上限,下游服务挂了之后上游疯狂重试,直接把整个集群拖垮了。

3.4 数据校验:入口处拦住,比后面擦屁股强

Apollo-11 这类项目,数据来源往往不可控。外部传进来的数据,格式对不对、字段全不全、值合不合法,都不能假设。我的原则是:在数据入口处做严格校验,不合格的直接拒绝,不要让它流进核心逻辑。

校验分三层。第一层是结构校验,检查必填字段是否存在、类型是否正确。第二层是格式校验,检查字段格式是否符合预期,比如时间格式、邮箱格式。第三层是业务校验,检查字段值在业务上是否合理,比如数量不能为负、状态值必须在枚举范围内。

def validate_input(data): # 结构校验 required = ["id", "timestamp", "payload"] for field in required: if field not in data: raise ValidationError(f"missing field: {field}") # 格式校验 if not isinstance(data["id"], str) or len(data["id"]) == 0: raise ValidationError("invalid id") if not is_valid_timestamp(data["timestamp"]): raise ValidationError("invalid timestamp") # 业务校验 if data.get("count", 0) < 0: raise ValidationError("count must be non-negative") return True

校验失败的错误信息要具体,告诉调用方哪个字段有问题、期望是什么。这样对方能自己修,不用来回沟通。我一般会在错误信息里带上字段名和期望格式,比如"timestamp must be ISO8601 format, got: 2024/01/01"。

4. 实操过程与核心环节实现

4.1 环境准备:从零到能跑起来

假设你现在要启动一个 Apollo-11 项目,第一步是把环境搭起来。我习惯用容器化的方式,把依赖和运行时都封装在容器里,这样换台机器也能一键复现。

先写 Dockerfile。基础镜像选团队最熟的那个,不要追新。依赖安装分两层:先装系统级依赖,再装语言级依赖。这样改代码的时候不用重装系统依赖,构建速度快很多。

FROM python:3.11-slim WORKDIR /app # 系统依赖 RUN apt-get update && apt-get install -y \ gcc \ && rm -rf /var/lib/apt/lists/* # 语言依赖 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "-m", "apollo11.main"]

然后写 docker-compose.yml,把项目依赖的服务(数据库、缓存等)一起编排进来。这样一条命令就能把整个环境拉起来。

version: "3.8" services: app: build: . ports: - "8080:8080" environment: - DB_HOST=db - CACHE_HOST=cache depends_on: - db - cache db: image: postgres:15 environment: - POSTGRES_PASSWORD=devpassword cache: image: redis:7

环境起来之后,先跑一个冒烟测试,确认核心链路能通。冒烟测试不用覆盖所有功能,只要确认“启动不报错、核心接口能响应、数据库能连上”就行。这一步能拦住 80% 的环境问题。

4.2 核心模块实现:以调度器为例

Apollo-11 这类项目通常有一个核心调度模块,负责把任务分发给执行单元。这个模块的设计直接决定了整个系统的吞吐和稳定性。

我的调度器设计遵循**“生产者-消费者”模型**。生产者负责生成任务,放进队列;消费者从队列取任务执行。队列用有界队列,防止任务堆积把内存撑爆。消费者数量可配置,根据机器配置和任务类型调整。

import queue import threading class Scheduler: def __init__(self, worker_count=4, queue_size=1000): self.task_queue = queue.Queue(maxsize=queue_size) self.worker_count = worker_count self.workers = [] self.running = False def start(self): self.running = True for i in range(self.worker_count): t = threading.Thread(target=self._worker_loop, name=f"worker-{i}") t.start() self.workers.append(t) def submit(self, task): try: self.task_queue.put(task, timeout=1) except queue.Full: logger.warning("task queue full, rejecting task") raise def _worker_loop(self): while self.running: try: task = self.task_queue.get(timeout=1) self._execute(task) except queue.Empty: continue except Exception as e: logger.error(f"worker error: {e}") def _execute(self, task): # 实际执行逻辑 pass

这里有几个参数需要根据实际情况调。worker_count太小吞吐不够,太大上下文切换开销高。我的经验值是 CPU 核数的 2 到 4 倍,如果是 IO 密集型任务可以再高一些。queue_size要能容纳峰值流量,但也不能太大,否则任务积压时内存会爆。一般设成平均处理速度乘以 60 秒的量。

注意:队列满了之后的行为要明确。我选择的是拒绝并报错,让上游知道系统过载了。另一种做法是阻塞等待,但这会把压力传导到上游,可能导致上游也卡住。具体选哪种,看你的业务能不能接受拒绝。

4.3 参数计算:批量大小的确定

批量处理是 Apollo-11 这类项目的常见需求。批量大小设多少,直接影响吞吐和延迟。设太小,每次处理的开销占比高;设太大,单次处理时间长,延迟高,而且失败时重试成本大。

我的计算方法分三步。第一步,测单条处理时间,比如 10 毫秒。第二步,测批量处理的固定开销,比如建立连接、准备上下文,假设 50 毫秒。第三步,算总时间:总时间 = 固定开销 + 批量大小 × 单条时间。然后算平均每条时间:平均每条时间 = 总时间 / 批量大小。

批量大小总时间(ms)平均每条(ms)
16060
1015015
5055011
100105010.5
500505010.1

从表里能看出来,批量从 1 增加到 50,平均每条时间从 60 毫秒降到 11 毫秒,收益很大;但从 50 增加到 500,只从 11 降到 10.1,收益很小,而延迟从 550 毫秒涨到 5050 毫秒。所以拐点在 50 左右,再大就不划算了。

实际选的时候,还要考虑失败重试的成本。批量越大,一次失败影响的数据越多。我一般会在拐点基础上再打个折,选 30 到 50 之间。这个值不是固定的,数据量大了之后要重新测,因为固定开销和单条时间都可能变。

4.4 部署与灰度:别一次性全量

Apollo-11 这类项目上线,最忌讳一次性全量部署。我的做法是灰度发布,先上 10% 的流量,观察一段时间,没问题再逐步扩大。

灰度发布的关键是流量切分。可以按用户 ID 取模,可以按请求头里的标记,也可以按机器维度。我一般用机器维度,先部署一台新版本,把少量流量导过去,观察日志和指标。确认没问题后,再逐台替换。

# 假设有 10 台机器,先替换 1 台 for i in $(seq 1 10); do if [ $i -eq 1 ]; then deploy_new_version "node-$i" else keep_old_version "node-$i" fi done # 观察 30 分钟,检查错误率和延迟 check_metrics --duration 30m # 没问题,继续替换下一批

灰度期间要重点看三个指标:错误率、延迟、资源使用率。错误率涨了说明新版本有 bug,延迟涨了说明性能有问题,资源使用率涨了说明有泄漏或低效。任何一个指标异常,立刻回滚。

提示:回滚方案要在部署前就准备好,并且演练一遍。我见过太多团队部署出问题了才手忙脚乱找回滚脚本,结果耽误了最佳恢复时间。

5. 常见问题与排查技巧实录

5.1 问题速查表

现象可能原因排查方法解决方案
启动报错找不到配置配置文件路径不对打印配置加载路径检查工作目录和挂载
处理速度突然变慢下游限流或资源不足看下游指标和本机资源扩容或加限流
内存持续增长对象未释放或缓存无上限用内存分析工具抓快照加缓存上限或修泄漏
任务积压消费者不够或任务卡住看队列长度和消费者状态加消费者或修卡住的任务
日志里大量重试下游不稳定或超时太短看下游错误率和超时配置调超时或加退避
数据不一致并发写或事务问题查并发日志和事务边界加锁或调整事务

5.2 排查思路:从现象到根因

排查问题的核心思路是二分法。先确认问题出在哪个环节,再在那个环节里继续二分,直到定位到具体代码或配置。

举个例子,用户反馈“处理结果不对”。第一步,确认是所有结果都不对还是部分不对。如果全部不对,问题大概率在核心逻辑或配置;如果部分不对,问题可能在数据或并发。第二步,如果是部分不对,看不对的数据有什么共同点,比如都是某个时间段、某个来源、某个类型。找到共同点,范围就缩小了。第三步,针对这个范围,复现问题。能稳定复现,就好办了,加日志、打断点,一步步跟。

我常用的一个技巧是对比法。拿一份正常的数据和一份异常的数据,逐字段对比,看差异在哪里。很多时候差异字段就是线索。另一个技巧是最小化复现,把异常数据不断删减,直到找到触发问题的最小数据集。这个过程可能有点枯燥,但非常有效。

5.3 独家避坑技巧

第一个坑是时间处理。Apollo-11 这类项目经常跨时区,时间处理稍不注意就出错。我的做法是:内部统一用 UTC 时间戳,只在展示层转成本地时间。时间戳用整数,不用字符串,避免格式解析问题。时间比较用时间戳,不用字符串比较。

第二个坑是浮点数精度。涉及金额或精确计算时,绝对不能用浮点数。用整数(以分为单位)或者用 Decimal 类型。我踩过一次坑,用浮点数算金额,结果对账时差了几分钱,查了一整天才发现是精度问题。

第三个坑是并发写。多个消费者同时写同一个资源时,一定要加锁或者用原子操作。我见过一个项目,两个消费者同时更新同一条记录,结果后写的覆盖了先写的,数据丢了。后来加了乐观锁,用版本号控制,问题才解决。

第四个坑是依赖升级。第三方库升级可能引入不兼容变更。我的做法是:锁定版本,用 lock 文件固定所有依赖的精确版本。升级时单独开分支,跑完整测试,确认没问题再合并。绝不自动升级。

第五个坑是日志泄露敏感信息。日志里不能出现密码、令牌、身份证号等敏感信息。我一般会在日志输出前做一层脱敏,把敏感字段替换成掩码。这个要在项目初期就做,后期补很麻烦,因为要改的地方太多。

5.4 性能调优的实操记录

性能调优不能凭感觉,要先测量再优化。我一般用 profiling 工具找出热点,然后针对热点优化。

有一次 Apollo-11 的处理速度上不去,我用 profiling 跑了一遍,发现 70% 的时间花在一个字符串拼接上。原来代码里用+拼接大量字符串,每次都创建新对象。改成用列表收集再 join,速度直接提升 5 倍。

# 优化前 result = "" for item in items: result += str(item) + "," # 优化后 parts = [] for item in items: parts.append(str(item)) result = ",".join(parts)

另一个常见问题是数据库查询。N+1 查询是性能杀手,一次请求触发几百次数据库查询。解决办法是批量查询,一次把需要的数据都查出来,然后在内存里做关联。我一般会用 ORM 的预加载功能,或者手写批量查询。

调优之后一定要回归测试,确认功能没受影响。性能优化最容易引入 bug,因为改了核心逻辑。我一般会准备一套基准测试,优化前后都跑一遍,对比结果和性能。

6. 项目后续扩展的几个方向

Apollo-11 这类项目跑起来之后,通常会面临扩展需求。我分享几个我实际做过的扩展方向,供你参考。

第一个方向是增加可观测性。除了日志,加上指标和链路追踪。指标用 Prometheus 格式暴露,链路追踪用 OpenTelemetry 标准。这样系统的运行状态一目了然,排查问题也更快。

第二个方向是支持插件化。把易变层的逻辑抽成插件,通过配置文件加载。这样新增功能不用改核心代码,只需要写一个插件。插件接口要定义清楚,包括初始化、执行、清理三个阶段。

第三个方向是增加管理接口。提供一个 HTTP 接口,可以查询任务状态、手动触发任务、调整配置。这个接口要加认证,不能裸奔。我一般用简单的令牌认证,够用就行。

第四个方向是做压力测试。用工具模拟高并发,找出系统的瓶颈。压力测试要在类生产环境做,数据量也要接近真实。测试结果用来指导扩容和优化。

这些扩展不用一次做完,按需推进就行。关键是核心链路要稳,扩展都是锦上添花。我个人的体会是,Apollo-11 这类项目,前期把边界划清楚、把核心逻辑写扎实,后面扩展起来会很顺;前期图快省事,后面每加一个功能都要还债。踩过几次坑之后,我现在启动任何项目,都会先花时间把“不做清单”和模块划分想清楚,磨刀不误砍柴工。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 1:40:31

ROS自定义msg完全指南:从定义、编译到C++/Python收发实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:40:28

CD105抗体分选胰腺癌成纤维细胞亚群技术解析

1. 项目背景与核心问题胰腺癌微环境中的成纤维细胞异质性一直是肿瘤研究领域的难点。传统观点认为癌症相关成纤维细胞&#xff08;CAFs&#xff09;普遍具有促肿瘤作用&#xff0c;但近年研究发现某些CAFs亚群反而能抑制肿瘤生长。这种功能上的矛盾性使得研究者迫切需要找到可靠…

作者头像 李华
网站建设 2026/10/15 1:39:53

Seasar DI容器实战:从依赖注入到AOP与数据库访问的完整教程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:39:32

Vue3+Cesium响应式集成实战:解决容器失联、相机抢权与实体不渲染

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 1:38:01

VTK点云处理三要素:加载、变换、合并实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华