日历相关的算法

Posted by lili on March 27, 2019

这是2011年写的一篇文章,主要介绍日历相关的概念。这些基本概念今天依然是有用的,因此把它整理到我的博客上。历法其实是很有意思的学问,通过本文可以更清楚了了解中国的农历。八年过去了,对于Java语言来说date time相关的API发生了很大的变化,首先是Joda-Time在Java8之前成为事实的标准,Java8通过JSR 310引入了新的API。另外之前计算农历的icu目前看已经有问题了,因此本文介绍新的time4j库用来计算农历。

目录

日历相关的地理知识

人类最早关注历法是由于生活的需要,比如每天的日升日落和月升月落帮助调节生物钟,周期性的规律让人发现时间的存在。四季的更替,春华秋实,每个季节都有不同的食物,农业的发展让我们的祖先更加重视太阳的运动规律。日食月食等天文现象让人恐惧,星宿的变化似乎预示这某位重要人物的降临或离开……,所有这些,都使得历法在古人的生活中变得非常重要。这些天文现象影响了人类生活的方方面面,古人也对此做出了自己的解释,现代科学的萌芽也与天文密切相关。在中国,统治者自命”天子”,顺应”天命”,如果不是风调雨顺,那么必然是统治者触犯上天,所以解释和预测天文现象是天子的职责。而在西方,教皇是政治和宗教的领袖,其权力的合法性也与上天密切相关。所以近代科学与宗教的斗争集中体现在天文学上,比如我们熟悉的哥白尼和伽利略。其实在中国的历史上也过类似的斗争,比如西洋传教士和中国保守的天文学家的斗争,最出名的要数汤若望。除了对于历法的优劣比拼之外,更反映了朝廷的政治斗争。结果是汤若望被鳌拜逮捕,后来被康熙平反。为了理解日历,我们先来回顾一下与历法相关的高中的地理知识。

地球自转

地球自西向东自转,轴心指向北极星。自转一周24小时(太阳日),恒星日为23时56分4秒。因为时间最初的定义就是用太阳日的,所以是自转一周的时间是24这个整数。自转非常重要,它会引起昼夜更替。如果没有自转,那么一昼夜就是一年了!

地球公转

地球自西向东公转,轨道为椭圆,太阳位于一个焦点上,一个回归年为365.24219879日,一个恒星年为365.2564日。回归年定义为太阳从一个春分点到下一个春分点的时间;而恒星年是观察太阳连续两次经过地球与某一恒星(认为它在天球上固定不动)连线的时间间隔,它是地球公转的真正周期。为什么恒星年会比回归年稍长一些呢?那是由于月球、太阳和行星的引力影响,使赤道部分比较突出的椭形地球的自转轴绕黄道作缓慢的移动(相应的春分点自黄道以每年20.24分速度西退, 差不多71年西移1度,大约25800年移动一周),即岁差现象。也就是说地球自转的轴其实不是完全固定的,而是会发生缓慢变化,感兴趣的读者可以参考这篇文章。虽然恒星年更加准确的描述了地球绕太阳的公转,但是由于回归年以春分点为参考,它反映了地球不同地区受太阳照射的变化周期,即季节的变化周期。因此回归年可以更好指导我们的农业等生产生活,所以我们通常说的年一般就是指的回归年。

黄赤交角

由于黄赤交角的存在,导致太阳直射点在南北回归线内交替变化,而不是始终直射赤道,从而引起四季的更替。公历(Gregorian Calendar)的四季变化点: 春分秋分(Equinox),冬至夏至(Solstice)。二分点昼夜时长相等,二至点昼夜分别达到最长。

地球绕太阳的轨道叫黄道,不过在地心说的古代,黄道就是太阳绕地球的轨道。黄道和赤道相交的日子,也就是春分或者秋分,被叫做黄道吉日,民间传说这天适合婚嫁,不过一年才两天太少不够用,所以老黄历有一套计算黄道吉日的方法。

二十四节气

除了4个季节变化点,中国古代把360°的地球轨迹又分成24节气,每个节气运动15°,这对指导农业生产至关重要。另外中气对于农历的历法计算也非常重要。二十四节气中,偶数的节气叫”中气”,比如”雨水”和”春分”就是”中气”。我们小时候背过的二十四节气歌:春雨惊春清谷天,夏满芒夏暑相连,秋处露秋寒霜降, 冬雪雪冬小大寒。

时区与夏令时

由于地球自转一周24小时候,每个经度上的人看到太阳的时间都不相同,为了方便,每隔15°经度,设置一个时区。比如北京是在东八区,所以中国都使用北京时间,但是中国横跨的经度很大,比如西藏,所以他们的作息时间可能比我们晚。他们可能14点才吃午饭。冬天和夏天的昼夜时长存在差异,为了充分利用阳光,很多国家实行了夏令时,也就是夏天到了后,时钟拨快一个小时候,夏天完了再拨回来。中国也实行过几年夏令时,不过后来取消了。

世界时、协调世界时(UTC)和格林尼治时间(GMT)

有了太阳或者月亮的周期性规律,那么就可以定义时间了。最简单的就是利用太阳——日出而作日落而息。但是碰到阴天或者雨天没太阳就惨了。不过生物都有生物钟,个体的生存问题倒也不是特别严重。但是作为群居性的动物,人类的交流需要准确的时间,所以有必要准确的定义时间。那么把太阳的起落作为一个时间单位是最自然不过的了,这样就有了”天”这个单位,但是天的粒度太大,所以古代又定义了时辰,相对于现在的两小时。一天24小时,每个时辰两小时,所以一天有12个时辰。古人用12地支来命名不同的时辰,比如午时表示11:00-13:00。

