Java 小记 - 时间的处理与探究

前言

时间的处理与日期的格式转换几乎是所有应用的基础职能之一,几乎所有的语言都会为其提供基础类库。作为曾经 .NET 的重度使用者,赖其优雅的语法,特别是可扩展方法这个神级特性的存在,我几乎没有特意关注过这些个基础类库,他们如同空气一般,你呼吸着,却不用感受其所在何处。煽情结束,入坑 Java 后甚烦其时间处理方式,在此做个总结与备忘。

主题图片

1. Date 制造的麻烦

1.1 SimpleDateFormat 存在的问题

初级阶段,我仍对基础类库保留着绝对的信任,时间类型毫不犹豫地使用了 Date,并且使用 SimpleDateFormat 类去格式化日期,介于项目中会频繁使用他们,我做了类似如下的封装:

public class DateUtils {
    public static SimpleDateFormat DATE_FORMAT;

    static {
        DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");
    }

    public static String getFormatDate(Date date) {
       return DATE_FORMAT.format(date);
    }

    public static Date parseSimpleDate(String strDate) throws ParseException {
        return DATE_FORMAT.parse(strDate);
    }
}

单元测试跑过之后我便如数应用了:

@Test
public void formatDateTest() throws ParseException {
    Date date = DateUtils.parseSimpleDate("2018-07-12");
    boolean result = DateUtils.getFormatDate(date).equals("2018-07-12");

    Assert.assertTrue(result);
}

然而项目上线后频繁报 java.lang.NumberFormatException 异常,被好一顿吐槽,一查资料才知道 SimpleDateFormat 竟然是线程不安全的。看了下源码,定位到问题所在:

protected Calendar calendar;
// Called from Format after creating a FieldDelegate
private StringBuffer format(Date date, StringBuffer toAppendTo,
                            FieldDelegate delegate) {
    // Convert input date to time field list
    calendar.setTime(date);

    boolean useDateFormatSymbols = useDateFormatSymbols();

    for (int i = 0; i < compiledPattern.length; ) {
        int tag = compiledPattern[i] >>> 8;
        int count = compiledPattern[i++] & 0xff;
        if (count == 255) {
            count = compiledPattern[i++] << 16;
            count |= compiledPattern[i++];
        }

        switch (tag) {
        case TAG_QUOTE_ASCII_CHAR:
            toAppendTo.append((char)count);
            break;

        case TAG_QUOTE_CHARS:
            toAppendTo.append(compiledPattern, i, count);
            i += count;
            break;

        default:
            subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
            break;
        }
    }
    return toAppendTo;
}

血槽已空,就这么使用了内部变量,回过头看下了注释,人家早已友情提示,呵呵:

/**
 * Date formats are not synchronized.
 * It is recommended to create separate format instances for each thread.
 * If multiple threads access a format concurrently, it must be synchronized
 * externally.
 */

单元测试中复现:
SimpleDateFormat 多线程测试

1.2 SimpleDateFormat 线程不安全的解决方案

最简单,最不负责任的方法就是加锁:

synchronized (DATE_FORMAT) {
    return DATE_FORMAT.format(strDate);
}

因格式化日期常会应用在列表数据的遍历处理中,弃之。还有一种较好的解决方案就是线程内独享,代码修改如下:

public class DateUtils {

    private static ThreadLocal<DateFormat> THREAD_LOCAL = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

    public static String getFormatDate(Date date) {
        return THREAD_LOCAL.get().format(date);
    }

    public static Date parseSimpleDate(String strDate) throws ParseException {
        return THREAD_LOCAL.get().parse(strDate);
    }
}

看起来还算不错,兼顾了线程安全与效率,但,好死不死的,当初把 DATE_FORMAT 定义为 public,并且在某些特殊的场景中直接使用了该静态变量,总之不能通过只改一个工具类解决所有问题,左右都是麻烦,于是乎干脆直接抛弃 SimpleDateFormatorg.apache.commons.lang3.time.DateFormatUtils 取而代之,更改代码如下:

public class DateUtils  extends org.apache.commons.lang3.time.DateUtils {

