如何在 Swift 中使用 Codable 转换带有可选小数秒的日期字符串?
How to convert a date string with optional fractional seconds using Codable in Swift?
我正在用 Swift 的 Codable 替换旧的 JSON 解析代码,运行 遇到了一些麻烦。我想这与其说是一个 Codable 问题,不如说是一个 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 来处理它。有什么建议吗?
您可以使用两种不同的日期格式化程序(带和不带小数秒)并创建自定义 DateDecodingStrategy。如果解析 API 返回的日期失败,您可以按照@PauloMattos 在评论中的建议抛出 DecodingError:
iOS 9,macOS 10.9,tvOS 9,watchOS 2,Xcode 9 或更高版本
自定义 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 [=11=].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 = .singleValueContainer()
try container.encode(Formatter.iso8601withFractionalSeconds.string(from: [=12=]))
}
}
edit/update:
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
}()
}
习俗DateDecodingStrategy
和DateEncodingStrategy
与上图相同
// 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)
}
替代@Leo 的回答,如果您需要为旧的 OS 提供支持(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 { [=10=].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 { [=10=].string(from: date) }.first
}
}
,您可以将其用作日期解码策略:
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())
虽然在实现中有点丑陋,但它的优点是与 Swift 在格式错误的数据情况下抛出的解码错误保持一致,因为我们不改变错误报告机制)。 =19=]
例如:
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))
一个新选项(从 Swift 5.1 开始)是一个 属性 包装器。 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
}
Swift 5
要将 ISO8601 字符串解析为日期,您必须使用 DateFormatter。在较新的系统中(f.ex。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(f.ex。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 个格式化程序。为了使它更简单,请查看 Tomorrow on GitHub,您可以在其中查看此问题的完整解决方案。
要将字符串转换为日期,您使用:
let date = Date.fromISO("2020-11-01T21:10:56.22+02:00")
我正在用 Swift 的 Codable 替换旧的 JSON 解析代码,运行 遇到了一些麻烦。我想这与其说是一个 Codable 问题,不如说是一个 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 来处理它。有什么建议吗?
您可以使用两种不同的日期格式化程序(带和不带小数秒)并创建自定义 DateDecodingStrategy。如果解析 API 返回的日期失败,您可以按照@PauloMattos 在评论中的建议抛出 DecodingError:
iOS 9,macOS 10.9,tvOS 9,watchOS 2,Xcode 9 或更高版本
自定义
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 [=11=].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 = .singleValueContainer()
try container.encode(Formatter.iso8601withFractionalSeconds.string(from: [=12=]))
}
}
edit/update:
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
}()
}
习俗DateDecodingStrategy
和DateEncodingStrategy
与上图相同
// 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)
}
替代@Leo 的回答,如果您需要为旧的 OS 提供支持(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 { [=10=].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 { [=10=].string(from: date) }.first
}
}
,您可以将其用作日期解码策略:
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())
虽然在实现中有点丑陋,但它的优点是与 Swift 在格式错误的数据情况下抛出的解码错误保持一致,因为我们不改变错误报告机制)。 =19=]
例如:
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))
一个新选项(从 Swift 5.1 开始)是一个 属性 包装器。 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
}
Swift 5
要将 ISO8601 字符串解析为日期,您必须使用 DateFormatter。在较新的系统中(f.ex。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(f.ex。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 个格式化程序。为了使它更简单,请查看 Tomorrow on GitHub,您可以在其中查看此问题的完整解决方案。
要将字符串转换为日期,您使用:
let date = Date.fromISO("2020-11-01T21:10:56.22+02:00")