当泛型遇上现实:从表象到本质的技术思考

当泛型遇上现实:从表象到本质的技术思考 最近在优化一个序列化框架时,遇到了一些类型安全方面的意外行为。这让我重新审视了JVM泛型系统的底层机制。虽然类型擦除是个老话题,但当我深入分析Kotlin的reified实现时,发现了一些值得思考的细节。 一个简单现象引发的思考 我们都知道Java的类型擦除,但最近在排查问题时,我又重新观察了这个现象: 1 2 3 4 5 List<String> stringList = new ArrayList<>(); List<Integer> intList = new ArrayList<>(); // 在运行时,它们的Class信息完全相同 System.out.println(stringList.getClass() == intList.getClass()); // true 这是类型擦除的基本表现:编译器将泛型参数替换为边界类型。不过这让我想到一个问题:既然编译器会进行严格的泛型类型检查,那反射是如何绕过这些检查机制的? 反射揭示的真相 为了理解这个机制,我做了个实验: 1 2 3 4 5 6 7 8 9 10 11 List<String> list = new ArrayList<>(); list.add("hello"); // 通过反射调用add方法 Method add = List.class.getMethod("add", Object.class); // 注意参数是Object add.invoke(list, 42); // 成功添加Integer到String List // 遍历时才会出错 for (String s : list) { // 这里会ClassCastException System.out.println("String: " + s); } 这个结果其实很有启发性。关键在于getMethod("add", Object.class)——我们必须用Object.class,因为编译后的方法签名就是add(Object obj)。 通过字节码分析可以看到: 1 2 3 4 5 6 // List.add方法的实际签名 11: invokeinterface #12, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z // 遍历时的类型转换 138: invokeinterface #87, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object; 143: checkcast #62 // class java/lang/String 类型擦除的巧妙之处在于:编译器在需要类型转换的地方插入checkcast指令,将类型检查推迟到实际使用时。反射之所以能绕过编译期检查,是因为它直接操作字节码层面,而JVM运行时只验证原始类型,不验证泛型参数。 但这又让我思考另一个问题:如果反射能绕过类型检查,为什么反射API还能获取到一些泛型信息呢? 反射API的能力边界 为了深入理解类型擦除的补偿机制,我创建了一个具体的泛型类来测试反射API能获取哪些泛型信息: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 一个简单的泛型类,用于测试反射能力 public class GenericClassDemo<T extends Number> { private List<T> items = new ArrayList<>(); public void addItem(T item) { items.add(item); } public List<T> getItems() { return items; } public <E> void processWithGenericMethod(E element, List<? super E> sink) { sink.add(element); } } 现在让我们测试反射API在这个类上的表现: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import java.lang.reflect.*; import java.util.Arrays; Class<GenericClassDemo> clazz = GenericClassDemo.class; // 类级别的类型参数 - 可以获取! TypeVariable<?>[] typeParameters = clazz.getTypeParameters(); for (TypeVariable<?> typeParam : typeParameters) { System.out.println("Type Parameter: " + typeParam.getName()); Type[] bounds = typeParam.getBounds(); for (Type bound : bounds) { System.out.println(" Bound: " + bound.getTypeName()); } } // 输出:Type Parameter: T // Bound: java.lang.Number // 字段的泛型信息 - 可以获取! Field itemsField = clazz.getDeclaredField("items"); Type fieldType = itemsField.getGenericType(); System.out.println("Field type: " + fieldType); // 输出:Field type: java.util.List<T> // 方法的泛型信息 - 可以获取! Method addMethod = clazz.getMethod("addItem", Number.class); Type[] paramTypes = addMethod.getGenericParameterTypes(); System.out.println("addItem parameter type: " + paramTypes[0]); // 输出:addItem parameter type: T // 泛型方法的参数信息 - 也可以获取! Method genericMethod = clazz.getMethod("processWithGenericMethod", Object.class, List.class); Type[] genericParamTypes = genericMethod.getGenericParameterTypes(); System.out.println("processWithGenericMethod parameter types: " + Arrays.toString(genericParamTypes)); // 输出:processWithGenericMethod parameter types: [E, java.util.List<? super E>] 但是有一个关键的限制——当我们创建具体的实例时: 1 2 3 4 5 6 GenericClassDemo<Integer> instance = new GenericClassDemo<>(); // 运行时实例的具体类型参数 - 无法获取! System.out.println("Instance class: " + instance.getClass()); System.out.println("Instance type parameter: Cannot retrieve (type erasure)"); // 输出:Instance class: class com.example.GenericClassDemo // Instance type parameter: Cannot retrieve (type erasure) 这个对比很有启发性:泛型声明信息可以通过Signature属性保留,但运行时实例的具体类型参数确实被擦除。反射API的能力边界恰好体现了类型擦除的精确范围。 在字节码层面,编译器会保存完整的泛型签名: 1 2 3 4 5 // GenericClassDemo类的签名 Signature: #25 // <T:Ljava/lang/Number;>Ljava/lang/Object; // addItem方法的签名 Signature: #18 // (TT;)V 这就是为什么反射API能获取泛型信息——信息并未完全消失,而是以另一种形式保留在字节码中。 这里需要澄清一个重要概念:类型擦除 ≠ 类型信息完全消失。更准确地说,类型擦除是一个分层的过程——编译期的泛型类型检查被移除,但通过Signature属性等机制,足够的信息仍被保留以支持反射API。反射绕过类型检查的根本原因不是”信息丢失”,而是它直接操作字节码层面,跳过了编译器设置的类型安全护栏。 不过这又让我想到另一个问题:既然JVM在类型擦除后只保留原始类型信息,Kotlin的reified是怎么做到的? Kotlin reified的巧思 在使用Jackson的Kotlin扩展时,我注意到这样的API: 1 2 val mapper = jacksonObjectMapper() val person: Person = mapper.readValue<Person>(json) // 看起来保留了类型信息 这看起来超越了JVM类型擦除的限制。为了理解这个机制,我对比了普通泛型函数和reified函数: 1 2 3 4 5 6 7 8 9 10 // 普通泛型函数 - 无法检查类型 fun <T> checkNormal(obj: Any): Boolean { // return obj is T // 编译错误!类型T被擦除 return false } // reified函数 - 可以检查类型 inline fun <reified T> checkReified(obj: Any): Boolean { return obj is T // 编译通过且工作正常 } reified的关键在于inline修饰符。当我们调用checkReified<String>("hello")时,Kotlin编译器会将函数体内联到调用点,并将类型参数T替换为具体的String。 这样,原本的obj is T在字节码中就变成了obj is String的直接类型检查。这里体现了Java编译器和Kotlin编译器的根本差异: Java编译器的处理方式 1 2 3 4 // Java泛型方法 public <T> boolean check(Object obj) { return obj instanceof T; // 编译错误! } Java编译器直接拒绝编译这种写法,实际的错误信息为: 1 2 3 4 5 6 TestInstanceof.java:3: error: Object cannot be safely cast to T return obj instanceof T; ^ where T is a type-variable: T extends Object declared in method <T>check(Object) 1 error 这是因为类型擦除后,编译器无法在运行时获取T的具体类型信息。Java设计者选择了在编译期就阻止这种潜在错误的做法。 Kotlin编译器的处理方式 1 2 3 inline fun <reified T> checkReified(obj: Any): Boolean { return obj is T // 编译通过! } Kotlin编译器通过内联展开在编译时解决了这个问题: 我们可以通过字节码验证这一点。当调用checkReified<String>("hello")时: 1 2 3 4 // Kotlin内联展开后的实际字节码指令 60: ldc #87 // class java/lang/String ... 134: instanceof #87 // class java/lang/String 注意第134行的instanceof #87指令——Kotlin编译器直接引用具体的java/lang/String类,而不是擦除后的Object。这证明了编译器确实将类型参数T替换为了具体的String类型。 但这又引出了一个新的疑问:第三方库的reified函数是如何跨JAR边界工作的? 编译器协作的精妙设计 Jackson Kotlin模块提供的reified函数让我很好奇——如果reified依赖于内联展开,那么如何跨JAR包边界工作? 这里需要理解”编译单元”的概念:编译单元是指一次编译操作处理的代码范围。比如Jackson Kotlin模块是一个独立的JAR包(一个编译单元),而我们的应用代码是另一个编译单元。当我们在应用代码中调用Jackson的readValue<Person>(json)时,就是在跨编译单元使用reified函数。 深入分析后发现,Kotlin编译器为第三方库reified函数设计了一个巧妙的四层协作机制: 1. “一体两面”的架构设计 第三方库中的inline reified函数在编译后会产生两个不同的组件: 方法存根(Method Stub):为Java调用者准备的后备方案 1 2 3 // 字节码中实际存在的方法存根(Java方法签名) public static final synthetic Object readValue(ObjectMapper, String); // 调用时会立即抛出异常,提示需要内联 元数据函数体(Inlinable Body):存储在@kotlin.Metadata中的完整逻辑 1 2 3 4 // 存储在元数据中的实际实现 inline fun <reified T> ObjectMapper.readValue(content: String): T { return readValue(content, T::class.java) } 2. 四层协作机制 @Metadata注解:存储Kotlin特有信息 1 2 3 4 5 6 7 // Java注解语法(在字节码中的表现) @kotlin.Metadata( mv = {1, 5, 1}, // Kotlin版本信息 bv = {1, 0, 3}, // 二进制版本 k = 2, // 文件类型 // 包含完整的内联函数体序列化数据 ) ACC_SYNTHETIC标记:标记编译器生成的特殊方法 1 2 public static final synthetic boolean needClassReification(); flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL, ACC_SYNTHETIC needClassReification()函数:编译器识别标记 1 2 3 4 // 用于标记需要类型具体化的函数 @PublishedApi internal fun needClassReification(): Nothing = throw UnsupportedOperationException("Function with reified type parameter") reifiedOperationMarker()占位符:编译时替换点 1 2 3 4 // 编译器占位符,运行时永不执行 @PublishedApi internal fun reifiedOperationMarker(): Nothing = throw UnsupportedOperationException("This function should be called only during compilation") 3. 编译器协作流程 当我们调用mapper.readValue<Person>(json)时: 库扫描:Kotlin编译器发现@kotlin.Metadata注解 方法分析:识别ACC_SYNTHETIC标记和特殊函数 内联展开:从元数据中读取完整函数体 类型替换:将T::class.java替换为Person::class.java 代码生成:生成最终调用 1 2 // 最终生成的Java字节码调用 readValue(content, Person.class) 这个机制的精妙之处在于:完全使用JVM标准特性,无需定制JVM,但为Kotlin编译器提供了执行内联和类型具体化所需的所有信息。 4. 内联类生成的实际证据 当我们实际编译调用第三方reified函数的代码时,可以观察到编译器确实生成了具体的TypeReference类: 1 2 3 4 5 6 // 为 Person 类型生成的内联类 public final class com.example.ThirdPartyReifiedTestKt$testJacksonReifiedFunctions$$inlined$readValue$1 extends com.fasterxml.jackson.core.type.TypeReference<com.example.Person> // 完整的泛型签名保留 Signature: #3 // Lcom/fasterxml/jackson/core/type/TypeReference<Lcom/example/Person;>; 1 2 3 4 5 6 // 为 List<Person> 类型生成的内联类 public final class com.example.ThirdPartyReifiedTestKt$testJacksonReifiedFunctions$$inlined$readValue$3 extends com.fasterxml.jackson.core.type.TypeReference<java.util.List<? extends com.example.Person>> // 复杂泛型签名的完整保留 Signature: #3 // Lcom/fasterxml/jackson/core/type/TypeReference<Ljava/util/List<+Lcom/example/Person;>;>; 这些编译器生成的类名揭示了内联展开的命名规律: $$inlined$readValue$1:表示第一个内联的readValue调用 $$inlined$readValue$3:表示第三个内联的readValue调用,每个类型参数对应一个独立的TypeReference类 重新理解类型系统的层次 经过这一轮分析,我对JVM泛型系统有了更清晰的认识。类型擦除不是简单的”删除”,而是多层次类型系统的协调: 源码层:我们编写强类型的泛型代码,享受IDE的类型检查 编译期:编译器执行类型安全检查,同时通过多种机制保留泛型信息: Signature属性:完整泛型信息的保留 Java编译器会在字节码中保存完整的泛型签名,这是类型擦除的重要补偿机制: 1 2 3 4 5 6 7 // 泛型类的签名(GenericClassDemo<T extends Number>) Signature: #25 // <T:Ljava/lang/Number;>Ljava/lang/Object; // 泛型方法的签名 Signature: #18 // (TT;)V // addItem(T item) Signature: #21 // ()Ljava/util/List<TT;>; // getItems() Signature: #24 // <E:Ljava/lang/Object;>(TE;Ljava/util/List<-TE;>;)V // processWithGenericMethod LocalVariableTypeTable:调试信息中的类型追踪 在启用调试信息编译时,还会生成额外的类型表: 1 2 3 4 5 6 7 8 9 // 普通变量表(总是存在) LocalVariableTable: Start Length Slot Name Signature 8 227 1 stringList Ljava/util/List; // 泛型变量表(仅调试模式) LocalVariableTypeTable: Start Length Slot Name Signature 8 227 1 stringList Ljava/util/List<Ljava/lang/String;>; 注意两个表的关键差异:普通表显示擦除后的类型List,而泛型表保留完整信息List<String>。 字节码层:不同的类型检查策略: 1 2 3 4 5 // Java: 延迟类型检查 143: checkcast #62 // class java/lang/String // Kotlin reified: 直接类型检查 134: instanceof #87 // class java/lang/String 运行时:JVM执行擦除后的代码,但仍可通过Signature属性访问泛型信息 设计权衡的思考 通过对比分析,我们可以看到不同语言的设计权衡: 方面Java 类型擦除Kotlin Reified 字节码大小紧凑,共享字节码内联展开,每个调用点独立 运行时开销checkcast 指令检查编译时优化,无运行时开销 API 设计需要传递 Class 参数直接使用类型参数 互操作性完全兼容Java 无法调用真正的 reified Kotlin的reified机制在编译期和运行时之间找到了巧妙的平衡点:通过内联函数在编译时恢复类型信息,同时通过编译器协作机制实现跨库调用。 这种设计思路反映了现代语言发展的趋势——不是对抗底层平台的限制,而是在编译器层面提供更好的抽象,通过巧妙的工程实现来突破技术约束。 你在项目中遇到过类似的类型系统边界问题吗?或者发现了其他语言处理这类问题的有趣方案?

2025/9/14
articleCard.readMore

Agent 构建中的三个诱人陷阱

