Думаю, многие согласятся, что работа с датами практически в любом языке программирования это невероятная головная боль. Дата и время не десятичны, часовые пояса — господи, почему они не могут быть просто целыми — ну и конечно бесчисленное множество форматов даты и времени. Можно, конечно, возразить, что мол есть ISO 8601 и чего тебе дураку ещё надо, но давайте, положа руку на сердце, скажем — как часто вам доводилось сталкиваться с соблюдением этого стандарта в сторонних API? Не знаю, как с этим обстоят дела за рубежом, надеюсь, в комментариях мне подскажут, но на постсоветских просторах ситуация — обнять и плакать. Каждый использует свой собственный, только ему удобный, формат времени и даты и разбирайся с этим как хочешь.


Я расскажу про собственный опыт и про найденное решение.


Проблема, с которой я столкнулся, не выглядела так уж и ужасно, формат даты в API, с которым мне предстояло взимодействовать, выглядел так: YYYY-MM-DD hh:mm:ss. Смотрится вроде нормально, не так ли?


Нет.


Абсолютно ненормально.


Те, кто знаком с форматом ISO 8601, уже поняли в чем подвох. Для наглядности:


package main

import (
    "encoding/json"
    "log"
    "time"
)

type Dated struct {
    DateTime time.Time
}

func main() {
    input := []byte("{\"datetime\": \"1900-01-01 12:00:04\"}")
    var d Dated
    err := json.Unmarshal(input, &d)
    if err != nil {
        log.Fatal(err)
    }
}

В результате что? Правильно, ошибка:


parsing time ""1900-01-01 12:00:04"" as ""2006-01-02T15:04:05Z07:00"": cannot parse " 12:00:04"" as "T"

Что логично, ведь в Go из коробки есть ограниченное количество форматов времени и даты,
которые он может распарсить:


ANSIC       = "Mon Jan _2 15:04:05 2006"
UnixDate    = "Mon Jan _2 15:04:05 MST 2006"
RubyDate    = "Mon Jan 02 15:04:05 -0700 2006"
RFC822      = "02 Jan 06 15:04 MST"
RFC822Z     = "02 Jan 06 15:04 -0700" // RFC822 with numeric zone
RFC850      = "Monday, 02-Jan-06 15:04:05 MST"
RFC1123     = "Mon, 02 Jan 2006 15:04:05 MST"
RFC1123Z    = "Mon, 02 Jan 2006 15:04:05 -0700" // RFC1123 with numeric zone
RFC3339     = "2006-01-02T15:04:05Z07:00"
RFC3339Nano = "2006-01-02T15:04:05.999999999Z07:00"
Kitchen     = "3:04PM"
// Handy time stamps.
Stamp      = "Jan _2 15:04:05"
StampMilli = "Jan _2 15:04:05.000"
StampMicro = "Jan _2 15:04:05.000000"
StampNano  = "Jan _2 15:04:05.000000000"

Отсюда: https://golang.org/src/time/format.go


Как вы можете заметить, формата YYYY-MM-DD hh:mm:ss там нет. Окей, беда понятна, но что делать то?


На самом деле проблема решается достаточно несложно, достаточно вспомнить, как в Go работает json.Unmarshall. А работает он просто — у каждого поля структуры, в которую мы пытаемся смапить наш JSON, он ищет соответствие в нашем наборе байт и, если находит, смотрит на тип поля структуры и вызывает у этого типа функцию UnmasrhallJSON, в которую
передаёт найденное значение.


Соответственно, чтобы нам распарсить нестандартную дату, надо задать для поля кастомный
тип, который будет реализовывать интерфейс Unmarshaler, то бишь иметь собственную функцию UnmasrhallJSON:


type CustomDate struct {
    time.Time
}

func (c *CustomDate) UnmarshalJSON(b []byte) (err error) {
    layout := "2006-01-02 15:04:05"

    s := strings.Trim(string(b), "\"") // remove quotes
    if s == "null" {
        return
    }
    c.Time, err = time.Parse(layout, s)
    return
}

type Dated struct {
    DateTime CustomDate
}

Ну и чтоб два раза не вставать, давайте добавим кастомный маршалер, нам ведь рано или поздно придется грузить данные в ту API, откуда мы их взяли, а значит, надо их снова привести к их нестандартному формату. В итоге всё вместе будет выглядеть так:


package main

import (
    "encoding/json"
    "fmt"
    "log"
    "strings"
    "time"
)

type CustomDate struct {
    time.Time
}

const layout = "2006-01-02 15:04:05"

func (c *CustomDate) UnmarshalJSON(b []byte) (err error) {
    s := strings.Trim(string(b), `"`) // remove quotes
    if s == "null" {
        return
    }
    c.Time, err = time.Parse(layout, s)
    return
}

func (c CustomDate) MarshalJSON() ([]byte, error) {
    if c.Time.IsZero() {
        return nil, nil
    }
    return []byte(fmt.Sprintf(`"%s"`, c.Time.Format(layout))), nil
}

type Dated struct {
    DateTime CustomDate
}

func main() {
    input := []byte("{\"datetime\": \"1900-01-01 12:00:04\"}")
    var d Dated
    err := json.Unmarshal(input, &d)
    if err != nil {
        log.Fatal(err)
    }
    log.Println("Unmarshal:")
    log.Println(d.DateTime)

    b, err := json.Marshal(d)
    if err != nil {
        log.Fatal(err)
    }
    log.Println("Marshal:")
    log.Println(string(b))
}

Обратите внимание на сигнатуру функций Marshal/Unmarshal. Они разные — MarchalJSON принимает значение, а UnmarshalJSON — ссылку

Результат:


Unmarshal:
1900-01-01 12:00:04 +0000 UTC
Marshal:
{"DateTime":"1900-01-01T12:00:04Z"}

Вот, собственно, и всё.


Резюмируя


Парсинг нестарндартных дат в Go — дело нетривиальное, но несложное, достаточно написать собственный маршаллер/анмаршаллер, в зависимости от того, какую цель вы преследуете; также этот подход поможет справиться не только с датами — так можно успешно парсить любой нестандартный контент.


Если вы готовы предложить другие интересные способы решения этой проблемы — буду рад увидеть их в комментариях. Ну и в заключение хочу пожелать вам пореже сталкиваться с нестандартными форматами чего бы то ни было.


Всем спасибо.