由于地球绕太阳的运动并不规律,所以真太阳日每天都不同,平太阳日虽然准确一些,但是也是变化的。现在最准确的计时方法应该是原子时钟。原子时秒的定义是:铯-133原子基态的两个超精细能级间在零磁场下跃迁辐射9192631770周所持续的时间。世界时的定义是以本初子午线的平子夜起算的平太阳时,又称为格林尼治时间。但是这两种计时的精度不一样,原子时的精度为纳秒,而世界时为毫秒,协调世界时(Coordinated Universal Time)是一种折中方案,并且在互联网上使用,比如网络时间协议 (NTP)就是使用的UTC时间。

如果大家仔细观察UTC的缩写,可能会觉得奇怪,协调世界时的三个英文单词的缩写应该是CUT,怎么是UTC呢?这里还有个小故事。它的法文是Temps Universel Cordonné,缩写是TUC,当时制定游戏规则的人都想用自己母语的缩写,争来争去达成妥协,谁的都不用,就用UTC了。

历法的相关概念

公历(Gregorian Calendar)

公历又叫格列历、阳历和太阳历,中国在辛亥革命后的民国元年开始采用。它的前身是儒略历(Julian calendar),这是西方十六世纪前采用的历法,由儒略·凯撒颁发。它一年12个月,月的天数就是我们现在公历的方法,除了2月受闰年的影响,其余各月天数固定,并分大小月。4年一闰年。这样平均一年365.25天。比实际公转周期的365.2422日长11分14秒,即每400年约长3日,所以为了矫正这个误差,公历的闰年又加了一条规则:如果能被100整除,那么必须被400整除。以前觉得这个规则很诡异,看到这个后就非常自然了——相对于每400年去掉3个闰年。这样每年平均长365.2425日,与公转周期的365.2422日十分接近,可基本保证到公元5000年之前的误差不超过1天。

阴历(lunar calendar)

阴历是按月亮的月相周期来安排的历法。由于月球绕地球的转动周期是29.5天左右,所以如果一年设12个月,那么一年大概为354或者355天。按照月亮的月相来计时的方法的优点是简单,缺点是这样一年的时间和太阳年相差了11天左右。中国的农历在民间也被称为阴历,其实这种叫法是不准确的,中国的农历结合了阳历和阴历,并且以阳历为主,阴历只是一个时间单位。真正的阴历只有伊斯兰的历法,它的主要用途是指导他们的宗教节日等,因此穆斯林的斋戒节有时在夏天,有时在冬天。但是阴历没法指导生活,所以在伊斯兰国家另外设置阳历来指导生活和农业生产。

农历

中国以前使用的历法,又称阴历,夏历。前面说了,阴历每年差了11天左右,19年就是209天,除以阴历每月29.5天就是7.084。这种方法由古希腊人默冬在公元前432年提出:在19个阴历年中安置7个闰月,即可与19个回归年相协调。其实在他之前100多年中国人就已经发现了这个规律。这个周期的英文名字叫Metonic cycle,中文一般翻译成默冬章而不是默冬周期,因为这个周期在中国历法叫一个”章”。这就是19年里设置7个闰月的缘故,那这7个闰月放到19年中的哪些年中呢?如果是闰年,这个闰月放到哪个月后呢?这是中国古代历法需要解决的重要问题。3年一闰,5年二闰,19年七闰,这是安放闰年的方法,其依据没找到,或许它的好处是使得每年的时间尽量接近太阳年吧。那么如果这年是闰年,那么闰月放到哪呢?农历闰月的安插,自古以来完全是人为的规定,历代对闰月的安插也不尽相同。秦代以前,曾把闰月放在一年的末尾,叫做”十三月”。汉初把闰月放在九月之后,叫做”后九月”。到了汉武帝太初元年,又把闰月分插在一年中的各月。以后又规定”不包含中气的月份作为前一个月的闰月”,直到现在仍沿用这个规定。另外阴历一个月29.5天,所以又有大月和小月的说法。大月30天,小月29天。但哪个月大月,哪个月小月,算法就比较复杂了,不像公历那么简单。这样一来,农历一年可能的天数包括354,355,356,383,384,385。

农历的年和岁

前面说过了,中国的农历其实是以阳历为主的,毕竟农历最重要的用途就是指导农业生产。农历的岁定义为一个冬至日到下一个冬至日的时间,这和公历年的定义很相似,只不过公历定义的是春分日到下一个春分日。如果地球运动是规律的椭圆的话,那么这两个时间应该完全一样,不过事实上它们还是有0.004天的差别。

过去中国人说年龄,一般说多少岁,指的是虚岁。虚岁的精确定义是某个人从出生到现在为止经历过的农历的岁的数量。比如某人出生在农历一九九九年九月九日,那么到了两千年的一月一日(大年初一)他就两岁(虚岁),也就是他在这个世界时经历了一九九九年(岁)和二零零零年(岁),因此通常虚岁要比周岁大1.5岁左右。古代男子二十弱冠,表示成年,换成现在也就是18.5周岁左右,和我们现在的成年标准相差不大。虚岁中经历的岁的定义是有分歧的,有的地方认为过了冬至(阳历)就长了一岁,有的地方是过春节(阴历)或者立春(阳历)。

前面说了中国的农历有24节气,其中对于历法来说,冬至是最为重要的节气。周朝的时候以冬至所在的月为一年的开始。汉代以后,这个月变成十一月,它之后的第二个月成为正月。正月初一为春节。由于1岁=12.37月,如果这一个岁包含了完整的12个月(朔望月,也就是大约29.5日),那么这一个岁就叫闰岁。由于一岁包含至少13个月,而只有12个中气,那么至少有一个月没有中气,因此农历规定没有中气的月为闰月,闰月的天数和上一个月一样多,包含闰月的年叫闰年。

奇怪的2033年

这一年的11月是闰月,所以这年是闰年,但它不包含12个完整的月,所以不是闰岁。2034年是闰岁,但不是闰年。

聋子年和双春年

如果立春在春节前,那么这年就叫聋(子)年,比如2010年就是,如果这个聋子年下半年还不包含另一个立春,那么就叫双聋年。如果这年包含两个立春,那么就叫双春年。