在构建 Cline AI agent 的过程中,我们发现最危险的不是那些一眼就能看出来的坏想法,而是那些理论上听起来完美、实践中却一败涂地的诱人陷阱。 引言 在 Cline 构建 AI agent 的征程中,我们发现最危险的其实不是那些一眼就能识破的馊主意,而是那些理论上完美无缺、实践中却惨败收场的”陷阱”。这些思维病毒早已蔓延整个行业,吞噬了数百万工程师小时,把一个又一个团队拖进了架构死胡同。 以下是我们见过的三大最常见陷阱: Multi-Agent Orchestration(多智能体编排) RAG (Retrieval Augmented Generation) 索引代码库 指令越多 = 效果越好 [译者注] 技术名词保持英文便于检索。RAG 在国内常被直译为”检索增强生成”,但工程实践中仍以英文为主。 咱们一个个来看! (1) Multi-Agent Orchestration 那种科幻电影般的 agent 愿景——‘rear agent、quarter agent、analyzer agent、orchestrator agent’——听起来确实很酷,派出一群子 agent 然后整合它们的结果。但现实是:大部分有用的 agent 工作其实都是单线程的。 目前多智能体系统的最大突破来自 Anthropic,但就连他们也坦言,构建和调教多个 agent 是极其困难的。他们的团队这样说: “Agent 系统中的错误会叠加放大,传统软件里的小毛病到了这里可能让整个 agent 彻底偏轨。一步走错,agent 就可能走上完全不同的路径,结果变得完全不可预测。基于我们文章中提到的种种原因,从原型到生产的鸿沟往往比想象中要大得多。” [译者注] 这段话点出了多 agent 系统的核心困境:错误放大效应。在分布式系统设计中,这被称为”故障级联”。 来源:AI Agents are Microservices with Brains 当然,我们也不是完全否定多 agent 方案。对于一些小而具体的用例,用些功能有限的子 agent 还是挺合理的。 比如说,主 agent 线程创建几个子 agent 来并行读取文件。或者用子 agent 来处理从网络抓取数据这种简单任务。但说白了,这些跟并行调用工具没什么本质区别,我甚至怀疑这算不算得上”真正的”多智能体编排。 (2) RAG (Retrieval Augmented Generation) RAG 是另一个诱人的坑。这个概念延续自上下文窗口还很小的时代,那时候给 agent 提供查询”整个代码库”的能力确实很有吸引力。但 RAG 的炒作并没有转化为实际可用的编码 agent 工作流,因为它经常会产生零散的代码片段,完全无法为模型提供真正的”上下文理解”。纸面上看起来很强大,但实际上,就连 GREP 这样的简单工具都比它好用,特别是对 agent 来说。 让 agent 像人类一样工作——列出文件、用 grep 搜索、然后打开完整的文件来阅读——这样的效果几乎总是更好。Cline 从一开始就确立了这套方法的标准,后来 Amp Code 和 Cursor 都跟着采用了这种做法。 RAG 及其复杂变种介绍 大多数公司最初都选择了向量数据库,因为 2023 年那些”与代码聊天”的 VS Code 扩展刚出现时,模型只有 8,092 个 token 的上下文窗口,所以每一行进入模型的代码都必须精心挑选。那时候这样做确实说得通,这也解释了为什么那么多基础设施和热钱涌入了向量数据库公司,有些公司甚至筹集了数亿美元,比如 Pinecone。但 Cline 于 2024 年 7 月发布时,当时领先的编码模型是拥有 200K token 上下文窗口的 Claude 3.5 Sonnet,所以它从来没有受到需要拼接无关上下文片段的限制。 [译者注] 从 8K 到 200K 的上下文窗口跨越是个分水岭。这个时间差导致了技术路径的根本性分歧。 (3) 指令越多 = 效果越好 觉得在系统提示里堆砌越来越多”指令”就能让模型变聪明?这完全是个错误观念。过载的提示只会把模型搞糊涂,因为额外的指令经常会相互冲突,产生噪音。最后你只是在行为上打地鼠,而不是获得有用的输出。对今天的大多数前沿模型来说,最好是退一步让它们自己发挥,而不是通过提示不断对它们大吼大叫。措辞要仔细斟酌。 来源:Signal vs. Noise 信号 vs. 噪声 Cline 在 2024 年中期发布时,Sonnet 3.5 是当时的顶级模型,那个时候用大量示例和想法填充提示确实有意义。但当 Sonnet 4 系列出现时,这套方法彻底失效了,其他所有 agent 系统也跟着翻车。经过反复测试,我们意识到核心问题:指令太多会产生矛盾,矛盾会产生混乱。 新一代前沿模型——Claude 4、Gemini 2.5 和 GPT-5——在遵循简洁指令方面要强得多。它们不需要长篇大论,它们需要的是最精炼的内容。这就是新的现实。 [译者注] 这一观察与软件设计原则不谋而合:KISS (Keep It Simple, Stupid)。模型能力的提升反而要求我们做减法。 结论 多智能体编排、RAG 和塞满内容的提示在纸面上看起来很诱人,但没有一个能在真正的开发工作流中存活下来。真正获胜的 agent 是那些拥抱简单性的:像开发者一样阅读代码,相信模型的能力,让路走得通畅。 虽然整个行业还在追逐架构上的复杂性,但基本面已经发生了根本转变。少即是多,清晰胜过聪明,最好的 AI agent 往往是最简单的。 [译者注] 复杂性往往是解决方案的敌人,而非朋友。在 AI 工程化的道路上,保持理性比追逐热点更重要。 — Ara 让你的工程团队拥有一个完全协作的 AI 搭档。开源、完全可扩展,专为放大开发者影响力而设计。 原文链接:3 Seductive Traps in Agent Building 作者:Ara Khan (@arafatkkatze) 发布日期:2025 年 8 月 26 日

2025/8/27
articleCard.readMore

把质量内建进设计:从“测试是负担”到“测试即设计”

业务复杂 ≠ 代码必然复杂。好代码常常看着普通,关键在于设计和可测试性平衡得好。 引言:为什么质量改进总是做不好? 我们都见过这种情况:团队加了各种检查清单、覆盖率红线、扫描工具。 几个月后,流程更复杂了,质量却没怎么变好。 问题不在执行力度,而在于把质量活动放错了时间:设计定好了才让测试介入,只能事后补漏洞。 核心思路:把质量从”事后检查”变成”设计时就考虑进去”,让测试帮助改进设计。 测试应该验证”设计是否合理”,而不是”代码是否正确”。 理解这一点,才解释得通:为什么很多“质量改进”看似动作频繁却收效甚微。 大量所谓失败的质量改进,其根源是诊断偏差:把“认知与方法”的缺口,误判为“态度与能力”的缺陷。 我们缺的往往不是意愿,而是对“质量必须被前置设计进去”的共识。 正因为测试目的被误读,我们才会不断叠加流程——模板、检查项、额外审批——却依旧缺乏演进信心。 把测试前置为设计反馈回路,才能释放团队在结构演化上的创造力。 Build Quality In(内建质量,指从设计、开发到交付各个环节,始终将可度量、可验证的质量目标固化为交付物的内在属性,不依赖于终极测试关卡。这一理念强调风险应前移并在流程中被分解,而不是留到最后集中暴露) 其真正含义是:每一环节交付的中间产物自带“可验证标准”,而不是把风险挤压到最后一轮集中测试。 换句话说:质量是过程属性,是设计出来的,而不是扫出来的。 职责重塑:测试是谁的事? 组织方式测试责任认知结果 QA 负责开发完扔给 QA 测试被动、滞后 开发补测写完代码再补测试补救、慢反馈 测试驱动设计测试推动设计主动、预防 测试是开发者的责任,不是 QA 的专属。 把测试外包出去,等于把系统演进的控制权也外包了。 团队对测试的认知通常经历三个阶段: 测试是 QA 的事 → 代码能跑就行 测试是开发的事 → 写完代码补测试 测试是设计的事 → 用测试来驱动设计 覆盖率不是质量,”改动后敢不敢部署”才是。 很多 90% 覆盖率的项目,改一行代码还是怕出事,因为测的是执行路径,不是业务行为。 设计原则:让复杂业务不制造复杂代码 以订单系统为例,如果把支付、库存、通知都耦合在一起,业务再简单代码也会变复杂。 两个关键点: 可组合性:组件能独立变化和复用 解耦:模块边界清晰,依赖关系明确 这两个直接决定了可测试性。 代码不好测,通常是设计问题,不是测试技术问题。 依赖倒置与构造注入 依赖倒置(DIP, Dependency Inversion Principle:让高层模块不依赖具体实现、仅依赖抽象接口,从而提升灵活性与可测试性) 构造注入(Constructor Injection,用显式传参而非内部直接实例化依赖,以便测试和扩展) 先看一个典型“隐式依赖”日常写法,随后用构造注入展示如何让依赖显式、可替换。 反例(内部直接创建依赖 → 紧耦合、难替换、难测): 1 2 3 4 5 6 7 public class OrderService { private final PaymentProcessor paymentProcessor = new PaymentProcessor(); public void placeOrder(Order order) { paymentProcessor.process(order.getPaymentDetails()); } } 改进代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Service public class OrderService { private final PaymentGateway payment; private final Inventory inventory; private final Notification notification; public OrderService(PaymentGateway payment, Inventory inventory, Notification notification) { this.payment = payment; this.inventory = inventory; this.notification = notification; } public void processOrder(Order order) { payment.charge(order.getAmount()); inventory.reserve(order.getItems()); notification.sendConfirmation(order.getEmail()); } } 为什么这能降复杂?因为: 可替换:测试中传入 Mock/Fake,环境可控。 可演进:实现可变而接口不变,测试稳定。 可理解:依赖图显式化,认知负荷更低。 这三点共同把变动半径压缩到装配层。 把对象创建权上移(构造注入)= 将“变动点”推到最外围;业务核心越“纯”,测试越“薄”。 隔离框架:让业务逻辑保持纯粹 完成对象级依赖显式化后,第二类常见耦合源是“框架侵入业务”。策略:让 Controller 仅做“协议/IO → 领域调用”转发,避免领域逻辑渗入框架层,使业务在纯 Java 环境下即可单测。 简例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @RestController class OrderController { private final OrderApplicationService app; OrderController(OrderApplicationService app){this.app = app;} @PostMapping("/orders") public OrderDTO place(@RequestBody OrderDTO dto){ return app.place(dto.toDomain()).toDTO(); } } // 纯业务,可直接 new 来测试 public class OrderApplicationService { private final OrderService domain; public OrderApplicationService(OrderService domain){this.domain = domain;} public Order place(Order order){return domain.process(order);} } 测试什么:结果而不是过程 脆弱的测试: 1 verify(paymentClient, times(1)).charge(any()); 稳定的测试: 1 2 orderService.processOrder(order); assertThat(order.getStatus()).isEqualTo(PROCESSED); 测试应该关注业务结果,而不是内部实现细节。 测试焦点:以对外行为契约取代内部调用序列 测什么:组件对外的行为契约(状态、返回值、对外交互语义),而不是它“怎么做”。 好处:重构自由度大、测试稳定、设计抽象度更高。 经验法则:能用一个 Given-When-Then 讲清楚的场景,就不要写“调用序列监视”。 经验:能用 Given-When-Then 说清楚的,就别用 verify。 1 2 3 4 5 6 Feature: 订单处理 Scenario: 成功处理有效订单 Given 用户有一个包含商品的订单 When 用户提交订单 Then 订单状态为"已处理" And 用户收到确认邮件 “行为测试”好比:下单后只看结果——订单状态改变、邮件发送完成;“实现细节测试”好比:盯内部是否“恰好调用 X 一次”,一旦内部重构(拆分、合并、缓存)测试就会挂。 列出该功能对外可观察的 1~3 个结果,测试只断言这些;仅当结果不可直接观察时,再退而使用 verify。 这是前文所称“语义覆盖”的具体化——验证承诺,而非执行路径。 质量度量:用什么指标 别只看覆盖率,看这些: 改完代码敢不敢直接上线? 缺陷数在下降吗? 出问题多久能发现? 测试金字塔: 单元测试:测领域逻辑,最快定位问题 集成测试:测边界协作 端到端:测关键流程 金字塔倒过来,通常是设计没做好。 总结 我们做了什么: 测试从后置变前置 职责从外包变内聚 用依赖倒置 + 组合 + 薄层隔离框架 测行为契约,减少 verify 用真实指标衡量质量 几个对照思考 业务复杂 ≠ 代码必须复杂:遇到”看不懂的实现”时,可以先问问”是否把业务决策隐藏在技术细节里”。 主要成本在修改:每次改动如果都需要”开启多人口头同步”才能放心发布,通常说明缺少可验证的自动化回归测试集。测试可以看作是摊平未来修改成本的”前置投资”。 设计是持续活动:每加入一个特性,都值得重新检视”依赖是否最小化””边界是否清晰”以及”关键行为是否能被直接测试”。 测试是一把尺:当你犹豫“是否需要抽象”时,可以问自己——这个抽象是否让关键行为的测试表达更短、更清晰?如果是,那么这个抽象通常是正确的方向。 认知惯性 ≠ 技术难点:很多开发者感觉”写测试浪费时间”,往往是对“前置投入换取未来自由”这一逆直觉收益模型的抗拒。识别到这只是惯性后,可用“小步演示削减回归/调试时间”建立共识。 这些对照共同指向:让测试成为持续设计校准机制,而非末端裁决。 结语 把测试从“末端审判”变成“设计工具”,把质量从“外部检查”变成“内部属性”。 当我们用可组合设计、行为契约测试与清晰验收标准内建质量,复杂业务也能产出“平平无奇”且稳定可演进的代码。 当前这次改动,如果我删掉所有 verify 调用,只保留对最终结果(状态/返回值/对外交互)的断言,测试是否仍清晰表达业务意图?若否,请回溯:依赖是否显式?行为是否可观察?测试是否覆盖契约而非路径? 这三个对了,质量就在设计里了。

2025/8/10
articleCard.readMore

folo verify

This message is used to verify that this feed (feedId:97818615095497728) belongs to me (userId:97817750660390912). Join me in enjoying the next generation information browser https://folo.is.

2025/7/30
articleCard.readMore

围墙花园里的阅读游击 - wewe-rss & RSSHub,重夺信息控制权

控制权旁落 RSS 并未消亡。 变化的不是协议,而是信息分发领域的权力结构。 用户在事实上让渡了选择看什么、何时看的权利。 微信公众号重塑了这种结构,通过两层约束: 技术约束:内容被封装于 App 内,无标准 API,无 RSS 输出通道。 算法约束:信息流按商业目标而非时间或信息价值排序。 其结果是一种注意力被动引导机制——用户的浏览行为,被用于优化平台的商业目标。 系统解剖 graph TD subgraph 微信封闭生态 A[公众号文章] -->|技术隔离| B(微信App) B -->|算法黑箱| C{信息流} C -->|注意力收割| D(用户) end subgraph RSS工具生态 A -->|微信读书接口| E(wewe-rss) F[其他平台内容] -->|API/爬虫| G(RSSHub) E -->|RSS输出| H[统一RSS协议] G -->|RSS输出| H H -->|整合阅读| I(Folo) I -->|控制权归还| D end 技术隔离层 无 RSS,无 API,内容被封装在微信内。 算法黑箱层 算法根据用户停留时长、交互数据等指标对内容进行排序。用户的每一次点击,都在优化这个系统以更高效地实现其排序目标。 穿孔方案 面对技术和算法的双重约束,开源社区提供了协同的解决工具: 微信公众号的专用工具:wewe-rss wewe-rss 专门针对微信公众号这一特殊封闭系统。它不破坏系统,而是利用其现有接口。 通过调用微信读书的同步接口,将公众号内容转化为标准 RSS。这不是破解,是协议嫁接: 接口合法性:使用微信读书官方 API 格式标准化:输出 RSS 2.0,兼容任意阅读器 延迟容忍:以 15-30 分钟的延迟,换取系统稳定性 多平台的通用工具:RSSHub RSSHub 覆盖微信之外的广阔信息源——微博、Twitter、知乎、GitHub 等 1000+平台。它采用企业级架构: 多源适配:每个平台都有专门的适配器 反爬虫技术:浏览器自动化、代理轮换、签名验证 社区驱动:标准化的贡献流程和质量保证体系 工具生态的协同价值 这两个工具在实际使用中形成互补覆盖: wewe-rss 处理微信公众号这一中文互联网重要信息源 RSSHub 覆盖其他所有主流平台和网站 Folo 作为统一终端,整合所有 RSS 源的阅读体验 它们共同指向同一目标:将分散的封闭内容重新导入开放协议。 局限依然存在:付费墙内容无法获取,接口可能关闭。但这是一种可接受的脆弱性——在绝对控制与完全开放之间,这个工具生态提供了可行的中间路径。 脆弱的平衡 这套方案的生命周期,取决于微信读书的接口策略,存在不确定性。 但它证明了:中心化平台的控制并非绝对。 通过技术工具,个体可以重新取回部分信息控制权。 这不是颠覆,而是一种结构性再平衡。 RSS 这个 1999 年的协议,在 2025 年成为制衡平台中心化的有效工具。 这提示我们:开放协议通常比封闭应用拥有更长的生命周期。当用户需求与平台利益发生冲突时,基于开放标准的工具生态会自发涌现出解决方案。 行动与思考 盘点:识别你真正需要关注的信源。 部署:搭建wewe-rss处理微信公众号,部署RSSHub覆盖其他平台。 整合:将所有 RSS 源导入Folo或你选择的 RSS 阅读器。 重塑:用主动拉取替代被动投喂的阅读习惯。 信息控制权的回归,是使用习惯的重塑。