    public static String DATE_PATTERN = "yyyy-MM-dd";

    public static String getFormatDate(Date date) {
        return DateFormatUtils.format(date, DATE_PATTERN);
    }

    public static Date parseSimpleDate(String strDate) throws ParseException {
        return parseDate(strDate, DATE_PATTERN);
    }
}

1.3 烦人的 Calendar

除了日期格式的转换,应用中的对时间处理的另一大需求就是计算,感激 org.apache.commons.lang3.time.DateUtils 这个工具类为我们做了绝大部分的封装,能想到的一些基础的计算都可以直接 “无脑” 使用了。但有时仍然免不了要传 Calendar 中的各种参数进入,特别是那一堆烦人的常量:

public final static int ERA = 0;
public final static int YEAR = 1;
public final static int MONTH = 2;
public final static int WEEK_OF_YEAR = 3;
public final static int WEEK_OF_MONTH = 4;
public final static int DATE = 5;
public final static int DAY_OF_MONTH = 5;
public final static int DAY_OF_YEAR = 6;
public final static int DAY_OF_WEEK = 7;
public final static int DAY_OF_WEEK_IN_MONTH = 8;
public final static int AM_PM = 9;
public final static int HOUR = 10;
public final static int HOUR_OF_DAY = 11;
public final static int MINUTE = 12;
public final static int SECOND = 13;
public final static int MILLISECOND = 14;
public final static int ZONE_OFFSET = 15;
public final static int DST_OFFSET = 16;
public final static int FIELD_COUNT = 17;
public final static int SUNDAY = 1;
public final static int MONDAY = 2;
public final static int TUESDAY = 3;
public final static int WEDNESDAY = 4;
public final static int THURSDAY = 5;
public final static int FRIDAY = 6;
public final static int SATURDAY = 7;
public final static int JANUARY = 0;
public final static int FEBRUARY = 1;
public final static int MARCH = 2;
public final static int APRIL = 3;
public final static int MAY = 4;
public final static int JUNE = 5;
public final static int JULY = 6;
public final static int AUGUST = 7;
public final static int SEPTEMBER = 8;
public final static int OCTOBER = 9;
public final static int NOVEMBER = 10;
public final static int DECEMBER = 11;
public final static int UNDECIMBER = 12;
public final static int AM = 0;
public final static int PM = 1;
public static final int ALL_STYLES = 0;

可能存在即合理,我定然是没有资格评判什么,我只是不喜欢。大而全的方法固然得存在,但是不是得和常用的方案区别开呢,或许会有声音说:“你可以自己动手抽离呀”,是啊,例:

public static Date getBeginOfMonth(Date date) {
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);

    calendar.set(Calendar.DATE, 1);
    calendar.set(Calendar.HOUR_OF_DAY, 0);
    calendar.set(Calendar.MINUTE, 0);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);

    return calendar.getTime();
}

public static Date getEndOfMonth(Date date) {
    Calendar calendar = Calendar.getInstance();
    calendar.setTime(date);

    calendar.set(Calendar.DATE, calendar.getActualMaximum(Calendar.DATE));
    calendar.set(Calendar.HOUR_OF_DAY, calendar.getActualMaximum(Calendar.HOUR_OF_DAY));
    calendar.set(Calendar.MINUTE, calendar.getActualMaximum(Calendar.MINUTE));
    calendar.set(Calendar.SECOND, calendar.getActualMaximum(Calendar.SECOND));
    calendar.set(Calendar.MILLISECOND, calendar.getActualMaximum(Calendar.MILLISECOND));

    return calendar.getTime();
}

即使这些代码只会存在于工具类中,但,只能说不喜欢吧,他们不该从我的手里写出来。

2. Instant 的救赎

Java8 中新增的日期核心类如下:

Instant
LocalDate
LocalTime
LocalDateTime

其余的还有一些时区的以及计算相关的类会在后续的代码示例中提及,这儿主要说下 Instant,查看源码可看到其仅包含两个关键字段:

/**
    * The number of seconds from the epoch of 1970-01-01T00:00:00Z.
    */