一个月有多少天

和公历相比,农历是一种”测量”的历法,而不是”算术”的历法。也就是说,公历的月的大小都是人为指定的,可以预先计算的;而农历的年月需要”测量”,比如测量冬至日来确定岁,同样,月也是测量月亮的运动来确定的。所以农历的”计算”会很复杂,而且如果地球或者月球的轨道发生变化(比如科幻小说里的火星撞地球之类),那么它也会跟着发生变化。当然,科学家预测最近的几百年应该不会有太大的变化,所以制作”百年历”还是没有问题的(更新:实际上根据香港天文台的官网,2058年9月28日的农历就很难预测),”万年历”就不好说啦。

测量点

由于需要的测量月亮的变化来确定,所以测量点非常重要。1929年前的测量点都是在北京,经度为东经116°25′,使用的时区却是120°的东八区,1949年后搬到了南京紫金山天文台,这个地方的经度是东经118°46′。这个变化看似微小,却引发了1978年”连续两天中秋节”的故事。

每天的开始

农历每天的开始是凌晨0:00,有的历法是把中午12点作为一天的开始。

每月第一天

农历里新月是一个月的第一天,有的历法把满月后的一天作为下月第一天。一个农历月是29.5天,具体到每个月,根据观察会有大月(30天)和小月(29)的区别。月的”计算”也会比较复杂,而且会出现连续多个”大月”的情况,比如1990年十月到1991年一月连续4个大月。

十五的月亮十六圆?

农历的月采用月相的变化来表示,那么十五应该是满月的时候。八月十五中秋节作为传统的节日,所以大家特别关注这晚的月亮。但是民间有”十五的月亮十六圆”的说法,这样什么道理吗?经过统计[1],从1984年到2049年,满月出现在十五的次数为306,而出现在十六的次数为380,另外十七124次,十四6次。所以从统计意义上说十六确实更圆一些。

干支纪年法

我们现在说2011年五月五日(端午节)其实是非常不伦不类的,农历的说法应该是辛卯年五月初五。公元纪年是公历的用法,我国过去是干支纪年法。十天干是:甲(jiǎ)、乙(yǐ)、丙(bǐng)、丁(dīng)、戊(wù)、己(jǐ)、庚(gēng)、辛(xīn)、壬(rén)、癸(guǐ)。十二地支是:子(zǐ)、丑(chǒu)、寅(yín)、卯(mǎo)、辰(chén)、巳(sì)、午(wǔ)、未(wèi)、申(shēn)、酉(yǒu)、戌(xū)、亥(hài)。如果两两组合,那么共有120种可能,不过天干地支用来纪年是并不是任意两个组合都可以的。它的方法是先天干和地支两两配对。甲子、乙丑、丙寅、丁卯、戊辰、己巳、庚午、辛未、壬申、癸酉,这时天干用完了,地支还剩两个,于是天干循环使用甲戌、乙亥,这时地支也用完了,那么也循环使用,最终得到60中可能的组合,仔细分析,其实就是天干和地支奇数和奇数能配对,偶数和偶数能配对。所以如果有人说甲丑年,那么别忙着算啦。这样的方法只能计数一甲子,也就是60年,然后循环使用,所以60年前和60年后的干支纪年没有差别。过了六十岁的人自称过了花甲之年,也就是过了一个甲子(60年)。

年号

干支纪年似乎有”歧义”,但是过去的皇帝都会在自己的统治期间设置年号,这样就不会有歧义了。在明清之前,很多皇帝经常更换年号,而到了明清,每个皇帝就只有一个年号了。所以我们称康熙皇帝而不会称贞观皇帝。一般皇帝执政时间不会超过60年,所以年号+干支能唯一标识一个年,但是像康熙皇帝执政了61年,这就有点麻烦了。

辛亥革命后到1949年前人们废除了干支纪年的方法,并且使用了公历,但是民间农历依然盛行,却没法说宣统xx年了,所以使用了民国xx年。这种纪年法1949年在大陆被废除,采用了公元纪年法,但是现在在台湾,人们仍然说民国xx年。

生肖和八字

不知什么时候,人们把十二地支和12种动物联系起来了,这样每个年都被叫做鼠年或者虎年。注意,生肖也是跟农历相关的,准确的说以立春为起点。所以比如我,虽然出生在1983年(猪年),但却属狗的。另外过去除了纪年用干支,纪月纪日和纪时也用干支,这样一个人的出生的年月日时就能用八个字来表示了,这就是八字。

农历的现实意义

从上面我们已经知道,农历里用来指导农业生产最重要的其实是二十四节气(其实也就是公历),而使用了月来作为度量时间的单位,但是太阳回归年和朔望月之间 没法匹配,所以引入闰年。农历的阴历部分的现实意义和伊斯兰历很像了,左右基本只剩下指导节日了,现在中国最重要的节日比如春节、中秋节、元宵节和端午节等。另外中国很多节日是公历(节气)相关的,比如清明节、冬至节和芒种节等等。

农历和公历的换算

上面说了一大堆,可能工程师最关心的就是换算了。因为日常生活使用公历就足够了,但是有些传统节日的计算需要把农历转换成公历。也就是说把农历转成公历是最常见的用途,不过如果做个什么算命网站,公历转农历也是需要的。

前面说过了,农历是一种”测量”算法,所以准确的计算需要”预测”太阳和月亮的运行轨迹,这是天文局的任务。对于软件工程师来说,一般都是把官方计算的结果保存下来。一般只需要保存这年是否闰年,如果是,闰的是几月。另外还需要知道这年每个月的大小。网上搜索一般能找到1800-2200年的,由于来源不明,是否正确我也不敢保证,用最近的一些节日验证倒没有大问题。比如这篇博客就是这样的算法,使用一个数组lunarInfo保存了1900年-2050年每年的信息。比如2009年0x0cab5,最后一个5表示闰五月,如果最后一个为0,那么就是没有闰月,第一个字节0表示闰月是小月,中间3个字节12bit表示一到十二月是否大月。

