基本概念

在处理时间相关的问题的时候,首先需要明确几个基本概念:时间戳,绝对时间和相对时间。

时间戳在广义上是指,能够唯一地标识某一刻的时间。在狭义上(下文中的时间戳都按狭义的概念理解)就是一个long类型的值,指代某一个时刻。比如在Java和其他一些语言里面long timestamp = 0L,就表示0时区里1970年1月1日0时0分0秒。相应换算成我们常用的北京时间(+08:00)的话,就是1970年1月1日8时0分0秒。

这里要说的绝对时间主要是指,long类型和带时区的字符串类型。比如0L和”1970-1-1 00:00:00+00:00”,这样就能唯一确定一个时刻。相应的,”1970-1-1 00:00:00”就是一个相对时间。如果不提供时区信息,就无法准确定位某一个时刻。

在日常的开发中,往往需要做一些时间相关的处理,比如用户输入了一个时间戳,想把它转化为一个“可读”的日期字符等。

Joda基本用法

Joda-Time provides a quality replacement for the Java date and time classes.

在jdk1.7以前,java内置的时间处理相关的类功能比较弱,还有一些并发上的问题,所以多用joda这个库中所提供的API来操作时间。

Maven仓库

<dependency>
  <groupId>joda-time</groupId>
  <artifactId>joda-time</artifactId>
  <version>2.10.1</version>
</dependency>

类和主要API说明

Joda中最常用的类有3个。

  • Instant - 封装了时间戳的对象
  • DateTimeZone - 时区类
  • DateTime - full date and time with time-zone

Instant

import org.joda.time.DateTime;
import org.joda.time.Instant;

// 获取当前系统时间戳
Instant now = Instant.now();

// 从给定的long类型数字构造事假戳对象
Instant instant = Instant.ofEpochMilli(0);

// 获取这个对象保存的时间戳
instant.getMillis();

// 转化为DateTime对象
DateTime dateTime = instant.toDateTime();

// 从一个字符串中按ISO格式解析并构造时间戳对象
Instant newYear1 = Instant.parse("2019-1-1T00:00:00+08:00");

// 从一个字符串中按自定义格式解析并构造时间戳对象
Instant newYear2 = Instant.parse("2019.1.1T00:00:00+08:00", DateTimeFormat.forPattern("yyyy.MM.dd'T'HH:mm:ssZZ"));

DateTimeZone

import org.joda.time.DateTimeZone;

// 获取当前默认的时区
DateTimeZone now = DateTimeZone.getDefault();

// 都是构造+08:00时区
DateTimeZone bejing = DateTimeZone.forID("+08:00");
DateTimeZone shanghai =DateTimeZone.forID("Asia/Shanghai");

// 获取DateTimeZone对象保存的时区信息
System.out.println(shanghai.getID());

// 获取所有可用的时区
for(String zone : DateTimeZone.getAvailableIDs()) {
    System.out.println(zone);
}

DateTime

import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.format.DateTimeFormat;

// 用系统当前时间戳和默认时区构造
DateTime dateTime1 = new DateTime();
DateTime dateTime5 = DateTime.now();

// 用给定时间戳和默认时区构造
DateTime dateTime2 = new DateTime(0);

// 用给定时间戳和给定的时区构造
DateTime dateTime3 = new DateTime(0, DateTimeZone.getDefault());

// 用系统当前时间戳和给定的时区构造
DateTime dateTime6 = DateTime.now(DateTimeZone.getDefault());

// 从一个字符串中按ISO格式解析并构造DateTime对象
DateTime dateTime7 = DateTime.parse("2019-1-1T00:00:00+08:00");

// 从一个字符串中按自定义格式解析并构造DateTime对象
DateTime dateTime8 = DateTime.parse("2019.1.1T00:00:00+08:00", DateTimeFormat.forPattern("yyyy.MM.dd'T'HH:mm:ssZZ"));

