资讯动态

Java实现程序员老黄历:农历转换与宜忌生成全解析

发布时间:2026/10/18 18:46:58 来源:尧图企业网站定制
简介Java实现的程序员老黄历实例是一份面向Java初学者及对趣味编程感兴趣的开发者的PDF资料以程序员老黄历为载体讲解枚举类型在实际项目中的应用。文档从ActivitiesEnum与SpecialsEnum两个枚举类入手详细展示了name、good、bad、weekend等字段的封装以及写单元测试、洗澡、加班、重构等29种日常活动的参数设计并穿插介绍Java语言特点与“将日常活动对应吉凶文案”的设计思想便于读者理解枚举定义、构造器与getter/setter的写法。压缩包内共1个PDF文件大小约107KB内容紧凑适合对照代码学习。目前已有433人学习对于想用简单案例巩固Java基础或寻找趣味练习的开发者具有一定参考价值。1. 程序员老黄历先聊清楚这东西到底在算什么程序员老黄历不是迷信工具它是一个把传统黄历的“宜忌框架”和程序员日常梗结合的日期推算程序。每天打开终端看一眼“宜重构、忌加班”本质是在跑一套完整的历法换算公历转农历、干支纪年纪月纪日、建除十二神最后把结果映射到程序员词条池。很多人以为它只是个随机文本生成器真正实现过的人才知道公历转农历这一层就能卡住大半新手网上不少现成代码直接调系统农历接口那不是算法是黑匣子。这份Java实例把整套链路从数据表到输出都拆开了适合有Java基础、想练日期处理和模块拆分的开发者。下面按“数据模型→历法推算→宜忌生成与输出→踩坑→进阶验证”的顺序拆全程用可跑的代码说话。2. 数据模型与选型农历转换怎么做先看宜忌词条怎么铺2.1 传统黄历到程序员梗词条表的结构设计老黄历的核心输出是“宜”和“忌”两组词条但不要把词条写死在某一天的逻辑里。正确做法是拆成两层第一层是传统黄历的规则因子比如建除十二神、日支五行第二层是程序员梗词条库。两层之间用映射关系连接这样想改词条、加梗完全不需要动历法算法。先定义宜忌词条的基础结构public class YiJiEntry { private final String action; // 词条内容如 重构老代码 private final int weight; // 权重抽取时按权重配比 private final String category; // 分类REFACTOR, TEST, DEPLOY, MEETING... public YiJiEntry(String action, int weight, String category) { this.action action; this.weight weight; this.category category; } public String getAction() { return action; } public int getWeight() { return weight; } public String getCategory() { return category; } }这里的weight是后来调试时加上的。最初版本没有权重直接平均抽取结果“忌加班”和“忌开会”出现频率一样没有情绪冲击力。加权重之后“忌上线”“忌加班”这类高风险词条在特定建除日会被提升到三倍概率整体输出就更有老黄历那种“今天确实不太适合干这个”的感觉。词条库和建除十二神的映射我建议直接用EnumMappublic enum JianChu { JIAN(0, 建), CHU(1, 除), MAN(2, 满), PING(3, 平), DING(4, 定), ZHI(5, 执), PO(6, 破), WEI(7, 危), CHENG(8, 成), SHOU(9, 收), KAI(10, 开), BI(11, 闭); private final int index; private final String name; JianChu(int index, String name) { this.index index; this.name name; } public static JianChu fromIndex(int index) { return values()[index]; } }映射表放在一个独立的字典类里每个建除神对应一个“宜池”和一个“忌池”public class YiJiDictionary { public static final MapJianChu, ListString YI_POOL new EnumMap(JianChu.class); public static final MapJianChu, ListString JI_POOL new EnumMap(JianChu.class); static { YI_POOL.put(JianChu.JIAN, List.of(启动新项目, 定技术方案, 开新分支)); JI_POOL.put(JianChu.JIAN, List.of(摸鱼, 拖延, 临时改需求)); YI_POOL.put(JianChu.CHU, List.of(删无用代码, 清理依赖, 拆冗余模块)); JI_POOL.put(JianChu.CHU, List.of(加临时方案, 堆TODO, 留死代码)); YI_POOL.put(JianChu.CHENG, List.of(发布小版本, 上线灰度, 合并主干)); JI_POOL.put(JianChu.CHENG, List.of(裸奔部署, 跳过回归, 直接改生产)); // 其余建除日的词条池在资源包里给完整实现 } }这里的List.of是 Java 9 之后才有的如果你还在用 Java 8需要改成Arrays.asList。实例源码默认按 Java 17 写如果你要移植到老项目第一件事就是把这些不可变集合换掉。2.2 LunarDate农历结果该用什么结构承载公历转农历之后结果不能直接用字符串返回否则后面干支计算、建除推算都要反复解析字符串又慢又容易出错。定义一个干净的农历日期结构public class LunarDate { private final int year; private final int month; private final boolean leap; // 是否为闰月 private final int day; public LunarDate(int year, int month, boolean leap, int day) { this.year year; this.month month; this.leap leap; this.day day; } public int getYear() { return year; } public int getMonth() { return month; } public boolean isLeap() { return leap; } public int getDay() { return day; } Override public String toString() { return year 年 (leap ? 闰 : ) month 月 day 日; } }leap字段必须有。很多人一开始忽略闰月结果遇到闰二月、闰四月这种年份后续月份全部错位一天输出的宜忌跟着乱套。用int month加boolean leap的结构可以表达“闰二月”和“三月”的区别让月份偏移计算保持精确。字段全用final构造完就不可变避免后续在别处无意修改。这个类不需要复杂的equals/hashCode但建议加上因为测试时会拿它和期望值做断言。2.3 查表法 vs 天文算法这个实例为什么选查表农历换算有三条路一是调用系统API二是查表法三是天文算法。系统API最简单但平台相关换环境行为不一致不适合做学习实例。天文算法精度高计算量也高不适合这种命令行小工具。查表法用一张覆盖 1900-2100 年的数据表每一年用一个 int 记录闰月月份、闰月大小、十二个月大小零依赖速度极快是这类项目的首选。经典农历数据表的编码规则是从低位开始低 4 位表示闰月月份0 表示无闰月第 4 位表示闰月是大月还是小月再往上 12 位依次表示正月到腊月的大小1 为大月 30 天0 为小月 29 天。开头几年数据长这样private static final int[] LUNAR_INFO { 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260 // 1900-1904 // 完整 1900-2100 数据表在资源包里 };拿0x04bd8举例它的十六进制展开成二进制低 4 位是1000说明 1900 年有闰八月对应的月份位表示 1900 年农历八月是小月。这种位运算写法读起来像天书但数据密度高201 年的信息只用 201 个 int 就存完了。我一般会在代码里留一份“人话版”注释把位含义逐段标出来否则半年后自己回来看也会骂人。选择查表法还有一个理由数据是确定的适合做快照测试。天文算法存在不同实现之间差几分钟的精度问题查表法只要基准日对输出永远一致。对老黄历这种偏娱乐的工程实例确定性比理论精度重要得多。3. 核心推算逻辑从公历日期到当天宜忌3.1 公历转农历基于1900年数据表的offset算法把公历日期转农历核心思路是先算“距离 1900 年 1 月 31 日农历庚子年正月初一的总天数偏移”然后按年扣除一整年的农历天数剩下不够一年的部分再逐月扣除。这里最关键的是把闰月插入正常月份序列否则遇到闰年就乱。public class LunarCalendar { private static final LocalDate BASE_SOLAR LocalDate.of(1900, 1, 31); private static final int BASE_LUNAR_YEAR 1900; public static LunarDate fromSolar(LocalDate solar) { int offset (int) ChronoUnit.DAYS.between(BASE_SOLAR, solar); int lunarYear BASE_LUNAR_YEAR; while (offset lunarYearDays(lunarYear)) { offset - lunarYearDays(lunarYear); lunarYear; } int leapMonth leapMonth(lunarYear); ListMonthInfo months buildMonthList(lunarYear, leapMonth); int index 0; while (offset months.get(index).days()) { offset - months.get(index).days(); index; } MonthInfo mi months.get(index); return new LunarDate(lunarYear, mi.month(), mi.leap(), offset 1); } private static ListMonthInfo buildMonthList(int year, int leapMonth) { ListMonthInfo list new ArrayList(); for (int m 1; m 12; m) { list.add(new MonthInfo(m, false, monthDays(year, m))); if (leapMonth m) { list.add(new MonthInfo(m, true, leapMonthDays(year))); } } return list; } private static int lunarYearDays(int year) { int days 0; for (int m 1; m 12; m) { days monthDays(year, m); } return days (leapMonth(year) 0 ? leapMonthDays(year) : 0); } private static int leapMonth(int year) { return LUNAR_INFO[year - 1900] 0xf; } private static int monthDays(int year, int month) { if ((LUNAR_INFO[year - 1900] (0x10000 month)) ! 0) { return 30; } return 29; } private static int leapMonthDays(int year) { if ((LUNAR_INFO[year - 1900] 0x10000) ! 0) { return 30; } return 29; } }逻辑说明offset是公历日期相对农历起点的总天数差lunarYearDays算某农历年的总天数包含闰月。buildMonthList把闰月插到它对应的正常月份后面这样“闰二月”就成了二月和三月之间的一个独立月份后续天数扣除不需要特殊边界判断。参数说明BASE_SOLAR是算法锚点如果国内某些农历数据表把基准日定在 1900 年 1 月 31 日你换数据表时这个锚点必须同步换否则整体偏移一天。0x10000 month是位运算取第 month 位month 从 1 开始所以正月对应最高位。3.2 干支推算年干支、月干支、日干支的三种取法干支是十个天干和十二个地支的组合循环周期是 60称为六十甲子。老黄历里的年、月、日各有一套干支计算方式完全不同。年干支最直接公历年份减 4对 60 取余。比如 2024 年是甲辰年验证一下(2024 - 4) % 60 40六十甲子序号 40 正是甲辰。月份干支要用“五虎遁”口诀甲己之年丙作首乙庚之岁戊为头。意思是甲年、己年的正月从丙寅开始乙年、庚年的正月从戊寅开始。用公式表达public class GanZhiUtil { private static final String[] GAN {甲, 乙, 丙, 丁, 戊, 己, 庚, 辛, 壬, 癸}; private static final String[] ZHI {子, 丑, 寅, 卯, 辰, 巳, 午, 未, 申, 酉, 戌, 亥}; public static String yearGanZhi(int lunarYear) { int index (lunarYear - 4) % 60; return GAN[index % 10] ZHI[index % 12]; } public static String monthGanZhi(int lunarYear, int lunarMonth) { int yearGan (lunarYear - 4) % 10; int monthGan (yearGan % 5) * 2 2 (lunarMonth - 1); monthGan (monthGan % 10 10) % 10; int monthZhi (lunarMonth 1) % 12; return GAN[monthGan] ZHI[monthZhi]; } public static String dayGanZhi(LocalDate solar) { LocalDate base LocalDate.of(2000, 1, 1); int offset (int) ChronoUnit.DAYS.between(base, solar); // 基准日干支序号需要先用权威日历核对一次 int index Math.floorMod(offset 29, 60); return GAN[index % 10] ZHI[index % 12]; } }逻辑说明monthGanZhi里的(yearGan % 5) * 2 2就是五虎遁的公式化写法。年干是甲0、己5时0 % 5 * 2 2 2对应丙年干是乙1、庚6时结果是 4对应戊。月支从正月开始固定是寅(lunarMonth 1) % 12正好让正月落到序号 2寅。这里我没有对公历转农历再取月而是直接用农历月属于简化处理真实黄历以节气分月想做到节气级精度需要额外叠加二十四节气表资源包里的完整版补了这一步。3.3 建除十二神月支到日支的偏移量建除十二神的算法网上说法不一其实原理很简单以当月月支为起点日支在其后第几位就是第几个神。月支寅对应正月日支从建开始往后数。公式public static JianChu jianChu(int monthZhiIndex, int dayZhiIndex) { int offset (dayZhiIndex - monthZhiIndex 12) % 12; return JianChu.fromIndex(offset); }参数说明monthZhiIndex是月支的地支序号dayZhiIndex是日支的地支序号两者差值模 12 后得到 0 到 11正好对应建、除、满、平、定、执、破、危、成、收、开、闭。这套规则在传统黄历里配合“建日宜出行、除日宜扫舍”等说法使用在程序员版本里就对应到“宜开新分支”“宜删无用代码”。到这里核心历法链路已经通了一半公历日期进来农历、年干支、月干支、日干支、建除十二神全部算完。接下来要把这些因子组装成可读的宜忌输出。4. 宜忌生成与输出命令行和Web两种落地方式4.1 宜忌池的生成策略随机种子定住结果才天天一致老黄历最容易翻车的地方是“同一天每次运行结果都不一样”。问题出在直接用了Random无参构造而它默认以当前时间戳做种子。正确做法是用一个稳定的日期相关值做种子保证同一天无论运行多少次输出都一样。public class AlmanacGenerator { public AlmanacResult generate(LocalDate date) { LunarDate lunar LunarCalendar.fromSolar(date); String yearGz GanZhiUtil.yearGanZhi(lunar.getYear()); String monthGz GanZhiUtil.monthGanZhi(lunar.getYear(), lunar.getMonth()); String dayGz GanZhiUtil.dayGanZhi(date); int dayZhiIndex indexOfZhi(dayGz); int monthZhiIndex indexOfZhi(monthGz); JianChu jianChu JianChuUtil.jianChu(monthZhiIndex, dayZhiIndex); long seed date.toEpochDay() * 31L jianChu.ordinal(); ListString yi pickFromPool(YiJiDictionary.YI_POOL.get(jianChu), 3, seed, true); ListString ji pickFromPool(YiJiDictionary.JI_POOL.get(jianChu), 3, seed, false); return new AlmanacResult(date, lunar, yearGz, monthGz, dayGz, jianChu, yi, ji); } private ListString pickFromPool(ListString pool, int count, long seed, boolean reverse) { ListString copy new ArrayList(pool); Collections.shuffle(copy, new Random(seed (reverse ? 7 : 3))); return copy.stream().limit(count).toList(); } }逻辑说明seed date.toEpochDay() * 31L jianChu.ordinal()前半部分让同一天永远是同一个种子后半部分把建除因子掺进去让不同建除日的抽取结果错开。宜和忌各用不同的偏移量7和3避免宜忌列表恰好抽中同一批词条。参数说明limit(3)控制每天输出三条宜三条忌。词条池如果少于三条运行会抛异常所以字典里每个建除日的宜池和忌池至少要有五到六条词条。资源包里的词条池数量是每条八条起留足替换空间。4.2 命令行输出参数解析、ANSI彩色和中文编码命令行版要处理的细节比想象中多参数解析、日期校验、输出编码、颜色控制每一处都有坑。核心代码public class MainCli { public static void main(String[] args) throws Exception { LocalDate date parseDate(args); AlmanacResult result new AlmanacGenerator().generate(date); System.out.println(formatText(result)); } private static LocalDate parseDate(String[] args) { for (int i 0; i args.length; i) { if (-d.equals(args[i]) i 1 args.length) { return LocalDate.parse(args[i 1]); } } return LocalDate.now(); } private static String formatText(AlmanacResult r) { String reset \u001B[0m; String green \u001B[32m; String red \u001B[31m; StringBuilder sb new StringBuilder(); sb.append(r.getDate()).append(\n); sb.append(r.getLunar().toString()).append( ) .append(r.getYearGz()).append(年 ) .append(r.getMonthGz()).append(月 ) .append(r.getDayGz()).append(日\n); sb.append(建除).append(r.getJianChu().getName()).append(\n); sb.append(green).append(宜).append(String.join( / , r.getYi())).append(reset).append(\n); sb.append(red).append(忌).append(String.join( / , r.getJi())).append(reset).append(\n); return sb.toString(); } }逻辑说明-d参数指定日期不传默认取当天。ANSI 颜色码里\u001B[32m是绿色\u001B[31m是红色\u001B[0m是复位。之所以要在每行结尾显式复位是因为颜色码有传染性不复位的话终端后续所有输出都是绿的。Windows 的 cmd 默认不解释 ANSI 码需要在程序启动时开启虚拟终端处理或者直接用新版 Windows Terminal。中文乱码问题在第五章会详细讲这里先给结论编译用javac -encoding UTF-8 MainCli.java运行用java -Dfile.encodingUTF-8 MainCli -d 2024-06-18Java 18 之后默认 UTF-8 会好一些但线上环境没人保证用新版。4.3 零依赖Web版JDK内置HttpServer封装接口命令行版跑通之后想给别人用浏览器访问不需要引入 Spring Boot。JDK 自带的com.sun.net.httpserver.HttpServer就能支撑一个单文件小服务适合这种工具型实例。public class WebServer { public static void main(String[] args) throws Exception { HttpServer server HttpServer.create(new InetSocketAddress(8080), 0); server.createContext(/almanac, exchange - { String query exchange.getRequestURI().getQuery(); LocalDate date query null ? LocalDate.now() : LocalDate.parse(query.replace(date, )); AlmanacResult result new AlmanacGenerator().generate(date); String json toJson(result); byte[] bytes json.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set(Content-Type, application/json; charsetutf-8); exchange.sendResponseHeaders(200, bytes.length); try (OutputStream os exchange.getResponseBody()) { os.write(bytes); } }); server.start(); } private static String toJson(AlmanacResult r) { return {\date\:\ r.getDate() \, \yi\:[\ String.join(\,\, r.getYi()) \], \ji\:[\ String.join(\,\, r.getJi()) \]}; } }逻辑说明HttpServer.create绑定 8080 端口createContext注册/almanac路径。浏览器访问http://localhost:8080/almanac?date2024-06-18就返回 JSON。这个版本刻意手工拼接 JSON不引第三方库够用且零依赖。如果参数解析失败LocalDate.parse会抛异常建议在真实使用中包一层 try-catch 返回 400但实例里保留裸写法原因是让你先看清主干流程。toJson手工拼接有个隐患词条里如果出现双引号或反斜杠JSON 就坏了。当前词条库都是中文短语没有这些字符所以能跑。后续想认真用还是建议换成 Jackson 或者手写一个最小转义函数。5. 避坑与排查农历转换、编码、随机种子的五个翻车现场5.1 公历转农历结果差一天现象程序输出农历日期比手机自带的农历日历晚一天。比如 2024 年 2 月 10 日应该是正月初一程序却输出正月十五反过来某一天又早一天。原因绝大多数是基准日偏移问题。网上流传的农历数据表起点不一致有的从 1900 年 1 月 31 日算有的从 1900 年 2 月 1 日算还有的是闰月大小月位理解反了导致某个月天数算错。解决先用一个权威日期验证基准日。我的习惯是拿 2024 年 2 月 10 日正月初一和 2023 年 1 月 22 日正月初一两个锚点同时测两个都对才算通过。如果只差一天调整BASE_SOLAR正负一天不要改数据表。5.2 春节跨年时干支纪年没有跟着变现象2024 年 2 月 10 日之前的输出还是“癸卯年”但有些黄历软件从立春起就换成了“甲辰年”两边不一致用户以为是 Bug。原因干支纪年在传统历法里以立春为岁首农历年以春节为岁首两套规则差最多半个月。我的实例采用农历春节作为切换点因为数据表的年维度就是按农历年组织的。如果你按立春切换需要额外引入二十四节气表计算量会上升不少。解决在 README 里明确写清楚“本实例以春节为年干支切换点”。如果使用者确实需要节气级精度就补一张节气日期表在monthGanZhi和yearGanZhi之前先判断是否已过立春。5.3 同一天每次运行宜忌都不一样现象上午跑一次是“宜重构、忌加班”下午跑一次变成“宜摸鱼、忌开会”用户以为程序有随机 Bug。原因Collections.shuffle没给固定种子或者new Random()用了无参构造。无参构造以系统时间为种子每次 shuffle 结果都不同。解决统一用date.toEpochDay()派生种子并且不要只种一次。我在排查时发现如果在pickFromPool里用同一个Random实例反复 nextInt抽取顺序会变化正确做法是每个池子单独用种子构造新的Random。改完后建议连续跑十次输出不一致就说明种子逻辑还有问题。5.4 命令行中文乱码现象代码里字符串全是正常的控制台输出却变成????或者一团乱码。原因Windows 中文版 cmd 默认编码是 GBKJava 字符串按 UTF-8 编码输出时对不上反过来源码文件编码没指定时javac 会用平台默认编码读源码中文常量直接进 class 文件前就乱了。解决源码保存为 UTF-8编译时加-encoding UTF-8运行时加-Dfile.encodingUTF-8。在 Windows 上还可以在代码里用PrintStream包一层并指定StandardCharsets.UTF_8但最省事的还是换 Windows Terminal 或直接在 IDE 里跑。5.5 闰月导致后续月份全部错位现象1900 年、2023 年这类有闰月的年份农历九月之后输出的月份比实际晚一个月宜忌词条对应的建除神也跟着错。原因buildMonthList没有把闰月插进序列而是单独累加天数导致后续月份索引错位。比如闰二月被当成普通二月处理三月实际是闰二月程序却按三月算。解决用前面 3.1 的buildMonthList方案把闰月作为一个独立月份对象插入正常序列后续循环天然支持闰月。正则表达式去抓“闰”字的方案是死路永远会有边界情况。6. 进阶技巧给老黄历加上“日运指数”并做输出验证6.1 日运指数把“玄学”变成可解释的加权值只有宜忌词条显得单薄加一个 0 到 100 的“日运指数”会让输出更像完整产品。算法不复杂基础分 60建除吉凶加权再叠加固定种子产生的随机浮动。public int dailyScore(LocalDate date, JianChu jianChu) { int score 60; switch (jianChu) { case CHENG, KAI - score 15; case PO, BI - score - 15; case JIAN, MAN - score 5; case WEI, PING - score - 5; default - { } } long seed date.toEpochDay() * 17L; score new Random(seed).nextInt(21) - 10; return Math.max(1, Math.min(100, score)); }逻辑说明建、除、成、开属于相对积极的建除日加分破、闭属于消极日减分平、危属于需要谨慎的日子微减。随机浮动区间控制在[-10, 10]防止出现“宜忌很好但指数只有 2 分”的自相矛盾观感。参数说明nextInt(21) - 10产生正负浮动种子用toEpochDay() * 17L固定。这里不能用nextInt(100)否则浮动幅度太大指数就和宜忌脱节了。6.2 快照测试锁死输出防止加功能就翻车最后分享一个我回回踩坑的教训这个项目改起来太容易了加一个词条、调一下权重看起来都是局部改动结果某一天的干支输出全变了。后来我用快照测试锁死输出。Test public void snapshot20240618() { AlmanacResult result new AlmanacGenerator().generate(LocalDate.of(2024, 6, 18)); assertEquals(农历甲辰年五月十三, result.getLunar().toString()); assertEquals(甲辰, result.getYearGz()); assertEquals(庚午, result.getMonthGz()); assertEquals(壬午, result.getDayGz()); assertEquals(JianChu.PING, result.getJianChu()); assertEquals(3, result.getYi().size()); assertEquals(3, result.getJi().size()); }这个测试锁定的是 2024 年 6 月 18 日这一整天的关键输出。以后任何人改动农历表、干支公式或建除算法只要让这一天的结果变化测试立刻失败。快照日期多选几个覆盖闰月年、春节前后、普通日期三种情况。从那以后我每次改完生成逻辑都强制先把快照测试完整跑一遍再去做功能验证。你以为改的是词条池结果动摇了基准日偏移这种事发生过不止一次。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