ICU4J

最早博客文章注意介绍的是ICU4J,但是根据stackoverflow上的回答,ICU4J计算在2018-11-07就和香港天文台的观测出现了偏差,因此不建议使用。另外ICU4J的版本变化也非常奇怪,最早是4.8.x,一下子就变成了49.1,现在最新的版本是63.1了,我当时应该使用的是4.6.x。因为公历转农历是唯一的,但是由于有闰月,农历可能会有两个八月十五(可惜国家只会放一次假),因此农历转公历会更加tricky。

下面是公历转农历的算法:

SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
Date date=df.parse("2011-6-6");
ChineseCalendar cal=new ChineseCalendar(date);
System.out.println(cal.get(ChineseCalendar.YEAR));
System.out.println(cal.get(ChineseCalendar.MONTH));
System.out.println(cal.get(ChineseCalendar.DATE));
System.out.println(cal.get(ChineseCalendar.IS_LEAP_MONTH));

上面的代码输出说明2011年6月6日就是农历的五月初五,并且五月不是闰月(IS_LEAP_MONTH),因此可以判定是节日。

农历转公历的算法如下:

SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
Date date=df.parse("2011-5-5");
ChineseCalendar cal=new ChineseCalendar(date);
while(true){
	int month=cal.get(ChineseCalendar.MONTH);
	int day=cal.get(ChineseCalendar.DATE);
	if(month!=4||day!=5){
		cal.add(ChineseCalendar.DATE, 1);
		continue;
	}else{
		Calendar cal2=Calendar.getInstance();
		cal2.setTime(cal.getTime());
		System.out.println(cal2.get(Calendar.YEAR)+"-"+
			(cal2.get(Calendar.MONTH)+1)+"-"+cal2.get(Calendar.DATE));
		break;
	}
}

ICU4J并没有直接提供农历转公历的代码,因此上面的算法类似与逆向穷举。也就是遍历公历的日期,看它对应的农历日期是否端午节。这个算法有个假设,农历的月份和日期”落后”于公历,比如农历五月初五,那么它的公历比如是大于5月5日的,这个命题的证明比较简单:农历的春节(正月初一)对应于公历日期的范围是1/21-2/21之间,也就是说公历比农历”早”至少21天,公历每月最多31天,农历每月最少29天,如果农历要”赶上”公历,那么至少得21/2=10.5个月,而且公历的二月只有最多29天,这样变成11.5个月,而且很多月(至少两个月)不是31天,这样累计下来就永远”赶”不上了。当然证明的前提是”农历的春节(正月初一)对应于公历日期的范围是1/21-2/21之间”,这个命题可以参考[1],如果对这个算法还不放心,那么可以双向找最近的那个农历日期。

有很多节气也是节日,比如清明节,节气是公历,计算公式比较简单,读者可以参考二十四节气

Java的Calendar

注:这部分内容也是之前博客的内容,目前除了遗留代码不再建议使用Date和Calendar了。

Calendar是一个接口,获取Calendar实例的方法一般是Calendar rightNow = Calendar.getInstance(); Calendar的getInstance静态方法会根据本地的时区(TimeZone)和区域(Locale)选择合适的Calendar,比如我们这里,返回的其实是GregorianCalendar,因为中国官方的历法现在是日历。JDK1.6的getInstance()方法的会调用如下方法:

private static Calendar createCalendar(TimeZone timezone, Locale locale) {
	if ("th".equals(locale.getLanguage())
			&& "TH".equals(locale.getCountry()))
		return new BuddhistCalendar(timezone, locale);
	if ("JP".equals(locale.getVariant())
			&& "JP".equals(locale.getCountry())
			&& "ja".equals(locale.getLanguage()))
		return new JapaneseImperialCalendar(timezone, locale);
	else
		return new GregorianCalendar(timezone, locale);
}

除了泰国和日本,其余都是GregorianCalendar。中国现在官方的历法也是公历。泰国使用的佛历,其实基本就是公历,只不过纪年的开始不是基督耶稣的诞辰年,而是佛祖的诞辰年。日本的历法也是公历,不过纪年采用天皇的年号,这个比较麻烦,等现任天皇驾崩后又得维护数据了。

Calendar的set方法会修改Fields(比如修改年月日等),但不会离开触发计算,必须调用get getTime等方法后才会现算。Calendar默认是”宽容”的,比如设置1月32日,不过出错,它会计算成2月1日。一个月的第一周,很可能一个周分布在两个月中,这样可能导致一个月有5周,那么怎么样这个周算这个月 呢?getMinimalDaysInFirstWeek告诉我们,包含此周的天数如果大于等于这个值,那么这周就是这个月,默认是1,也就是说如果一个周分布在两个月,那么这个周同时属于两个月。每周的第一天是哪天?getFirstDayOfWeek() 默认是Sunday,如果计算时想要符合中国人的习惯,那么需要设置这个值为Monday。

time4j的农历计算

根据so的这个问题,似乎time4j的农历计算更加准确(但不管怎么准确最后还得看观测)。我们下面简单的介绍一下怎么用time4j来进行农历相关的计算,这部分主要参考了time4j的文档。后文我们还会介绍time4j,这里我们只是简单的介绍农历相关的内容。

计算新年和清明

下面的代码可以计算农历2020年的新年第一天(大年初一)和清明节对应的公历是哪一天。SOLAR_TERM是节气的意思,而SolarTerm.MINOR_03_QINGMING_015代表清明,更多节气请参考这里,它都是包含汉语拼音的,因此应该比较容易明白。

ChineseCalendar newyear = ChineseCalendar.ofNewYear(2020);
ChineseCalendar qingming = newyear.with(ChineseCalendar.SOLAR_TERM, SolarTerm.MINOR_03_QINGMING_015);
System.out.println(newyear.transform(PlainDate.axis())); // 2020-01-25
System.out.println(qingming.transform(PlainDate.axis())); // 2020-04-04

