55

我正在用 Swift 的 Codable 替换我的旧 JSON 解析代码,并且遇到了一些障碍。我想这不是一个可编码的问题,而是一个 DateFormatter 问题。

从结构开始

 struct JustADate: Codable {
    var date: Date
 }

和一个json字符串

let json = """
  { "date": "2017-06-19T18:43:19Z" }
"""

现在让我们解码

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

let data = json.data(using: .utf8)!
let justADate = try! decoder.decode(JustADate.self, from: data) //all good

但是,如果我们更改日期以使其具有小数秒,例如:

let json = """
  { "date": "2017-06-19T18:43:19.532Z" }
"""

现在它坏了。日期有时会以小数秒返回,有时则不会。我用来解决它的方法是在我的映射代码中,我有一个转换函数,它尝试使用和不使用小数秒的 dateFormats。但是,我不太确定如何使用 Codable 来处理它。有什么建议么?

4

4 回答 4

86

您可以使用两种不同的日期格式化程序(带和不带小数秒)并创建自定义 DateDecodingStrategy。如果在解析 API 返回的日期时失败,您可以按照@PauloMattos 在评论中的建议抛出 DecodingError:

iOS 9、macOS 10.9、tvOS 9、watchOS 2、Xcode 9 或更高版本

自定义ISO8601 DateFormatter:

extension Formatter {
    static let iso8601withFractionalSeconds: DateFormatter = {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"
        return formatter
    }()
    static let iso8601: DateFormatter = {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
        return formatter
    }()
}

习俗DateDecodingStrategy

extension JSONDecoder.DateDecodingStrategy {
    static let customISO8601 = custom {
        let container = try $0.singleValueContainer()
        let string = try container.decode(String.self)
        if let date = Formatter.iso8601withFractionalSeconds.date(from: string) ?? Formatter.iso8601.date(from: string) {
            return date
        }
        throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
    }
}

习俗DateEncodingStrategy

extension JSONEncoder.DateEncodingStrategy {
    static let customISO8601 = custom {
        var container = $1.singleValueContainer()
        try container.encode(Formatter.iso8601withFractionalSeconds.string(from: $0))
    }
}

编辑/更新

Xcode 10 • Swift 4.2 或更高版本 • iOS 11.2.1 或更高版本

ISO8601DateFormatter现在支持formatOptions .withFractionalSeconds

extension Formatter {
    static let iso8601withFractionalSeconds: ISO8601DateFormatter = {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return formatter
    }()
    static let iso8601: ISO8601DateFormatter = {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime]
        return formatter
    }()
}

海关DateDecodingStrategyDateEncodingStrategy上图一样。


// Playground testing
struct ISODates: Codable {
    let dateWith9FS: Date
    let dateWith3FS: Date
    let dateWith2FS: Date
    let dateWithoutFS: Date
}

let isoDatesJSON = """
{
"dateWith9FS": "2017-06-19T18:43:19.532123456Z",
"dateWith3FS": "2017-06-19T18:43:19.532Z",
"dateWith2FS": "2017-06-19T18:43:19.53Z",
"dateWithoutFS": "2017-06-19T18:43:19Z",
}
"""

let isoDatesData = Data(isoDatesJSON.utf8)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .customISO8601

do {
    let isoDates = try decoder.decode(ISODates.self, from: isoDatesData)
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWith9FS))   // 2017-06-19T18:43:19.532Z
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWith3FS))   // 2017-06-19T18:43:19.532Z
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWith2FS))   // 2017-06-19T18:43:19.530Z
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWithoutFS)) // 2017-06-19T18:43:19.000Z
} catch {
    print(error)
}
于 2017-09-27T23:37:18.017 回答
4

斯威夫特 5

要将 ISO8601 字符串解析为日期,您必须使用 DateFormatter。在较新的系统(例如 iOS11+)中,您可以使用 ISO8601DateFormatter。

只要您不知道日期是否包含毫秒,您就应该为每种情况创建 2 个格式化程序。然后,在将 String 解析为 Date 期间,因此使用两者。

旧系统的 DateFormatter

/// Formatter for ISO8601 with milliseconds
lazy var iso8601FormatterWithMilliseconds: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.timeZone = TimeZone(abbreviation: "GMT")
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"

    return dateFormatter
}()

/// Formatter for ISO8601 without milliseconds
lazy var iso8601Formatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.timeZone = TimeZone(abbreviation: "GMT")
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"

    return dateFormatter
}()

适用于较新系统的 ISO8601DateFormatter(例如 iOS 11+)

lazy var iso8601FormatterWithMilliseconds: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()

    // GMT or UTC -> UTC is standard, GMT is TimeZone
    formatter.timeZone = TimeZone(abbreviation: "GMT")
    formatter.formatOptions = [.withInternetDateTime,
                               .withDashSeparatorInDate,
                               .withColonSeparatorInTime,
                               .withTimeZone,
                               .withFractionalSeconds]

    return formatter
}()