2025/7/21
articleCard.readMore

AI 时代,我们是在写代码,还是在“写知识”?—— 极客时间 8x 课程笔记

本文是极客时间 8x 课程的读书笔记。我本想取名 G8 (Geekbang 8x),但听起来不太对劲,所以就有了现在这个标题。 “软件的价值,最终由其承载的知识决定。代码,只是这些知识的可执行形态。” 引言:从“构建者”到“知识工程师”的身份转变 我们习惯于将软件开发看作一种“建造”活动,谈论“构建”软件、“编写”代码。 但随着 AI 大语言模型(LLM)的普及,我们或许需要换个角度审视自己的工作。 如果说编码是软件开发的“最后一公里”,那在此之前的需求讨论、架构设计和技术选型,又是什么呢? 这些过程,更像是一种知识的传递、提炼与组织。 本文想探讨一个观点:在 AI 时代,软件工程的核心正从“构造软件”转向“管理知识”。 这一转变,对我们的日常工作意味着什么? 一、软件:一种可执行的知识 让我们暂时抛开代码,思考软件的本质。 软件是知识的载体:真正的“产品”,是软件中蕴含的业务逻辑、领域规则和架构决策。代码只是这些知识的最终表现形式。 软件是知识的“可执行”形态:它让抽象的知识能够在现实世界中产生具体影响。 软件开发的核心,是知识的获取、学习与传递:编码只是这一漫长知识旅程的最终环节。 代码是最终产物,但真正的价值在于其背后的业务逻辑、领域规则和架构决策。这些才是软件的核心“知识”。 LLM:认知杠杆,而非认知替代 LLM 在这个新范式中扮演什么角色?它是一个强大的认知杠杆,极大地提升了知识转化为代码的效率,但它并未替代人类的认知过程。 人的认知水平是协作的上限:人与 LLM 协作的最终产出质量,取决于人对问题的理解深度和判断力。 LLM 的核心能力是基于上下文的理解与生成:通过提供精准的上下文,我们可以将其“调教”为特定领域的辅助专家。 LLM 存在固有技术限制: 上下文与 Token 限制,使其难以一次性处理庞杂的系统。 “幻觉”现象,意味着其产出需要严格验证。 因此,将 LLM 视为无所不能的“代码生成器”是一种误解。 它的真正价值在于成为知识传递链条中的高效“处理器”,而我们则需要成为高效的“知识管理者”。 二、知识工程:一种新的工作模式 如果软件开发是知识工作,我们该如何实践? 知识工程,可以理解为:将软件开发视为一个“提取、组织知识,使其能被 LLM 理解,并最终通过 LLM 将这些知识转化为可工作软件”的完整过程。 这里的关键瓶颈,不再是编码速度,而是知识传递的效率与保真度。 1. 知识与任务分离:与 LLM 精准对话 与 LLM 交互时,清晰地分离**知识(背景信息)与任务(具体指令)**至关重要。这能帮助 LLM 建立正确的上下文,形成明确的关注点。 坦白说,我最初也尝试过一种不太明智的方法:将一份长达数页的需求文档直接丢给 LLM,期望它能“一口吃成个胖子”,直接生成整个模块。结果可想而知,它要么陷入逻辑混乱,要么给出一堆看似正确却无法组合的“代码碎片”。 这次失败让我意识到,与 LLM 协作,我们不能扮演“甩手掌柜”的角色,而必须成为一名**“知识主厨”**。我们需要亲手将大块的“需求原料”精心切分、预处理,才能让 LLM 这位“超级副厨”高效地烹饪出佳肴。 例如,与其直接说“写一个登录函数”,不如先提供知识: 知识(Context):“系统使用 JWT 认证,密码采用 bcrypt 加密,用户信息存储在 users 表中。” 任务(Task):“基于以上信息,编写一个 login(username, password) 函数,验证成功后返回 JWT。” 有趣的是,敏捷开发中的用户故事(User Story),恰好是一种优秀的知识管理工具。它天然地侧重于定义问题(“我是谁”、“我想要什么”、“为什么”),而非预设解决方案,这使其成为向 LLM 传递需求的理想形式。 2. 通过反馈迭代:提炼隐性知识 知识工程并非单向传递,而是一个双向的、迭代的探索过程。 通过 LLM 的反馈来反思并修正我们对知识的描述,是知识工程的核心循环。 我们可以将这个过程,比作请一位超级画师(LLM)为我们画一幅肖像画。我们与 LLM 的每一次交互,都是在向它描述自己的特征。当发现它画出的细节(生成的代码)有偏差时,问题往往不在于它的“画技”,而在于我们这位“模特”没有描述清楚自己的样貌。 LLM 的“误解”就像一面镜子,忠实地反射出我们自身知识表达的模糊与缺漏。因此,这个迭代过程,与其说是“调试机器”,不如说是**“校准自我”**。它的最终目标是得到一幅双方都认可的、纤毫毕现的“知识蓝图”,而非仅仅“修复代码”。当 LLM 对你的描述产生“误解”时,我们首先要反思的是,自己对知识的表达是否足够清晰、准确。 3. 驾驭不同类型的知识 软件开发涉及的知识远非单一类型,我们必须学会驾驭它们: 显式知识(Explicit Knowledge):能够被清晰表达、记录和传播的”know-what”,如技术文档、业务规则。 隐式知识(Implicit Knowledge):尚未被记录,但可以通过交流和文档化转为显式知识。 默会知识(Tacit Knowledge):这是最宝贵也最难处理的部分。它是个人经验、直觉、技能的核心,是难以形式化的”know-how”。例如,一位资深架构师在众多方案中做出权衡的直觉,或是一位高级工程师对代码“坏味道”的敏锐嗅觉。定义问题通常比解决问题更难,而对问题的敏锐感知,正是默会知识的体现。 为何同样的技术文档(显式知识)在不同人手中,会产生天壤之别的实现? 为何有经验的工程师能在看似平静的代码中,直觉性地感知到潜在的设计缺陷? 这些都体现了默会知识的核心价值。 三、研发流程重塑:从代码管理到知识管理 在新范式下,现有研发流程需要重构为以知识为中心的管理过程,其核心目标是从流程中捕获关键知识,并通过 LLM 有效沉淀。 围绕“默会知识”的传递来构建流程,是实现知识工程的关键。 1. 应用与提取“默会知识” 应用知识:对于成熟的、有明确解决方案的问题(例如,为新项目搭建 CI/CD 流水线),我们可以通过提示词模板对成熟的任务流程进行建模,高效应用那些已被充分学习的默会知识。 提取知识:默会知识的提取,本质上是**提炼“思维链”(Chain of Thought, CoT)**的过程。通过鼓励 LLM 解释其推理过程,我们可以反向形式化那些隐性的专家经验。RAG(检索增强生成)等模式,同样有助于新知识的学习与提取。 2. 应对认知偏差:先对齐思路,再写代码 认知偏差是团队协作中的巨大障碍:不同成员对同一问题可能持有完全不同的假设和理解,导致共识难以形成。 这种分歧的后果是:团队难以形成统一共识,讨论陷入循环,新人培养周期延长,系统设计出现不一致,引入大量隐蔽的质量缺陷。我们是否曾困惑,为何同样的技术讨论,有些团队能迅速聚焦并达成一致,而有些团队却在表面问题上争论不休?认知偏差往往是根源。 知识工程强调先对齐思路,再动手编码。 推行任务审查(Task Review):将审查的重心从代码(Code Review)上移至任务本身,在编码前,就通过讨论、文档或图表,确保团队对“做什么”和“为什么做”达成一致。这看似增加了前期的沟通成本,但相较于修复因认知偏差导致的大量后期代码返工和隐蔽缺陷,这种“前期投入”的收益是巨大的。这是一种典型的“左移”(Shift-Left)思想在知识管理上的体现。 建立认知行为基线:管理者应着力于建立团队统一的认知与行为基线,确保对问题的理解和处理方式有一致的标准。 3. 混乱中的清醒:及时止损 当个体或团队对问题感到混乱(Chaotic)——即无法理解问题、被恐慌驱动而盲目行动时,任何试图“解决”问题的努力都可能加剧混乱。 此时,理性地及时止损,暂停行动,回归问题的澄清与理解,是避免大量返工的明智选择。 四、任务划分与质量内建:新范式下的交付关键 1. 任务划分:与 LLM 协作的“接口” 由于 LLM 的技术限制,需求必须被分解为足够小的、原子化的任务,才能转化为高质量的提示词。这个过程并非随意的拆分,而需同时兼顾软件架构与测试策略。 可测试性(Testability)是进程内架构最重要的属性之一。一个好的任务划分,本身就应该导向一个易于测试的实现。 2. 质量控制优先于生成效率 在 LLM 辅助开发中,我们的精力分配需要发生根本转变:将更多精力投入质量控制,而非一味追求生成效率。 反馈循环的瓶颈在于如何高效验证 LLM 生成结果的正确性与有效性。因此,我们必须大力倡导**内建质量(Build Quality In)**的理念,通过测试驱动(TDD/BDD)来提炼需要给予 LLM 的精准反馈,而非仅仅依赖于编码后的手动调试。 我们应当转变思维,从”如何修复 LLM 生成的错误代码”到”如何设计流程,使 LLM 更容易生成正确代码”。 当我们过于关注生成速度时,往往因大量调试修复而降低总体效率;而当我们专注于质量时,整体进度反而可能加快。 这是否意味着,在 AI 时代,“慢即是快”可能成为新的工程法则? 五、工程师的转型:从“编码者”到“知识工程师” 这场范式转型,最终将重塑工程师的核心价值。 角色转变:从专注于编码实现的**“编码者”(Coder),转向侧重于知识管理、任务分解与验证的“知识工程师”(Knowledge Engineer)。对纯粹编码技能的要求或许会降低,但对知识提炼、系统思考、质量保障**等能力的要求将显著提升。 技能重组正在发生: 知识提炼能力:从复杂、模糊的业务需求中提取清晰、结构化的知识。 系统性思考:在碎片化任务中保持整体视角,确保局部解决方案符合全局最优。 元认知水平:对自身思考过程的觉察、监控与调整能力。 跨学科整合:将技术、业务、用户体验等多维度知识有机融合。 核心价值:工程师的核心价值,在于为 LLM 提供足够丰富且精准的上下文信息——这包括功能需求、业务知识、架构决策、测试策略等一切关乎“生产正确代码”的关键信息。 我们的关注点,正从“如何构造软件”,历史性地转向**“如何提取和组织知识,让知识变成 LLM 能够理解的形式”**。 从这个角度看,“提示词工程”的本质,是如何精准地组织与表达知识。 因此,“知识工程”,或许是一个更合理、也更深刻的名称。 结语 AI 时代的软件开发,核心挑战从编码转向了知识管理。掌握好知识提炼、任务分解和质量控制这些技能,将是工程师适应新环境的关键。 {"content":"AI 时代:软件=知识工程","children":[{"content":"引言","children":[{"content":"软件开发本质是知识传递与组织","children":[],"payload":{"tag":"li","lines":"9,10"}},{"content":"AI 促使工程师从“构建者”转向“知识工程师”","children":[],"payload":{"tag":"li","lines":"10,12"}}],"payload":{"tag":"h2","lines":"7,8"}},{"content":"软件本质","children":[{"content":"软件是业务与架构知识的可执行载体","children":[],"payload":{"tag":"li","lines":"14,15"}},{"content":"代码只是知识的最终表现","children":[],"payload":{"tag":"li","lines":"15,16"}},{"content":"开发核心是知识的获取、学习与传递","children":[],"payload":{"tag":"li","lines":"16,18"}}],"payload":{"tag":"h2","lines":"12,13"}},{"content":"知识工程","children":[{"content":"关键瓶颈是知识传递效率与保真度","children":[],"payload":{"tag":"li","lines":"20,21"}},{"content":"明确区分知识(背景)与任务(指令)","children":[],"payload":{"tag":"li","lines":"21,22"}},{"content":"用户故事是理想的知识管理工具","children":[],"payload":{"tag":"li","lines":"22,23"}},{"content":"通过反馈迭代提炼隐性知识","children":[],"payload":{"tag":"li","lines":"23,24"}},{"content":"驾驭显式、隐式、默会三类知识","children":[],"payload":{"tag":"li","lines":"24,26"}}],"payload":{"tag":"h2","lines":"18,19"}},{"content":"流程重塑","children":[{"content":"以知识为中心重构研发流程","children":[],"payload":{"tag":"li","lines":"28,29"}},{"content":"流程需捕获并沉淀关键知识","children":[],"payload":{"tag":"li","lines":"29,30"}},{"content":"默会知识传递是流程设计关键","children":[],"payload":{"tag":"li","lines":"30,31"}},{"content":"应用:用模板高效复用成熟知识","children":[],"payload":{"tag":"li","lines":"31,32"}},{"content":"提取:鼓励思维链,反向形式化专家经验","children":[],"payload":{"tag":"li","lines":"32,33"}},{"content":"先对齐思路,再写代码,推行任务审查","children":[],"payload":{"tag":"li","lines":"33,34"}},{"content":"建立团队认知行为基线","children":[],"payload":{"tag":"li","lines":"34,35"}},{"content":"混乱时及时止损,回归澄清","children":[],"payload":{"tag":"li","lines":"35,37"}}],"payload":{"tag":"h2","lines":"26,27"}},{"content":"任务与质量","children":[{"content":"需求需原子化,便于 LLM 理解与生成","children":[],"payload":{"tag":"li","lines":"39,40"}},{"content":"任务划分需兼顾架构与测试","children":[],"payload":{"tag":"li","lines":"40,41"}},{"content":"可测试性是架构核心属性","children":[],"payload":{"tag":"li","lines":"41,42"}},{"content":"质量控制优先于生成效率","children":[],"payload":{"tag":"li","lines":"42,43"}},{"content":"反馈循环瓶颈在于高效验证","children":[],"payload":{"tag":"li","lines":"43,44"}},{"content":"推崇内建质量(TDD/BDD),精准反馈","children":[],"payload":{"tag":"li","lines":"44,46"}}],"payload":{"tag":"h2","lines":"37,38"}},{"content":"工程师转型","children":[{"content":"角色:从编码者到知识工程师","children":[],"payload":{"tag":"li","lines":"48,49"}},{"content":"能力重组:","children":[{"content":"知识提炼:结构化复杂需求","children":[],"payload":{"tag":"li","lines":"50,51"}},{"content":"系统性思考:全局视角","children":[],"payload":{"tag":"li","lines":"51,52"}},{"content":"元认知:自我觉察与调整","children":[],"payload":{"tag":"li","lines":"52,53"}},{"content":"跨学科整合:融合技术、业务、体验","children":[],"payload":{"tag":"li","lines":"53,54"}}],"payload":{"tag":"li","lines":"49,54"}},{"content":"核心价值:为 LLM 提供丰富精准上下文","children":[],"payload":{"tag":"li","lines":"54,55"}},{"content":"关注点转向知识组织与表达","children":[],"payload":{"tag":"li","lines":"55,57"}}],"payload":{"tag":"h2","lines":"46,47"}},{"content":"结语","children":[{"content":"知识始终是软件核心价值","children":[],"payload":{"tag":"li","lines":"59,60"}},{"content":"有效传递与组织知识决定成败","children":[],"payload":{"tag":"li","lines":"60,61"}},{"content":"软件工程正迈向认知科学新范式","children":[],"payload":{"tag":"li","lines":"61,62"}}],"payload":{"tag":"h2","lines":"57,58"}}],"payload":{"tag":"h1","lines":"5,6"}} {"colorFreezeLevel":2}

2025/6/28
articleCard.readMore