计算冬至

冬至(Winter Solstice)是阳历的一个节气,我们可以计算某年冬至对于的阴历日期:

PlainDate winter = AstronomicalSeason.WINTER_SOLSTICE
         .inYear(2018)
         .toZonalTimestamp(ZonalOffset.ofHours(OffsetSign.AHEAD_OF_UTC, 8))
         .getCalendarDate();

ChineseCalendar chineseDate = winter.transform(ChineseCalendar.class);
ChronoFormatter<ChineseCalendar> formatter =
       ChronoFormatter.setUp(ChineseCalendar.axis(), Locale.CHINA)
         .addPattern("EEE, d. MMMM r(U) ", PatternType.CLDR_DATE)
         .addText(ChineseCalendar.SOLAR_TERM)
         .build();
System.out.println(formatter.format(chineseDate));

输出:周六, 16. 十一月 2018(戊戌) 冬至

AstronomicalSeason里定义了春分、夏至、秋分和冬至。WINTER_SOLSTICE代表冬至,inYear(2018)表示求2018年的冬至,然后把它转换成东八区(时区比UTC早8个小时)的时间。

然后把这个日期转换成农历(ChineseCalender),最后输出农历日期。ChronoFormatter用于格式化的输出,主要使用了addPatten。其中EEE代表周几,d代表日期(农历),MMMM代表月,r代表年,U代表干支纪年,最后通过addText(ChineseCalendar.SOLAR_TERM)输出节气。

计算端午节

下面的代码计算2019年的端午节对应的公历日期:

ChineseCalendar duanwu=ChineseCalendar.of(EastAsianYear.forGregorian(2019), EastAsianMonth.valueOf(5), 5);
System.out.println(duanwu.transform(PlainDate.axis()));
输出:2019-06-07

ChineseCalendar.of有三个参数,分别代表农历年、月和日。在大陆,我们使用公历年来定义农历年,也就是和公历2019年重合天数最多的那个农历年就是农历二零一九年。如果是台湾,我们可以使用:

ChineseCalendar duanwu=ChineseCalendar.of(EastAsianYear.forMinguo(108),EastAsianMonth.valueOf(5), 5);
System.out.println(duanwu.transform(PlainDate.axis()));

民国108年也就是公元2019年。

农历闰月

农历2020年闰四月,下面的代码把闰四月五日转换成公历:

ChineseCalendar date=ChineseCalendar.of(EastAsianYear.forGregorian(2020), EastAsianMonth.valueOf(4).withLeap(), 5);
System.out.println(date.transform(PlainDate.axis()));
输出:2020-05-27

上面代码的关键是EastAsianMonth.valueOf(4).withLeap(),表示我们要的是闰四月。

如果这个月不是闰月,我们要求闰月就会抛出异常,比如:

ChineseCalendar date=ChineseCalendar.of(EastAsianYear.forGregorian(2020),EastAsianMonth.valueOf(3).withLeap(), 5);
System.out.println(date.transform(PlainDate.axis()));

2020年三月不是闰月,因此上面的代码会抛出异常指明Invalid date。

怎么判断某年是否有闰某月

我们通常想知道某年是否闰年(有闰月),并且具体哪个月是闰月。time4j没有直接的API,但是我们可以使用上面的异常来判断是否闰月,我们可以封装这个函数:

public static boolean isLeapMonth(int year, int month) {
  try {
    ChineseCalendar.of(EastAsianYear.forGregorian(year), EastAsianMonth.valueOf(month).withLeap(), 1);
    return true;
  }catch(Exception e) {
    return false;
  }
}

有了上面的函数,我们可以来判断某年是否有闰月,并且哪个月是闰月。

     for(int y=2010;y<=2020;y++) {
    	 for(int m=1;m<=12;m++) {
    		 if(isLeapMonth(y,m )) {
    			 System.out.printf("%d/%d is leap month.%n", y, m);
    		 }
    	 }
     }

判断一个月是大月还是小月

下面的代码判断2019年五月的天数,30天就是大月,29是小月。

ChineseCalendar date=ChineseCalendar.of(EastAsianYear.forGregorian(2019), EastAsianMonth.valueOf(5), 1);
System.out.print(date.getMaximum(ChineseCalendar.DAY_OF_MONTH));

Joda-Time

快速上手

在JDK8之前,Joda-Time是用于替代难用的java.util.Date和Calendar的最常见第三方库,JDK8的新date-time API参考了很多Joda-Time的内容。虽然JDK8之后建议使用jdk自带的date-time API,但是在很多老的项目里还是需要用到Joda-Time。注:如果开始一个新的项目,但是由于各种原因不能使用JDK8,那么建议使用ThreeTen,它提供了和JDK8兼容的API,这样如果哪天系统升级到JDK8,就不需要修改了。为什么叫这么奇怪的名字呢?因为新的date-time API是作为JSR 310来开发的(关于JSR有兴趣的读者可以参考这里,它类似Python的PEP,开发者在开发一个新的需求是会向社区广泛争取意见,这就是一个一个的JSR,开发通过后才会合并到JDK里),最后代码都合并到JDK8里。因为JSR的编号是310,因此这个后向兼容的库就叫ThreeTen(3-10)。

本节下面的内容主要参考了quick start

日期和时间

日期和时间是一个非常复杂的东西。Joda-Time里最常用的是如下五个类:

  • Instant - 不可以修改的表示一个时刻(瞬间)的类

  • DateTime - 替代JDK Calendar的一个不可修改的类

  • LocalDate - 不可修改的代表日期的类,无时区信息

  • LocalTime - 不可修改的表示时间的类,无时区信息

  • LocalDateTime - 不可修改的代表日期和时间的类,无时区信息