/// Formatter for ISO8601 without milliseconds
lazy var iso8601Formatter: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()

    // GMT or UTC -> UTC is standard, GMT is TimeZone
    formatter.timeZone = TimeZone(abbreviation: "GMT")
    formatter.formatOptions = [.withInternetDateTime,
                               .withDashSeparatorInDate,
                               .withColonSeparatorInTime,
                               .withTimeZone]

    return formatter
}()

概括

如您所见,需要创建 2 个格式化程序。如果你想支持旧系统,它有 4 个格式化程序。为了更简单,请查看GitHub 上的 Tomorrow,您可以在其中查看此问题的完整解决方案。

要将字符串转换为日期,请使用:

let date = Date.fromISO("2020-11-01T21:10:56.22+02:00")

于 2020-04-05T18:05:05.517 回答
3

一个新选项(从 Swift 5.1 开始)是 Property Wrapper。CodableWrappers库有一个简单的方法来处理这个问题

对于默认 ISO8601

@ISO8601DateCoding 
struct JustADate: Codable {
    var date: Date
 }

如果您想要自定义版本:

// Custom coder
@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
public struct FractionalSecondsISO8601DateStaticCoder: StaticCoder {

    private static let iso8601Formatter: ISO8601DateFormatter = {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = .withFractionalSeconds
        return formatter
    }()

    public static func decode(from decoder: Decoder) throws -> Date {
        let stringValue = try String(from: decoder)
        guard let date = iso8601Formatter.date(from: stringValue) else {
            throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected date string to be ISO8601-formatted."))
        }
        return date
    }

    public static func encode(value: Date, to encoder: Encoder) throws {
        try iso8601Formatter.string(from: value).encode(to: encoder)
    }
}
// Property Wrapper alias
public typealias ISO8601FractionalDateCoding = CodingUses<FractionalSecondsISO8601DateStaticCoder>

// Usage
@ISO8601FractionalDateCoding
struct JustADate: Codable {
    var date: Date
 }
于 2019-10-28T15:35:44.233 回答
0

作为@Leo 答案的替代方案,如果您需要为较旧的操作系统提供支持(ISO8601DateFormatter仅从 iOS 10、mac OS 10.12 开始可用),您可以编写一个自定义格式化程序,在解析字符串时使用这两种格式:

class MyISO8601Formatter: DateFormatter {

    static let formatters: [DateFormatter] = [
        iso8601Formatter(withFractional: true),
        iso8601Formatter(withFractional: false)
        ]

    static func iso8601Formatter(withFractional fractional: Bool) -> DateFormatter {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss\(fractional ? ".SSS" : "")XXXXX"
        return formatter
    }

    override public func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?,
                                 for string: String,
                                 errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
        guard let date = (type(of: self).formatters.flatMap { $0.date(from: string) }).first else {
            error?.pointee = "Invalid ISO8601 date: \(string)" as NSString
            return false
        }
        obj?.pointee = date as NSDate
        return true
    }

    override public func string(for obj: Any?) -> String? {
        guard let date = obj as? Date else { return nil }
        return type(of: self).formatters.flatMap { $0.string(from: date) }.first
    }
}

,您可以将其用作日期解码策略:

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())

虽然在实现上有点丑陋,但它的优点是与 Swift 在出现格式错误的数据时抛出的解码错误一致,因为我们没有改变错误报告机制)。

例如:

struct TestDate: Codable {
    let date: Date
}

// I don't advocate the forced unwrap, this is for demo purposes only
let jsonString = "{\"date\":\"2017-06-19T18:43:19Z\"}"
let jsonData = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())
do {
    print(try decoder.decode(TestDate.self, from: jsonData))
} catch {
    print("Encountered error while decoding: \(error)")
}

将打印TestDate(date: 2017-06-19 18:43:19 +0000)

添加小数部分

let jsonString = "{\"date\":\"2017-06-19T18:43:19.123Z\"}"

将产生相同的输出:TestDate(date: 2017-06-19 18:43:19 +0000)

但是使用不正确的字符串:

let jsonString = "{\"date\":\"2017-06-19T18:43:19.123AAA\"}"

如果数据不正确,将打印默认的 Swift 错误:

Encountered error while decoding: dataCorrupted(Swift.DecodingError.Context(codingPath: [__lldb_expr_84.TestDate.(CodingKeys in _B178608BE4B4E04ECDB8BE2F689B7F4C).date], debugDescription: "Date string does not match format expected by formatter.", underlyingError: nil))
于 2018-01-16T20:02:41.480 回答