Java 小記 - 時間的處理與探究

前言

時間的處理與日期的格式轉換幾乎是全部應用的基礎職能之一,幾乎全部的語言都會爲其提供基礎類庫。做爲曾經 .NET 的重度使用者,賴其優雅的語法,特別是可擴展方法這個神級特性的存在,我幾乎沒有特地關注過這些個基礎類庫,他們如同空氣通常,你呼吸着,卻不用感覺其所在何處。煽情結束,入坑 Java 後甚煩其時間處理方式,在此作個總結與備忘。java

主題圖片

1. Date 製造的麻煩

1.1 SimpleDateFormat 存在的問題

初級階段,我仍對基礎類庫保留着絕對的信任,時間類型堅決果斷地使用了 Date,而且使用 SimpleDateFormat 類去格式化日期,介於項目中會頻繁使用他們,我作了相似以下的封裝:apache

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;
}

血槽已空,就這麼使用了內部變量,回過頭看下了註釋,人家早已友情提示,呵呵:app

/**
 * 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 取而代之,更改代碼以下:ui

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 中的各類參數進入,特別是那一堆煩人的常量:spa

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");
}

固然,對於經常使用的格式也應當封裝入工具類中。


個人公衆號《捷義》
qrcode_for_gh_c1a4cd5ae0fe_430.jpg

相關文章
相關標籤/搜索