private final long seconds;
/**
    * The number of nanoseconds, later along the time-line, from the seconds field.
    * This is always positive, and never exceeds 999,999,999.
    */
private final int nanos;


/**
    * Gets the number of seconds from the Java epoch of 1970-01-01T00:00:00Z.
    * <p>
    * The epoch second count is a simple incrementing count of seconds where
    * second 0 is 1970-01-01T00:00:00Z.
    * The nanosecond part of the day is returned by {@code getNanosOfSecond}.
    *
    * @return the seconds from the epoch of 1970-01-01T00:00:00Z
    */
public long getEpochSecond() {
    return seconds;
}

/**
    * Gets the number of nanoseconds, later along the time-line, from the start
    * of the second.
    * <p>
    * The nanosecond-of-second value measures the total number of nanoseconds from
    * the second returned by {@code getEpochSecond}.
    *
    * @return the nanoseconds within the second, always positive, never exceeds 999,999,999
    */
public int getNano() {
    return nanos;
}

秒和纳秒组合的绝对时间差不多是现在公认的最好的时间处理方式了吧,全世界各地的绝对时间都是相同的,所以可以先把烦人的时区还有那矫情的夏令时丢一边,是一个非常好的中间值设计。

2.1 Instant 与 LocalDateTime 的互转

由于 Instant 不包含时区信息,因此转换时需要指定时区,我们来看看以下示例:

@Test
public void timeZoneTest() {
    Instant instant = Instant.now();

    LocalDateTime timeForChina = LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Shanghai"));
    LocalDateTime timeForAmerica = LocalDateTime.ofInstant(instant, ZoneId.of("America/New_York"));
    long dif = Duration.between(timeForAmerica, timeForChina).getSeconds() / 3600;

    Assert.assertEquals(dif, 12L);
}

上海用的是东八区的时间,纽约用的是西五区的时间,地理时差应为 13 个小时,但美国使用了夏令时,因此实际时差为 12 个小时,以上单元测试能通过证明 LocalDateTime 已经帮帮我们处理了夏令时问题。源代码如下:

public static LocalDateTime ofInstant(Instant instant, ZoneId zone) {
    Objects.requireNonNull(instant, "instant");
    Objects.requireNonNull(zone, "zone");
    ZoneRules rules = zone.getRules();
    ZoneOffset offset = rules.getOffset(instant);
    return ofEpochSecond(instant.getEpochSecond(), instant.getNano(), offset);
}
...

可看出获取时间偏移量的关键类为:ZoneRules,由此反过来转换也非常简单,参照源码中的写法:

@Test
public void instantTest() {

    LocalDateTime time = LocalDateTime.parse("2018-07-13T00:00:00");
    ZoneRules rules = ZoneId.of("Asia/Shanghai").getRules();
    Instant instant = time.toInstant(rules.getOffset(time));

    LocalDateTime timeBack = LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Shanghai"));

    Assert.assertEquals(time, timeBack);
}

2.2 再谈格式化

新增的日期格式转换类为 DateTimeFormatter,虽然还达不到如 C# 那般随心所欲,但至少是线程安全了,可以放心使用,其次,好歹也预置了几个常用的格式模板,为对其进一步地封装提供了一些便利性。

常用的字符串转日期方式如下:

@Test
public void parse() {
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
    LocalDateTime time = LocalDateTime.parse("2018-07-13 12:05:30.505",formatter);
    System.out.println(time);
}

以上示例代码哟有三点让人十分不爽,其一,我需要获得的是一个时间类型,他不存在格式的问题,显式指定模板接而进行转换看起来很傻;其二,时间的显示格式是正则轻易可穷尽的,就那么几种,还需要显式传入模板,看起来很傻;其三,LocalDateTime.parse() 不支持 LocalDate 格式的模板,看起来很傻;

因此我对其做了一个简易的封装,示例如下:

public class DateUtils {

    public static HashMap<String, String> patternMap;