Instant适合于表示一个时间的时间戳,这和时区无关。LocalDate适合于表示生日,它不关心时间。LocalTime适合与表示一个商店的上下班时间。DateTime用于替代JDK的Calendar类,它包含时区信息。

使用日期和时间相关的类

上面所有的类都提供很多类型的构造函数,同时都接受通用的Object作为构造函数的参数,这样的目的是便于把各种类型转换成Joda-Time的对象。比如DateTime的构造函数可以使用如下类型的参数来构造:

  • Date
  • Calendar
  • String ISO8601格式的时间字符串
  • Long 1970-1-1开始的毫秒数
  • 任何其它的Joda-Time类

比如下面的代码把java.util.Date对象转换成DateTime:

  java.util.Date juDate = new Date();
  DateTime dt = new DateTime(juDate);

在Joda-Time里获取日期时间的某个属性非常方便,比如下面的代码:

  DateTime dt = new DateTime();
  int month = dt.getMonthOfYear();  // 和我们的常识一样,1代表1月;12代表12月
  int year = dt.getYear();

上面首先构造一个DateTime对象,它表示当前计算机的时间(包括时区信息)。使用getMonthOfYear()就可以得到现在是几月,2代表2月,这和Calendar里的1代表2月不同(这太违背常识了)。要获取当前的年份也很简单,使用getYear()函数。

Joda-Time的对象都是不可修改的,但是我们可以基于已有对象产生新的对象,比如:

  DateTime dt = new DateTime();
  DateTime year2000 = dt.withYear(2000);
  DateTime twoHoursLater = dt.plusHours(2);

上面的代码首先构造一个dt对象,表示当前的时间,然后用withYear修改年为2000,生成新的year2000对象。也可以用plusHours给dt对象增加两个小时,生成towHoursLater对象。

除了基本的get方法(比如getMonthOfYear),Joda-Time也提供属性方法(比如monthOfYear),它返回一个属性对象,这个对象可以提供更加多的功能,比如:

  DateTime dt = new DateTime();
  String monthName = dt.monthOfYear().getAsText();
  String frenchShortName = dt.monthOfYear().getAsShortText(Locale.FRENCH);
  boolean isLeapYear = dt.year().isLeap();
  DateTime rounded = dt.dayOfMonth().roundFloorCopy();

dt.getMonthOfYear()和dt.monthOfYear().get()是一样的,但是dt.monthOfYear()可以更多的方法,比如根据不同的Locale生成不同的文本。对于year()我们还可以调用isLeap判断是否闰年。

Calendar和时区

Joda-Time支持多种历法(可惜没有中国的农历)和时区。Chronology类DateTimeZone类分别用来支持历法和时区。

Joda-Time默认使用ISO历法系统(公历,ISO8601),但是1583年之前是不准确的,因为1583年罗马教皇才规定使用公历。注意:当然不同的国家和地区切换的具体时间都不相同。

Calendar使用继承的方式来扩展,比如使用GregorianCalendar来实现抽象类Calendar。但是不同的历法差别很大,很难有太多的共性,所以Joda-Time使用插件的方式来扩展。

比如我们要使用埃及历法,那么可以使用下面的代码:

 Chronology coptic = CopticChronology.getInstance();

时区是Chronology的一部分,我们可以在获取Chronology时传入时区(如果不传通常就是格林威治时间),比如:

  DateTimeZone zone = DateTimeZone.forID("Asia/Tokyo");
  Chronology gregorianJuian = GJChronology.getInstance(zone);

在构造GJChronology时使用东京的时区。GJChronology是世界上很多国家(主要是西方国家)使用的历法,它是Gregorian/Julian的组合,在1583年之前使用儒略(Julian)历,而1583年之后使用格里(Gregorian)历。格里历就是我们现在的公历,4年一闰,被100整除是有特殊的规则。而儒略历是4年一闰,而且在公元8年之前,闰年是不规则的人为的规定,而在更久远的公元前45年之前都没有历法(纪年)。我们在构造GJChronology可以传入两种历法的切换时间,默认是1582年10月15日,这是格里历首次使用的时间,但是不同的国家有不同的规定,具体参考wiki。ISOChronology实现的是ISO8601规范,基本就是格里历,但是它没有公元前这样的概念。公元1年对应的就是0001年,公元前1年就是0000年,公元前2年就是-0001年。

另外大家看文档时会发现和我们学的英语不同,Joda-Time使用CE和BCE代表公元和公元前,而不是BC和AD,其实它们是相同的意思。BC是Before Christ的缩写,也就是圣人诞生之前;而AD是Anno Domini的缩写,它是一个拉丁语,意思就是圣人诞生之年。因为BC和AD基督教气味太浓(凭什么圣人是耶稣而不是孔子或者老子?),所以有人使用CE和BCE。CE是Common Era的缩写,也就是公元的意思;而BCE是Before Common Era的缩写,也就是公元前的意思。

间隔(Interval)和时长(Duration)

间隔表示开始和结束两个时间,这其实没太多用处,所以JDK8没有这个类。Duration表示两个时刻的差值,用毫秒数来表示,这个数是给计算机读的。另外还有一个Period类,它是给人用的,比如两天后或者一个月后。注意Period的长度要依赖与起始时间,比如今天是1月31日,一个月后可能是2月28日,也可能是2月29日(闰年)。

下面是简单的示例代码:

  DateTime dt = new DateTime(2005, 3, 26, 12, 0, 0, 0);
  DateTime plusPeriod = dt.plus(Period.days(1));
  DateTime plusDuration = dt.plus(new Duration(24L*60L*60L*1000L));

当然,大部分情况下Period.days(1)和new Duration(24L60L60L*1000L)是相同的。但是对于很多欧洲国家实行夏令时,那么在切换的那天可能就要多出一个小时来。

注:理论上还有闰秒的影响,但是因为闰秒是人为规定的,无法用程序预先计算,因此大部分日历库包括Joda-Time多不支持闰秒。

