JSON Serialization

16 junho, 2020

Durante o desenvolvimento de aplicativos é muito comum realizar uma comunicação com informações externas via rede. Para estabelecer esta comunicação é necessário formatar os dados tanto para enviá-los quanto para recebê-los. Em Swift esse processo é conhecido como Encodable e Decodable.

Segundo a documentação:

  • Encodable: Um tipo que pode se codificar para uma representação externa.
  • Decodable: Um tipo que pode decodificar a partir de uma representação externa.

Uma maneira de utilizar ambos protocolos em uma mesma entidade é conformar com o alias Codable. O Codable foi introduzido no Swift 4 como substituto ao NSCoding. Portanto, esse tipo permite codificar e decodificar para formatos diferentes.

Ao informar que uma entidade conforma com estes protocolos é necessário que todas as suas propriedades também conformem com o protocolo indicado. Muitos tipos comuns do Swift Standard Library e Foundation são codable por padrão. Quando houver um tipo customizado, é necessário que este também se conforme com o protocolo.

No exemplo a seguir podemos verificar o erro emitido pelo Xcode quando tentamos conformar o tipo Student com Codable. Como o tipo School não tem essa especificação, Student não está em total conformidade. Os demais tipo, Int e String, não geram erros pois são importados do framework Foundation:

Disconformed Protocol

Encoding

Como vimos, o protocolo Encodable permite o tipo para ser enviado à uma camada externa do aplicativo. É possível codificar nossos tipos para diversos formatos, como JSON, XML e Plist.

Para preparar nosso tipo com o formato JSON podemos utilizar JSONEncoder da seguinte maneira:

struct Student: Codable {
    var id: Int
    var name: String
    var school: School
}

struct School: Codable {
    var name: String
}

let school = School(name: "Swift School")
let student = Student(id: 1, name: "Steve Jobs", school: school)

let encoder = JSONEncoder()
let data = try encoder.encode(student)

É possível visualizar como está a representação JSON ao converter para String:

print(String(data: data, encoding: .utf8)!)
{
	"id": 1,
	"name": "Steve Jobs",
	"school": {
		"name": "Swift School"
	}
}

Como podemos ver, os dados são aninhados conforme estrutura declarada nas structs.

Decoding

Para realizar o processo inverso, ou seja, para decodificar instâncias de um tipo de dados a partir de objeto JSON, podemos utilizar o JSONDecoder.

Nesse processo precisamos especificar o tipo para a conversão e a origem dos dados. No exemplo abaixo foi utilizado um JSON hardcoded mas a origem pode ser de qualquer lugar.

