Java实现程序员老黄历:规则引擎与农历推算实战
发布时间:2026/10/18 19:21:06 锦皓数字建站

简介这是一份以 Java 语言复刻经典“程序员老黄历”的实例讲解文档面向对枚举类型、面向对象设计感兴趣的 Java 学习者可用于理解如何把日常开发场景封装成可调用的趣味程序。文档围绕 ActivitiesEnum 等核心枚举展开逐一说明活动名称、宜忌描述、周末标识等字段构成并展示从枚举定义、getter/setter 封装到随机宜忌输出的完整思路同时也涉及 SpecialsEnum 对特殊日期的处理逻辑。文中以“写单元测试、晚上上线、修复 BUG”等程序员日常活动为例呈现不同活动在不同日期下的好坏判断方式适合读者直接对照练习巩固 Java 基础并学习小型功能模块的代码组织方法。资源为单个 PDF 文件压缩包约 107KB轻量易读可作为课堂补充或课余项目参考。目前已有 433 人学习值得初次接触枚举与趣味程序设计的开发者下载阅读。1. Java实现的程序员老黄历把“宜忌”做成一个正经规则引擎在很长一段时间里我一直觉得“程序员老黄历”是个玩票项目——用随机数生成几句吉祥话逗个乐子。直到某开发者把自己的版本开源出来我拆完源码才发现完全不是那么回事它把农历转换、干支纪日、十二建除、节气推算和一套可配置的宜忌优先级仲裁机制老老实实编码成了一个可复现、可测试的Java工程。换句话说这不是玄学demo而是一个披着“黄历”外衣的规则引擎实战。它能解决一个非常具体的问题给你的开发工作流加一个“今日提醒层”比如今天是“宜重构、忌上线”结合Git提交记录做自动提醒。适合两类人——想练手Java领域建模和规则判定的初中级开发者以及需要给内部工具加点“仪式感提醒”的团队。本文用一份完整可运行的Java实例带你从字段设计、算法实现到命令行交互全部落地。2. 宜忌规则的数据建模从“拍脑袋”到可仲裁的领域模型2.1 为什么需要一套独立的“规则层”而不是写死if else最初版本的模拟项目X所有人都会下意识写成if (day 1) 宜提交这种硬编码。但真正拆完老黄历源码后你会发现它把所有判定都收敛到了RuleEngine和LunarDate两个核心类里宜忌不是“今天命中什么就输出什么”而是“今天命中什么再经过权值仲裁最终决定显示什么”。这里的关键点是黄历的“宜”和“忌”之间天然存在冲突。比如某一天既是“宜开会”又是“忌交易”如果你只是顺序遍历规则最后显示的完全取决于Map的遍历顺序这在Java里是不可控的。老黄历的做法是引入优先级priority和互斥组conflictGroup在判定后再做一轮仲裁。public class Rule { private String id; // 规则唯一标识例如 yi_commit private String category; // 类别YI宜或 JI忌 private String content; // 展示内容例如 提交代码 private int priority; // 0-100越大越优先展示 private String conflictGroup; // 互斥组ID同组规则最多展示一条 private ListString tags; // 匹配标签例如 git, deploy, refactor // 省略getter/setter }这段代码的逻辑并不复杂但有一个容易被忽略的设计conflictGroup和priority是配对使用的。priority解决的是“同组内谁胜出”conflictGroup解决的是“哪些规则不能同时展示”。这样设计之后新增一条规则就变成了往配置文件里塞一条记录而不是去改switch-case。2.2 基于加权评分的宜忌仲裁算法光有规则还不够黄历实例里有一套打分机制每天会有多条规则命中最终展示哪几条由“标的的权重”和“规则优先级”共同决定。拿提交代码举例如果git commit和上线发布同时命中仲裁器会计算这两个事件候选的最终得分public class RuleArbitrator { public ListRule arbitrate(ListRule matchedRules) { // 先按互斥组分组组内取优先级最高者 MapString, Rule groupBest new LinkedHashMap(); for (Rule rule : matchedRules) { String key rule.getConflictGroup() null ? UUID.randomUUID().toString() : rule.getConflictGroup(); Rule existing groupBest.get(key); if (existing null || rule.getPriority() existing.getPriority()) { groupBest.put(key, rule); } } // 再按优先级降序最多输出3条宜、2条忌 ListRule result new ArrayList(groupBest.values()); result.sort(Comparator.comparingInt(Rule::getPriority).reversed()); return result.stream() .filter(r - YI.equals(r.getCategory())).limit(3) .collect(Collectors.toList()); } }参数说明limit(3)和limit(2)是经验值——宜最多展示3条忌最多展示2条这样命令行输出的版面不会过长。如果你想把结果接入钉钉机器人或企业微信可以调整这两个数字。仲裁器的逻辑顺序很关键先分组、再排序、最后截断。如果先排序再分组低优先级规则可能提前占据截断名额导致高优先级规则反而被挤出。2.3 规则来源JSON配置与枚举兜底规则从哪来项目里约定一个rules.json作为默认规则库同时把最基础的几条规则做成枚举兜底防止配置加载失败时整个程序不可用。[ { id: yi_commit, category: YI, content: 提交代码, priority: 60, conflictGroup: code_commit, tags: [git] }, { id: ji_deploy, category: JI, content: 上线发布, priority: 80, conflictGroup: deploy, tags: [deploy] } ]JSON映射到Rule对象用的是 Jackson注意日期字段不需要——规则本身是静态的动态的是“今天的干支/建除/节气”这些时间维度。所以整个数据流是日期数据农历、干支、建除 规则库JSON → 匹配器 → 仲裁器 → 输出。把这两个维度分开的好处是你可以只改JSON就调整“某类日子适合干什么”不碰任何Java代码。这里有一个我在拆包时觉得处理得漂亮的地方tags字段不是摆设它支持“按工作场景过滤”。你可以在命令行加--tag deploy这样老黄历只展示与部署相关的宜忌适合值班日快速扫一眼。3. 农历与干支推算老黄历的时间地基3.1 为什么直接依赖 LocalDate 不够老黄历的输出核心是农历。java.time.LocalDate只能拿到公历农历需要单独推算。拆这个项目时我一度以为会引入第三方农历库结果发现它用的是“查表法 修正”这个更可控的方案——把1900年到2100年的农历月份大小、闰月标记存在一张二维数组里然后逐日推算。这个方案的优势是可控、可验证适合教学和内部工具。公历转农历的核心公式public class LunarCalendar { // 以某个已知农历日为基准公历1900-01-31对应农历1900年正月初一 private static final LocalDate BASE_GREGORIAN LocalDate.of(1900, 1, 31); private static final int BASE_LUNAR_YEAR 1900; private static final int BASE_LUNAR_MONTH 1; private static final int BASE_LUNAR_DAY 1; // 1900-2100年的农历年数据表每一位代表一个月 private static final int[] LUNAR_INFO { ... }; }推算逻辑先计算目标公历日期与基准日期相差的天数然后从基准年开始逐月减去每个农历月的实际天数29或30天直到剩余天数小于下个月的天数此时就定位到了目标农历日。这里的LUNAR_INFO数组每一项用二进制位表示一年的月份信息——低位表示每月是29天还是30天高位有一个闰月标记位。3.2 天干地支与十二建除的编码技巧拿到农历之后干支纪年、纪月、纪日、纪时是另一套编码逻辑。老黄历用“天干十、地支十二”的最小公倍数60作为循环周期也就是六十甲子。项目里用两个字符串数组做循环映射public class GanZhi { private static final String[] TIAN_GAN {甲, 乙, 丙, 丁, 戊, 己, 庚, 辛, 壬, 癸}; private static final String[] DI_ZHI {子, 丑, 寅, 卯, 辰, 巳, 午, 未, 申, 酉, 戌, 亥}; public static String getDayGanZhi(int offsetDays) { // 以某个已知甲子日为基准偏移 int ganIndex Math.floorMod(offsetDays, 10); int zhiIndex Math.floorMod(offsetDays, 12); return TIAN_GAN[ganIndex] DI_ZHI[zhiIndex]; } }这里有一个坑Math.floorMod与%的差异。offsetDays如果跨了公元前/公元后的边界%可能返回负数导致数组索引越界。floorMod保证结果始终是正数这是源码里容易看漏但必须用对的地方。十二建除建、除、满、平、定、执、破、危、成、收、开、闭则是基于农历月支与日支的关系推算逻辑相对固定。3.3 节气判定用简化公式够用吗项目里节气部分没有做高精度天文计算而是用了一套傅里叶展开的简化公式。常见做法是采用寿星公式它把每个节气的儒略日计算简化为几个正弦项叠加精度控制在几分钟内。public class SolarTerm { // 简化版计算某年某节气的大致公历日期 public static LocalDate getTermYear(int year, int termIndex) { double days 365.2422 * (year - 1900) 15.2184 * termIndex 0.1 * Math.sin(1.8 * termIndex) 1.9 * Math.sin(3.2 * termIndex); return LocalDate.ofYearDay(year, (int) Math.floor(days)); } }逻辑说明termIndex取值0到23对应从小寒开始的24个节气。days是相对于1月1日的偏移天数。注意这里不需要精确到时分因为老黄历的宜忌判定只精确到“日”——一个节气落在哪一天远比它具体是几点几分重要。如果你的项目需要精确到分钟建议换成寿星公式的完整版或者引入天文历法库但那样会显著增加包体积和复杂度。这套时间地基建立之后每天的“农历日期、干支日、建除日、节气”就都变成了可查询的字段RuleEngine的匹配器只需要拿这些字段去比对规则的tags条件。4. 双形态落地命令行版与Web接口版4.1 命令行版用Picocli做参数解析老黄历实例的默认形态是命令行工具这也是我推荐新手先从命令行入手的原因——不碰前端把核心引擎跑通再说。它用Picocli做参数解析支持--date指定日期、--tag过滤场景、--json输出结构化结果。Command(name laohuangli, mixinStandardHelpOptions true, version 1.0) public class CliApp implements Runnable { Option(names {--date}, defaultValue today) String dateStr; Option(names {--tag}, defaultValue ) String tagFilter; Option(names {--json}, defaultValue false) boolean jsonOutput; Override public void run() { LocalDate date parseDate(dateStr); DailyAdvice advice engine.evaluate(date, tagFilter); if (jsonOutput) { System.out.println(advice.toJson()); } else { System.out.println(advice.toPlainText()); } } public static void main(String[] args) { System.exit(new CommandLine(new CliApp()).execute(args)); } }这里--defaultValue today的写法值得注意它不是简单的字符串默认值而是在parseDate方法里统一处理“今天”这个语义。private LocalDate parseDate(String dateStr) { if (today.equals(dateStr)) { return LocalDate.now(ZoneId.of(Asia/Shanghai)); } return LocalDate.parse(dateStr); }参数说明强制指定ZoneId是非常必要的。很多开发者图省事用LocalDate.now()它会取JVM默认时区。如果你的服务器时区是UTC程序会在早上8点前把“昨天”当成“今天”宜忌全错。这一点在第5章还会再踩一次。4.2 Web接口版Spring Boot 3的轻量封装命令行版跑通之后项目里还提供了一个Web封装核心是一个Controller 缓存。为什么需要缓存因为干支推算和节气计算的逻辑虽然有确定结果但如果同时有多个请求每次都重新算一遍是浪费的。RestController RequestMapping(/almanac) public class AlmanacController { private final RuleEngineEngine engine; private final ConcurrentHashMapString, DailyAdvice cache new ConcurrentHashMap(); GetMapping(/today) public DailyAdvice today() { return cache.computeIfAbsent(today, k - engine.evaluate(LocalDate.now(ZoneId.of(Asia/Shanghai)), )); } GetMapping(/{date}) public DailyAdvice byDate(PathVariable String date) { return cache.computeIfAbsent(date, k - engine.evaluate(LocalDate.parse(k), )); } }这里用computeIfAbsent而不是put再返回的好处是并发场景下同一个 key 只会被计算一次避免多个线程重复计算浪费CPU。需要注意这个缓存没有过期时间——因为这本身就只查日期日期一旦过去就不会再变了没必要设置TTL。真正的生产环境如果担心内存泄漏可以加一个maxSize限制或者定时清理但老黄历作为内部工具这个略简单但正确的缓存策略已经够了。4.3 打包配置Jar包与可执行脚本两个形态都写在同一个Maven工程里通过maven-shade-plugin打出可执行fat jar。需要注意不要让Picocli和Spring Boot的自动配置冲突——如果用Spring Boot做Web服务命令行入口要用单独的Main-Class否则Spring Boot的启动器会抢占入口。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.1/version executions execution phasepackage/phase goalsgoalshade/goal/goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasstop.example.cli.CliApp/mainClass /transformer /transformers /configuration /execution /executions /plugin打包之后直接java -jar almanac.jar --date 2024-01-15 --json就能输出结构化结果。如果是给团队内部使用通常会在内网沟通工具里配一个斜杠命令触发或者直接用脚本定时推送到值班群——这一步就留给读者自己接入了项目提供到jar包这一层已经够用。5. 避坑指南老黄历实例的五个翻车现场5.1 现象某一天的农历日期比真实日历晚了一天原因基准日选择错误。很多农历转换算法会以“某个公历日期对应某个农历日期”为基准例如“1900-01-31对应正月初一”。如果基准日当天的起始时刻处理不当或者基准日本身就差一天后面所有推算都会系统性偏移。排查这类问题最有效的方式是找一个有农历的日历App抽查三年内每个月的初一是否对齐。解决把基准日校准到明确无误的锚点并在单元测试里固定三个测试日期年初、年末、闰月。例如Test public void testLunarConversion() { // 2023年是闰二月重点验证闰月前后的日期 assertEquals(LunarDate.of(2023, 闰, 2, 1), LunarCalendar.from(LocalDate.of(2023, 3, 22))); assertEquals(LunarDate.of(2023, 3, 1, 1), LunarCalendar.from(LocalDate.of(2023, 4, 20))); }注意我这里用了“闰”这个中文字段实际编码时可以用枚举或布尔值。基准日校验这步值得单独写一个测试类因为它是整个时间推算链条的根。5.2 现象同一台机器上上午和下午运行结果不同原因这是我把代码部署到服务器上时发现的——服务器时区可能不是Asia/Shanghai而LocalDate.now()取的是系统默认时区。在UTC时间下北京时间上午8点前仍然是UTC日期老黄历会展示昨天的宜忌。解决全局搜索所有LocalDate.now()和new Date()的调用统一替换为LocalDate.now(ZoneId.of(Asia/Shanghai))。这里要做一个方便的小工具类public class Dates { public static LocalDate today() { return LocalDate.now(ZoneId.of(Asia/Shanghai)); } // 禁止使用无参now() private Dates() {} }从那以后我每次写涉及日期的工具类都会强制走一遍today()而不是裸调now()。如果团队里有人图省事直接写LocalDate.now()会被提醒再做一次时区校准。这个坑绝对值得写在团队规范里。5.3 现象同一天既显示“宜提交代码”又显示“忌提交代码”原因规则库里出现了两个tags相同、category相反的规则而仲裁器没有处理“同一语义互斥”。我只设置了conflictGroup但漏了一种情况——两条规则它们内容相同但类别不同却分属不同的conflictGroup导致两位“针锋相对”的规则都进入了最终展示列表。解决仲裁器在分组之前需要增加一个“标记语义校验”步骤让同语义规则必须被强制归入同一个conflictGrouppublic class RuleValidator { public ListRule validateAndNormalize(ListRule rules) { SetString seenSemantics new HashSet(); for (Rule rule : rules) { String key rule.getContent(); if (!seenSemantics.add(key)) { // 同一个语义只能存在一条规则否则按优先级保留一条 // 这里可以在配置加载时直接拒绝启动 } } return rules; } }这个校验放在配置文件加载阶段启动时如果发现冲突直接报错而不是等运行期输出矛盾结果。配置加载失败要报错这是配置驱动的项目里最稳的一条规定。5.4 现象节气日期偏差越来越大到了秋分差了将近半天原因这是简化公式和“截断到整数天”双重误差导致的。最初以为简化公式误差只在几分钟但节气本身是以具体时刻定义的比如说“秋分这一天”精确到某时某分的交节时刻。如果公式输入不够准或所在年份不同误差累积方式不同简单取整日期就会出现前后漂移。解决至少采用完整版寿星公式并且对结果做“跨天校验”——计算出的交节时刻如果在23:59之前但非常接近午夜要额外比对前后两天的成果public static LocalDate accurateTermDay(int year, int index) { double jd calcTermJD(year, index); // 寿星公式完整版 LocalDate day julianDayToLocalDate((int) Math.floor(jd)); // 如果交节时刻超过当天24点需要把day加1 double time jd - Math.floor(jd); if (time 0.98) { day day.plusDays(1); } return day; }这里的jd是儒略日。0.98的含义是一天中的时间比例对应23:31左右。超出这个阈值说明交节时刻接近午夜存在跨天风险。如果对精度要求很高建议直接用天文算法库计算J2000历元而不是自己推傅里叶展开。5.5 现象打成Jar包之后项目启动报找不到 rules.json原因开发时用IDE运行rules.json在源码目录下从classpath可以正常读取。打包之后资源文件的路径发生了变化直接new File(rules.json)是找不到的——Jar里的文件不是文件系统里的文件。解决不要用new File()读classpath资源改用它自带的getResourceAsStream()同时把规则文件放在src/main/resources下保证打包后进入jartry (InputStream in Laohuangli.class.getResourceAsStream(/rules.json)) { ListRule rules objectMapper.readValue(in, new TypeReference() {}); } catch (IOException e) { throw new IllegalStateException(规则文件加载失败请检查jar包完整性, e); }这里有一条经验凡是打包后“文件找不到”的错误一律先检查是不是用了new File()。Jar包内部的结构和文件系统不同很多初学Java的上手这个项目时就在这一步卡了三四个小时。如果你要支持外部自定义规则文件可以约定一个“先用外部文件找不到再回退到classpath”的策略但默认永远用getResourceAsStream。6. 进阶技巧把规则库拆分给团队按需配置做到前面这一步老黄历实例已经是一个可用的内部工具。再往前走一步就是让它变成“团队可配置”的轻量规则平台。下面是我在实际使用中验证过的做法。外部化规则文件与热加载把rules.json放到一个约定目录比如同级的config/目录启动时先查找外部文件找不到再从classpath兜底。这需要弱化对classpath的依赖所以额外加一个--config参数Option(names {--config}, defaultValue ) String configPath; public void run() { Path path Paths.get(configPath); ListRule rules; if (Files.exists(path)) { rules loadFromJson(path); } else { rules loadFromClasspath(/rules.json); } engine.withRules(rules); }这样团队成员改成自己那份rules.json的时候就不用重新打Jar包了。如果配合定时扫描文件最后修改时间还能做到“改完配置自动生效”而无需重启进程——“热加载”这个能力真正干活的时候才知道多省心。抽检验证与灰度老黄历这类工具很容易陷入“算得对不对没人验证”的境地。我的习惯是每个季度做一次抽检用农历App随机选25个日期分布在闰月、月初、月末、节气前后启动程序输出结果人工比对核心字段——农历日、干支日、节气。这个流程看起来原始但能第一时间发现基准日错误或公式漂移。对比结果时优先看三个字段农历日是否对齐、节气是否在同一天、干支日是否连续相邻日期干支必须逐日递进。如果出现跨日跳变说明数组索引或者floorMod出了问题而不是数据本身错了。我一般会在代码里放一个--verify隐藏参数执行连续365天的干支序列检查确保“日-干支”映射是完整无断档的循环。灰度发布方面不建议一上来就给全员开启。先在少数几个开发者的终端设备上运行一周确认输出无异常后再扩大到值班范围。因为这个工具如果瞎显示“忌上线”还是会造成一些同事的心理波动——工具虽小也要认真对待。我从这次拆解中踩到的最深的坑是“只校验今天、不看边界”。从那以后我每次给工具类加日历逻辑都会强制走一遍“跨年、跨闰月、跨闰秒如果有”三连测。规则引擎本身不难难的是把“看不见的时间边界”全部按死。希望这些细节能帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。