简介

本部分内容主要参考了User Guide

Instant

Instant表示一个时刻,它是从1970-01-01T00:00Z到当前的毫秒数,这和JDK的Date以及Calendar是一致的。它是和时区无关的一个概念,因为不管是在英国还是在中国,当前的时刻(不考虑相对论)都是一样的。比如现在是北京时间2019-03-30T20:38:01秒xxx毫秒,而在格林威治的时间是2019-03-30T12:38:01秒xxx毫秒。虽然北京时间要早八个小时,但是这一瞬间距离1970-01-01T00:00Z的毫秒数都是一样的。

Field

我们可以从一个DateTime对象里获取不同的Field,比如年、月和日等,我们可以用getYear()、getMonthOfYear()和getDayOfMonth()来获得年、月和日。完整的Field类表在这里

Property

每个Field都对应一个Property。Property里有更详细的信息。比如getYear()返回当前年,而year()函数返回一个Property,我们可以用这个Property来查询是否闰年。

Interval

两个时刻,和大部分Java的区间概念类似,这是半开半闭的。

Duration

两个时刻的差值,其实就是毫秒数,可以是负数,表示前者比后者晚。

Period

前面介绍过,这是给人看的时间差,比如一秒、两小时、三天、四星期和五个月等等,它是与开始时刻(或者结束时刻)有关的。比如1月1日后的一个月是2月1日,而2月1日后的一个月是3月1日,但是这两个月的天数是不同的,前者是31天,而后者是28或者29天。

Chronology

不同的历法,默认是ISOChronology,这对于目前大部分国家来说都是使用的。但是前面也介绍过了,如果你在看某个欧洲的历史文献,要知道1853年之前的准确的时间(Instant)就不能用ISOChronology。

TimeZone

Joda-Time使用DateTimeZone来表示时区,我们可以用洲/城市来说明时区,比如:

DateTimeZone zone = DateTimeZone.forID("Europe/London");

因为中国现在是统一时区,因此北京和上海都是一样的东八区的北京时间:

DateTimeZone zoneSH = DateTimeZone.forID("Asia/Shanghai");

注意,中国有”Asia/Shanghai”和”Asia/Chongqing”等等,如果传入”Asia/Beijing”那么就会抛异常。身在帝都的码农可能要表示不服,但这是历史问题,参考这篇文章。Joda-Time的完整城市和时区的列表在这里

另外对于UTC时区,因为要经常使用,所以有一个特殊的静态变量:

DateTimeZone zoneUTC = DateTimeZone.UTC;

如果我们知道某个时区和UTC的时差(-12到12),也可以这样:

DateTimeZone zoneUTC = DateTimeZone.forOffsetHours(8);

上面仍然得到的是东八区的时区(也就是北京时间)。当然我们也可以得到当前系统的默认时区(这在不同的机器上会得到不同的结果):

DateTimeZone defaultZone = DateTimeZone.getDefault();

使用接口

在Java里,我们习惯使用接口,比如:

List<String> list = new ArrayList<>();

但是前面介绍过了,Joda-Time的设计不是通过基础而是使用插件,因此基类里没有太多有用的东西,我们通常直接使用对应的类,比如:

DateTime dt = new DateTime();

而不是使用:

ReadableDateTime dt = new DateTime();

DateTime

构造

无参数的构造函数得到当前时间(默认的当前时区,当然如果计算机的时区设置错了,比如你在中国把时区设置成UTC,但是时间又是设置成北京时间,那么你得到的时间比”真正”的时间要晚8小时。当然你可以把时区设置成UTC,时间也设置成UTC的时间,那么绝对时间是对的,但是你总不能对中国人说你在中国的睡觉时间是UTC时间下午3点吧)。

DateTime dt = new DateTime();

我们也可以传入标注的ISO8601时间串:

ateTime dt = new DateTime("2004-12-13T21:39:45.618-08:00");

注意:DateTime一定要说时区,否则没有意义。因为你说现在是九点,这是你所在的时区的九点,对于老外来说可能是别的时间。

和JDK对象的转换

和Date的转换:

    // from Joda to JDK
    DateTime dt = new DateTime();
    Date jdkDate = dt.toDate();

    // from JDK to Joda
    dt = new DateTime(jdkDate);

和Calendar的转换:

    // from Joda to JDK
    DateTime dt = new DateTime();
    Calendar jdkCal = dt.toCalendar(Locale.CHINESE);

    // from JDK to Joda
    dt = new DateTime(jdkCal);

因为Calendar需要Locale来显示不同的字符串,因此通常需要Locale,所以toCalendar有一个Locale的参数。如果传入null,则使用系统默认的Locale。

和GregorianCalendar的转换:

    // from Joda to JDK
    DateTime dt = new DateTime();
    GregorianCalendar jdkGCal = dt.toGregorianCalendar();

    // from JDK to Joda
    dt = new DateTime(jdkGCal);

查询日期和时间

比如:

int iDoW = dt.getDayOfWeek();

这里1表示周一,7表示周日。当然如果怕记错,也可以使用DateTimeConstants.SUNDAY。而之前的Calendar.SUNDAY是1,这是和很多人(至少中国人)的常识是不符合的,这是按照西方把周日当成一周的开始。但是Joda-Time这么搞某些西方人会不会抗议违反了他们的常识?

因此最好的办法还是不要这样:

if(dt.getDayOfWeek() == 7){
    // go to sleep
}

应该写成:

if(dt.getDayOfWeek() == DateTimeConstants.SUNDAY){
    // go to sleep
}

访问属性

属性里有更加详细的信息:

DateTime.Property pDoW = dt.dayOfWeek();
String strST = pDoW.getAsShortText(); // returns "Mon", "Tue", etc.
String strT = pDoW.getAsText(); // returns "Monday", "Tuesday", etc.

注:上面的输出依赖于默认的Locale。如果在作者的电脑上运行的话输出是”星期六”。我们也可以明确的指定Locale:

