设计模式
快来分享你的内容吧~
- 01-22 15:30·Java后端
- 2025-12-26·后端开发过去的一年里,我参与并主导了多个不同阶段的项目: 重构已经积累大量历史负担的老系统,也从 idea 开始,独立推进过项目的立项、架构设计、研发与上线,同时还在多个团队之间进行深度协作。 是在这些实践中,我逐渐意识到,很多工程问题并不是“技术能力不足”,而是对接口、边界以及变化的理解存在偏差。 当你需要为系统的长期演进负责,而不只是完成一次性交付时,接口不再只是 HTTP 入口,而是变成真正的协作契查看全文Issie:文中提到接口隔离变化,这里补充一组我在生产环境中反复验证过的设计原则视角。SOLID 原则,本质上是对“变化如何被隔离”的不同约束方式:- S(单一职责):一个模块只对一种变化负责,职责越单一,变化越容易被局部吸收- O(开闭原则):通过扩展接口能力应对变化,而不是修改既有结构- L(里氏替换):接口作为“契约”的底线,新实现必须在语义上可替换,否则就不是扩展而是破坏- I(接口隔离):接口应服务
1246分享 - 2025-12-26·Java后端策略模式简介及应用场景 二、策略模式简介及应用场景:☀️☀️☀️ 策略模式(Strategy Pattern)核心解读 策略模式是行为型设计模式的核心之一,定义为: 定义一系列算法,把它们一个个封装起来,并且使它们可互相替换。此模式让算法的变化独立于使用算法的客户。 1.1 通俗理解 把“完成同一任务的不同方法”(比如支付可选择微信、支付宝)抽离为独立的“策略类”,主逻辑(支付流程)只需调用统一接查看全文加油鸭:感谢分享这篇结构清晰、案例详实的策略模式总结!从核心思想到大厂实战,再到Spring集成和应用场景拓展,内容非常全面,对理解和落地策略模式很有帮助,点赞!525分享
- 2025-11-29·Java后端
- 2025-09-01·Java后端
Notion 的公式栏里,藏着一台虚拟机——逆向 + 用 600 行 JS 复刻它的编译器与栈式 VM
> 本文基于对 Notion 公开前端产物的静态分析,所有指令名、变量名均为还原命名、行为等价,仅供学习与研究。文中区分了「逆向实抓」与「合理推断」,请放心食用。 在 Notion 里建一张表,加一个公式列,敲下: ```text prop("时薪") * prop("工时") ``` 回车,数字立刻出现。平平无奇——直到你打开 DevTools,在压缩后的前端代码里翻到一个 31KB 的模块,发现里面赫然躺着:一个**词法分析器**、一个**递归下降解析器**、一个把语法树编译成**字节码**的编译器,以及一台逐条执行字节码的**栈式虚拟机**。 一个笔记软件,为了算一列公式,在你的浏览器里塞了一台虚拟机。 为什么?这篇文章就顺着这个问题,把这台 VM 逆向出来,再用不到 600 行 JavaScript 把它复刻一遍——重点是它最精彩的两个设计:**用生成器实现「算到一半能挂起、取完数据再从原地继续」的求值**,以及**把 lambda 当作「字节码数据」在运行时重新喂回 VM**。  *** ## TL;DR * 逆向对象是 Notion 前端两个 rspack 模块:`448187`(VM + 编译器)与 `947152 / 942007`(函数目录,命名空间 `formula2`)。 * 它是一台**纯 JavaScript 解释器**,全程没有 `WebAssembly`。`formula2` 暴露了 **31 个算子 + 65 个函数**(`map`/`filter`/`sort` 等列表高阶函数、`let`/`lets` 绑定、正则字符串函数都在)。 * 栈上的每个值是带类型标签的「盒子」`{type, value}`。 * 编译器把操作数**逆序压栈**;VM 主循环「`ip` 先自增、后分派」;`if` 被编译成跳转字节码。 * 王牌是**生成器**:求值到 `prop("X")` 这种需要远端数据的地方就 `yield` 挂起,调度器取回数据后 `.next(data)` 让它从同一条指令继续。 * lambda 不是闭包,而是**编译好的子字节码当成常量压栈**;库函数执行时 `yield*` 把它重新喂回 VM——所以 VM 必须是**可重入**的。 * 我把整套东西做成了一个**可单步、全状态可视化**的教学网页(单文件 HTML,零依赖),源码在 [GitHub](https://github.com/fluffyox/notion-vm)。 *** ## 一、为什么不直接 `eval`? 最朴素的实现是:把用户公式拼成 JS,丢给 `eval` 或 `new Function`。Notion 没这么做,原因有三个,每一个都直接逼出了「编译器 + 虚拟机」这套架构。 **第一,同一个公式要在一整列上反复跑。** 一个公式列有几千行,每行都要算一遍;筛选、排序、滚动都会触发重算。把公式**编译一次**得到字节码,然后这列的每一行复用同一份字节码——这就是 compile-once-run-many。每次都重新解析语法树是巨大的浪费。 **第二,求值过程必须能「暂停」。** 公式里可以写 `prop("关联表").map(...)`,沿着 relation/rollup 去引用别的行、别的表。这些数据常常**不在本地**,要异步去取。如果用同步的 `eval`,碰到缺数据就只能阻塞或报错。Notion 要的是「同步的写法、异步的执行」:算到需要远端数据的那一刻,**把整个求值过程冻结起来**,去把数据取回来,再从冻结点继续。 **第三,公式语言有 lambda。** `map`、`filter`、`sort` 的参数是一段「对每个元素都要重新跑一遍」的表达式。它需要被表示成一个**可反复调用的独立执行单元**。 这三条约束——高频重算、异步可挂起、列表 lambda——单靠递归解释一棵语法树是很难优雅满足的。于是就有了一台字节码虚拟机。这跟 SQLite 把 SQL 编译成字节码喂给它的虚拟机 VDBE 是同一个思路(顺带一提,Notion 原生端的本地存储正是 SQLite)。 *** ## 二、流水线总览 一行公式从字符串到结果,要走五道工序: ```mermaid graph LR SRC["公式源码"] --> TOK["词法<br/>Token 流"] TOK --> AST["语法分析<br/>AST 语法树"] AST --> BC["编译器<br/>栈式字节码"] BC --> VM["栈式虚拟机<br/>生成器解释循环"] VM --> RES["结果盒子<br/>type + value"] VM -. 挂起取数 .-> NET["记录缓存/网络"] NET -. next 恢复 .-> VM ``` 词法和语法分析是教科书内容,本文不展开(我的复刻里是一个递归下降 + 优先级爬升的解析器)。真正有意思的是后三段:编译器、虚拟机、以及它们之间那条「挂起取数」的虚线。我们一段段拆。 *** ## 三、值是带类型标签的「盒子」 第一个设计决定:栈上跑的不是裸 JS 值,而是统一的「盒子」——`{type, value}`。逆向出的类型有: `number`、`text`(`value` 是富文本数组)、`checkbox`、`date`、`person`、`block`(行指针)、`array`、`undefined`,以及一个特别的 `compiledCode`(一段子字节码,后面讲 lambda 时会用到)。 为什么不用裸值?因为 `1 + "x"` 在公式里要做文本拼接、`date < date` 要走时区感知比较、`undefined` 在数值上下文里要当 0——**运算的语义由类型决定**。把类型随值一起带在盒子里,分派起来才干净。 配套的是一个看似普通、实则关键的栈类: ```js class Stack { constructor() { this.u = []; } push(v) { this.u.push(v); } popValueOrCode() { return this.u.pop(); } // 允许弹出 compiledCode popValue() { // 禁止弹出 compiledCode const v = this.u.pop(); if (v && v.type === "compiledCode") throw new Error("unexpected compiled code"); return v; } } ``` 注意它有**两种弹出**。普通运算用 `popValue`:如果你试图把一段「代码」当成「值」去做加法,它直接抛错。而库函数取它的惰性参数(lambda)时用 `popValueOrCode`,允许拿到那段代码。这个区分,是整个 lambda 机制的支柱——记住它,第六节会回来。 *** ## 四、第一个反直觉点:操作数逆序压栈 栈式 VM 的常识是:算 `a - b`,先把 `a`、`b` 压栈,再执行减法。但逆向出来的编译器,**操作数是反着压的**。 ```js function compileBin(node) { const { op } = node; // ……除法、取模、and/or 走库函数,此处省略…… const t = (op === "+" || op === "-") ? "add" : op === "*" ? "multiply" : op === "^" ? "exponentiation" : (op === "==" || op === "!=") ? "equality" : "relational"; // 逆序压栈:先发射 rhs,再发射 lhs return I(node.rhs).concat(I(node.lhs)).concat([{ type: t, op, node }]); } ``` `I(rhs)` 在前、`I(lhs)` 在后。以 `1 - 2` 为例,编译产物是: ```text 0 loadConstant number 2 ← 先压右操作数 1 loadConstant number 1 ← 再压左操作数(它在栈顶) 2 add (op: "-") ``` 为什么要这样?因为栈是**后进先出**。我们希望执行减法时,**先弹出的是左操作数**。逆序压栈之后,左操作数 `1` 正好在栈顶,于是: ```js case "add": { const a = frame.stack.popValue(); // 弹出 = 1(左操作数) const b = frame.stack.popValue(); // 再弹 = 2(右操作数) frame.stack.push(addOp(node, a, b)); // 算 a - b = -1,顺序正确 break; } ``` 这个规则在二元运算、函数参数、数组字面量里是统一应用的(参数也逆序发射,执行时 `pop` 重建书写顺序)。它不影响结果,但你不知道这条约定的话,照着写出来的减法、除法会全部算反——这是逆向时一个很容易栽的坑。 *** ## 五、VM 主循环:先自增,后分派 虚拟机的心脏是一个 `while` 循环。逆向出的版本有个细节:**取出当前指令后,先把指令指针 `ip` 自增,再去分派执行**。 ```js function* F(instrs, ctx) { const frame = { instrs, ip: 0, stack: new Stack(), ctx }; // …把 frame 压入运行时 frames 栈,用于可视化… while (frame.ip < instrs.length) { const T = instrs[frame.ip]; frame.ip++; // ★ 先自增,后分派 switch (T.type) { case "loadConstant": frame.stack.push(T.value); break; case "loadName": frame.stack.push(lookupBinding(ctx, T.name)); break; case "loadToken": frame.stack.push(yield* resolveToken(T, ctx)); break; // 可挂起 case "add": { const a = frame.stack.popValue(), b = frame.stack.popValue(); frame.stack.push(addOp(T.node, a, b)); break; } case "multiply": { const a = frame.stack.popValue(), b = frame.stack.popValue(); frame.stack.push(mulOp(a, b)); break; } case "relational": { const a = frame.stack.popValue(), b = frame.stack.popValue(); frame.stack.push(yield* relOp(T.node, a, b)); break; } // 可挂起 case "array": { const vs = []; for (let i = 0; i < T.count; i++) vs.push(frame.stack.popValue()); frame.stack.push({ type: "array", values: vs }); break; } case "relativeJump": frame.ip += T.offset; break; case "jumpIfTruthy": { const c = frame.stack.popValue(); if (truthy(c)) frame.ip += T.offset; break; } case "callLibraryFunction": { const args = []; for (let i = 0; i < T.argCount; i++) args.push(frame.stack.popValueOrCode()); frame.stack.push(yield* T.fn.eval(args, ctx)); break; } // 可挂起 + 可重入 case "runLets": frame.stack.push(yield* runLets(T, ctx)); break; } } return frame.stack.popValue(); } ``` (上面为聚焦主线略去了少量错误守卫,完整版见仓库。) ```mermaid graph TD A["ip = 0"] --> B{"ip < 指令数?"} B -- 否 --> Z["弹出栈顶作为返回值"] B -- 是 --> C["取指 T = instrs[ip]"] C --> D["ip++(先自增,后分派)"] D --> E{"按 T.type 分派"} E --> F1["loadConstant:压入常量"] E --> F2["add / multiply:弹2个算1个压回"] E --> F3["loadToken:yield 取数(可挂起)"] E --> F4["callLibraryFunction:yield* 调用函数"] E --> F5["jumpIfTruthy:改写 ip"] F1 --> B F2 --> B F3 --> B F4 --> B F5 --> B ``` 「先自增后分派」的意义,在跳转指令上才显出来:`jumpIfTruthy` 的偏移量 `offset` 是相对**已经自增过的** `ip` 计算的。复刻时如果偏移基准算错一格,整段跳转会错位。这就引出下一节。 注意 `switch` 里有好几个 `yield*`——它们是这台 VM 能「挂起」和「重入」的入口,是后两节的主角。 *** ## 六、`if` 被编译成跳转——顺便揭穿一个误解 公式里的 `if(条件, 真值, 假值)`,**不是一个普通函数**。如果它是函数,那么调用前两个分支都得先求值(参数总是先于调用被算出来),`if` 就失去短路能力了。逆向出的做法是:编译器把 `if` 直接**展开成跳转字节码**。 ```js function compileIf(node) { const cond = emit(node.args[0]); const thenBC = emit(node.args[1]); const elseBC = emit(node.args[2]); return [ ...cond, { type: "jumpIfTruthy", offset: elseBC.length + 1 }, // 条件为真 → 跳过 else 段 ...elseBC, { type: "relativeJump", offset: thenBC.length }, // else 执行完 → 跳过 then 段 ...thenBC, ]; } ``` 布局是「条件 → 跳转 → else 段 → 无条件跳转 → then 段」。条件为真时跳过整个 else 段、落到 then 段;为假时顺序落入 else 段、执行完再无条件跳过 then 段。两个分支永远只走一个——这才是真正的短路。`ifs`(多路条件)则被递归地拆成嵌套的 `if`。 **反过来,`and` / `or` 是急性求值的普通函数。** 它们的参数在调用前就已经被全部算到栈上了,所以 `and`/`or` **不短路**。在公式引擎里,唯一的短路控制流来自 `if`/`ifs` 的跳转。这个区别,不看字节码是不会注意到的。 *** ## 七、王牌:用生成器做一台「可挂起」的虚拟机 现在来到全篇最漂亮的设计。 回想第一节的动机二:求值碰到远端数据要能暂停。Notion 的实现是——**VM 主体 `F` 是一个生成器函数**(`function*`)。当执行到 `loadToken`(也就是读 `prop("X")`)而本地没有这条记录时,它不阻塞、不报错,而是 `yield` 出一个「我需要这个记录」的请求: ```js function* resolveToken(T, ctx) { if (T.token.kind === "property") { const data = yield { t: "fetch", pointer: ctx.rowPointer, property: T.token.name }; if (data == null) throw new Error("MissingThisRow"); // 取不到 → 结构化错误 return data; // 取到 → 作为值盒子返回,压栈 } } ``` `yield` 之后,**这个生成器就地冻结**——它的指令指针 `ip`、操作数栈、整条调用栈,全被 JavaScript 运行时原样保存在生成器对象里。外层的调度器(驱动循环)接管: ```js async function drive(bytecode, ctx) { const gen = F(bytecode, ctx); let injected; for (;;) { const { value: ev, done } = injected === undefined ? gen.next() : gen.next(injected); injected = undefined; if (done) return ev; // 求值完成,ev 是结果盒子 if (ev.t === "fetch") { const box = await getRecord(ev.pointer, ev.property); // 本地命中就立即返回,缺数据就走网络 injected = box; // 把数据通过 .next(box) 喂回挂起点 } } } ``` 关键在 `gen.next(injected)`:传给 `next` 的值,会成为生成器内部那个 `yield` 表达式的返回值。也就是说,数据取回来后,VM 从**那条 `loadToken` 的同一位置**继续往下跑,仿佛中间什么都没发生。 ```mermaid sequenceDiagram participant VM as 虚拟机·生成器 participant SCH as 调度器 participant DATA as 记录缓存/网络 VM->>VM: 执行到 loadToken(读 prop) VM-->>SCH: yield 需要的记录指针 Note over VM: 在此冻结:ip、操作数栈、<br/>整条调用栈被原样保留 SCH->>DATA: 本地有这条记录吗? alt 命中本地缓存 DATA-->>SCH: 立即返回值盒子 else 缺数据 DATA-->>SCH: 发起网络请求,返回值盒子 end SCH-->>VM: gen.next 把盒子喂回 Note over VM: 从同一条指令解冻、继续执行 ``` 一句话总结这个机制:**生成器捕获的那个挂起态,本身就是一个可以冻结、可以解冻的调用栈。** 当数据本来就在本地时,整个过程一次 `yield` 都不发生,纯同步,零开销;只有真要去远端取数时才挂起。这就是「同步的写法、异步的执行」。 > 我在复刻的教学工具里,把这个「冻结」做成了一个会盖在虚拟机面板上的覆盖层:求值撞到一个标记为「冷」的属性时,整台 VM 的 `ip`、栈、调用栈定格不动,取数返回后再「解冻」从原地继续。看一眼那个动画,比读十遍文字都直观。 *** ## 八、lambda 的真相:不是闭包,是「字节码即数据」 `map([1,2,3], current * current)` 里的 `current * current`,是一段要对每个元素都重跑的代码。Notion 怎么表示它? 不是闭包。逆向出的答案更硬核:**编译时把这段表达式单独编译成一串子字节码,包成一个 `compiledCode` 盒子,当成常量压栈。** ```js function compileCall(node) { const fn = LIB[node.name]; const args = node.args.slice(); let out = []; for (let k = args.length - 1; k >= 0; k--) { // 参数同样逆序压栈 const an = args[k]; if (fn.lazy && fn.lazy.has(k)) { // 该形参是「惰性/代码」参数? // 不直接发射这段表达式,而是把它编译成子字节码,作为常量压栈 out.push({ type: "loadConstant", value: { type: "compiledCode", instructions: I(an) } }); } else { out = out.concat(I(an)); // 普通参数照常发射 } } out.push({ type: "callLibraryFunction", name: node.name, argCount: args.length, fn }); return out; } ``` 每个库函数自带一张「哪些参数是惰性的」表(比如 `map` 的第 2 个参数)。惰性参数不会被立即求值,而是变成一颗 `compiledCode`「代码弹珠」躺在栈上。 到了运行时,`map` 的实现用 `popValueOrCode`(还记得第三节那两种弹出吗?)把这颗代码弹珠取出来,然后**对每个元素,`yield*` 把这段子字节码重新喂回 VM 主体 `F` 跑一遍**,跑之前往上下文里注入两个绑定——当前元素 `current` 和下标 `index`: ```js function* runLambda(codeBox, el, idx, ctx) { const childCtx = { ...ctx, values: [{ kind: "Binding", id: "current", value: el }, { kind: "Binding", id: "index", value: num(idx) }, ...ctx.values], }; return yield* F(codeBox.instructions, childCtx); // ★ VM 重新调用自己 } ``` `yield* F(...)` 这一句,就是 **VM 在执行库函数的过程中,又递归地驱动了一个新的 VM 帧**。这正是为什么主循环里那么多 `yield*`、为什么 VM 必须是**可重入**的生成器:lambda 的执行 = 子字节码 + 再次进入 VM。 ```mermaid graph TD M["main 帧:执行 sum(map(...))"] --> C1["遇到 callLibraryFunction map"] C1 --> L["map.eval 对每个元素调用 runLambda"] L --> R["yield* F(λ 子字节码, ctx'):注入 current / index"] R --> N["新建一个 VM 帧(VM 重入自己)"] N --> RET["lambda 求完值,帧弹出,结果回到 map"] RET --> C1 ``` 光说不够,看一段我的复刻在跑 `sum(map([1, 2], current * 10))` 时,逐指令打印的真实执行轨迹(精简了列): ```text 0 ip=0 loadConstant compiledCode(λ) stack:[] frames:[main] 1 ip=1 loadConstant number 2 stack:[λ] frames:[main] 2 ip=2 loadConstant number 1 stack:[λ 2] frames:[main] 3 ip=3 array count=2 stack:[λ 2 1] frames:[main] 4 ip=4 callLibraryFunction map stack:[λ [1 2]] frames:[main] 5 ip=0 loadConstant number 10 stack:[] frames:[main › λ current=1 [#0]] 6 ip=1 loadName current stack:[10] frames:[main › λ current=1 [#0]] 7 ip=2 multiply stack:[10 1] frames:[main › λ current=1 [#0]] 8 ip=0 loadConstant number 10 stack:[] frames:[main › λ current=2 [#1]] 9 ip=1 loadName current stack:[10] frames:[main › λ current=2 [#1]] 10 ip=2 multiply stack:[10 2] frames:[main › λ current=2 [#1]] 11 ip=5 callLibraryFunction sum stack:[[10 20]] frames:[main] RESULT 30 ``` 看第 5 行那一刻:`frames` 从 `[main]` 长出了 `[main > lambda current=1 [#0]]`——VM 重入了自己,调用栈多了一层 lambda 帧,`current` 被绑成了第一个元素。第 7 行栈是 `[10 1]`,正应了第四节的逆序压栈:先弹 `1`(即 `current`)、后弹 `10`,算 `current * 10`。两个元素各跑完一遍 lambda 帧后,回到 `main`,`sum` 把 `[10, 20]` 折叠成 `30`。 `map`/`filter`/`find`/`some`/`every`/`sort` 全都共用这一套底座。`let`/`lets` 也是类似套路:编译成一条 `runLets` 指令,逐个求值绑定、压进 `ctx.values` 头部,`loadName` 再反查。 *** ## 九、算术里的小心思:整数走快路、小数才付精度税 被坑过 `0.1 + 0.2 !== 0.3` 的人都知道浮点的麻烦。表格软件对数值精度是较真的。逆向出的加法语义是这样权衡的: ```js function addOp(node, a, b) { const op = node.op === "-" ? "-" : "+"; const aNum = a.type === "number" || a.type === "undefined"; const bNum = b.type === "number" || b.type === "undefined"; if (op === "+" && aNum && bNum) { // 两边都是数(undefined 当 0) const x = a.type === "undefined" ? 0 : a.value; const y = b.type === "undefined" ? 0 : b.value; return num(isInt(x) && isInt(y) ? x + y // 整数 → 原生加法(快路径) : decAdd(x, y)); // 含小数 → 任意精度加法(慢路径) } // 减法同构;任一边不是纯数字 → 转富文本后拼接成 text return txt(boxToText(a) + boxToText(b)); } ``` 精髓在那个三元表达式:**两个操作数都是整数,就走原生 `+`**(比任意精度库快一个数量级,而整数运算的 JS 原生结果是精确的);**只要含小数,才切到任意精度路径**,保证 `0.1 + 0.2` 得到 `0.3` 而不是 `0.30000000000000004`。常见的整数运算不为不存在的精度问题买单,小数才付这份「精度税」。`-`、`*`、`^` 都是同样的整数快路径 + 慢路径分流。 其余几个语义也值得记一笔:除法零除返回 `undefined`(不是 `Infinity`);`round` 的精度参数必须是整数且绝对值 ≤ 12(每个函数都自带参数类型校验和结构化错误);`min`/`max` 返回**原始盒子**以保留类型。 *** ## 十、海量派生为什么不退化成 N\*M 次往返? > 诚实声明:这一节描述的是 **VM 之上的调度层**,属于合理推断 + 业界标准做法,**不是从那两个模块逐字逆出**的。前面九节都有实抓代码支撑,这节没有,请区别对待。 第七节解决了「一个单元格挂起取数」。但一张大表有成百上千个派生单元格,每个都可能挂起、都要请求关系记录。如果每个请求各自单独往返,那就是 N 行 x M 条关系 = N\*M 次网络调用,必然卡死。 标准解法是在 VM 之上放一个**调度层**,把同一轮里所有挂起的请求**收集、去重、合并成一次批量取数**(就是 DataLoader 那套)。多个生成器各自挂在自己的 `yield` 上,调度器把它们的数据需求并起来、去重、一次性取回,再唤醒全部挂起的生成器。 ```text 4 个派生格,各自挂起,需要的关系记录有重叠 R1:[a b c] R2:[b c d] R3:[c d e] R4:[a e] │ │ │ │ └──── 收集 + 去重 → {a b c d e} ───────┘ │ 一次批量取数(1 次往返) │ └──── 唤醒全部挂起的生成器,各自恢复 ────┘ 朴素:3+3+3+2 = 11 次往返 合并后:1 次往返 ``` 注意:**底层用的还是第七节那套完全相同的挂起/恢复协议**,区别只在 VM 之上的调度层是否合并请求。再叠加依赖图标脏 + 拓扑重算(只重算受影响的单元格)、视口懒求值(只算屏幕上可见的行)、服务端聚合下推等手段,「断网只挂掉一个格子、而不是整张表」才成为可能。 *** ## 十一、我把它做成了一个能单步的「虚拟机示波器」 读到这你大概已经有画面了。但「字节码逆序压栈」「生成器挂起恢复」「VM 重入自己跑 lambda」这些,光看文字总隔一层。所以我把整套引擎复刻了一遍(不到 600 行、零运行时依赖),又给它套了一个可视化外壳——一台「虚拟机示波器」: * **三段流水线一屏可见**:左边语法树、中间字节码(lambda 的子字节码可以展开)、右边虚拟机执行。 * **逐指令单步**,或按速度自动播放。当前 `ip` 高亮,对应的语法树节点同步点亮。 * **操作数栈画成物理盘片**,按类型着色,`push`/`pop` 带动画;调用栈帧实时显示 `main > lambda current=... [#i]` 的重入层级。 * **两种模式**:教学模式每条指令都暂停;真实模式只在取数处挂起——直接把「真实 VM 只在哪儿停」摆给你看。 * **挂起/恢复的「冻结」动画**:把某个属性标成「冷·需取数」,求值撞上它时整台 VM 定格、弹出覆盖层,取数返回后再解冻继续。 * 还有个进阶面板演示第十节的请求合并:N 行如何不退化成 N\*M 次往返。 为了靠谱,引擎在 Node 里跑了 37 个公式/错误/挂起用例全过,整页又用 jsdom 做了端到端冒烟(单步执行、lambda 重入、冻结覆盖层、结果正确)全过。 我把它整理成了一个开箱即跑的小工程: ```text notion-vm/ ├── build.sh # 组装 src/* → dist/notion-vm.html ├── src/{engine,ui}.js, style.css, body.html ├── test/{test.js, step_smoke.js, dom_smoke.mjs} └── dist/notion-vm.html # 自包含单文件,浏览器直接打开 ``` ```bash npm run build # 从源码重建单文件 HTML npm test # 37 个公式/错误/挂起用例(纯 node,无需装依赖) ``` > 在线 Demo 与完整源码:[github.com/fluffyox/notion-vm](https://github.com/fluffyox/notion-vm) *** ## 收尾:它到底是什么? 逆完这一圈,很容易冒出一个念头:Notion 是不是一个跑在浏览器里的迷你操作系统?哲学层面确实有几分像——一切皆 block(一切皆文件)、字节码 VM(用户态运行时)、权限分级、事务队列(调度器)、GC…… 但要较真的话,它更像一个**带内嵌运行时的、local-first 的数据库**:没有硬件、没有内存保护、权威数据在服务端,公式语言也不是图灵完备的——它是一门受限的领域 DSL,不是通用编程语言。这台 VM 的存在,不是为了「能算任何东西」,而是为了让「一列公式在几千行上反复、可暂停、可沿关系链取数地求值」这件事,变得高效而可控。 一个笔记软件的公式栏,背后是一套相当完整的编译器 + 虚拟机工程。下次你在 Notion 里敲下一个公式,不妨想想:那一刻,你的浏览器里有一台小小的虚拟机,正把你的字符串编译成字节码、逐条执行,碰到要去远端取的数据就优雅地冻结自己,等数据回来再从原地醒来。 *** *如果这篇对你有用,欢迎点赞 / 收藏 / 关注。逆向与复刻的全部代码都在 [GitHub 仓库](https://github.com/fluffyox/notion-vm) 里,欢迎对着字节码自己玩。*
基于策略模式的短信验证码发送设计
> 短信验证码几乎是后台系统里最常见的基础能力之一。 > > 但很多项目在一开始做这块功能时,目标通常只有一个:**先把验证码发出去。** > > 于是代码很容易直接绑死某一家厂商,模板散落在业务里,切换通道靠 `if...else`,一旦后面要接第二家、第三家短信服务,模块复杂度就会迅速失控。 **如果你要设计一套短信验证码发送模块,应该怎样用设计模式把它做得更清晰、更容易扩展,也更方便做多通道切换。** --- ## 一、为什么短信验证码模块值得单独设计 很多系统在最开始做短信功能时,往往只有一个目标:先把验证码发出去。 于是代码通常会长成这样: ```java if (vendor.equals("A")) { // 调 A 厂商 SDK } else if (vendor.equals("B")) { // 调 B 厂商接口 } else { // 报错 } ``` 这种写法短期很快,但很快就会遇到一连串问题: 1. 业务代码直接依赖某一家短信厂商,后面很难替换。 2. 登录、注册、找回密码、活动提醒这些模板逻辑会散落在不同地方。 3. 一旦主通道故障,整个验证码链路会跟着出问题。 4. 每接入一家新厂商,原有代码就会继续膨胀。 5. 调用方知道的内部细节太多,模块边界越来越模糊。 所以,短信验证码模块真正要解决的不是“怎么调 SDK”,而是这四个更本质的问题: | 要解决的问题 | 设计目标 | | -------------------------- | ------------------ | | 业务层不应该到处接厂商接口 | 提供统一发送入口 | | 厂商实现差异很大 | 用抽象隔离差异 | | 通道可能失败 | 具备切换与降级能力 | | 模板、签名、账号经常变化 | 配置化管理 | 如果把这四件事做好,短信功能才算是一个模块。 如果只做“调一次接口”,那最多只能算一段代码。 --- ## 二、先从整体看:这类模块应该长什么样 为了让没看过源码的人也能先建立整体印象,我们先不看代码,先看结构。 ```mermaid flowchart LR A["业务系统<br/>登录 / 注册 / 找回密码"] --> B["统一发送入口<br/>SmsFacade"] B --> C["通道配置<br/>ChannelConfig"] B --> D["状态缓存<br/>Redis / Cache"] B --> E["发送策略接口<br/>SmsChannelHandler"] E --> F["厂商 A 实现"] E --> G["厂商 B 实现"] E --> H["厂商 C 实现"] ``` **业务层不直接面对厂商,而是只面对一个统一入口;真正的厂商差异,被压到了模块内部。** 这就是后面所有设计模式能够成立的前提。 --- ## 三、3 个典型设计模式 1. **门面模式**:统一对外入口。 2. **策略模式**:隔离不同短信厂商。 3. **注册表 / 工厂思想**:根据通道类型选择具体实现。 这三个模式已经足够把这类模块讲明白。 --- ## 四、模式一:门面模式,先把复杂度挡在模块后面 ### 1. 什么叫门面模式 门面模式的核心思想很朴素: **对外只给一个简单入口,对内把复杂流程藏起来。** 如果用生活中的例子来比喻,酒店前台就是一个典型门面。 住客不会自己去找保洁、找房态系统、找门锁系统、找财务系统。住客只跟前台说一句“我要入住”,前台负责把后面的复杂事情协调起来。 短信模块也是一样。 业务系统真正想表达的只有一句话: “帮我给这个手机号发一条某种业务类型的验证码短信。” 调用方不应该知道这些内部细节: 1. 当前默认用哪家短信厂商。 2. 如果失败了应该切换到谁。 3. 模板 ID 在不同厂商下如何映射。 4. 这个通道是不是刚刚被临时禁用过。 5. 错误次数要不要累计到缓存里。 这些都应该是模块自己的事。 ### 2. 门面模式在短信模块里的作用 门面模式落到这类模块里,通常会有一个统一入口类,比如: ```java public class SmsFacade { public boolean sendCode(String phone, String bizType, Map<String, String> params) { // 1. 读取可用通道 // 2. 选择起始通道 // 3. 检查通道状态 // 4. 找到对应厂商实现 // 5. 发送并处理结果 return true; } } ``` 这段代码最重要的不是方法体,而是这个入口本身。 它让业务层形成一个非常清晰的依赖关系: ```java smsFacade.sendCode(phone, "login", Map.of("code", code)); ``` 业务层到这里就应该结束。 不要再让业务层知道厂商名、模板编码、签名格式、接口路径这些底层信息。 --- ## 五、模式二:策略模式,把“不同厂商的不同发法”拆开 ### 1. 为什么这里天然适合策略模式 短信厂商的差异非常明显: 1. 请求地址不一样。 2. 鉴权方式不一样。 3. 模板参数格式不一样。 4. 响应结构不一样。 5. 成功失败判断规则也不完全一样。 但从业务角度看,它们做的又是同一件事: **发短信。** 这正是策略模式最适合发挥作用的地方。 策略模式不是为了显得高级,而是因为这里真的存在: 1. 同一个目标。 2. 多种实现方式。 3. 实现方式之间可能随时替换。 ### 2. 先抽出一个稳定接口 这时最合理的做法,就是先定义一个稳定的发送接口: ```java public interface SmsChannelHandler { boolean send(ChannelConfig channel, List<String> phones, Map<String, String> params, String vendorTemplateId); } ``` 这个接口传达的是一个非常重要的设计意识: **系统只约定“发送能力”,不约定“发送细节”。** 接口只关心“你能不能发”,而不关心“你内部怎么发”。 ### 3. 每个厂商一个策略实现 然后,每家厂商都各自实现: ```java public class VendorAHandler implements SmsChannelHandler { @Override public boolean send(ChannelConfig channel, List<String> phones, Map<String, String> params, String vendorTemplateId) { // 组装 A 厂商请求 // 调 A 厂商接口 // 返回发送结果 return true; } } ``` ```java public class VendorBHandler implements SmsChannelHandler { @Override public boolean send(ChannelConfig channel, List<String> phones, Map<String, String> params, String vendorTemplateId) { // 组装 B 厂商请求 // 调 B 厂商接口 // 返回发送结果 return true; } } ``` 这样一拆,效果非常直接: | 不用策略模式 | 用策略模式 | | ------------------------ | ---------------------- | | 一个类里塞满所有厂商逻辑 | 一个厂商一个实现类 | | 新增厂商要改旧代码 | 新增类即可 | | 测试时容易牵一发动全身 | 每个厂商可独立测试 | | 业务代码会被厂商细节污染 | 业务代码只调用统一入口 | ### 4. 这张图能把策略模式看得更直观 ```mermaid classDiagram class SmsChannelHandler { <<interface>> +send(channel, phones, params, templateId) boolean } class VendorAHandler class VendorBHandler class VendorCHandler SmsChannelHandler <|.. VendorAHandler SmsChannelHandler <|.. VendorBHandler SmsChannelHandler <|.. VendorCHandler ``` 动作稳定,算法变化,这就是策略模式最典型的落点。 ### 5. 策略模式的使用 真正要学会的是这种拆分思维: 1. 先找到稳定不变的动作。 2. 再找到会变化的实现方式。 3. 用接口把稳定和变化隔开。 只要抓住这三步,很多基础模块都能用同样的方法设计。 > 不只是短信。 > > 邮件、推送、支付、对象存储、地图服务,甚至 OCR、翻译、语音识别,本质上都可以这么拆。 --- ## 六、模式三:注册表 / 工厂思想,运行时到底怎么选中正确实现 ### 1. 只用了策略模式还不够 已经抽出了 `SmsChannelHandler` 接口,也写了多个厂商实现,但还差最后一步: **运行时,系统怎么知道当前应该调用哪一个实现?** 如果这一步还写成一堆 `if...else`,那前面的策略模式就打折了。 所以,这里还需要一个“选择策略”的机制。 这个机制在很多项目里可以理解成: 1. 注册表思想。 2. 轻量工厂思想。 ### 2. 什么叫注册表思想 你可以把它理解成一本“实现清单”。 系统会先把所有短信发送实现登记好: ```java Map<String, SmsChannelHandler> registry = new HashMap<>(); registry.put("ALI", new AliSmsHandler()); registry.put("HUAWEI", new HuaweiSmsHandler()); registry.put("TENCENT", new TencentSmsHandler()); ``` 等真正发送时,就不再写 `if...else`,而是按“通道类型”直接去查: ```java SmsChannelHandler handler = registry.get(channelType); handler.send(channel, phones, params, templateId); ``` 这件事说白了就是: **先登记,再按名字取。** ### 3. 为什么它也可以理解成工厂思想 从工程效果上看,它和工厂模式做的是同一件事: **根据输入条件,返回正确的对象。** 所以这里没必要太纠结术语。 对这篇文章的读者来说,更重要的是理解它解决了什么问题: **调用方不用关心具体厂商类名,系统自己根据配置选出正确实现。** ### 4. 运行时到底怎么选中正确实现 真正的过程其实很简单: 1. 先拿到当前要用的通道。 2. 看这个通道的 `type` 是什么。 3. 再去注册表里找到同名实现。 4. 调用这个实现完成发送。 可以画成这样: ```mermaid flowchart LR A["读取当前通道"] --> B["拿到通道类型"] B --> C["到注册表中查找实现"] C --> D["取出对应处理器"] D --> E["执行发送"] ``` 所以系统真正做的,不是“我要不要调阿里云类”。 系统真正做的是: **当前通道类型是什么,这个类型在注册表里对应哪一个实现。** ### 5. 为什么它特别适合短信场景 因为短信模块天然有两个变化点: 1. 厂商实现会变。 2. 运行时选中的通道也会变。 如果没有注册表 / 工厂思想,代码很容易重新退化成这样: ```java if ("ALI".equals(channelType)) { // 调阿里云 } else if ("HUAWEI".equals(channelType)) { // 调华为云 } else if ("TENCENT".equals(channelType)) { // 调腾讯云 } ``` 这会带来两个直接问题: 1. 每增加一个厂商,就要改原有判断逻辑。 2. 统一发送入口会越来越臃肿。 它是在保护前面已经拆好的策略结构,不让系统重新退回 `if...else`。 ### 6. 故障切换时,这个模式怎么配合工作 短信验证码通常不只配一个通道。 因为主通道短时间故障时,系统往往要切到备用通道。 这时运行过程通常是这样: ```mermaid flowchart TD A["先尝试主通道"] --> B["根据 type 找到对应实现"] B --> C["执行发送"] C --> D{"是否成功"} D -- 是 --> E["结束"] D -- 否 --> F["切换到下一个通道"] F --> B ``` 这里最值得注意的一点是: **切换的是通道,命中的是实现。** 也就是说: 1. 门面层决定下一条要尝试的通道。 2. 注册表 / 工厂机制根据新通道的 `type` 取出新实现。 3. 新实现继续发送。 这样职责就很清楚。 ### 7. 这个模式和策略模式怎么分工 它们解决的问题不一样: | 模式 | 解决的问题 | | ----------------- | ---------------------------- | | 策略模式 | 不同厂商的发送逻辑怎么拆开 | | 注册表 / 工厂思想 | 运行时怎么选中正确的厂商实现 | 1. 策略模式解决“有哪些发法”。 2. 注册表 / 工厂思想解决“这次该用哪种发法”。 两者配合起来,模块才完整。 ### 8. 设计判断 以后做类似模块时,要思考一下: **已经有多个实现了,那运行时谁来负责选中正确那个?** 如果这个问题还只能靠 `if...else` 回答,那通常就说明还缺一个注册表 / 工厂层。 这不只是短信模块适用。 邮件、推送、支付、对象存储、OCR、翻译接口,很多“多供应商接入”场景,本质上都一样。 --- ## 七、把三个模式连起来看,模块就清楚了 单看每个模式都不难,但很多开发者真正卡住的地方,是不知道它们如何组合。 这里是一张完整流程图。 ```mermaid sequenceDiagram participant Biz as 业务层 participant Facade as SmsFacade participant Cache as 状态缓存 participant Registry as HandlerRegistry participant HandlerA as 厂商A处理器 participant HandlerB as 厂商B处理器 Biz->>Facade: sendCode(phone, bizType, params) Facade->>Cache: 查询通道状态 Facade->>Registry: 按通道类型获取处理器 Registry-->>Facade: 返回对应 Handler Facade->>HandlerA: send(...) alt 主通道成功 HandlerA-->>Facade: success Facade-->>Biz: 返回成功 else 主通道失败 HandlerA-->>Facade: fail Facade->>Cache: 记录失败状态 Facade->>Registry: 获取备用通道处理器 Registry-->>Facade: 返回备用 Handler Facade->>HandlerB: send(...) HandlerB-->>Facade: 返回结果 Facade-->>Biz: 返回最终结果 end ``` 1. 业务层只找门面。 2. 门面不自己发短信,而是去找策略。 3. 找哪一个策略,由注册表或工厂机制决定。 4. 如果失败,再由门面继续调度下一个通道。 这就是这类模块最核心的设计骨架。 --- ## 八、除了设计模式,这类模块还有两个很重要的配套思路 虽然本文重点讲模式,但如果完全不提这两点,开发者照着做时还是容易踩坑。 ### 1. 配置化,不要把厂商细节写死在代码里 下面这些信息,最好都放进配置里,而不是写死: 1. 通道类型。 2. 通道开关。 3. 签名。 4. 模板映射。 5. 主备顺序。 6. 失败阈值。 7. 禁用时长。 可以抽象成这样的配置结构: ```yaml sms: fail-threshold: 5 disable-minutes: 30 channels: - id: primary type: VENDOR_A enabled: true signature: your-sign templates: login: tpl_a_login register: tpl_a_register - id: backup type: VENDOR_B enabled: true signature: your-sign templates: login: tpl_b_login register: tpl_b_register ``` **会变化的东西尽量放配置,不要塞进业务代码。** ### 2. 故障切换不是设计模式,但它决定模块是否真正可用 验证码链路最怕的就是: “代码没报错,但用户就是收不到验证码。” 所以,一个能拿出去复用的短信设计,不能只考虑成功路径。 它至少要考虑: 1. 主通道失败怎么办。 2. 失败次数怎么记录。 3. 临时故障的通道要不要短时间禁用。 4. 多实例部署时状态怎么共享。 **设计模式决定模块是否清晰,故障切换决定模块是否可靠。** --- ## 九、如果你自己要设计一套短信验证码模块,可以按这个顺序做 建议按下面这个顺序设计,而不是一上来就接 SDK。 ```mermaid flowchart TD A["先定义统一发送入口"] --> B["再抽象发送策略接口"] B --> C["再实现不同厂商策略"] C --> D["再设计注册表 / 工厂选择机制"] D --> E["再把模板、签名、通道做成配置"] E --> F["最后补失败切换、缓存状态和监控"] ``` 这个顺序有一个很重要的设计原则: **先设计边界,再写实现。** 为什么很多短信模块后期越来越难改? 不是因为厂商 SDK 太复杂,而是因为一开始就把实现写进了业务层,没有先把边界抽出来。 如果顺序反过来,通常会更糟: 1. 先接 SDK。 2. 再把 SDK 调用复制到各个业务。 3. 再发现需要多个厂商。 4. 再发现需要主备切换。 5. 最后只能在旧代码上不断打补丁。 所以,真的想写出能长期维护的模块,顺序不能错。 --- ## 十、设计判断 ### 什么时候应该用门面模式 当调用方只需要一个简单动作,但这个动作背后其实包含多步协作时,就应该考虑门面模式。 短信验证码就是这种场景。 ### 什么时候应该用策略模式 当目标相同,但实现方式有多种,而且未来还可能继续增加时,就应该考虑策略模式。 多短信厂商就是这种场景。 ### 什么时候需要注册表 / 工厂思想 当你已经有多个策略实现,但运行时还需要根据条件动态选出一个正确实现时,就应该引入注册表或工厂机制。 通道类型选厂商,就是这种场景。 --- ## 十一、总结 如果你以后也要写类似模块,无论是短信、邮件、推送、支付,还是 OCR、地图、翻译接口,要先考虑这些问题 ### 问题 1. 有没有一个统一对外入口。 2. 有没有把不同供应商实现抽成策略接口。 3. 有没有一个清晰的机制,能在运行时选出正确实现。 4. 有没有把模板、签名、通道顺序等易变信息做成配置。 5. 有没有考虑主通道失败后的切换逻辑。 6. 有没有避免把供应商细节泄漏到业务层。 --- ## 结语 这篇文章的重点,是一套可以迁移的设计方法: 1. 用门面模式收口入口。 2. 用策略模式隔离厂商差异。 3. 用注册表 / 工厂思想完成动态选择。 剩下的配置化、缓存状态、失败切换、监控告警,都是围绕这套骨架继续往上搭。
Ai应用生成使用MongoDB作为存储上下文的记忆
1 首先引入依赖 ```js <!-- MongoDB 依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifactId> </dependency> ``` 2 定义一个储蓄类,用于完成上线文的存储(MongoChatMemoryStore) 主要作用是实现ChatMemoryStore接口,重写以下三个方法,完成上下文的存储。 为了存储方便,我们可以定义一个ChatMessages的实体类用于映射MongoDB的属性。 ```js @Data @AllArgsConstructor @NoArgsConstructor @Document("chat_history") public class ChatMessages { //唯一标识,映射到 MongoDB 文档的 _id 字段 @Id private ObjectId messageId; private String content; //存储当前聊天记录列表的json字符串 // 当然还可以加字段,不过目前够用 } ```  ```js @Component @Slf4j public class MongoChatMemoryStore implements ChatMemoryStore { private final MongoTemplate mongoTemplate; public MongoChatMemoryStore(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } @Override public List<ChatMessage> getMessages(Object memoryId) { try { Query query = new Query(Criteria.where("memoryId").is(memoryId.toString())); ChatMessages chatMessages = mongoTemplate.findOne(query, ChatMessages.class); return chatMessages != null ? ChatMessageDeserializer.messagesFromJson(chatMessages.getContent()) : new ArrayList<>(); } catch (Exception e) { log.error("Error retrieving messages for memoryId: {}", memoryId, e); return new ArrayList<>(); } } @Override public void updateMessages(Object memoryId, List<ChatMessage> messages) { try { Query query = new Query(Criteria.where("memoryId").is(memoryId.toString())); Update update = new Update() .set("content", ChatMessageSerializer.messagesToJson(messages)) .set("updatedAt", new Date()); mongoTemplate.upsert(query, update, ChatMessages.class); } catch (Exception e) { log.error("Error updating messages for memoryId: {}", memoryId, e); } } @Override public void deleteMessages(Object memoryId) { try { Query query = new Query(Criteria.where("memoryId").is(memoryId.toString())); mongoTemplate.remove(query, ChatMessages.class); } catch (Exception e) { log.error("Error deleting messages for memoryId: {}", memoryId, e); } } } ``` 3 修改AI相关类(AiCodeGeneratorServiceFactory.java) *注意:这块我的实现方式跟鱼哥的有些初入,但是最终实现的效果是一样的* ```js @Resource private MongoChatMemoryStore mongoChatMemoryStore; @Bean public AiCodeGeneratorService aiCodeGeneratorService() { return AiServices.builder(AiCodeGeneratorService.class) .chatModel(chatModel) .streamingChatModel(openAiStreamingChatModel) // 根据 id 构建独立的对话记忆 .chatMemoryProvider(memoryId -> MessageWindowChatMemory .builder() .id(memoryId) .chatMemoryStore(mongoChatMemoryStore) .maxMessages(20) .build()) .build(); } @Bean public AiCodeGeneratorService aiToolsCodeGeneratorService() { return AiServices.builder(AiCodeGeneratorService.class) .chatModel(chatModel) .streamingChatModel(openAiStreamingChatModel) // 根据 id 构建独立的对话记忆 .chatMemoryProvider(memoryId -> MessageWindowChatMemory .builder() .id(memoryId) .chatMemoryStore(mongoChatMemoryStore) .maxMessages(20) .build()) .tools(new FileWriteTool()) .build(); } ``` 4 最后在配置文件在加入MongoDB的配置 ```js spring: data: mongodb: uri: mongodb://admin:*****ad@192.168.56.10:27017/chat_history_db?authSource=admin ``` 5 总结 在工作中经常使用redis作为缓存,对于存储持久化信息老感觉不怎么对胃口。故此在对比了Es\Mongo\Pgsql后选择MongoDb。
工厂方法模式简介及应用场景
# 工厂方法模式简介及应用场景:☀️☀️☀️ 不积跬步无以至千里,不积小流无以成江河。欢迎交流~ # 1. 工厂方法模式(Factory Method Pattern)核心解读 工厂方法模式是创建型设计模式的核心之一,定义为: 定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。 ## 1.1 通俗理解 把“创建同类对象的不同实现”(比如创建不同品牌的手机:华为、苹果)抽离为独立的“工厂类”,主逻辑(业务代码)只需调用工厂接口获取对象,无需关心对象的具体创建细节。 ## 1.2 核心角色(4个) |角色|作用| |---|---| |抽象产品(Product)|定义所有具体产品必须实现的接口/抽象类(如手机的 call() 方法)| |具体产品(ConcreteProduct)|实现抽象产品,封装产品的具体功能(如华为手机、苹果手机)| |抽象工厂(Factory)|定义创建产品的接口,包含一个创建产品的抽象方法(如 createPhone())| |具体工厂(ConcreteFactory)|实现抽象工厂的创建方法,负责实例化具体产品(如华为工厂、苹果工厂)| ## 1.3 核心优势 开闭原则:新增产品时,仅需新增“具体产品类”和“具体工厂类”,无需修改原有工厂和业务代码; 解耦创建与使用:业务逻辑只依赖抽象产品和抽象工厂,不依赖具体实现,降低耦合度; 责任单一:工厂类专注于创建产品,业务类专注于使用产品,符合单一职责原则; 扩展性强:可在运行时通过切换具体工厂,动态创建不同的产品实例。 # 2. 工厂方法模式在Java中的典型示例(手机创建场景) 以“创建不同品牌手机”为例,对比不用工厂方法模式和用工厂方法模式的实现。 ## 2.1 反例:不用工厂方法模式(耦合严重) ```Java // 具体产品类 public class HuaweiPhone { public void call() { System.out.println("华为手机打电话"); } } public class IPhone { public void call() { System.out.println("苹果手机打电话"); } } // 业务逻辑直接依赖具体产品,耦合严重 public class PhoneService { // 新增手机品牌需修改此方法,违反开闭原则 public Object createPhone(String brand) { if ("HUAWEI".equals(brand)) { return new HuaweiPhone(); // 直接创建华为手机 } else if ("IPHONE".equals(brand)) { return new IPhone(); // 直接创建苹果手机 } else { throw new IllegalArgumentException("不支持的手机品牌"); } } public void usePhone(String brand) { // 需强制类型转换,代码臃肿 if ("HUAWEI".equals(brand)) { HuaweiPhone phone = (HuaweiPhone) createPhone(brand); phone.call(); } else if ("IPHONE".equals(brand)) { IPhone phone = (IPhone) createPhone(brand); phone.call(); } } } // 调用方 public class Client { public static void main(String[] args) { PhoneService service = new PhoneService(); service.usePhone("HUAWEI"); } } ``` 问题: 新增品牌(如小米)需修改 createPhone() 和 usePhone() 方法,违反开闭原则; 需手动强制类型转换,代码冗余且易出错; 产品创建细节暴露在业务逻辑中,维护成本高。 ## 2.2 正例:用工厂方法模式重构(大厂标准写法) 步骤1:定义抽象产品(手机接口) ```Java // 所有手机的统一接口(抽象产品) public interface Phone { void call(); // 核心功能:打电话 } ``` 步骤2:实现具体产品(各品牌手机) ```Java // 华为手机(具体产品) public class HuaweiPhone implements Phone { @Override public void call() { System.out.println("【华为手机】发起通话,支持5G高清通话"); // 华为手机专属功能逻辑(如鸿蒙系统适配) } } // 苹果手机(具体产品) public class IPhone implements Phone { @Override public void call() { System.out.println("【苹果手机】发起通话,支持FaceTime联动"); // 苹果手机专属功能逻辑(如iOS生态适配) } } ``` 步骤3:定义抽象工厂(手机工厂接口) ```Java // 手机工厂的统一接口(抽象工厂) public interface PhoneFactory { Phone createPhone(); // 创建手机的抽象方法 } ``` 步骤4:实现具体工厂(各品牌手机工厂) ```Java // 华为手机工厂(具体工厂) public class HuaweiFactory implements PhoneFactory { @Override public Phone createPhone() { System.out.println("华为工厂:生产华为Mate系列手机"); // 手机创建前的初始化逻辑(如硬件检测、系统预装) return new HuaweiPhone(); } } // 苹果手机工厂(具体工厂) public class IPhoneFactory implements PhoneFactory { @Override public Phone createPhone() { System.out.println("苹果工厂:生产iPhone 16系列手机"); // 手机创建前的初始化逻辑(如iOS激活、隐私设置) return new IPhone(); } } ``` 步骤5:定义环境类(统一调用入口) ```Java // 封装工厂的统一调用入口 public class PhoneContext { // 工厂容器:缓存所有手机工厂 private static final Map<String, PhoneFactory> FACTORY_MAP = new HashMap<>(); // 静态初始化工厂 static { FACTORY_MAP.put("HUAWEI", new HuaweiFactory()); FACTORY_MAP.put("IPHONE", new IPhoneFactory()); } // 外部统一调用方法 public Phone createPhone(String brand) { PhoneFactory factory = FACTORY_MAP.get(brand); if (factory == null) { throw new IllegalArgumentException("不支持的手机品牌:" + brand); } return factory.createPhone(); } // 动态添加新工厂 public void addFactory(String brand, PhoneFactory factory) { FACTORY_MAP.put(brand, factory); } } ``` 步骤6:调用方使用(极简) ```Java public class Client { public static void main(String[] args) { PhoneContext context = new PhoneContext(); // 创建华为手机 Phone huaweiPhone = context.createPhone("HUAWEI"); huaweiPhone.call(); // 创建苹果手机 Phone iphone = context.createPhone("IPHONE"); iphone.call(); // 新增小米手机(无需修改原有代码) // context.addFactory("XIAOMI", new XiaomiFactory()); // Phone xiaomiPhone = context.createPhone("XIAOMI"); // xiaomiPhone.call(); } } ``` 输出结果: ```Plain Text 华为工厂:生产华为Mate系列手机 【华为手机】发起通话,支持5G高清通话 苹果工厂:生产iPhone 16系列手机 【苹果手机】发起通话,支持FaceTime联动 ``` ## 2.3 Spring环境下的进阶写法(大厂实战) 结合注解+自动注入,无需手动维护工厂容器: ```Java // 1. 定义工厂注解 @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface PhoneFactoryAnnotation { String value(); // 手机品牌 } // 2. 具体产品交给Spring管理 @Component public class HuaweiPhone implements Phone { /* 实现方法同上 */ } @Component public class IPhone implements Phone { /* 实现方法同上 */ } // 3. 具体工厂添加注解+交给Spring管理 @PhoneFactoryAnnotation("HUAWEI") @Component public class HuaweiFactory implements PhoneFactory { @Autowired private HuaweiPhone huaweiPhone; @Override public Phone createPhone() { System.out.println("华为工厂:生产华为Mate系列手机"); return huaweiPhone; } } @PhoneFactoryAnnotation("IPHONE") @Component public class IPhoneFactory implements PhoneFactory { @Autowired private IPhone iphone; @Override public Phone createPhone() { System.out.println("苹果工厂:生产iPhone 16系列手机"); return iphone; } } // 4. 环境类自动加载工厂 @Component public class PhoneContext { private final Map<String, PhoneFactory> factoryMap; // Spring自动注入所有PhoneFactory类型的Bean public PhoneContext(List<PhoneFactory> factories) { factoryMap = new HashMap<>(); for (PhoneFactory factory : factories) { String brand = factory.getClass().getAnnotation(PhoneFactoryAnnotation.class).value(); factoryMap.put(brand, factory); } } // 创建手机方法(同上) public Phone createPhone(String brand) { PhoneFactory factory = factoryMap.get(brand); if (factory == null) { throw new IllegalArgumentException("不支持的手机品牌:" + brand); } return factory.createPhone(); } } // 5. 业务层调用 @Service public class OrderService { @Autowired private PhoneContext phoneContext; public void deliverPhone(String brand) { // 订单逻辑... Phone phone = phoneContext.createPhone(brand); System.out.println("配送手机:" + phone.getClass().getSimpleName()); phone.call(); } } ``` ## 3. 工厂方法模式的典型应用场景(大厂高频) 除手机创建场景外,工厂方法模式在大厂项目中的核心应用: ### 3.1 多产品系列创建 场景:电商平台的商品创建(实物商品、虚拟商品)、物流系统的包裹创建(普通包裹、冷链包裹); 实现:每种商品/包裹对应“具体产品+具体工厂”,业务层通过抽象工厂统一创建。 ### 3.2 算法/接口适配 场景:日志框架(Logback、Log4j 适配)、ORM框架(MyBatis 不同数据源适配); 实现:每种日志/数据源适配方式封装为工厂类,核心逻辑通过抽象工厂调用。 ### 3.3 框架内置的工厂方法模式 大厂常用框架大量使用工厂方法模式: JDK:Calendar(不同时区的日历实例)、NumberFormat(不同格式的数字格式化); ```Java // Calendar的工厂方法应用 List<Integer> list = Arrays.asList(3,1,2); Calendar chinaCalendar = Calendar.getInstance(TimeZone.getTimeZone("Asia/Shanghai")); Calendar usCalendar = Calendar.getInstance(TimeZone.getTimeZone("America/New_York")); ``` Spring:BeanFactory(Bean创建工厂)、TransactionManager(不同数据源的事务管理器); MyBatis:SqlSessionFactory(创建SqlSession实例,适配不同环境)。 # 4. 大厂为什么重视设计模式? 很多新手认为设计模式是“花架子”,但大厂对其重视程度极高,核心原因: ## 4.1 解决大规模代码的维护性问题 大厂项目代码量可达百万行,人员流动频繁: 无设计模式:代码是“面条式”new对象,新人接手需逐行读代码,改一行崩一片; 有设计模式:代码遵循统一规范(如工厂方法模式的“接口+实现”),新人快速理解,维护成本降低80%。 ## 4.2 支撑高扩展的业务需求 大厂业务迭代极快(如电商每年新增N种商品类型): 无设计模式:新增功能需修改核心代码,风险高; 有设计模式:新增产品仅需加新类,符合“开闭原则”,上线风险几乎为0。 ## 4.3 实现代码复用+标准化 大厂强调“不重复造轮子”: 工厂方法模式将对象创建逻辑封装为独立类,可在多模块复用; 设计模式是“程序员的通用语言”,说“这里用工厂方法模式”,团队全员理解代码结构,沟通成本降低。 ## 4.4 适配高并发/高可用架构 工厂方法模式的“解耦”特性是高并发架构的基础: 商品创建场景:可按商品类型路由到不同工厂/服务器,实现分流; 数据库连接场景:可按业务模块切换不同连接池工厂,提升性能。 # 5. 总结 工厂方法模式核心:封装对象创建逻辑为独立工厂,解耦“创建逻辑”与“使用逻辑”; 典型应用:多产品创建、框架扩展、数据源适配等需动态创建对象的场景; 大厂价值:维护性(易读易改)、扩展性(新增产品不碰旧代码)、复用性(工厂类可复用); 避坑点:若仅创建单一产品且永不扩展,无需使用(避免过度设计);复杂多产品族场景,可升级为“抽象工厂模式”。 > (注:文档部分内容可能由 AI 生成)
当我开始为系统长期演进负责之后
> 过去的一年里,我参与并主导了多个不同阶段的项目: > 重构已经积累大量历史负担的老系统,也从 idea 开始,独立推进过项目的立项、架构设计、研发与上线,同时还在多个团队之间进行深度协作。 > > 是在这些实践中,我逐渐意识到,很多工程问题并不是“技术能力不足”,而是对接口、边界以及变化的理解存在偏差。 > > 当你需要为系统的长期演进负责,而不只是完成一次性交付时,接口不再只是 HTTP 入口,而是变成真正的协作契约;模块不再只是代码集合,而会逐渐呈现出“作品”的形态。 > > 这篇文章,正是基于这一年真实工程实践中的反思与总结,尝试从接口设计的角度,重新理解变化、边界与每一个工程师的责任。 ### **接口隔离的是变化,而不仅仅只是入口** 在日常讨论中,我们往往将接口等同于 HTTP 接口,将其理解为一次请求的入口。但实际上,这只是接口的一种表现形式,而不是其本质。 接口真正的意义,在于模块之间的协作边界。 它定义的不是从“哪里进入系统”,而是不同模块如何在不相互干扰的前提下协同工作。 可以将接口理解为两个人之间的协作契约: 一方的行为依赖于另一方的输出,但双方只需约定输出的形式与语义,而不关心对方的内部实现方式。只要契约稳定,内部如何演进,彼此都不应受到影响。 因此,良好的协作方式并不是多人共同开发同一个模块,而是通过清晰的模块边界实现隔离,每个模块都是一个完整的整体,只对外暴露必要的能力,对内则可以自然地演进。 就像画家不会在别人的作品上反复覆盖和修改,而是在自己的画布上完成创作。模块亦然,它应该被视为一件完整的作品,任何新增的功能都不应破坏其既有结构。 真正成熟的设计,并不是在已有结构上不断“修补”,而是在一开始就预留变化的空间。当变化可以在既定边界内被吸收,系统便能持续演进;而当变化必然破坏原有结构时,就应当被明确拒绝,或者通过新的模块、新的边界,重新构建更合适的设计。 接口的价值,正在于此。 隔离的不是访问入口,而是变化本身。 这是接口设计的抽象层理解,但它并不足以解释工程中的真实选择。 ### 拉回现实 回到真实的工程环境,接口被破坏,很少时因为“不懂设计原则”。 更多的时候,都是现实的条件在不断挤压工程边界。 需求变更来的很急, 联调时间被压缩, 修改一个返回结构,似乎比重新设计一个模块要“更快”。 于是我们开始在以后接口上不断追加字段、塞特殊逻辑、绕过既有约束。 从局部来看,这是一次次“务实”的选择; 但从整体来看,整个系统的逻辑已然被篡改。 接口不再隔离变化,而是成为变化的集中入口。 模块不再是稳定的整体,而是变成了可以被随意侵入的公共区域。 这种失控并非一蹴而就,而是在一次”先这样把“”下次再改“的妥协中逐渐形成。 真正的问题不在于接口被修改,而在于修改是否仍然尊重模块边界。 ### 作品意识 > 作品意识,是工程师在面对变化时,用来做取舍的内在约束。 > 所谓作品意识,并不是追求复杂设计,也不是拒绝变化。 **我是否愿意为这个模块的整体性负责。** 当你把一个模块视为“作品”,你会自然地产生几个判断: - 新需求是否真的属于这个模块地职责范围 - 这次修改,是在扩展结构,还是在破坏结构 - 是否存在一种方式,可以在不侵入他人边界地前提下完整目标 正因为如此,具备作品意识地人,往往更愿意拒绝不合适地变化,或者选择重新划分边界,而不是在原有结构上不断修补。 这并不意味着效率更低。相反,它是在用更长时间的维度思考问题。 ### 现实选择与长期结果 > 当这种取舍在系统中反复出现时,工程实践会逐渐分化出两种路径。 > 在工程实际中,确实存在着两种不同的选择路径: 一种选择是,把系统视为一组可以随时改动的功能集合。 在这种视角下,接口只是入口,模块只是文件夹,能跑起来就是成功。 另一种选择是,把系统视为由多个稳定作品组成的整体。 接口是边界,模块是承诺,修改意味着责任。 短期内,两者的交付速度可能没有明显差异; 但随着时间推移,系统的可理解性、可演进性和协作成本,会出现明显分化。 ### 回到接口的意义 接口之所以重要,并不是因为它“限制了你能做什么”, 而是因为它**保护了已经做对了的部分,不被随意破坏**。 当接口能够隔离变化, 模块才能保持完整, 作品才能持续演进。 这也是为什么,接口设计最终反应的,并不是技术水平,而是工程师对自己作品的态度。 > 当一个系统允许随意修改他人模块时,它已经不再是协作,而是一种消耗。 接口存在的意义,是让变化有序发生,让作品得以保留。 >
策略模式简介及应用场景
关于策略模式在编码过程中的应用,这是一篇跟AI对话得出的学习文章,欢迎交流~ # 策略模式简介及应用场景:☀️☀️☀️ ## 1. 策略模式(Strategy Pattern)核心解读 策略模式是**行为型设计模式**的核心之一,定义为: > 定义一系列算法,把它们一个个封装起来,并且使它们可互相替换。此模式让算法的变化独立于使用算法的客户。 ### 1.1 通俗理解 把“完成同一任务的不同方法”(比如支付可选择微信、支付宝)抽离为独立的“策略类”,主逻辑(支付流程)只需调用统一接口,切换方法时无需修改主逻辑。 ### 1.2 核心角色(3个) | 角色 | 作用 | | -------------------------------- | ------------------------------------------------------------ | | **环境类(Context)** | 持有策略对象引用,提供统一调用入口,不负责选择具体策略(也可负责) | | **抽象策略(Strategy)** | 定义所有具体策略必须实现的接口/抽象类(如支付的 `pay()` 方法) | | **具体策略(ConcreteStrategy)** | 实现抽象策略,封装具体算法(如微信支付、支付宝支付的逻辑) | ### 1.3 核心优势 - **开闭原则**:新增策略无需修改原有代码,仅需新增类; - **消除冗余 if-else**:避免主逻辑中大量条件分支的臃肿代码; - **代码复用+可测试**:每个策略独立封装,可单独测试、复用; - **动态切换**:运行时可根据条件切换策略(如用户选择支付方式后自动匹配)。 ## 2. 策略模式在Java中的典型示例(支付场景) 以“支付方式选择”为例,对比**不用策略模式**和**用策略模式**的实现。 ### 2.1 反例:不用策略模式(臃肿if-else) ``` // 主逻辑耦合所有支付方式,新增/修改需改代码 public class PaymentService { public void pay(String payType, BigDecimal amount) { if ("WECHAT".equals(payType)) { System.out.println("微信支付:" + amount + "元"); // 微信支付的SDK调用、签名等逻辑(数百行) } else if ("ALIPAY".equals(payType)) { System.out.println("支付宝支付:" + amount + "元"); // 支付宝支付的逻辑(数百行) } else if ("BANK".equals(payType)) { System.out.println("银行卡支付:" + amount + "元"); } else { throw new IllegalArgumentException("不支持的支付方式"); } } } // 调用方 public class Client { public static void main(String[] args) { PaymentService service = new PaymentService(); service.pay("WECHAT", new BigDecimal("100")); } } ``` **问题**: - 新增支付方式需修改核心方法,违反开闭原则; - 代码臃肿,维护成本高; - 支付逻辑无法单独测试、复用。 ### 2.2 正例:用策略模式重构(大厂标准写法) #### 步骤1:定义抽象策略(支付接口) ``` // 所有支付方式的统一接口 public interface PaymentStrategy { void pay(BigDecimal amount); // 支付方法 String getPayType(); // 获取支付类型(用于匹配) } ``` #### 步骤2:实现具体策略(各支付方式) ``` // 微信支付策略 public class WechatPayment implements PaymentStrategy { @Override public void pay(BigDecimal amount) { System.out.println("【微信支付】发起支付,金额:" + amount + "元"); // 微信SDK调用、签名等逻辑 } @Override public String getPayType() { return "WECHAT"; } } // 支付宝支付策略 public class AlipayPayment implements PaymentStrategy { @Override public void pay(BigDecimal amount) { System.out.println("【支付宝支付】发起支付,金额:" + amount + "元"); // 支付宝SDK调用、验签等逻辑 } @Override public String getPayType() { return "ALIPAY"; } } // 银行卡支付策略 public class BankPayment implements PaymentStrategy { @Override public void pay(BigDecimal amount) { System.out.println("【银行卡支付】发起支付,金额:" + amount + "元"); // 银行接口调用、身份验证等逻辑 } @Override public String getPayType() { return "BANK"; } } ``` #### 步骤3:定义环境类(统一入口) ``` // 封装策略的统一调用入口 public class PaymentContext { // 策略容器:缓存所有支付策略 private static final Map<String, PaymentStrategy> STRATEGY_MAP = new HashMap<>(); // 静态初始化策略 static { STRATEGY_MAP.put(new WechatPayment().getPayType(), new WechatPayment()); STRATEGY_MAP.put(new AlipayPayment().getPayType(), new AlipayPayment()); STRATEGY_MAP.put(new BankPayment().getPayType(), new BankPayment()); } // 外部统一调用方法 public void pay(String payType, BigDecimal amount) { PaymentStrategy strategy = STRATEGY_MAP.get(payType); if (strategy == null) { throw new IllegalArgumentException("不支持的支付方式:" + payType); } strategy.pay(amount); } // 动态添加新策略 public void addStrategy(PaymentStrategy strategy) { STRATEGY_MAP.put(strategy.getPayType(), strategy); } } ``` #### 步骤4:调用方使用(极简) ``` public class Client { public static void main(String[] args) { PaymentContext context = new PaymentContext(); context.pay("WECHAT", new BigDecimal("100")); // 微信支付 context.pay("ALIPAY", new BigDecimal("200")); // 支付宝支付 // 新增策略(无需修改原有代码) // context.addStrategy(new UnionPayPayment()); // context.pay("UNION", new BigDecimal("300")); } } ``` **输出结果**: ``` 【微信支付】发起支付,金额:100元 【支付宝支付】发起支付,金额:200元 ``` ### 2.3 Spring环境下的进阶写法(大厂实战) 结合`注解+自动注入`,无需手动维护策略容器: ``` // 1. 定义策略注解 @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface PayStrategy { String value(); // 支付类型 } // 2. 具体策略添加注解+交给Spring管理 @PayStrategy("WECHAT") @Component public class WechatPayment implements PaymentStrategy { /* 实现方法 */ } @PayStrategy("ALIPAY") @Component public class AlipayPayment implements PaymentStrategy { /* 实现方法 */ } // 3. 环境类自动加载策略 @Component public class PaymentContext { private final Map<String, PaymentStrategy> strategyMap; // Spring自动注入所有PaymentStrategy类型的Bean public PaymentContext(List<PaymentStrategy> strategies) { strategyMap = new HashMap<>(); for (PaymentStrategy strategy : strategies) { String payType = strategy.getClass().getAnnotation(PayStrategy.class).value(); strategyMap.put(payType, strategy); } } // 支付方法(同上) public void pay(String payType, BigDecimal amount) { /* 逻辑同上 */ } } // 4. 业务层调用 @Service public class OrderService { @Autowired private PaymentContext paymentContext; public void createOrder(String payType, BigDecimal amount) { // 订单逻辑... paymentContext.pay(payType, amount); } } ``` ## 3. 策略模式的典型应用场景(大厂高频) 除支付场景外,策略模式在大厂项目中的核心应用: ### 3.1 业务规则动态切换 - **场景**:电商优惠策略(满减、折扣、优惠券)、物流配送策略(顺丰、京东); - **实现**:每种规则封装为策略类,下单时根据订单信息动态匹配。 ### 3.2 算法/接口适配 - **场景**:消息推送(短信、钉钉、企业微信)、OSS存储(阿里云、腾讯云); - **实现**:每种推送/存储方式封装为策略,核心逻辑通过统一接口调用。 ### 3.3 框架内置的策略模式 大厂常用框架大量使用策略模式: - **JDK**:`Comparator`(排序策略)、`ThreadPoolExecutor`的拒绝策略; ``` // Comparator的策略模式应用 List<Integer> list = Arrays.asList(3,1,2); list.sort(Comparator.naturalOrder()); // 升序策略 list.sort(Comparator.reverseOrder()); // 降序策略 ``` - **Spring**:`Resource`(资源加载策略)、`HandlerMapping`(请求映射策略)。 ## 4. 大厂为什么重视设计模式? 很多新手认为设计模式是“花架子”,但大厂对其重视程度极高,核心原因: ### 4.1 解决大规模代码的维护性问题 大厂项目代码量可达百万行,人员流动频繁: - 无设计模式:代码是“面条式”if-else,新人接手需逐行读代码,改一行崩一片; - 有设计模式:代码遵循统一规范(如策略模式的“接口+实现”),新人快速理解,维护成本降低80%。 ### 4.2 支撑高扩展的业务需求 大厂业务迭代极快(如电商每年新增N种支付方式): - 无设计模式:新增功能需修改核心代码,风险高; - 有设计模式:新增策略仅需加新类,符合“开闭原则”,上线风险几乎为0。 ### 4.3 实现代码复用+标准化 大厂强调“不重复造轮子”: - 策略模式将通用逻辑封装为独立类,可在多模块复用; - 设计模式是“程序员的通用语言”,说“这里用策略模式”,团队全员理解代码结构,沟通成本降低。 ### 4.4 适配高并发/高可用架构 策略模式的“解耦”特性是高并发架构的基础: - 支付场景:可按支付类型路由到不同线程池/服务器,实现分流; - 存储场景:可按数据量切换存储介质(内存/磁盘/OSS),提升性能。 ## 5. 总结 - **策略模式核心**:封装算法为独立策略,解耦“使用逻辑”与“具体实现”; - **典型应用**:支付、优惠、推送、存储适配等需动态切换逻辑的场景; - **大厂价值**:维护性(易读易改)、扩展性(新增功能不碰旧代码)、复用性(策略类可复用); - **避坑点**:若策略少于2个且永不扩展,无需使用(避免过度设计)。
因为设计模式————加班14天,没写出来一个需求
这几天在美团加班了14天,没写出来代码。 不解释,纯菜。 ### 适配器模式 我有一个非常简单的需求:更新一个字段。 有人说:那还不简单,一个update直接解决啊。你菜就直说,别绕弯子。 这时候。就遇见了第一个问题:适配器模式。 美团有两个不同的公司:美团、大众点评。他们底层代码是不一致的。 我上线的所有功能,当我更新的时候,美团开始售卖,大众点评也开始售卖。 如何实现?适配器模式。 * 我更新了以后,通过适配器模式实现两个不同的变更。不需要同时改动美团和大众点评的代码 所以,我只能调用提供的接口来进行变更。 * 我不能直接操作数据库,我需要操作适配器 通过适配器来调整参数。 ### 策略模式 然后就出现了第二个问题:美团有版本1.0和版本2.0 美团有版本1.0、版本2.0,同时还得更新大众点评。 于是我们就可以了解这个项目的全貌了: * 通过策略模式,来控制流量走哪个接口 * 然后通过适配器模式,来同时更新美团和大众点评 于是大聪明就出现了: * 先通过策略模式实现流量的转发,控制1.0还有2.0 * 然后再通过策略模式实现业务的切换 * 最后通过适配器实现双端售卖 ### 策咯模式套策略模式套适配器模式 但是有个问题我得给你聊:美团有个业务,他数据量太大了,底层数据库扛不住。 那咋办?我有个建议:上es。 双写,然后读es。 太简单了:实现一个策略模式,读取es和mysql 于是大聪明就出现了:策略模式 大聪明已经看懂了: * 通过策略模式实现流量的转发,控制1.0还是2.0 * 然后通过策略模式实现业务的切换 * 在业务中,实现策略模式,实现es和mysql的双写 ### DDD解耦方式 而这个逻辑,被封装进了一个服务中。 人话:通过微服务进行aop切面 * 我调用某个服务 * 服务给你构建上下文,然后将新的上下文传给你 * 你基于这个服务实现切面 我们可以通过一个统一的上下文,来管理策略的控制。 × 你传入一个特定的参数,我们通过参数来控制上下文,来实现策略的转发 好消息好消息:我开发的需求正好击穿了上下文模式。 你看:我传入了一个上下文,他是用来实现策略模式的。 但是问题是:我的场景没办法传入上下文。 我新接入了一个业务方,想要接入这个,需要单独的给这个业务方构建上下文。 但是:请问构建上下文的微服务是哪个? ### 线程安全 + 重试 然后我们继续聊:上下文模式需要传入上下文,但是上下文有线程安全问题。 原因也非常简单: * 我的接口有性能问题,必须使用多线程 * 而多线程不能共享一个上下文 我复述一下这个问题: * 一个接入方,他必须使用统一的上下文 * 但是我这个接入方,他需要可变上下文 * 但是我有线程安全问题,所以必须单独构建上下文 ### if 我很生气,于是我就开始写if分支了。 我不传上下文,我直接手写if else不行? 然后主R表示:你这样无法维护,没法下线 * 你得实现策略模式,依赖美团实现的策略模式套策略模式,实现策略模式 然后我就凉凉了。 ### 学不会? 简单来说,滥用了策略模式,导致学习成本极高。 不用策略模式,开发成本也极高
springboot+vue+小程序做的积分兑换系统
# 用户端功能 1. 用户端具有注册、登录、退出功能。 2. 用户端能实现对个人信息的编辑、自动关联所属村庄。 3. 用户端可以查看当前积分、历史记录(按“获取/消费”分类)、本村积分规则。 4. 用户端可以报名活动和会议(村级/乡镇级)、进行行为申报(拍照上传证明,如垃圾分类)。 5. 用户可以查看本村及跨村通用商品(标注“仅限本村”“全乡镇通用”),提交兑换申请。 6. 用户可以收到消息通知:积分到账提醒、活动报名成功、兑换审核结果、乡镇/村级公告。 # 管理端功能 7. 管理端实现对所有用户信息进行增删改等操作。 8. 管理员端审核村民申报的行为(通过/驳回,填写理由)、批量添加活动参与积分。 9. 管理员端进行活动管理,发布本村活动(设置时间、地点、积分奖励)、查看报名列表。 10. 管理员端进行商品管理,上架/下架本村兑换商品(设置积分值、库存、兑换规则)。 11. 管理员端通过数据查看本村积分排行、村民参与度分析、积分消耗TOP商品。 12. 后端管理员可以新增/编辑村庄信息、配置各村积分规则模板、查看各村数据汇总。 13. 后端管理员进行发布乡镇级活动(全乡镇村民可参与)、设置统一积分奖励。 14. 后端管理员可以与政务系统同步村民户籍信息、与连锁商户对接商品库存。 15. 后端管理员可以查看乡镇积分总览、各村活跃度排名、积分规则效果分析。                                 
C语言做的停车场车牌识别系统
# 智能停车场车牌识别系统 这是一个使用C语言实现的智能停车场管理系统,具备车牌识别、计费系统和车位引导功能。 ## 功能特性 ### 🚗 车牌号码识别 - 支持预定义车牌模式匹配 - 基本格式验证(6-8位字符) - 大小写不敏感识别 - 支持全国各省市车牌格式 ### 💰 计费系统 - 基于停车时长计费 - 每小时10元收费标准 - 不足1小时按1小时计算 - 自动计算并显示费用 ### 🅿️ 车位引导 - 50个停车位管理 - 自动分配空闲车位 - 实时显示车位使用情况 - 车位使用率统计 ## 编译说明 使用简单的gcc指令编译,无需Makefile: ```bash gcc -o parking_system main.c parking_system.c -Wall ``` ## 运行程序 ```bash ./parking_system ``` ## 使用说明 ### 主菜单选项 1. **车辆入场** - 输入车牌号码,系统自动识别并分配车位 2. **车辆出场** - 输入车牌号码,系统计算费用并释放车位 3. **查看停车场状态** - 显示当前车位使用情况和收入统计 4. **查看支持的车牌模式** - 显示系统支持的车牌格式 5. **退出系统** - 安全退出程序 ### 支持的车牌格式 系统预定义了以下车牌模式: - 京A12345 (北京车牌) - 沪B67890 (上海车牌) - 粤C11111 (广东车牌) - 苏D22222 (江苏车牌) - 浙E33333 (浙江车牌) - 鲁F44444 (山东车牌) - 豫G55555 (河南车牌) - 川H66666 (四川车牌) - 渝I77777 (重庆车牌) - 津J88888 (天津车牌) **注意**:系统也支持其他标准格式的车牌(6-8位字符) ### 计费规则 - 收费标准:10元/小时 - 不足1小时按1小时计算 - 按小时向上取整 ## 系统架构 ``` parking_system.h - 头文件,定义结构体和函数声明 parking_system.c - 核心功能实现 main.c - 主程序入口 README.md - 项目说明文档 ``` ### 核心数据结构 - `ParkingSystem` - 停车场系统主结构 - `Car` - 车辆信息结构 - `ParkingSpot` - 停车位状态结构 - `PlatePattern` - 车牌模式结构 ## 技术特点 - **简单易用**:命令行界面,操作直观 - **模块化设计**:功能分离,便于维护 - **内存安全**:使用静态数组,避免内存泄漏 - **实时更新**:停车状态实时同步 - **数据持久**:会话期间数据保持完整 ## 扩展建议 1. 添加数据持久化功能(文件存储) 2. 实现图形用户界面 3. 增加车牌图像识别功能 4. 添加用户权限管理 5. 实现多停车场管理 ## 注意事项 - 程序运行期间数据存储在内存中,重启后数据会丢失 - 车牌号码区分大小写,但系统会自动转换为大写进行匹配 - 最大支持100辆车的记录,50个停车位 - 建议在Windows/Linux环境下使用gcc编译运行  
C语言做的单词背诵测试器
# 单词学习测试器 一个用C语言实现的单词学习测试程序,支持从文件加载单词库、随机测试、错题本等功能。 ## 功能特点 - 📚 **文件加载**:从文本文件加载英文-英文单词库 - 🎲 **随机测试**:随机抽取单词进行测试 - 📊 **统计功能**:记录测试次数、正确率等统计信息 - 📝 **错题本**:自动保存错误次数多的单词 - 💾 **数据持久化**:测试数据自动保存到文件 ## 编译和运行 ### 简单GCC指令(推荐) ```bash # 编译程序 gcc word_test.c -o word_test.exe # 运行程序 word_test.exe ``` ### 其他编译选项 ```bash # 带警告标志 gcc -Wall -Wextra -std=c99 word_test.c -o word_test.exe # 带优化 gcc -O2 word_test.c -o word_test.exe ``` ## 文件说明 - `word_test.c` - 主程序源代码 - `word_library.txt` - 单词库文件(程序会自动创建示例) - `wrong_words.txt` - 错题本文件(程序自动生成) ## 单词库文件格式 单词库文件 `word_library.txt` 的格式如下: ``` hello greeting world earth computer machine programming coding ``` 每行一个单词,英文单词和含义用空格分隔。 ## 程序功能 ### 1. 开始随机测试 - 输入要测试的单词数量 - 程序随机抽取单词进行测试 - 显示单词和含义,然后询问回忆准确性 - 自动记录错误次数 ### 2. 查看统计信息 - 显示总单词数、测试次数、正确次数 - 计算并显示正确率 - 显示错误次数最多的单词 ### 3. 查看错题本 - 显示所有错误过的单词 - 按错误次数从高到低排序 - 帮助重点复习薄弱单词 ## C语言知识点 本程序涉及以下C语言知识点: ### 文件操作 - `fopen()` - 打开文件 - `fscanf()` - 格式化读取 - `fprintf()` - 格式化写入 - `fclose()` - 关闭文件 ### 随机数生成 - `srand()` - 设置随机数种子 - `rand()` - 生成随机数 - `time()` - 获取当前时间作为种子 ### 字符串处理 - `strcpy()` - 字符串复制 - `strcmp()` - 字符串比较 - `strstr()` - 字符串查找 - `strcspn()` - 查找字符位置 ### 内存管理 - `malloc()` - 动态内存分配 - `free()` - 释放内存 ### 结构体 - 定义和使用结构体 - 结构体数组 ## 使用示例 1. 首次运行程序会自动创建示例单词库 2. 选择"开始随机测试",输入测试数量(如5) 3. 记住显示的单词含义 4. 选择回忆准确性等级(1-3) 5. 查看测试结果和正确率 6. 在错题本中查看错误过的单词 7. 退出程序时数据会自动保存 ## 注意事项 - 程序使用自评方式判断准确性(1-3分制) - 错题本数据会在程序退出时自动保存 - 可以手动编辑 `word_library.txt` 添加更多单词 - 程序兼容Windows和Linux系统 ## 扩展建议 - 添加单词发音功能 - 支持多种测试模式(英文到英文、含义到单词) - 添加学习进度跟踪 - 支持导入/导出单词库 - 添加图形用户界面   