    static {
        patternMap = new HashMap<>();

        // 2018年7月13日 12时5分30秒,2018-07-13 12:05:30,2018/07/13 12:05:30
        patternMap.put("^\\d{4}\\D+\\d{2}\\D+\\d{2}\\D+\\d{2}\\D+\\d{2}\\D+\\d{2}\\D+\\d{3}\\D*$",
                "yyyy-MM-dd-HH-mm-ss-SSS");
        patternMap.put("^\\d{4}\\D+\\d{2}\\D+\\d{2}\\D+\\d{2}\\D+\\d{2}\\D+\\d{2}\\D*$",
                "yyyy-MM-dd-HH-mm-ss");
        patternMap.put("^\\d{4}\\D+\\d{2}\\D+\\d{2}\\D*$", "yyyy-MM-dd");

        // 20180713120530
        patternMap.put("^\\d{14}$", "yyyyMMddHHmmss");
        patternMap.put("^\\d{8}$", "yyyyMMdd");
    }

    public static LocalDateTime parse(String text) {

        for (String key : patternMap.keySet()) {
            if (Pattern.compile(key).matcher(text).matches()) {

                DateTimeFormatter formatter = DateTimeFormatter.ofPattern(patternMap.get(key));
                text = text.replaceAll("\\D+", "-")
                        .replaceAll("-$", "");

                return parse(formatter, text);
            }
        }
        throw new DateTimeException("can't match a suitable pattern!");
    }

    public static LocalDateTime parse(DateTimeFormatter formatter, String text) {

        TemporalAccessor accessor = formatter.parseBest(text,
                LocalDateTime::from,
                LocalDate::from);

        LocalDateTime time;
        if (accessor instanceof LocalDate) {
            LocalDate date = LocalDate.from(accessor);
            time = LocalDateTime.of(date, LocalTime.MIDNIGHT);
        } else {
            time = LocalDateTime.from(accessor);
        }

        return time;
    }
}

测试:

@Test
public void parse() {
    String[] array = new String[]{
            "2018-07-13 12:05:30",
            "2018/07/13 12:05:30.505",
            "2018年07月13日 12时05分30秒",
            "2018年07月13日 12时05分30秒505毫秒",
            "2018-07-13",
            "20180713",
            "20180713120530",
    };

    System.out.println("-------------------------");
    for (String s : array) {
        System.out.println(DateUtils.parse(s));
    }
    System.out.println("-------------------------");
}

以上示例应该够满足大部分的应用场景了,有特殊的情况出现继而往 patternMap 中添加即可。

反过来日期转字符串,这时候传入 pattern 是说的过去的,因为对此场景而言显示格式成为了核心业务,例:

@Test
public void format() {
    LocalDateTime time = LocalDateTime.of(2018, 7, 13, 12, 5, 30);
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");

    Assert.assertEquals(time.format(formatter), "2018-07-13 12:05:30.000");
}

当然,对于常用的格式也应当封装入工具类中。

结语

到此暂时告一段落,后续若有遇到更复杂的应用场景再继续补充。这篇文章拖了好几天才匆匆收尾,期间经历了一些事情可谓是...,前些阶段接二连三地上了好几个 java 项目,本以为紧绷的弦可以松一松,喘口气,花点时间做些总结,写写文章。

哪晓得,项目上线的第二天公司倒闭了,来了一堆特勤又是盘问又是查封,接着所有员工全部遣散回家。那几天整个很懵X,所有管理层没有一个出来解释情况(ps:貌似进去的进去,失联的失联),网上一爬才晓得互金界集体暴雷,然后,就完了。昨日助其黄袍加身,今日恨不能解其体,食其肉,一夜之间灰飞烟灭,我也算亲眼见识到了这幅场景。正好卡在交社保的关头,看着炸锅的维权群和各类小道消息,归就一声长叹。

无奈又得重新出发,虽可借塞翁失马宽慰之,但终免不了些许落寞。若君有良机,肯请告知~(能吃苦,自我驱动强,学习能力尚可😅),微信:youclk。


我的公众号《有刻》,我们共同成长!

posted @ 2018-07-18 15:01  捷义  阅读(1689)  评论(16编辑  收藏  举报