如何给 GitHub Copilot "洗脑”,让 AI 精准遵循指令产出高质量代码

引子:把 AI 新兵改造成精锐士兵 GitHub Copilot 就像一个天赋异禀但野路子出身的“新兵”。它枪法精准(编码能力强),但缺乏战场纪律(工程规范)。 你让它冲锋,它能迅速拿下山头,但阵地上一片狼藉:没有构筑工事(错误处理),不关心侧翼安全(边界情况),弹药随意堆放(命名混乱)。 我们需要的不是一个只会冲锋的“莽夫”,而是一个懂得协同作战、遵守战场纪律的“精锐士兵”。因此,我决定为它编写一套严格的“作战条令”(Prompts),对它进行一次彻底的“军事化改造”,让它乖乖听话。 不是魔法,是系统指令 经过一段时间的研究和实践,我发现 Copilot 这类 AI 工具实际上可以被深度”引导”,甚至达到一种”洗脑”的效果,让它们按照我们的意愿来行动。 它们并不是魔术盒子,而是遵循一套输入-输出原则的系统。如果我们能给它提供明确的指导和原则,它就能相应地调整自己的输出。 这个思路促使我整理了一个专门用来给 GitHub Copilot”洗脑”的指令集:prompts。 这个仓库里不是代码,而是一系列指导 AI 行为的 Markdown 文件。每个文件就像是给 AI 的一份规范或指南,告诉它应该怎样思考和行动。 这套指令能解决什么问题? 使用 AI 编程助手时,我们通常会遇到这些问题: 生成的代码能运行,但结构混乱,难以维护 没有考虑边界情况和异常处理 代码风格不一致,命名随意 缺乏适当的测试覆盖 不遵循项目已有的架构模式 这套指令集就是为了解决这些问题而设计的。它告诉 AI 该如何思考软件设计、如何编写清晰的代码、如何进行测试驱动开发,以及如何分解复杂问题。 指令集的构成 整个指令集分为几个主要部分: 核心行为定义:这部分告诉 AI 应该如何进行思考和工作,包括: 如何保持项目知识的连贯性(memory-bank) 如何有条理地回应用户(response-and-prompt-guidelines) 如何遵循 TDD 工作流(programming-workflow) 如何分解复杂任务(workflow-and-task-splitting) 代码质量规范:这部分告诉 AI 什么是好代码,什么是坏代码: 代码标准和最佳实践(code-standards) 代码异味和应避免的反模式(avoid-bad-smells) 如何编写有效的测试(testing-guidelines) 流程模板:这部分提供了从需求到实现的结构化方法: 如何将模糊的想法转化为明确的计划(req) 如何协助业务分析师编写用户故事(ba) 工具使用指南:这部分包含了一些高级技巧: 如何使用顺序思考解决问题(sequential-thinking) 快捷指令系统(shortcut-system-instruction) 这些“作战条令”是如何生效的? 你可能会好奇,为什么几份 Markdown 文件就能驯服一个复杂的 AI? 其根本原因在于,我们利用了大型语言模型的一个核心特性:它是一个基于上下文的、概率性的序列生成器。它本身没有真正的“理解”或“意识”,它的所有行为都是在预测“在当前上下文中,下一个最可能的词是什么”。 因此,这套指令的本质,就是一场**“上下文污染”(Context Contamination),或者说“概率空间操纵”(Probability Space Manipulation)**。 通过在它的工作环境中注入一套强有力的、结构化的规则(我们的“作战条令”),我们极大地改变了它进行概率计算的“初始条件”。当“编写单元测试”、“考虑异常”这些概念在上下文中被反复强调时,生成符合这些规范的代码的概率就被显著提高了。 我们不是在“教”它,而是在塑造一个让它“不得不”表现得更专业的环境。 这套“作战条令”的核心,就是用规则约束 AI 的“自由意志”: “慢思考”条令,强制它在行动前必须进行“沙盘推演”(展示思考过程)。 “自我批判”条令,要求它在每次“战斗”后必须提交“战后复盘报告”(自我评估)。 结构化的模板,则像是规定了标准的“军事作业程序”(SOP),确保它在任何情况下都能做出标准、可靠的战术动作。 说白了,这套指令的核心就是不让 AI “想当然”。它必须按照预设的流程来工作,该问的问题不能跳过,该考虑的边界情况不能遗漏。 如何在实际工作中使用这套指令 经过实践,我发现在 VS Code 中配置 Copilot 使用这些指令非常简单: 打开 VS Code 设置(Ctrl+, 或 Cmd+,) 搜索 github.copilot.chat.codeGeneration.instructions 添加指向指令文件的配置,例如: 1 2 3 4 5 6 7 "github.copilot.chat.codeGeneration.instructions": [ { "text": "避免生成与公共代码完全匹配的代码" }, { "file": "../prompts/.github/instructions/req.md" }, { "file": "../prompts/.github/instructions/ba.md" }, // 其它指令文件... { "file": "../prompts/.github/instructions/shortcut-system-instruction.md" } ] 需要注意的是,文件路径要正确。这里的路径是相对于你的 workspace 的。如果你的 prompts 仓库和当前项目不在同一位置,可能需要调整路径。 设置完成后,你会发现 Copilot 生成的代码质量明显提升:更规范、更健壮、考虑更周全。 一点思考:我们究竟在训练谁? 为 AI 制定“作战条令”的过程,其实和带新人有些相似 —— 你需要清晰地表达期望,提供良好的指导和范例,然后持续进行纠正和反馈。 但更有趣的是,这个过程在某种程度上也是对我们自己的“训练”。为了能给 AI 写出清晰的指令,我们必须首先在自己脑中将“好的代码”、“好的设计”、“好的流程”这些模糊的概念给形式化、结构化。 我们究竟是在训练 AI,还是在通过训练 AI 的过程,强迫自己进行更深层次的思考,从而成为更好的工程师? 这套指令系统的价值,或许不仅在于提升了 AI 的输出质量,更在于它像一面镜子,照见了我们自身在软件工程实践中的知识盲区,并促使我们去填补它。 如果你也在使用 AI 编程助手,不妨试试这套“作战条令”。如果有任何想法或改进建议,欢迎到 prompts 仓库提交 PR 或 Issue。

2025/6/17
articleCard.readMore

Reveal.js 高级特效演示

🚀 Reveal.js 高级特效演示 极致的视觉体验 背景视频 · 动态效果 · 交互体验 使用纯 Markdown 语法编写 所有特效通过 Reveal.js 注释实现 🎨 背景特效展示 渐变背景效果 支持多种背景效果: 线性渐变 径向渐变 图片背景 视频背景 纯色背景 图片背景 + 透明度 支持透明度调节的图片背景: 图片透明度 背景模糊 视觉效果 纯色背景 简洁的纯色背景设计: 蓝色主题 红色主题 绿色主题 橙色主题 💻 代码高亮演示 多语言代码展示 1 2 3 4 5 6 7 8 9 10 11 12 13 14 function fibonacci(n) { if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); } // 优化版本 function fibonacciOptimized(n, memo = {}) { if (n in memo) return memo[n]; if (n <= 1) return n; memo[n] = fibonacciOptimized(n - 1, memo) + fibonacciOptimized(n - 2, memo); return memo[n]; } Python 代码示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class AdvancedCalculator: def __init__(self): self.history = [] def calculate(self, operation, a, b): result = { '+': a + b, '-': a - b, '*': a * b, '/': a / b }.get(operation, "Invalid operation") self.history.append(f"{a} {operation} {b} = {result}") return result def get_history(self): return self.history 📊 图表与数据可视化 Mermaid 图表支持 graph TD A[开始] --> B{数据输入} B -->|有效数据| C[数据处理] B -->|无效数据| D[错误处理] C --> E[结果输出] D --> F[日志记录] E --> G[完成] F --> G 复杂流程图 flowchart TB subgraph 前端层 A[React App] --> B[Redux Store] B --> C[Component Tree] end subgraph 后端层 D[Express API] --> E[Database] E --> F[Cache Layer] end C -.->|HTTP Request| D F -.->|Data Response| C 序列图演示 sequenceDiagram participant 用户 participant 浏览器 participant 服务器 participant 数据库 用户->>浏览器: 输入数据 浏览器->>服务器: POST /api/data 服务器->>数据库: INSERT INTO table 数据库-->>服务器: 返回 ID 服务器-->>浏览器: JSON 响应 浏览器-->>用户: 显示结果 🎬 动画效果演示 列表动画效果 第一步 - 淡入上升效果 第二步 - 淡入下降效果 第三步 - 淡入左移效果 第四步 - 淡入右移效果 第五步 - 淡入效果 高级动画效果 高亮效果 红色高亮 蓝色高亮 绿色高亮 特殊效果 放大效果 缩小效果 删除线效果 堆叠动画 通过 fragment 实现内容堆叠显示: 第一层内容(淡入然后淡出) 第二层内容(同样淡入淡出) 第三层内容(最后显示) 🎯 交互式元素 点击触发动画 使用 fragment 索引控制动画顺序: 第一步(点击显示第一步) 第二步(点击显示第二步) 第三步(点击显示第三步) 自动动画演示 使用 data-auto-animate 实现元素自动过渡: 元素会自动过渡到下一个状态 新增的内容块 🎵 多媒体支持 视频嵌入 支持嵌入视频文件: HTML5 video 标签 多种格式支持 自定义控制界面 音频支持 支持嵌入音频文件: HTML5 audio 标签 自动播放控制 音量调节 外部内容嵌入 支持嵌入外部内容: YouTube 视频 iframe 内容 外部网页 🎨 主题与样式 内置主题展示 Black - 经典黑色主题 White - 纯净白色主题 League - 深灰色主题 Sky - 天蓝色主题 Solarized - 护眼主题 自定义样式 支持 CSS 自定义样式: 自定义渐变背景 半透明效果 动画效果 📊 数据可视化 表格样式 功能支持程度说明 Markdown✅ 完全支持原生 Markdown 语法 HTML✅ 完全支持HTML 标签支持 LaTeX✅ 支持数学公式渲染 Mermaid✅ 支持图表支持 数学公式 $$ E = mc^2 $$ $$ \int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi} $$ $$ \frac{d}{dx}\left( \int_{a}^{x} f(t) dt \right) = f(x) $$ 🎮 演示控制 快捷键指南 基本控制 空格 / → - 下一张 Shift + 空格 / ← - 上一张 F - 全屏模式 ESC - 退出概览 高级功能 S - 演讲者视图 O - 幻灯片概览 Alt + 点击 - 缩放 Ctrl + Shift + F - 搜索 演讲者视图 按 S 键打开演讲者视图,可以看到: 当前幻灯片 下一张幻灯片预览 演讲者备注 时间显示 幻灯片计时 🚀 高级特效 3D 转场效果 3D 凸面转场(使用 3D 效果的转场动画) 3D 凹面转场(另一种 3D 转场效果) 缩放转场(背景图片的缩放转场效果) 背景动画 背景转场效果 缩放动画 淡入淡出效果 🎯 实用功能 搜索功能 按 Ctrl + Shift + F 打开搜索框: 支持全文搜索 快速定位内容 缩放功能 按住 Alt 键并点击任意位置: 可以放大查看细节 再次点击恢复原大小 概览模式 按 ESC 键进入概览模式: 可以看到所有幻灯片的缩略图 点击任意幻灯片快速跳转 🎨 创意布局 网格布局 使用 CSS Grid 实现响应式布局: 卡片 1卡片 2卡片 3 渐变背景另一种渐变蓝色渐变 卡片布局卡片布局卡片布局 弹性布局 使用 Flexbox 实现自适应布局: 左侧内容右侧内容 弹性布局更宽的区域 自适应宽度自适应宽度 🎪 特殊效果 打字机效果 1 这是模拟的打字机效果... 闪烁效果 使用 CSS 动画实现闪烁效果: 闪烁效果 使用 CSS 动画 旋转效果 使用 CSS 动画实现旋转效果: 旋转效果 持续旋转 🎯 交互演示 点击计数器 交互式计数器演示: 点击按钮增加计数 实时显示点击次数 颜色选择器 动态背景颜色切换: 选择不同的背景颜色 一键重置为默认颜色 🎨 CSS 动画展示 自定义动画 滑入效果 - 从左侧滑入 弹跳效果 - 持续弹跳 脉冲效果 - 缩放脉冲 🎯 响应式设计 适配各种设备 桌面端 💻 完整功能 键盘快捷键 鼠标交互 移动端 📱 触控优化 手势支持 响应式布局 投影仪 📟 高清显示 大字体支持 对比度优化 打印 🖨️ PDF 导出 纸张优化 无背景打印 🎉 特效总结 展示的功能 视觉效果 ✅ 背景视频/图片 ✅ 渐变背景 ✅ 动画效果 ✅ 3D 转场 交互功能 ✅ 点击动画 ✅ 搜索缩放 ✅ 概览模式 ✅ 演讲者视图 内容支持 ✅ 代码高亮 ✅ 数学公式 ✅ 图表支持 ✅ 多媒体 高级特性 ✅ 自定义动画 ✅ 响应式设计 ✅ 插件扩展 ✅ 主题定制 🎊 感谢观看! Reveal.js 高级特效演示 体验网页演示的无限可能 使用 Markdown 创建专业演示文稿 纯文本编写,丰富效果呈现 🚀 现在开始使用

2025/1/20
articleCard.readMore

Feedly硬广-回归RSS

flowchart TD subgraph 被动信息接收 style 被动信息接收 fill:#f9d6c1,stroke:#000,stroke-width:2px; A[被动接受信息] --> B[算法过度控制] B --> C[信息茧房] C --> D[注意力消耗] end subgraph 信息主动消费 style 信息主动消费 fill:#c1e1f9,stroke:#000,stroke-width:2px; E[回归RSS] --> F[掌握信息主动权] F --> G[自主订阅] G --> H[控制信息摄入质量] H --> I[主动选择信息来源] I --> J[避免无用信息轰炸] J --> K[减少干扰] K --> L[专注高质量内容] L --> M[享受纯粹阅读乐趣] M --> N[高质量阅读体验] N --> O[注意力回归有价值内容] O --> F end D --> E

2025/1/5
articleCard.readMore

解决 LibreOffice 导出Excel 到 PDF 超链接丢失问题