// 转化为时间戳
long timestamp = dateTime8.getMillis();

// 获取保存的时区
DateTimeZone zone = dateTime8.getZone();

// 按ISO格式转化成字符串
System.out.println(dateTime8.toString());

// 按自定义格式转化成字符串
System.out.println(dateTime8.toString("yyyy.MM.dd'T'HH:mm:ssZZ"));
System.out.println(dateTime8.toString(DateTimeFormat.forPattern("yyyy/MM/dd'T'HH:mm:ssZZ")));

Joda in JDK1.8

在JDK1.8之后,Joda就已经作为官方标准加入到了Java内部类中,放置在java.time包下,但名字和用法发生了相应的变化。

Joda-Time is the de facto standard date and time library for Java prior to Java SE 8. Users are now asked to migrate to java.time (JSR-310).

其中主要的变化有两个。

DateTimeZone一份为二

  • ZoneId - 和DateTimeZone用法和作用范围基本一致,API的名字略微变化
  • ZoneOffset - ZoneId的子集,只能接受+08:00的格式,不支持Asia/Shanghai的格式
// 将ZoneId转化为ZoneOffset
public static ZoneOffset toZoneOffset(ZoneId zoneId) {
    return zoneId.getRules().getOffset(Instant.now());
}

DateTime一分为三

  • LocalDateTime - 只提供,不带时区的相对时间(比如”2019-1-1T00:00:00”)相关的处理。如果让想要从一个带时区的字符串中构造LocalDateTime,将报错
  • ZonedDateTime - 只提供,带时区的时间(比如”2019-1-1T00:00:00+08:00”)相关的处理。如果让想要从一个不带时区的字符串中构造ZonedDateTime,将报错
  • OffsetDateTime - ZonedDateTime的子集,也只能处理带时区的字符串,而且时区的格式接受+08:00的格式,不支持Asia/Shanghai的格式

ZonedDateTimeOffsetDateTime都可以接受一个时间戳作为构造函数的参数,但需要先手动封装成java.time.Instant对象。

可以看到,将DateTime分解之后,通用性降低了,用户需要知道自己的字符串是不是带时区的,才能选择对应的类进行处理,不像Joda中直接用DateTime就可以了。虽然降低了解析出错的可能性,但是确实给编程的增加了很多麻烦。

多种日期格式的识别

假如能够保证输入的日期是带时区的,但是格式可能是”yyyy.MM.dd’T’HH:mm:ssZZ”,也可能是”yyyy/MM/dd HH:mm:ssZZ”,怎么优雅地解析这些字符串呢?

从下面的代码中可以看到,先用DateTimeFormatter构造若干种基本的模式(’2011-12-03’,’2011/12/03’,’10:15:30’),然后再把它们组合起来。

public class DatetimeUtils {
	// such as '2011-12-03'.
    public static final DateTimeFormatter ISO_LOCAL_DATE_WIDTH_1_2;
    static {
    	ISO_LOCAL_DATE_WIDTH_1_2 = new DateTimeFormatterBuilder()
                .appendValue(ChronoField.YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
                .appendLiteral('-')
                .appendValue(ChronoField.MONTH_OF_YEAR, 1, 2, SignStyle.NEVER)
                .appendLiteral('-')
                .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NEVER)
                .toFormatter();
    }