let json = """
{
    "id": 2,
    "name": "Tim Cook",
    "school": {
        "name": "Swift School"
    }
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
let studentFromJson = try decoder.decode(Student.self, from: json)

Ao manipularmos o objeto que recebeu a decodificação podemos perceber que este possui todas as propriedades das structs.

print("Objeto estudante: \(studentFromJson)")
print("Nome estudante: \(studentFromJson.name)")
print("Escola do estudante: \(studentFromJson.school.name)")

Chaves JSON personalizadas

Para realizar os processos de envio e recebimento das informações é necessário que as chaves dos campos estejam corretas, caso contrário, ou a API ou o nosso aplicativo não reconhecerá o dado trafegado.

Afim de evitar refatoração futura de campos, podemos definir chaves de codificação personalizadas para as propriedades. Esse processo é feito ao definir o enum que conforme com o protocolo CodingKeys:

extension Student {
    enum CodingKeys: String, CodingKey {
        case id
        case name = "nome"
        case school = "escola"
    }
}

extension School {
    enum CodingKeys: String, CodingKey {
        case name = "nome"
    }
}

Todos os campos da struct devem estar listados como case. Quando esse enum existir apenas esses cases que serão utilizados na codificação e decodificação, portanto, mesmo que alguma propriedade não exija mapeamento, ela deverá ser incluída.

Após estas modificações a visualização do JSON será assim:

{
	"id": 1,
	"nome": "Steve Jobs",
	"escola": {
		"nome": "Swift School"
	}
}

Modificando hierarquia JSON

Também é possível alterar a hierarquia do JSON sem modificar nosso modelo de estrutura. Essa modificação possibilita criar hierarquias simples às mais complexas com aninhamentos.

Encodable

Em codificação, é necessário implementar o método encode(to:) para especificar a estrutura. Esse processo é baseado na criação de um dicionário do tipo KeyedEncodingContainer para armazenar as propriedades.

O exemplo abaixo mostra a alteração para uma estrutura direta sem o aninhamento anterior:

extension Student {
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(id, forKey: .id)
        try container.encode(school.name, forKey: .school)
    }
}

A exibição do JSON fica dessa forma:

{
	"id": 1,
	"nome": "Steve Jobs",
	"escola": "Swift School"
}

Quando necessário aumentar o nível do aninhamento podemos utilizar o método nestedContainer(keyedBy:forKey:). Para instanciar esse método é obrigatório informar um enum que também conforme com o protocolo CodingKey e também informar a chave de posicionamento do novo aninhamento.

No exemplo abaixo, alteramos o método encode(to:) para exibir um novo grupo de escolas:

extension Student {

    enumSchoolKeys: String, CodingKey {
        case best = "melhores_escolas"
    }

    func encode(to encoder: Encoder) throws {
        ...
        var schoolContainer = container.nestedContainer(keyedBy:SchoolKeys.self, forKey: .school)
        try schoolContainer.encode(school, forKey: .best)
    }
}

O resultado do JSON será assim:

{
	"id": 1,
	"nome": "Steve Jobs",
	"escola": {
		"melhores_escolas": {
			"nome": "Swift School"
		}
	}
}

Decodable

No processo de decodificação devemos implementar as modificações no construtor init(from:). Ou seja, como este é o ponto de entrada dos dados é onde devemos modificar a estrutura.

Para recebermos a seguinte estrutura:

{
    "id": 1,
    "nome": "Steve Jobs",
    "escola": "Swift School"
}

Devemos modificar a entrada dessa forma:

extension Student {
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        id = try container.decode(Int.self, forKey: .id)
        let schoolName = try container.decode(String.self, forKey: .school)
        school = School(name: schoolName)
    }
}

Para aumentar o nível do aninhamento na entrada dos dados pode utilizar o método nestedContainer(keyedBy:forKey:):

Para adaptar à entrada do JSON abaixo:

{
	"id": 1,
	"nome": "Steve Jobs",
	"escola": {
		"melhores_escolas": {
			"nome": "Swift School"
		}
	}
}

O init deve ficar dessa forma:

extension Student {
    init(from decoder: Decoder) throws {
        ...

        let schoolContainer = try container.nestedContainer(keyedBy:SchoolKeys.self, forKey: .school)
        school = try schoolContainer.decode(School.self, forKey: .best)
    }
}

Datas com Codable

Pode-se dizer que, lidar com datas em programação é um sofrimento para a maiorida dos desenvolvedores. Principalmente quando é necessário trafegar datas entre cliente e servidor. O importante nesse case é atender ao fato que o envio e recebimento de datas é feito como String, mas para haver a comunicação, deve ser estabelecido um formatado para esta informação.

Portanto, vamos adicionar um novo campo com data ao JSON:

{
	"id": 1,
    "nome": "Steve Jobs",
    "data_nascimento" : "24-02-1955",
	"escola": {
		"nome": "Swift School"
	}
}

Para facilitar a manipulação, podemos extender a classe DateFormatter com o formato de data do JSON. No exemplo abaixo é utilizado uma propriedade para facilitar seu reuso:

extension DateFormatter {
    static let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateFormat = "dd-MM-yyyy"
        return formatter
    }()
}

Essa propriedade deve ser vinculada ao JSONEncoder e JSONDecoder nas propriedades .dateEncodingStrategy e .dateDecodingStrategy, respectivamente:

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(.dateFormatter)

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