问题描述 在使用LibreOffice的Calc组件将电子表格导出为PDF文件时,如果启用了“Whole sheet export”(整页导出)选项,导出的PDF文件中的超链接将不会保留原始的URL,而是显示为本地文件路径。 这个问题在 LibreOffice 官方论坛上的提问 Stack Overflow 上的提问 上都有讨论。 解决思路 为了解决这个问题,我们考虑使用LibreOffice的UNO API来实现一个自定义的解决方案。这个方案的核心思想是: 遍历Calc文档中的每个工作表。 计算每个工作表内容适应一页所需的总宽度和高度。 根据这些尺寸为每个工作表生成自定义的纸张大小。 使用自定义纸张大小导出PDF,同时确保不使用“Whole sheet export”选项,以保留超链接。 ExcelSinglePageFilter解决方案 针对上述问题,ExcelSinglePageFilterJava类实现了自定义的PDF导出过滤器。 以下是该过滤器如何解决问题的详细解析。 过滤器初始化与文档检查 ExcelSinglePageFilter首先检查传入的文档是否为Excel文档。如果不是,它将直接调用链式调用chain.doFilter继续处理。 1 2 3 4 5 XSpreadsheetDocument xSpreadsheetDocument = queryInterface(XSpreadsheetDocument.class, document); if (xSpreadsheetDocument == null) { chain.doFilter(context, document); return; } 工作表遍历与处理 接着,该过滤器遍历所有工作表,并为每个工作表异步执行调整操作。对于隐藏的工作表,它将跳过处理。 1 2 3 4 5 String[] sheetNames = xSpreadsheetDocument.getSheets().getElementNames(); CompletableFuture[] futures = Arrays.stream(sheetNames).map(sheetName -> CompletableFuture.runAsync(() -> { // ... 省略部分代码 ... adjustOneSheet(sheetName, sheet, xPageStyles); })).toList().toArray(new CompletableFuture[0]); 处理每个工作表 对于每个工作表,代码首先检查工作表是否可见,然后计算工作表的总宽度和高度,包括单元格和图形对象。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private static void adjustOneSheet(String sheetName, XSpreadsheet sheet, XNameAccess xPageStyles) { // 计算工作表的总宽度和高度 int totalWidth = getTotalWidth(getxColumnRowRange(sheet), getLastColumn(sheet)); int totalHeight = getTotalHeight(getxColumnRowRange(sheet), getLastRow(sheet)); // 包括图形对象的尺寸 Size graphicalSize = getGraphicalObjectsSize(sheet); totalWidth = Math.max(totalWidth, graphicalSize.Width); totalHeight = Math.max(totalHeight, graphicalSize.Height); // 设置页面样式属性 XPropertySet xPageStyleProps = getPageStyleProps(sheet, xPageStyles); xPageStyleProps.setPropertyValue("Size", new Size(totalWidth, totalHeight)); setMarginToZero(xPageStyleProps); xPageStyleProps.setPropertyValue("ScaleToPages", (short) 1); } 计算图形对象尺寸 getGraphicalObjectsSize方法用于计算工作表中所有图形对象所占的最大宽度和高度。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private static Size getGraphicalObjectsSize(XSpreadsheet sheet) { XDrawPageSupplier drawPageSupplier = queryInterface(XDrawPageSupplier.class, sheet); XDrawPage drawPage = drawPageSupplier.getDrawPage(); int count = drawPage.getCount(); int maxWidth = 0; int maxHeight = 0; for (int i = 0; i < count; i++) { XShape shape = queryInterface(XShape.class, drawPage.getByIndex(i)); Point position = shape.getPosition(); Size size = shape.getSize(); maxWidth = Math.max(maxWidth, position.X + size.Width); maxHeight = Math.max(maxHeight, position.Y + size.Height); } return new Size(maxWidth, maxHeight); } 计算总宽度和总高度 getTotalWidth和getTotalHeight方法分别用于计算工作表的总宽度和总高度。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 private static int getTotalWidth(XColumnRowRange columnRowRange, int endColumn) { int totalWidth = 0; for (int j = 0; j <= endColumn; j++) { Object column = columnRowRange.getColumns().getByIndex(j); XPropertySet columnProps = queryInterface(XPropertySet.class, column); totalWidth += (int) columnProps.getPropertyValue("Width"); } return totalWidth; } private static int getTotalHeight(XColumnRowRange columnRowRange, int endRow) { int totalHeight = 0; for (int i = 0; i <= endRow; i++) { Object row = columnRowRange.getRows().getByIndex(i); XPropertySet rowProps = queryInterface(XPropertySet.class, row); totalHeight += (int) rowProps.getPropertyValue("Height"); } return totalHeight; } 完成导出 最后,等待所有异步任务完成后,调用链式调用chain.doFilter继续执行标准的PDF导出流程。 1 2 CompletableFuture.allOf(futures).join(); chain.doFilter(context, document); 代码链接 上述解决方案的原始代码可以在GitHub上找到,链接为: https://github.com/cuipengfei/jodconverter-samples/blob/main/samples/spring-boot-rest/src/main/java/org/jodconverter/sample/rest/ExcelSinglePageFilter.java 总结 ExcelSinglePageFilter通过自定义的PDF导出逻辑,成功避免了使用“Whole sheet export”选项,从而解决了超链接在PDF中丢失的问题。这种方法不仅保留了超链接的完整性,而且还提供了一种灵活的方式来调整每个工作表的显示尺寸,确保它们在PDF中以单页的形式呈现。

2024/7/28
articleCard.readMore

基于LibreOffice的MS Office文档格式转换

将Microsoft Office文件转换为其他格式的场景 在一些情况下,可能需要将Microsoft Office文件转换为其他格式: 兼容性问题:与不同的办公软件或操作系统进行交互,可能需要将MS Office文件转换为更通用的格式。例如,如果要与没有安装Microsoft Office的人分享文档,将其转换为PDF格式可能更合适。 归档和存档:将Office文件转换为更稳定、可持久保存的格式可以确保文件的长期保存和归档。某些文件格式(如PDF/A)专门用于长期存档目的,以确保文件内容的完整性和可访问性。 数据提取:你可能只对文档中的特定数据或内容感兴趣。通过将Office文件转换为其他格式(如纯文本或CSV),可以更容易地提取所需的数据,并在其他应用程序中进行分析或处理。 网页发布:如果要将MS Office文件发布到网页上,可能需要将其转换为HTML或其他网页友好的格式,以确保文件在网页上正确显示。 LibreOffice - 微软Office的开源替代 LibreOffice是一个免费、开源的办公套件,在某种程度上可以被视为微软Office的开源替代品。 LibreOffice Writer:对应于Microsoft Word。 LibreOffice Calc:对应于Microsoft Excel。 LibreOffice Impress:对应于Microsoft PowerPoint。 LibreOffice支持的转换格式 LibreOffice支持广泛的转换格式。具体请参考如下的表格: Format FamilyFrom (any of)To (any of) Text *.odt    OpenDocument Text *.ott    OpenDocument Text Template *.sxw    OpenOffice.org 1.0 Text *.rtf    Rich Text Format *.doc    Microsoft Word *.docx   Microsoft Word XML *.wpd    WordPerfect *.txt    Plain Text *.html   HTML *.pdf    Portable Document Format *.odt    OpenDocument Text *.ott    OpenDocument Text Template *.sxw    OpenOffice.org 1.0 Text *.rtf    Rich Text Format *.doc    Microsoft Word *.docx   Microsoft Word XML *.txt    Plain Text *.html   HTML *.wiki   MediaWiki wikitext Spreadsheet *.ods    OpenDocument Spreadsheet *.ots    OpenDocument Spreadsheet Template *.sxc    OpenOffice.org 1.0 Spreadsheet *.xls    Microsoft Excel *.xlsx   Microsoft Excel XML *.csv    Comma-Separated Values *.tsv    Tab-Separated Values *.pdf    Portable Document Format *.ods    OpenDocument Spreadsheet *.ots    OpenDocument Spreadsheet Template *.sxc    OpenOffice.org 1.0 Spreadsheet *.xls    Microsoft Excel *.xlsx   Microsoft Excel XML *.csv    Comma-Separated Values *.tsv    Tab-Separated Values *.html   HTML Presentation *.odp    OpenDocument Presentation *.otp    OpenDocument Presentation Template *.sxi    OpenOffice.org 1.0 Presentation *.ppt    Microsoft PowerPoint *.pptx   Microsoft PowerPoint XML *.pdf    Portable Document Format *.swf    Macromedia Flash *.odp    OpenDocument Presentation *.otp    OpenDocument Presentation Template *.sxi    OpenOffice.org 1.0 Presentation *.ppt    Microsoft PowerPoint *.pptx   Microsoft PowerPoint XML *.html   HTML Drawing *.odg    OpenDocument Drawing *.otg    OpenDocument Drawing Template *.svg    Scalable Vector Graphics *.swf    Macromedia Flash LibreOffice的headless模式 与常见的Chromium的headless模式类似,LibreOffice也提供headless模式。 没有图形用户界面(GUI):无论是LibreOffice的headless模式还是Chromium的headless模式,都在没有GUI的情况下运行,不会显示可见的窗口或用户界面。 命令行接口(CLI)控制:通过命令行接口进行控制和操作。可以在命令行中使用特定的命令和参数来执行相应的任务和操作。 自动化和批处理:LibreOffice的headless模式和Chromium的headless模式都适用于自动化和批处理任务。 服务器环境中的应用:适用于在服务器环境中使用。 这就给在服务端自动化进行文档类型转换提供了很大的便利。 资源池管理与容器化 - JodConverter JodConverter是一个用于将Office文档转换为其他格式的Java库。它支持与LibreOffice(也可以是OpenOffice)进行集成。 我们可以选择自己直接与headless的LibreOffice直接通信来完成文档格式转换,不过由JodConverter来代劳的好处是很明显的: 资源池化 容器化 JODConverter 的进程管理器(Process Manager)在资源池内维护 LibreOffice 进程。 将 LibreOffice 进程保持在资源池内,可以避免每次进行文档转换时都需要启动和终止 LibreOffice 进程的开销。 它可以检测到进程的健康状态,例如进程异常退出或崩溃,然后采取相应的措施,如重新启动进程。 JODConverter 还提供一个基于Debian的基础docker image,其中已经包含了LibreOffice。 我们的调用JODConverter的Java应用只需要基于该镜像build出来就好。 MS Office存量文件的兼容性 - 字体的开源替代 上面这张图左侧是原版的PPT,右侧是用JOD + LibreOffice转换出来的PDF。 可以看到右侧转换出PDF,文字之间出现了互相交叉重叠的现象,整个样式都乱掉了。 这其实并不是JOD或者是LibreOffice的bug。 而是由于左侧的PPT当中使用了一些微软的商用字体。 而LibreOffice运行在docker里面,它是拿不到这些微软的商用字体的,我们也不应该把有商用版权的字体置入docker image中。 这时我们可以使用一些开源的字体来代替微软的商用字体。 下图来自于LibreOffice的一篇博客: https://blog.documentfoundation.org/blog/2020/09/08/libreoffice-tt-replacing-microsoft-fonts/ LibreOffice提供了一个Font Replacement Table的功能。 左侧Font列是商用字体,右侧Replace with列是开源字体。 例如:当LibreOffice见到一个ppt文件内的某段文字使用了Arial字体时,就会自动用Arimo字体去渲染这段文字。 这样,既能够尽量保持视觉效果的一致性,也避免使用商用字体。 下图是使用了开源字体替代后的转换效果,右侧的PDF和左侧的PPT视觉差异已经不太大了。 作为一个LibreOffice桌面应用的使用者可以按照上述说明来做配置,从而最大程度的去兼容微软的商用字体。 而当我们使用headless模式时该如何做出等效的配置呢? LibreOffice - User Profile 上面提到的配置项,会被保存在LibreOffice User Profile内,在不同的OS内保存的路径为: 1 2 3 4 5 Windows %APPDATA%\libreoffice\4\user (LibreOffice 4 and above) GNU/Linux /home/<user name>/.config/libreoffice/4/user (LibreOffice 4 and above) 我们可以把开源字体文件以及保存下来的配置文件内置入docker image内。 并通过JOD指定启动LibreOffice时的参数,让headless模式下运行的LibreOffice加载到正确的配置文件,进而也能达成上图所示一样转换的效果。 如下Dockerfile示例中把开源字体和配置文件copy进Docker image 1 2 3 4 5 6 7 # support more fonts COPY cjk-fonts/* /usr/share/fonts/cjk/ COPY condensed-fonts/* /usr/share/fonts/condensed/ COPY ms-sub-fonts/* /usr/share/fonts/ms-sub-fonts/ # add user profile config files COPY ./profile/LibreOffice/4/user /tmp/jodconverter/user 如下示例中指定template-profile-dir 1 2 3 4 5 jodconverter: local: enabled: true port-numbers: 2002,2003 template-profile-dir: /tmp/jodconverter 这样可以确保JODConverter在启动LibreOffice进程的时候可以明确地告诉LibreOffice去加载哪一份配置文件。 Links JODConverter Wiki jodconverter-runtime docker基础镜像 LibreOffice PDF转换支持的命令行参数 宽度过大的Excel转PDF的问题 微软字体兼容性问题 LibreOffice User Profile默认路径 把自定义的字体与User Profile加入Docker Image 给JODConverter指定User Profile路径

2024/2/4
articleCard.readMore

Spring Integration JDBC分布式锁 - Transactions与Threads

第一个问题:在多个线程中同时运行隔离级别为serializable的事务而导致的无法重试获取锁的问题 Spring Integration JDBC分布式锁的实现会需要使用一个serializable级别的事务来获取锁。 如果多个线程同时尝试获取锁,这些事务之间可能会出现顺序问题。 具体而言,可能会遇到以下错误: 1 org.postgresql.util.PSQLException: ERROR: could not serialize access due to read/write dependencies among transactions 发生这样的问题其实也不可怕,因为JDBC锁会进行重试。 然而,当使用JPA Transaction Manager时,由于某些异常类型的原因,JDBC锁无法在发生这种错误的情况下进行重试。 可以在以下GitHub Issue中查看详细信息: https://github.com/spring-projects/spring-integration/issues/3733 可以使用以下代码重现此问题: https://github.com/cuipengfei/Spikes/blob/master/jpa/lock-transaction-threads/src/main/java/com/github/spring/example/service/Problem1Service.java 使用Data Source Transaction Manager来workaround第一个问题 可以明确指定让JDBC锁不使用JPA Transaction Manager,而是使用Data Source Transaction Manager来绕过此问题。 具体代码请参考: https://github.com/cuipengfei/Spikes/blob/master/jpa/lock-transaction-threads/src/main/java/com/github/spring/example/configs/CustomJDBCLockConfigs.java 可以执行该代码以观察workaround的效果: https://github.com/cuipengfei/Spikes/blob/master/jpa/lock-transaction-threads/src/main/java/com/github/spring/example/service/Problem1FixService.java 第二个问题:在同一个线程中先使用JpaTransactionManager启动一个事务,然后尝试用DataSourceTransactionManager获取JDBC锁所导致的事务隔离级别变化的问题 该问题的显著特征是:如果在一个方法上标注了@Transactional,然后在该方法内部先执行了一些JPA的SQL操作,然后再尝试获取JDBC分布式锁,就会出现无法更改事务隔离级别的问题。 问题的关键在于并行流(parallel stream)并不总是仅利用其自己线程池中的线程,它也会利用当前线程。 而恰好落在当前线程上的那一次尝试获取JDBC分布式锁的操作就会出现无法更改事务隔离级别的问题。 这是因为我们用来解决第一个问题而引入的DataSourceTransactionManager的文档中提及它具有如下行为: Note: The DataSource that this transaction manager operates on needs to return independent Connections. The Connections typically come from a connection pool but the DataSource must not return specifically scoped or constrained Connections. This transaction manager will associate Connections with thread-bound transactions, according to the specified propagation behavior. It assumes that a separate, independent Connection can be obtained even during an ongoing transaction. 可以通过以下代码观察parallel stream的行为: https://github.com/cuipengfei/Spikes/blob/master/jpa/lock-transaction-threads/src/main/java/com/github/spring/example/TestParallelStreamThreads.java 可以使用以下代码重现该问题: https://github.com/cuipengfei/Spikes/blob/master/jpa/lock-transaction-threads/src/main/java/com/github/spring/example/service/Problem2Service.java 第二个问题的不完善解决方法:强制parallel stream不使用当前线程 在解决该问题的过程中,我尝试了一种不太明智的方法,在这里也记录一下。 我最初的想法是,既然parallel stream会利用当前线程,从而导致落在当前线程上的那一次获取锁的操作失败,那么我干脆强制它不要使用当前线程。然而,这是一种非常简单粗暴的做法。 尽管这样做可以成功获取JDBC锁,但它也会导致一部分SQL游离在事务之外执行。 不仅仅是这个解决方法,上述的三份代码也都会有这个问题。 不太理想的解决方法的代码如下: https://github.com/cuipengfei/Spikes/blob/master/jpa/lock-transaction-threads/src/main/java/com/github/spring/example/service/Problem2BadFixService.java 第二个问题的较优解决方法:缩小事务范围,避免将业务操作和获取JDBC锁的操作混合在同一个被@Transactional标注的方法内 上述四份代码都存在一个共同的缺点,即@Transactional注解的范围太广。 这容易导致JPA Transaction Manager的范畴以及用于获取JDBC分布式锁的Data Source Transaction Manager的范畴互相交叉。 当这两者混在一起时,很容易出现DataSourceTransactionManager试图去改变一个已经被open过的transaction的隔离级别的问题。 第二个问题的较优解决方法的代码请参考: https://github.com/cuipengfei/Spikes/blob/master/jpa/lock-transaction-threads/src/main/java/com/github/spring/example/service/Problem2GoodFixService.java 用图来总结一下 flowchart TD style dstm fill:lightgreen,stroke:#333,stroke-width:4px style nrt fill:#FFCCCB,stroke:#333,stroke-width:4px jl[JDBC分布式锁] str[Serializable级别的事务] mt[多个线程] jtm[JPA Transaction Manager] dstm[❤️Data Source Transaction Manager❤️] nrt[🪳无法重试获取锁🪳] se[Serialization Error] se2[Serialization Error] subgraph 在多个线程中同时运行隔离级别为serializable的事务而导致的无法重试获取锁的问题 jl-->|默认使用|str mt-->|同时获取|jl jl-->|恰好用了|jtm str-->|容易撞车而导致|se jtm-->|hold不住|se se-->|从而导致|nrt jl-->|替换成使用|dstm dstm-->|可以hold住|se2 se2-->|从而解决|nrt end flowchart TD style nd fill:lightgreen,stroke:#333,stroke-width:4px style ile fill:#FFCCCB,stroke:#333,stroke-width:4px st[同一个线程中] t[事务] t2[事务] tm[一个范围很宽的标注了@Transactional的方法] js[JPA的SQL操作] tl[获取JDBC分布式锁] js2[JPA的SQL操作] tl2[获取JDBC分布式锁] ps[Parallel Stream] op[其自己线程池中的线程] ct[当前线程] ile[🪳无法更改事务隔离级别的问题🪳] nd[❤️正确做法应该是缩小@Transactional的范围❤️] subgraph 在同一个线程中先使用JpaTransactionManager启动一个事务然后尝试用DataSourceTransactionManager获取JDBC锁所导致的事务隔离级别变化的问题 ps-->|并不总是仅利用|op ps-->|也会利用|ct ct-->|那么就会在|st st-->|跑|tm tm-->|先执行了一些|js tm-->|然后再尝试|tl js-->|已经open了|t tl-->|再去试图更改其隔离等级|t t-->|从而导致|ile nd-->|可以及时关闭|t2 t2-->|避免把二者混在一起|js2 t2-->|避免把二者混在一起|tl2 js2-->|从而避免|ile tl2-->|从而避免|ile end 补充 Redis 上面的问题都是由于业务代码和获取锁的代码二者同时依赖于同一个数据库。 而Spring Integration的分布式锁除了可以使用JDBC,其实也可以使用Redis或其他底层技术。 如果把上述代码中的JdbcLockRegistry全部替换为RedisLockRegistry,而保持其它代码不变,所有错误都会消失,不会再重现。 因为无论用到了哪一个线程,哪一个DB Transaction,也无论@Transactional标记的宽或者窄,Redis总是不会和JDBC/DB撞车的。 可以通过修改上述代码中的此处来试用Redis: https://github.com/cuipengfei/Spikes/blob/c887a6f802bbfffc45ee29cbb91dac731243b7cd/jpa/lock-transaction-threads/src/main/resources/application.properties#L17-L18 Spring Boot 3 如果升级到Spring Boot 3.1.5 + JDK 17,则Spring Integration JDBC会升到6.1.4(上述代码用的是5.x),甚至不用替换成Data Source Transaction Manager,上述问题也会消失。 因为这一版本的Spring Integration JDBC的分布式锁实现在acquire lock时不再使用serializable的事务,而是改成了read committed。 这样,自然就规避了第一个问题,不再有serializable事务撞车。 而由于不再需要给锁使用Data Source Transaction Manager,自然也就解决了第二个问题,不再有同一个线程上两个transaction managers打架的问题。 不过,即便如此,缩小@Transactional的范围仍然是值得建议的。

2023/12/25
articleCard.readMore

内存涨上去不肯下来 - 未必是内存泄漏

在一个Kubernetes(K8s)集群中,部署了Prometheus和Grafana用于监控集群本身和应用的状态。 在其中一个Java应用对应的Pod级别观察到了内存上升的现象。具体而言,当该应用刚启动时,内存占用并不高。如果不发送请求给应用,内存将保持在启动时的水平上。 如果大量发送请求给应用并在短时间内持续发送,内存会迅速增加。这在一定程度上是正常的。 一旦内存增加之后,即使停止发送请求和压力,内存使用也不会下降,一直保持在高峰水平。 上面的状况是由Grafana中观察到的。 观察到的现象看起来像是内存泄漏,但实际上并不一定是内存泄漏。 原因有以下两点: 1 在K8s中运行的Prometheus默认只使用了Node Exporter 这意味着Prometheus收集的数据是从操作系统的角度来看进程的内存使用情况,而不是从Java虚拟机(JVM)进程内部观察。 如果想要从JVM内部的视角观察堆内存的使用情况,例如堆的大小和使用情况,就需要让应用容器内包含有Prometheus的jmx exporter。 2 关键是要观察堆内存的使用情况 要检查和确诊Java应用的内存泄漏,不能仅仅从操作系统的角度观察整个进程的内存使用情况,认为内存没有释放就是泄漏。这种观察方式是不准确的。 应该从JVM内部观察堆内存的使用情况,即使进行了垃圾回收(GC),堆内存仍然无法下降是一个明确的征兆。 例如,堆使用量(heap usage)基本上接近堆大小(heap size),并且堆使用量出现了频繁的小锯齿波动,这基本上表明GC在尝试清理旧的内存,但无法成功清理,这就是比较明显的迹象了。 因为JVM有时候不愿意释放从操作系统那里要来的内存。因此,仅仅根据从操作系统的角度观察内存是否增加而不下降来诊断为Java的内存泄漏是不准确的。

2023/8/26
articleCard.readMore

Spring Integration JDBC分布式锁 - TTL

最近在项目中需要使用Spring Integration提供的基于JDBC实现的分布式锁。 在实践的过程中,我们遇到了一些有趣的问题,现在在此记录和总结一下。 一共遇到了两个问题,第一个和time to live有关,第二个还是和time to live有关。 第一个问题:由于time to live默认值不够长而导致被动失去锁的问题 sequenceDiagramactor event_initiatorparticipant instance_1participant instance_2event_initiator->>instance_1: do somethingnote over instance_1: instance 1 获得了 lockinstance_1->>instance_1: start doing its thingevent_initiator->>instance_2: do another thingnote over instance_2: instance 2 等待 locknote over instance_2: 等 ......note over instance_2: 等 ......note over instance_1: lock的超时时间TTL到,instance 1还没干完活,但是它失去了 lock<br>失去不同于主动release<br>失去lock后,instance 1还会继续干活<br>而这些活里面可能会有SQL写操作note over instance_2: instance 2 获得了 lockinstance_2->>instance_2: start doing its thingnote over instance_1,instance_2: 此时二者同时干活,有撞车的风险,因为二者干活的先后顺序没有保证<br> instance 1尚未把它干完活后才能确定的状态写入DB,而instance 2已经开始干活了note over event_initiator,instance_2: 为了降低风险,可以: <br> ① 想办法尽量让instance 1能在超时前干完活 <br> ② 以防万一可以考虑在合适的时间节点延长锁的过期时间 根据上图所示,我们有两个实例。 事件的触发者首先让第一个实例去处理一个事件。第一个实例获取了一个锁并开始执行相应的任务。 此时,事件的触发者又让第二个实例去处理另一个事件。第二个实例也想获取同一个锁,但由于第一个实例已经开始处理了,第二个实例无法获取锁,只能等待。 在理想情况下,第一个实例会在完成任务后释放锁,然后第二个实例就可以获取锁并开始执行相应的任务,这样就不会有任何问题。 但是,如果由于某种原因第一个实例处理任务的速度太慢,就会出现问题。 因为Spring Integration JDBC分布式锁会遵循Time to Live的参数,该参数确定了在获取锁后最长可持有锁的时间。 如果超出了这个时间,而另一个人想要获取该锁,则可以获取到锁。这是为了避免锁的持有者挂掉而导致其他人全部干等的防死锁机制。 在这种情况下,就会有两个人同时运行。 我们本意是让锁保护资源以避免同时访问,但在这种情况下,资源会被同时访问。 如果这些访问中涉及到读取共享状态以决定后续行为以及写入其他人可能会读取的状态的操作,那么就会出现混乱。 在这种情况下,我们可以选择进行两个操作。 给time to live一个合理的值 实际上,相当于需要对运行时间进行合理的估算,然后尽量将time to live设置为能够覆盖该估算值的数值。 把这个估算出来的数字赋值给default lock repository的time to live就好了。 1 2 3 4 5 6 7 /** * Specify the time (in milliseconds) to expire deadlocks. * @param timeToLive the time to expire deadlocks. */ public void setTimeToLive(int timeToLive) { this.ttl = Duration.ofMillis(timeToLive); } DefaultLockRepository 选择在合适的时机renew 即使我们进行了合理的估算,但这只是一个估计值,不是绝对精确的值。 换言之,在某些情况下,运行时间仍可能延续到time to live过期之后,从而面临两个人同时访问的风险。 为了避免这种情况,可以在适当的时候进行renew操作。 例如,在执行耗时较长的操作之前调用lock registry的renewLock方法,这样就相当于在执行耗时较长的操作之前重新获取了一次锁。 1 2 3 4 5 6 7 8 9 public interface RenewableLockRegistry extends LockRegistry { /** * Renew the time to live of the lock is associated with the parameter object. * The lock must be held by the current thread * @param lockKey The object with which the lock is associated. */ void renewLock(Object lockKey); } RenewableLockRegistry 第二个问题:time to live对于运行在同一个进程中的两个线程来说是不会自动生效的 sequenceDiagramactor event_initiatorparticipant instance_1participant instance_2event_initiator->>instance_1: do somethingnote over instance_1: instance 1 获得了 lockinstance_1->>instance_1: start doing its thingevent_initiator->>instance_2: do another thingnote over instance_2: instance 2 等待 locknote over instance_2: 等 ......note over instance_2: 等 ......note over instance_1:instance 1的当前线程由于某种原因卡死了,没机会释放锁note over instance_1: lock的超时时间TTL到note over instance_2: instance 2 获得了 lockinstance_2->>instance_2: start doing its thingnote over event_initiator,instance_2: instance 1的线程由于某种原因卡死而没机会释放锁<br>在TTL过后instance 2可以拿到锁并做事<br>这是我们希望看到的事情,因为这样可以避免由于一个线程卡死不释放锁而导致别人干等的局面 在上述图中,我们描述了两个实例,即在不同机器(或容器)上运行的两个不同Java进程。这是跨进程协作的情况,这正是我们需要分布式锁的主要原因。 在这种场景下,time to live是有效的。它可以防止一个进程无法释放锁,从而导致其他进程一直等待锁而无法继续工作的局面的出现。 在跨进程协作中,time to live可以发挥作用,那对于同一个进程中的两个线程,是否同样有效呢? 从概念上来说,如果能将其设计成同样有效的,则可以减轻使用者的认知负担。 但是,在Spring Integration JDBC的分布式锁实现中,time to live并不是这样的。它对于同一个Java进程中的两个线程来说是不会自动生效的。 下面的图是一个它不会生效的具体例子。 sequenceDiagramactor event_initiatorevent_initiator->>instance_1: do somethinginstance_1->>instance_1_thread_1: 分配工作给线程1note over instance_1_thread_1: instance 1 - thread 1 获得了 lockinstance_1_thread_1->>instance_1_thread_1: start doing its thingevent_initiator->>instance_1: do another thinginstance_1->>instance_1_thread_2: 分配工作给线程2note over instance_1_thread_2: instance 1 - thread 2 等待 locknote over instance_1_thread_2: 等 ......note over instance_1_thread_2: 等 ......note over instance_1_thread_1: thread 1由于某种原因卡死了,没机会释放锁note over instance_1_thread_1: lock的超时时间TTL到note over instance_1_thread_2: thread 2 仍然获获取不到 locknote over instance_1_thread_2: 继续等也没用 正如上图所示,第二个线程或同一个进程中的任何其他线程,即使继续等待锁,也无济于事。这也解释了time to live在这种情况下的无效性。 这份代码可以重现上面两幅图所描述的场景 要解决这种情况下的问题,则需要用到下面提到的这个expireUnusedOlderThan方法。 1 2 3 4 5 6 7 8 9 10 public interface ExpirableLockRegistry extends LockRegistry { /** * Remove locks last acquired more than 'age' ago that are not currently locked. * @param age the time since the lock was last obtained. * @throws IllegalStateException if the registry configuration does not support this feature. */ void expireUnusedOlderThan(long age); } ExpirableLockRegistry 如果一个Java进程中的一个线程因为某种原因卡死了,从而无法释放锁。 在TTL过期之后,另一个线程在尝试获取同一个锁之前,可以调用expireUnusedOlderThan来强制释放该锁。 然后再尝试获取锁,就可以成功获取并继续工作。 为什么不把time to live设计的更具有概念上的一致性? 当涉及到跨越两个进程时,time to live会发挥作用。但是对于同一个进程中的两个线程来说,time to live就不再有效。这种同一个概念在不同的场景下表现出不同的行为,缺乏概念上的一致性,这可能会增加使用者的认知负担。 那Spring为什么要把它设计成这样呢? 以下是我的解读: 在分布式系统中,如果两个进程想要获得同一个锁,那么来得晚一些的进程实际上并不知道前一个进程目前处于什么状态,它是否已经死亡。在这种情况下,time to live成为了决策的唯一依据。如果time to live还没有过期,那么来得晚的进程认为它不应该获取该锁。一旦time to live过期,后续进程将把锁置于自己的控制之下,而不管前一个进程是否仍然活着。简而言之,由于缺少其他的决策依据,这种情况下只能选择依照time to live来简单粗暴的办事。 然而,对于同一个进程中的两个线程来说,情况就不同了。它们共享同一块内存空间(从实现层面来讲,同一个进程中的两个线程是共享同一份registry和repository的)。如果后续线程发现前一个线程在TTL过后还没有释放锁,则默认认为前一个线程仍然有继续工作的能力,因此它默认不会强制抢占锁。除非他在TTL之后等过一段时间之后觉得等不及了,这时候他可以选择强制把锁抢过来。 举个例子,就像在古代缺乏无线电通信的战争中。一支小队去执行任务,另一支小队待命。三个小时后,不论先前的小队是否成功,后续小队都必须出发参加战斗。因为除了这三个小时之外,你没有其他的决策依据。只要时间到了,我就开始行动。 但是,如果是同一支小队中的两个士兵,后面的士兵实际上可以在目视距离内看到前面的士兵是否仍然在执行任务。因此,即便是战友行动太慢了,他也可以选择多等待一会,让前面的士兵可以继续完成任务。当然,如果后面的士兵等不及了,想要立即行动,他也可以选择这样做。在这种情况下,后面的士兵具有更多的决策依据,因此他可以选择多等待一段时间或立即采取行动。 总结 第一个问题:由于time to live默认值不够长,而导致在还不该失去锁的时间点上过早地失去了锁(防死锁机制过早地介入了),解决方法是把TTL设置的足够大,以及在合适的时机做renew从而避免过早地失去锁。 第二个问题:time to live对于运行在同一个进程中的两个线程来说是不会默认生效的,从而导致应该失去锁的时间已经过了,但卡住的线程还没有释放锁,进而导致后续的线程拿不到锁的情况。解决方法是可以选择让同一个进程内的后来者线程使用expireUnusedOlderThan来强制剥夺锁(同一个进程中的两个线程,其中后来的那个具有更高的自由裁量权,不必只依据TTL一个指标来行事)。 mindmap root(Spring Integration JDBC Distributed Lock) 默认TTL太短 设置长一些 renew 卡死了,没机会释放锁 其它进程可以在TTL过后拿到锁 同一个进程内的其它线程可以选择使用expireUnusedOlderThan 锁的事,该失去就失去,不失去也强制失去。 锁的事,不该失去就不能失去,要失去也不许失去。

2023/7/29
articleCard.readMore

当测试代码使用随机生成的输入数据时,该如何去做出断言

一份有意思的代码 最近看到了一份使用随机生成的数据作为测试输入的有趣代码,把其大致思路用伪代码描述如下 需要被测的实现代码 1 2 3 4 5 function calculateSomething(inputData) { // 使用inputData来计算结果 // 假装这里有一些很复杂的逻辑 return result; } 这是被测的函数,在此不管它算的是什么,总之它接受input,返回result。 测试代码的helpers 1 2 3 4 5 6 7 8 9 10 function generateInputData(){ // 用来生成测试所需的input数据 // 所生成的数据具有一定的随机性 return randomlyGeneratedInputData; } fuction calculateExpectedResult(inputData){ // 用来计算assertion所需要的expected值 return expectedResult; } 这是测试代码的helper函数,一个用来生成测试所需的input,一个用来计算expected的值。 测试代码 1 2 3 4 5 6 7 8 9 var repeatTimes = 100; //总之是一个较大的数字,不一定非得是100 for (i=0; i<repeatTimes; i++){ var randomInputData = generateInputData(); //生成具有一定随机性的输入 var expected = calculatedExpectedResult(randomInputData); //用测试helper算出expected var actual = calculateSomething(randomInputData); //用被测方法算出actual expect(actual).toEqual(expected); //断言二者相等 } 这是测试代码,反复运行多次,每次都生成具有随机性的input,然后把input传递给calculatedExpectedResult和calculateSomething,最后断言二者返回值是一致的。 这份代码和常见的测试不同,它使用的input data不是预先设定好的,而是运行时随机生成的。这也是它有趣的原因。 如何解读测试结果 通常来说,当测试通过时,它意味着针对给定的输入,程序给出了符合预期的输出。 但是对于这一份代码来说,却并非如此。因为它的expected值是由一个helper函数计算得来,而非是一个已经被验证过是正确的值。 那么,如果上述的测试代码能够执行通过,它是在传递什么信息给开发者呢? 它意味着calculatedExpectedResult这个helper函数和calculateSomething这个被测函数之间,具有较高的相似性,二者针对多组一样的输入,可以给出一样的输出 如果我们把repeatTimes的数值调到非常高,测试还能通过的话,那就说明calculatedExpectedResult这个helper函数和calculateSomething这个被测函数之间的相似性非常高,简直可以达到同卵双胞胎甚至是克隆体这种以假乱真的程度 这是我们需要的吗? 我们需要去探寻世界上是否存在那么一个函数,它的行为可以做到和calculateSomething极其贴近吗? 我认为我们是不需要的。 我们需要的是去验证calculateSomething的行为是符合预期的。而不是去验证我能写出另一个和它的行为很像的函数来。 这就如同是:如果我去测试洗衣机的话,我希望验证的是某款洗衣机可以把衣服洗涤干净,并且不会损伤衣物。 而不是希望验证存在另一台洗衣机和我手里这一台表现一样。 要不然的话,我说不定会得到两台洗不干净衣服,还会损伤布料的洗衣机😄 当测试代码使用随机生成的输入数据时,该如何去做出断言 上面的代码虽然做的并不妥当,但是想要用随机生成的input数据去做测试其实并不是一个不合理的想法。 当我们人工编制的测试数据对于整体样本空间来说显得太小时,用随机数据去作为input数据也是一个不错的补充。 其关键在于,当我们给input引入了随机性的时候,我们该如何去assert其output是符合预期的? 如果我们还是想要和常规测试一样,严格地去assert输出的值和预期相等,那么就会陷入上述代码的误区里。 但是如果思路换一下,不一定非得强求能够严格地去assert输出的值和预期相等,而是去assert输出值符合一定的规则。 这样,就无需在测试代码里重复去实现一遍,而只需要描述我们预期输入和输出之间符合哪种规则。 Property Based Testing 而这,恰好就是Property Based Testing。 Property Based Testing是一种基于属性规约的测试方法,通过使用随机输入数据来验证程序的行为是否符合预期的属性规约。 在 Property Based Testing 中,测试用例是基于属性规约自动生成的。 Property Based Testing 的基本流程如下: 定义属性规约:定义程序的行为应该满足的属性规约,这些规约通常是通用的、可重用的、抽象的,而不是特定的测试用例。 生成随机数据:通过随机数据生成器生成随机数据,并将随机数据输入到程序中。 检查属性规约:将实际输出与定义的属性规约进行比较,如果程序的输出符合属性规约,则测试通过,否则测试失败。 修复代码:如果测试失败,则需要对程序进行修复,直到程序能够符合所有属性规约。 一些常用的 Property Based Testing 框架包括 QuickCheck、Hypothesis、ScalaCheck、fast-check 等。 下面是一段使用Property Based Testing的样例代码: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 const fc = require('fast-check'); // Property-Based Testing,测试加法函数 test('加法满足交换律', () => { fc.assert(fc.property(fc.integer(), fc.integer(), (x, y) => { return add(x, y) === add(y, x); })); }); test('加0不影响结果', () => { fc.assert(fc.property(fc.integer(), (x) => { return add(x, 0) === x; })); }); test('正数加负数,结果小于原数', () => { fc.assert(fc.property(fc.integer(1000, 1), fc.integer(-1, -1000), (x, y) => { return add(x, y) < x; })); }); test('负数加正数,结果大于原数', () => { fc.assert(fc.property(fc.integer(-1000, -1), fc.integer(1, 1000), (x, y) => { return add(x, y) > x; })); }); test('负数加负数,结果小于原数', () => { fc.assert(fc.property(fc.integer(-1000, -1), fc.integer(-1000, -1), (x, y) => { return add(x, y) < x; })); }); test('正数加正数,结果大于原数', () => { fc.assert(fc.property(fc.integer(1, 1000), fc.integer(1, 1000), (x, y) => { return add(x, y) > x; })); }); test('任何数加自己,结果是两倍', () => { fc.assert(fc.property(fc.integer(), (x) => { return add(x, x) === x * 2; })); }); 以上代码中所使用的fast-check(fc)会帮助我们生成大量的具有随机性的输入数据,但是我们并没有去assert add的返回值等于某个具体的数字,而是去判断add这个函数在其输入值符合特定规则时其返回值符合我们通过fc定义的规律。

2023/3/18
articleCard.readMore

卢瑟经济学

此书讲什么 马克思《资本论》讲的三件事: 原始积累靠暴力 贫富差距会扩大 最终自毁 经济与政治 经济学是有阶级属性的。 哪个阶级现在说了算,哪个阶级的经济学就流行。 反之亦然,可以通过观察哪个学说流行来倒推谁说了算。 地主: 重农主义 商人: 重商主义 新兴资产阶级: 自由市场和劳动价值论 工人阶级: 劳动价值论 资产阶级卷土重来: 边际效用价值论 不同的阶级有不同的价值理论,这就如同不同的宗教信仰对善行的定义是不一样的。 统治阶级的经济学,是为统治阶级寻找合法性的。被统治阶级的经济学,自然是为被统治阶级申诉的,顺便控诉对方。 市场是不是真的有效,到底什么创造价值,其实就看谁来解释。 按照卢瑟经济学,土地没有价值。土地的所有制,使土地所有者有权向在土地上生活工作的人要求贡赋。土地所有权越垄断,要求的地租就越高。土地垄断程度高的地方,绝对地租就高。所以房子的昂贵并不是土地的价值昂贵,而是土地的所有权高度垄断,导致地租昂贵。 危机的表征与对其的解释 妈妈,天这么冷,我们家为什么不生火呢? 因为你爸爸失业了,我们没有钱去买煤。 小孩继续问:“妈妈,爸爸为什么失业呢?” 他妈妈说:“因为你爸爸的煤矿倒闭了。〞 小孩接着问:“妈妈,爸爸的煤矿为什么会倒闭呢?” 他妈妈说:“因为没有人买你爸爸的煤矿的煤,煤卖不出去,所以煤矿倒闭了。” 与封建时期的危机不同,资本主义条件下的危机的特点: 不是产品不足而是产品过剩 不是劳动力被透支而是劳动力被闲置 不是没稂食吃,而是产能过剩导致企业破产,然后大家下岗,没钱买粮食吃 产能有的是,就是没销路;粮食有的是,就是没钱买;劳动力有的是,就是没活干 这种危机有很强的周期性,按时袭击经济 工业化的资本主义生产的特点是所有的人,都为别人生产商品,所有的人都消费别人生产的商品。 工人一旦失业就很难找到原有工资待遇水平类似的工作。因为全社会的资本家几乎在一夜之间觉得他们的劳动不值钱了。到处都是堆积的商品,老板做什么也不赚钱,索性停产,老板不生产,工人的劳动也就不值钱了,对老板就没有用了。 稳拿经济学假设工人工资水平下降,老板会增加劳动力的需求。问题是,老板的需求与产品的销售有关,当销售情况不改善的时候,工人工资再便宜,老板也没有兴趣多雇人。 社会金字塔的平级之间是可以自动调节的,但是上下级之间却不能自由调节。当社会需要的产品与资本家提供的产品不对称的时候,资本家能自行改变产品结构。当社会需要的劳动力与工人能提供的劳动力不对称的时候,工人能自动改行。但是当塔的上级不愿意投资和消费的时候,塔的下级却没有能力去代替上级投资或消费。这才是真正的困难。 凯恩斯 1936年,凯恩斯在他的著作《就业、利息和货币通论》中提出在资本主义制度下不是产品做得出就卖得掉,有很大一部分产品注定无法消费,如此必然导致资本家压缩生产,工人失业。为了挽救资本主义,多余的产品需要政府帮助消费掉,甚至是浪费掉。无法消费的产品的量随着贫富差距的增大而增大,换句话说,贫富差距越大的经济体,失业问题越严重。 消费需求不足和投资需求不足将产生大量的失业,形成生产过剩的经济危机。 当投资的利润还不如利率的时候,资本家就都不投资,持币观望,转而存银行吃利息。如果这个时候利率高,那么投资就非常少了。 其实还是消费需求不足。因为投资需求归根到底完全仰仗消费品的销售情况。 觊恩斯主义是用通胀换就业:后遗症就是通货膨胀。 弗里德曼 在弗里德曼看来,政府 第一要务是国防 第二要务是保证契约的实施 而财富分配则属于是一个人认为有益,另一个人认为是有害的 但是如果我们换一个角度看弗里德曼的要求的话,就会发现三件事说起来冠冕堂皇: 第一是不要外来势力干涉稳拿 第二是要求法律是保证契约的执行,不要插手稳拿利用契约合法地抢劫卢瑟 第三是不要试图触碰,甚至返还抢来的赃物 对弱者来说,与强者费厄泼赖就是最大的不公平。自愿和双方获利,只有在双方经济地位接近的时候才能存在。 关税保护了幼年时期的美国资本,避免被英国资本吞噬,避免成为英国的经济殖民地 弗里德曼和凯恩斯的理论冲突,与李嘉图和马尔萨斯的冲突异曲同工。都是两个集团在争夺经济主导权。 财产具有自我汇集的效应,古往今来小农经济就没有长期稳定存在过。小农如果不能进化为大地主,那么迟早会被地主吞并。 资本主义社会的矛盾的核心不是生产力的发展,而是资本主义分配制度。 自由市场加私有产权,只要一代人就能在社会上建立起人与人之间不可逾越的鸿沟。能力的竞争最多在一代人之同发挥作用,一代人之后资产确立统治地位。 其实,对大资本家来讲,周期性的经济危机未必是坏事。经济危机可以促进资本汇聚到强者也就是大资本家手中。 认识经济危机并不复杂,解决经济危机(至少从理论上)也不复杂,复杂的是后面纠葛的利益。经济危机袭扰人类将近 200 年了,解决的办法,无论是书面还是实战的都不少: 希特勒的解决方式是寻求生存空间。从经济殖民地获得廉价的原材料,过剩的产品向经济殖民地倾销。 凯恩斯的解决方式,是政府印钱,由政府创造需求。多余的产能浪费掉,资本家不是没钱不生产,不雇用工人吗?用印钞机给他们钱就是了。政府有印钞机还愁没钱支付?后果是通货膨胀或者资产泡沫。 罗斯福的解决方案,是给工人更大的权利,支持他们与资本家斗争。增加财产税让稳拿出血,通过转移支付,给卢瑟更多的福利。 这么多方法,多数效果都不好。这是因为稳拿处于塔尖的地位,直接调节总量而不触动分配模式的解决方案,不过是给稳拿更多的发财的机会,导致更严重的分配不公。如果触动分配模式,又难免遭到稳拿的强大阻碍,很可能半途而废。 资产的属性 对产来说,具体选择哪种方式,取决于哪种方式来钱最容易,或者说风险更小,效率更高。产是否发展生产,并不确定,是否会改善多数人的生活则更不确定。我们没有任何理由认为,产权明晰加自由市场经济一定会发展生产力,提供更多物美价廉的产品。 对苏联这样的前社会主义国家来说,完全市场化和产权私有化(或者说产权明晰化)的结果,唯一能确定的就是私人产权迅速膨胀,出现一批人造寡头,其余的多数人则沦为这些寡头的奴隶。 稳拿经济学追求帕累托最优,认为在这种情况下各种资源得到最有效地利用,紧缺资源获得最大价值。稳拿经济学认为,自由的市场,最终会达到帕累托最优:就是不可能再改善某些人的境况,而不使任何其他人受损。在市场中,社会的各类人群在不断追求自身利益最大化的过程中,可以使整个社会的经济资源得到最合理的配置。 自由市场会导致帕累托最优,这是完全正确的。但是这种帕累托最优,却不是多数人之福—既不是卢瑟之福,也不是稳拿之福。自由的市场最终会造成财产的高度集中,拥有这些产的稳拿对社会总产品有极大的分配权。数量众多的卢瑟,却只有极其有限的分配权。所以,每次天下大乱以前,社会都处于或接近帕暴托最优的状态:不伤害地主的利益,农民就无法改着自己的生活,就要死于饥寒交迫,这时农民就不再尊重市场交易规则,采用暴力推翻产的权威。 亚当斯密 & 科斯 亚当.斯密提出,个人满足私欲的活动将促进社会福利,只要自由放任,市场的看不见的手发挥作用,最终就将实现全社会的富足。科斯提出只要产权明晰,把一切交给市场中看不见的手来发挥作用;然后就能建立安宁祥和的社会,一切都会万事大吉。 在斯密的时代,正是大饼迅猛变大的时代。那时资本主义刚刚登场不久,每一个资本家的资本量都很小,社会中还有大量封建残余。资本主义处于自由竟争时期,正在经历第一次产业革命,从手工作坊向大工厂过渡,劳动者还有当小老板或者当雇用工人的選擇。那时对产来说最有效率的自我增值的手段是发展生产力,资本主义相对封建残余来说极大地推动了生产力发展,所以这样说并不为过。经过200多年以后,到今天还这样说,那就是彻头彻尾的胡说了。 稳拿经济学提出只要交易就能改善全社会的福利。卢瑟经济学认为改善福利的过程复杂得多。价值来自生产劳动,市场并不创造价值。个人也许可以通过炒房子炒地皮获得极大的收益,但是社会总体生活不会改善,因为总消费品的量,无论是产量,还是销售量都并没增加。 可能存在大饼整体萎缩,大家吃得越来越少,某些个体却吃得越来越多的情况。 资本论想说明的事情之一:工人创造的剩余价值越多,资本家的资产越多,工人与资本家之间差距不断拉大,最终成为资本的奴隶:死劳动(工人以前创造的剩余价值)牢牢抓住活劳动(工人)。 产是什么:产是用于获得剩余价值依据的物化的媒介。人类社会总产品是块大饼,产是稳拿用来切大饼的刀。 土地价格的攀升,不是土地自己产生价值,而是土地上产业资本不断壮大,地主要求其支付的贡税越来越多。如果土地上的产业萧条,土地价格必然暴跌。 依靠土地获得剩余价值,是对资本主义的阻力而不是动力。土地所有权高度集中,产业资本家获得的利润太薄,他们很难有足够的剩余价值扩大再生产。此外,土地垄断会造成土地利润高于社会平均利润,资本家的剩余资本必然会选择投机土地,而不是投资于原料或设备进行扩大再生产。引申一步,如果土地成为资本的主体,那么社会必然停滞不前,甚至倒退。历史上,土地所有权高度集中的地区很难诞生资本主义。 分配 绝大多数的交易,不过是从甲账户转移到乙账户。如果我们把金融机构看为一个整体的话,就会发现几乎所有的货币都在金融机构的手里。 资本主义世界的特点之一,就是强者恒强,这不仅仅表现在获得利润的时候强者有权先享用大饼,也表现在遇到危机的时候强者付出的代价更少。 国家是经济上占统治地位的阶级进行阶级统治的政治权力机构。是一个成长于社会之中而又凌驾于社会之上的、以暴力或合法性为基础的、带有相当抽象性的权力机构。国家有阶级性,国家由经济上占优势地位的阶级控制。国家有暴力性,而且是最终的暴力。国家的存在是为了维护统治阶级的经济利益。国家是一个阶级压迫另一个阶级的机器,是使一切被支配的阶级受一个阶级控制的机器。 由于暴力是唯一能对抗资本的要素,而代表国家行使暴力的官僚并不总是靠得住的。对资本来说,暴力的忠诚度是值得怀疑的。所以,资产阶级成为统治阶级以后,一直试图把国家的作用局限在最小的范围。对内保证社会治安,对外抵御外敌人侵,是资本主义国家的主要任务。至于维护社会的公平,扶助弱小,是资本家最忌讳的事情。 如果所有的劳动者都去大学深造,最终的结果将是大学深造对提高工资收入水平的作用目益下降。 产销过程 消费与生产是对立统一的,没有消费,就不会有生产。我们无法想象一个社会开采矿石,制造机器,最终的目的仅仅是为了开采更多的矿石和制造更多的机器。生产的各种产品最终都会被消费。 希望每当社会总需求不足的时候,就能出现新一轮技术革新导致投资高潮,进而消化掉多余产能,這是不现实的事情。 资本家投资的目的是获得更多的货币。如果社会1000万资本的平均利润率是10%,那么资本家如果投入1000万购买新设备、雇用新工人,那么他必然要求 100 万的利润。换句话说,原先社会存在1000 万的需求不足的缺口,在资本家投资以后,1000万的缺口消失了。但是,在1000万的投资实现产能以后,就将出现1100 万的需求缺口。所以,长期靠资本家投资替代工人需求是不现实的。 消费不足,并不见得是产业资本剥削得太严重,而是整个稳拿集团剥削得太严重。工人贡獻的剩余价值,在产业资本、土地,金融和暴力之间分配。 如果大批企业贷款偿还能力都发生了回题,那么银行会发现自己几乎在一夜之间成为最大的实业资本家,大批的货币资本收不回來,变成抵押物。更糟糕的是,这些抵押物没有人要,而且在不断贬值。 自从资本主义诞生起,尽管绝对消费水平不断上升,但是相对消费不足一直没有克服。 垄断 每次经济危机都会成为大资本屠杀小资本的过程。年次危机结束后所有的幸存者都有机会廉价获得遇难者的一切生产资源,包括原料、生产设备、熟练工,当然也包括他们的市场。 自然界的进化,物种由多样归于多样,面人类社会资本的进化,最终归于“一”。这个过程是单向、清晰、不可逆的。 如果把社会总生产看成一个庞大的网络,那么我们可以看到一些最基本的生产、生活要素在极少数企业的控制之下,这些企业就是真正的帝王资本。他们控制交叉的行业,影响、操纵整体经济。在他们之下,则是一些诸候。他们垄断本行业,影响、操纵某一种或几种商品。在这些诸侯之下,则是一些中、小资本,他们为上面帝王和诸候资本服务,是“臣民资本”。再向下,则是一些零散的资本,他们数量很多,充满帝王和诸候触角不愿触及的“鸡肋”行业,彼此之间激烈竟争。至于劳动力,则处于完全不入流的状态。 垄断帝国则是在“暴力归公”的基础上,完全依靠财产的力量,在市场交易中逐步建立起来的。需要注意的是,这里的暴力的“归公”、是归资产阶级的“公”,而不是全民的“公”,这里的暴力,是资产阶级的暴力,这种暴力不在资产阶级内部使用,却不吝于镇压无产阶级。 暴力和资本走到一起,有两种方式:资本领导暴力;暴力领导资本。两种方式的起源与各个资本主义国家历史进程有关。不论哪种方式,最终殊途同归:最终都將建立资本与暴力结合的密严阶级专政,这个专政將处于官僚统治集团的统治之下,如果我们认为垄断大企业的管理层也是一种准官僚集团的话。 统治阶级争夺被统治阶级的时候,彼此是仇敌。一旦一方彻底认输,统治阶级内部的阶级感情又重新油然而生。 并不是衣食足不足的问题,而是达到一个社会阶层之后,对普通劳动者紧缺的生活必需品,对某个阶层中人已经不再是紧缺的,而是极大丰富的。对普通人来说很珍贵的东西,对这个层次上的人来说,已经不值得为此发生冲突了。为这些基本生活必需品发生冲突,对这个层面的人来说,是丢脸的事情。这个层次的稳拿,在彼此谦逊礼貌的气氛中,瓜分着社会总产品的大饼。底层之间争斗的越激烈,顶层吃到的大饼份额越多。 如果要维护人类社会系统的长期存在就必须从两个方面着手:第一,为领导阶层引入新鲜血液,避免小范围通婚造成的种群退化;第二,破解财富的单向流动过程,避免财富过度集中导致社会内部压力持续上升。 由于强者恒强,任何人类社会的终极形态,不论是官僚主动或者资本家主动,地主主导或者奴隶主主导,如果不干涉的话,都是通往奴役之路。

2022/10/28
articleCard.readMore

一些关于银行的散碎知识

金本位时期金融机构的层级结构 19th century nation state financial institution hierarchy 央行的负债≈银行的资产≈货币 银行的负债≈私营部门的资产≈存款 亏钱先亏哪儿? 假设左侧亏了5块钱,且左右必须平,则右侧也必须减少5。 不能先减liability,因为债权人优先级较高,只能先减equity。 Liquidity和Solvency cash reserve > 0,则具有一定的流动性 net worth > 0,则具有一定的偿付能力 流动性和偿付能力的高低则取决于到底比零大多少。 资金来源 活期存款 (Demand deposit) 存款证书/大额存单 (Certificate of deposit) 附买回协议(repurchase agreement、repo,也称为附买回协定、附买回交易,回购协议、卖出回购或正回购) 商业票据(Commercial Paper) 负债业务是商业银行形成资金来源的业务 存款是银行对存款人的负债,是银行最主要的资金来源 四种风险 银行的风险种类较多,最主要的风险有四种: 信用风险、市场风险、流动性风险和操作风险 信用风险是指借款人因各种原因未能及时、足额偿还债务或银行贷款而违约的可能性。发生违约时,债权人或银行必将因为未能得到预期的收益而承担财务上的损失。 市场风险是指未来市场价格(利率、汇率、股票价格和商品价格)的不确定性对企业实现其既定目标的影响 流动性风险是指因市场成交量不足或缺乏愿意交易的对手,导致未能在理想的时点完成买卖的风险;或银行本身掌握的流动资产不能满足即时支付到期负债的需要,从而使银行丧失清偿能力和造成损失的可能性。 流动性风险,一方面是一种本原性风险,是由于流动性不足造成的;另一方面也是最常见的情况,是其他各类风险长期隐藏、积聚,最后以流动性风险的形式爆发出来。从这种意义上讲,流动性风险是一种派生性风险,即流动性不足,可能是由于利率风险、信用风险、经营风险、管理风险、法律风险、国家风险、汇率风险等风险源所造成的,银行最终陷入流动性风险中不能自拔。 操作风险的正式定义是:由于内部程序、人员和系统的不完备或失效,或由于外部事件造成损失的风险

2022/1/29
articleCard.readMore

TW十四载

2007年,我在读大二。 当时经常会去学校食堂对面的报刊亭买杂志,一本,是《大众软件》,另一本,是《程序员》。 印象中当时《程序员》上的多数文章充满了我没听过的各种缩写与稀奇古怪的名词,文风是老成持重,我看不懂,但很是佩服。 而这当中偶尔会夹杂着几篇文风犀利,睥睨天下的文章,加上作者头像很是非主流,我虽也看不懂,但印象深刻。 这些犀利文章的作者,便经常冠有TW的头衔。 2012年,我在一家小软件公司上了两年的班。 当时我们每半年发布一个版本,每到要发布前夕,程序员便都停止写代码,去做回归测试。 经常会发现半年前发布时还是ok的功能,在这六个月里被改错了,得修。 我隐隐觉得这不对劲,再六个月之后还是会出现类似的状况,老是靠大家停工去做回归测试不是个办法,我们总是会“狗熊掰棒子,掰一根丢一根”。 恰在此时,邮箱收到了来自TW的面试邀请。 到了这会,我其实已经记不清楚大学时看的《程序员》杂志上有什么内容了,不过TW这个名字还是有些印象。 再去一查,这家公司还出过不少书,推崇做自动化测试,注重软件质量,而这些正是我当时所在的小公司所缺乏的。 于是便接受了面试邀请,做了Mars Rover的题目,去参加了办公室面试。 面试时还见到了《软件设计精要与模式》的作者。 之后便加入TW成为了一名21世纪的程序员。 从第一次看到《程序员》杂志上的文章到眼下的2021年,有14年了。 14年挺长的,从上小学到高中毕业,也才12年。 时间跨度长,变化也就多,不过当时持有的很朴素的观念不太容易变: 软件至少要做对,一旦做对了之后,要避免“狗熊掰棒子,掰一根丢一根”。 就好像一个木匠师傅,用了锛凿斧锯,费力做成了板凳,肯定是不希望这个凳子给人坐了两天之后就开始“嘎吱吱”。 码匠师傅,亦当如是。

2021/12/17
articleCard.readMore

《大目标》一书中的有趣观点

引子 工业化、现代化、城市化、民主化、自由化、市场化、全球化,不一定要姓“西、资、基”。 第一章 五常的否决权是对大国毁灭性力量的承认。核武器,氢弹,洲际导弹,核潜艇,多弹头分导。 退缩不会让狼群放弃供给,逃跑不会让狼群放弃追捕,倒下不会让狼群放弃杀戮。 大国没资格投降。 美国对日本可以援引《共同防卫协定》只会日本空军。韩国三军的最高指挥权本来就归美军。 冷战的本质是3.5亿的苏联东欧工业人口和欧美日6亿的工业人口抢全球的资源和市场。 第二章 把人当人看是工业社会才能做到的事。医疗器具,消毒不再昂贵,简单高效。 当物质上的富足让社会不再需要合理合法地牺牲掉一些人而保证另一些人的生存时,才能进入文明。 1962年,中国粮食总产量1.6亿吨。1978年3亿吨。不是靠积极性。而是靠化肥。 化肥让农民有了余粮和闲暇,从农业中解放出来的生产力投入工业。 工业化:用机器造机器,然后用造出来的机器去造别的东西。 世界上勤俭的不止有中国人,勤俭了几千年也没有变的富裕。工业化才能让勤俭变成资本。 李鸿章去找“造器之器”,是找造火器的机器,而不是造机器的机器。 机器用钢铁造的,用的是煤炭或者煤炭生成的电能。欧盟来源于煤钢共同体。 第三章 美国“绿卡战士”的阵亡率比美籍士兵高出一倍。 钱并不是财富,说“有钱”其实意味着“有货”。 美元作为结算货币收全球铸币税。各国越小,产业链越分散,美元铸币税收的越开心。 经济活动的本质是物质生产。 枪杆子里面出石油。 第四章 工业与手工业的最大区别:工业可以升级。 西班牙,英国,美国,三个世界性的大帝国。 兴起的原因是先进的生产技术,发达的制造业能创造财富也支持了强大的军队。 然后不劳而获,停滞衰退。 第五章 与之前的强国相比,苏联的工业化不依赖殖民地的市场和资源。 重工业是核心,轻工业释放重工业制造的生产能力。 苏联利用西方几次危机,抄底技术与设备。 工业化人口数量决定了工业经济的规模,也和工业体系的复杂程度和先进程度密切相关。 独立工业体系需要的工业化人口数量是基本配件数量的5到10倍。 欧洲走向一体化的原因就在于此。 苏联的崩溃说到底是因为美国用自己的人口优势率先完成了产业升级。 在战略对抗中,要掌握比对手更多的工业化人口,要向盟友输出工业化社会,通过输出工业化社会来制造盟友。否则早晚遇到工业升级的人口障碍。 第六章 一切强权、帝国在工业革命面前都是过眼云烟。 终结大英帝国的是第二次工业革命。 中国不能靠出口来长期支持我们的工业化进程了。 第七章 没有人会把太监叫做绅士。 保持不断的进步才能有道理可以讲。 输出工业化是个长久的买卖,比输出消费品要长久。 现在一个地市级的地勘力量比工业化之前全国合集还多。 买办是第一障碍。 要输出工业化,而不是像殖民主义那样打击当地工业发展。

2020/7/5
articleCard.readMore

软件需求膨胀系数

泡水膨胀球 这是一种小玩具,干燥时拿在手里,小巧紧致。 泡在水里,一段时间之后,浑圆饱满,一只手都未必能捧的住。 放在干燥通风的地方晾晒,一段时间过后,又可以恢复原本的大小。 软件需求 软件需求与泡水膨胀球也有类似之处。 一开始时看似简单明了且完备正交,做起来发现越做越大,越做越多。 原本一只手能抓好几个的小球,后来变成两只手都抓不住一个。 然后的结果要么是超预算多花钱做完,要么是转嫁成本给乙方要求对方做完,要么是延期,要么是痛苦地去压缩需求。 无论哪种,都是不健康的。 膨胀系数 小球泡在水里会膨胀,暴露于干燥环境中会缩小。 这个过程可以用一个简化模型来表示: 膨胀系数 ≈ 吸水力 - 斥水力 小球体积 ≈ 初始体积 + 膨胀系数 x 时间 当吸水力大于斥水力时,膨胀系数为正数,在时间的作用下,球会膨胀。 当斥水力大于吸水力时,膨胀系数为负数,在时间的作用下,球会缩小。 当两个力接近相互平衡时,膨胀系数约等于零,在时间的作用下,小球体积趋于稳定。 我们希望看到小球体积怎样变化? 一直变肥?那不行。做软件是有成本的,时间人力都是成本。 持续变瘦?也不行。需求受到过分压抑也是不健康的。 健康的变化过程应该是: 先让小球丰满起来(充分收集需求,以免后面出现始料未及的“惊喜”) 然后削减多余的赘肉(使用奥卡姆剃刀) 之后长期维持健康范围内的波动 力的阶段性施加策略 时间的流逝是不受我们控制的,因而想要控制体积就要从膨胀系数下手。 而膨胀系数又与两个力相关,因而想要控制体积就要控制吸水力与斥水力。 首先让吸水力尽情释放,这时斥水力蛰伏着静待吸水力的衰减。 之后斥水力爬坡,达到与吸水力持平的水准,二者长期和平共处。 当我们如上使用这两种力的时候,小球体积的变化就会是这样的: 总结 调研阶段积极创造环境来让吸水力得以释放,先允许小球变肥。然后施加一定的斥水力,让小球苗条下来。 到了研发阶段,由于吸水力在之前就已经得到了有效的释放,这时施加适量的斥水力来平衡所剩无几的吸水力就能让小球体积的波动维持在可控的范围内。 在不同的阶段有策略地利用这两种力,则可以趋近前期需求收集充分考虑各方诉求不留大的隐患,后续研发稳定不坐过山车的目的。

2020/5/22
articleCard.readMore