	// such as '2011/12/03'.
    public static final DateTimeFormatter ISO_LOCAL_DATE_WITH_SLASH;
    static {
    	ISO_LOCAL_DATE_WITH_SLASH = new DateTimeFormatterBuilder()
                .appendValue(ChronoField.YEAR, 4, 10, SignStyle.EXCEEDS_PAD)
                .appendLiteral('/')
                .appendValue(ChronoField.MONTH_OF_YEAR, 1, 2, SignStyle.NEVER)
                .appendLiteral('/')
                .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NEVER)
                .toFormatter();
    }

	// such as '10:15:30' or '10:15:30.123'.
    public static final DateTimeFormatter ISO_LOCAL_TIME_WITH_MS;
    static {
    	ISO_LOCAL_TIME_WITH_MS = new DateTimeFormatterBuilder()
                .appendValue(ChronoField.HOUR_OF_DAY, 2)
                .appendLiteral(':')
                .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
                .appendLiteral(':')
                .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
                .optionalStart()
                .appendLiteral('.')
                .appendValue(ChronoField.MILLI_OF_SECOND, 3)
                .optionalEnd()
                .toFormatter();
    }

	// such as '2011-12-03T10:15:30+01:00' or '2011-12-03T10:15:30.123+01:00'.
    public static final DateTimeFormatter ISO_OFFSET_DATE_TIME_WITH_MS;
    static {
    	ISO_OFFSET_DATE_TIME_WITH_MS = new DateTimeFormatterBuilder()
                .parseCaseInsensitive()
                .append(ISO_LOCAL_DATE_WIDTH_1_2)
                .appendLiteral('T')
                .append(ISO_LOCAL_TIME_WITH_MS)
                .appendOffsetId()
                .toFormatter();
    }

	// such as '2011/12/03T10:15:30+01:00' or '2011/12/03T10:15:30.123+01:00'.
    public static final DateTimeFormatter ISO_OFFSET_DATE_TIME_WITH_SLASH;
    static {
    	ISO_OFFSET_DATE_TIME_WITH_SLASH = new DateTimeFormatterBuilder()
                .parseCaseInsensitive()
                .append(ISO_LOCAL_DATE_WITH_SLASH)
                .appendLiteral('T')
                .append(ISO_LOCAL_TIME_WITH_MS)
                .appendOffsetId()
                .toFormatter(); 
    }

    public static final DateTimeFormatter formatter = new DateTimeFormatterBuilder()		
			 // such as '2011-12-03T10:15:30+01:00' or '2011-12-03T10:15:30.123+01:00'.
			.appendOptional(ISO_OFFSET_DATE_TIME_WITH_MS)
			 // such as '2011/12/03T10:15:30+01:00' or '2011/12/03T10:15:30.123+01:00'.
			.appendOptional(ISO_OFFSET_DATE_TIME_WITH_SLASH)
			.toFormatter();

	public static long convertDatetimeStrToLong(String str) {
		ZonedDateTime zonedDateTime = ZonedDateTime.parse(str, formatter);
		return zonedDateTime.toInstant().toEpochMilli();
	}
}

小坑

UTC时区

做项目的时候发现了一个容易忽略的问题,如果自己试一下下面的代码会发现,所有表示为”+00:00”的ZoneOffset的toString()方法返回的都是”Z”,而不是预想中的”+00:00”。

因此如果通过解析ZoneOffset的数字,计算和0时区差多少小时的时候,要特别注意它自己是不是零时区。

  // convert ZoneId to ZoneOffset
  public static ZoneOffset toZoneOffset(ZoneId zoneId) {
    return zoneId.getRules().getOffset(Instant.now());
  }
  
  public static void main(String[] args){
    for(String zoneId : ZoneId.getAvailableZoneIds()){
      System.out.println(zoneId + ": " + DatetimeUtils.toZoneOffset(ZoneId.of(zoneId)));
    }
  }
  
--------------------

...
Africa/Casablanca: Z
Europe/Belfast: Z
America/Fortaleza: -03:00
Etc/GMT0: Z
Africa/Dakar: Z
Jamaica: -05:00
Africa/Bissau: Z
Asia/Tehran: +03:30
WET: Z
Europe/Astrakhan: +04:00
UCT: Z
EET: +02:00
Etc/UTC: Z
...

参考

  1. Joda官网,https://www.joda.org/joda-time/index.html#
  2. Oracle官网文档,https://docs.oracle.com/javase/8/docs/api/java/time/package-summary.html