String strTF = pDoW.getAsText(Locale.FRENCH); // returns "Lundi", etc.

修改属性

我们可以把日期改到星期一:

DateTime result = dt.dayOfWeek().setCopy(DateTimeConstants.MONDAY);

上面会产生一个新的DateTime对象。把日期修改为星期一的含义是什么呢?如果今天是星期二,那么相当于减一天;今天是星期天,那么减6天。

下面的代码求三天后的日期:

DateTime result = dt.plusDays(3);

修改时区

    DateTime dt = new DateTime();
    DateTime dtLondon = dt.withZone(DateTimeZone.forID("Europe/London"));

它的输出是:

2019-03-30T21:47:40.755+08:00
2019-03-30T13:47:40.755Z

dt和dtLondon表示的时刻都是相同的,但是时区相差8小时。

格式化

格式化和SimpleDateFormat类似,详细文档参考这里

jdk8 date-time

这部分内容主要参考了官方教程

Overview

日期和时间看起来很简单,任何一个手表都能提供一定精度的日期和时间信息,但是如果仔细分析会发现它并不简单(当然不简单!对于物理学家来说最复杂而且最根本的问题就是时间和空间,他们会奇怪码农们怎么会认为时间很简单?)。比如给一个日期加一个月,有的时候是31天,有的时候是30天,甚至还有28天和29天的情况。时区让问题变得复杂,另外某些国家和地区实现夏令时(daylight saving,名字是为了节约能量,但是也有人认为并不能节约能量),这就使得问题更加复杂,某个时间可能不存在或者出现两次!

date-time API使用ISO-8601标准作为默认的历法系统,前面在介绍Joda-Time的时候我们以及介绍过了,这是目前很多国家使用的公历。这个历法的核心类是LocalDateTime、ZonedDateTime和OffsetDateTime类,如果你想使用其它历法,可以去java.time.chron包里找一找(可惜没有中国的农历,如果要处理农历请使用time4j,前面以及简单介绍过了)。

date-time API使用Unicode Common Locale Data Repository (CLDR),这里包含了许多Locale的信息,比如中文的Locale会使用星期五而不是Friday。对于时区(尤其是怎么把Asia/Shanghai变成东八区),主要使用了Time-Zone Database (TZDB),这个数据库包含了1970年之后的时区修改记录(注意:时区完全是人为规定的,也许某个政权发生变化或者选了个新总统都有可能修改,比如现在很多欧洲国家呼吁取消夏令时)。

设计原则

  • 清晰(Clear)

新的API的行为更加明确,比如调用是传入null通常会触发异常。

  • 流畅(Fluent)

新的API使用起来更加流畅。因为不能返回null,因此通常可以链式的调用,比如:

LocalDate today = LocalDate.now();
LocalDate payday = today.with(TemporalAdjusters.lastDayOfMonth()).minusDays(2);

看不懂没关系,后面我们会介绍,但是也许我们可以猜测出来它是找到这个月的倒数第三天。

  • 不可变(Immutable)

之前的Calendar包括SimpleDateFormat等由于可变导致不是线程安全,带来很多并发的问题。如果自己做线程的同步,又很费劲。新的API的大部分(常见)对象都是不可变的,这样可以放心大胆的随便传递。我们每次的”修改”都不会改变原来的对象,只是会产生新的对象,比如:

LocalDate dateOfBirth = LocalDate.of(2012, Month.MAY, 14);
LocalDate firstBirthday = dateOfBirth.plusYears(1);

这些”修改”的函数都是以of、from和with开头,并且所有的对象没有set方法。

  • 可扩展性(Extensible)

新的API容易扩展,比如你可以写自己的TemporalAdjusters,这个在后面会介绍。

主要的包(packages)

  • java.time

表示日期和时间的核心类都在这个包下。它包括日期、时间、日期时间、时区、时刻(instant)、间隔(duration)和时钟(clock)等。这个包的类都是基于ISO-8601规范实现。

  • java.time.chrono

ISO-8061之外的其它历法,你也可以自己实现(估计没人会有兴趣)。

  • java.time.format

日期时间的格式化和解析(parsing)。

  • java.time.temporal

扩展API,主要是给库的开发者用到。

  • java.time.zone

时区,处理时区我们通常需要ZonedDateTime、ZoneId和ZoneOffset这些类。

方法命名约定

前缀 类型 用法
of 静态工厂 创建一个实例,这个方法主要是检查输入参数而不是进行转换
from 静态工厂 把输入参数转换成另外一个对象,通常会丢失信息,比如把DateTime变成Date就丢掉了Time的信息
parse 静态工厂 把字符串表示parse成日期时间对象
format 成员函数 格式化输出
get 成员函数 返回这个对象的部分信息,比如返回一个Date的月份
is 成员函数 查询某个对象的某个状态,比如Year对象的isLeap查询是否闰年
with 成员函数 返回这个对象的拷贝,同时修改某一个属性
plus 成员函数 返回这个对象的拷贝,给某个字段增加一些时间
minus 成员函数 返回这个对象的拷贝,给某个字段减少一些时间
to 成员函数 把这个对象转换成另外一种类型的对象
at 成员函数 把这个对象和另外一个组合,比如Date.at(Time)就得到DateTime对象

标准历法

Date-Time API有两种类型的时间对象,一种是给机器用的;而另一种是给人用的。比如年、月、日、时、分和秒等就是给人用的时间;而相对于一个时间轴原点的时长(单位是纳秒),也叫epoch。通常我们会把时间轴的原点定义为1970-01-01T00:00:00.0,也就是UTC时间为1970年1月1日的零点零分零秒。

DayOfWeek枚举

DayOfWeek枚举包含七个常量,分别表示星期一到星期天。除了7个枚举值,它们还对应7个数值,分别是1到7,根据ISO的标准,1代表星期一而7代表星期天。

time4j

TODO

参考资料