Pular para o conteúdo

Trilha de Kotlin · parte 40 de 59

Serialização com kotlinx.serialization e Jackson

Aula da fase 6 da trilha de Kotlin: Quais as diferenças de comportamento com nulos e campos ausentes entre as duas bibliotecas?

intermediárioPublicada em · 16 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Imagine dois tradutores contratados para converter a mesma carta de um idioma para outro. O primeiro conhece o contexto da empresa: se uma palavra falta na carta, ele assume o termo mais provável e segue em frente. O segundo não arrisca nada: se falta uma palavra, devolve a carta com uma nota dizendo o que faltou. Os dois são bons, e a escolha entre eles depende do que você quer que aconteça quando a carta chega incompleta. Serialização é essa tradução entre um objeto Kotlin e um texto JSON, nos dois sentidos. Esta aula compara duas bibliotecas que fazem isso no ecossistema Kotlin e Spring: o Jackson, que é o padrão do Spring Boot, e o kotlinx.serialization, da JetBrains. A diferença aparece nos casos em que a carta chega incompleta, com campos ausentes, nulos ou desconhecidos.

Serializar é transformar um objeto em texto, e desserializar é o caminho inverso. Em um serviço HTTP, a serialização acontece em cada requisição e em cada resposta, e é por isso que os comportamentos com nulos e campos ausentes afetam diretamente o contrato da API. Um campo que some de um JSON pode ser um erro do cliente, uma versão antiga da API ou um valor que ninguém precisava enviar. A biblioteca decide o que fazer, e a aplicação precisa conhecer essa decisão antes de aceitar a dependência.

o mapa da aula

A aula começa pela escolha de qual biblioteca o Spring Boot usa por padrão, porque essa escolha determina o comportamento de todas as requisições sem nenhum código adicional. Depois apresenta o módulo Kotlin do Jackson e o modelo do kotlinx.serialization, com as citações oficiais. Em seguida mostra um teste em que as mesmas entradas passam pelas duas bibliotecas, com as saídas reais, incluindo dois casos que a primeira versão desta análise não tinha coberto: nulável sem valor padrão e tipo primitivo recebendo null. Por fim, a aula registra o que foi verificado no contexto do Spring e o que continua sem verificação.

Versões desta aula: Jackson 3.1.5, com o módulo Kotlin de mesma versão, kotlinx-serialization-json 1.11.0, Kotlin 2.3.21 e Spring Boot 4.1.1. São as versões gerenciadas pelo BOM. O plugin de serialização do compilador é o de mesma versão do Kotlin.

1. qual biblioteca o Spring usa

A documentação do Spring Boot é direta sobre a escolha padrão: “Jackson 3 is the preferred and default library.” O Jackson 2 continua disponível, mas está marcado como obsoleto: “Support for Jackson 2 is deprecated and will be removed in a future Spring Boot 4.x release. It is provided purely to ease the migration from Jackson 2 to Jackson 3 and should not be relied up in the longer term.” A mudança de versão também trocou o pacote: o Jackson 3 usa tools.jackson, e o Jackson 2 usa com.fasterxml.jackson. Por isso, código que importa classes do Jackson precisa dos imports da versão em uso, e um import antigo quebra a compilação antes de qualquer requisição.

Quando um projeto precisa do comportamento antigo, a documentação lista propriedades para pedir o Jackson 2. Para Spring MVC, a propriedade é spring.http.converters.preferred-json-mapper, e o valor jackson2 a faz preferir o Jackson 2. A aula citou essa propriedade a partir da documentação e não a testou.

O kotlinx.serialization entra no Spring por outro caminho. A documentação do Spring Boot diz que o módulo Kotlin do Jackson “is required for serializing / deserializing JSON data in Kotlin. It is automatically registered when found on the classpath”. Ou seja, o Jackson continua sendo o conversor HTTP padrão, e o kotlinx.serialization é uma segunda via. A seção 3 mostra o que o Spring cria quando essa segunda via está no classpath.

2. o módulo Kotlin do Jackson

O Jackson foi escrito para Java, e uma data class Kotlin tem particularidades: um construtor com nomes de parâmetros, valores padrão e tipos não nulos que o bytecode não expressa por completo. O módulo Kotlin do Jackson resolve isso. O README do projeto diz que “If a null is deserialized while the reified type is non-null, DatabindException is thrown instead of returning it.” Ou seja, um null explícito para um tipo não nulo vira erro. Sobre campos que não estão no construtor, o mesmo texto diz que “Any fields not present in the constructor will be set after the constructor call.”

O README também recomenda habilitar DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, porque um null para um primitivo pode virar o valor zero sem aviso. O teste desta aula mostra que, na versão 3.1.5, essa proteção já vem ligada por padrão, como a seção 4 detalha. A recomendação do README não precisa ser ativada manualmente nesta versão.

3. o modelo do kotlinx.serialization

O kotlinx.serialization é uma biblioteca da JetBrains que funciona em várias plataformas. A documentação diz que “The kotlinx.serialization libraries follow their own versioning, independent of Kotlin.” Os formatos ficam no grupo org.jetbrains.kotlinx, com nomes que começam por kotlinx-serialization-. O formato JSON está em kotlinx-serialization-json. As classes são marcadas com @Serializable, e o plugin do compilador gera o código de serialização.

O objeto Json define o comportamento com nulos e campos ausentes. O teste usou a configuração padrão, uma com ignoreUnknownKeys = true e, na escrita, uma com encodeDefaults = true. O objeto tem outras opções, que a aula não testou.

Há uma pergunta prática: quando o kotlinx.serialization entra em uma aplicação Spring, o framework cria um bean Json? A documentação de Kotlin do Spring Boot não descreve esse bean. Os metadados do auto-configuration do módulo spring-boot-kotlinx-serialization-json mostram a condição @ConditionalOnClass(kotlinx.serialization.json.Json). Para verificar, a aplicação de teste recebeu o módulo e a biblioteca no classpath, e um endpoint injetou um ObjectProvider<Json>. A resposta foi:

{"bean":"presente","ignoreUnknownKeys":"false"}

O bean existe com o módulo presente, e a configuração padrão deixa ignoreUnknownKeys como false. A aula não testou o caso sem o módulo. Mesmo assim, o Jackson continua sendo o conversor HTTP, e a presença do bean não muda o JSON de uma resposta. Quem quiser que o Spring use kotlinx.serialization nas requisições precisa de configuração adicional, que esta aula não testou.

Pergunta de entrevistaPor que usar o módulo Kotlin do Jackson ao serializar data classes?

O Jackson foi escrito para Java, e uma data class Kotlin tem particularidades que o bytecode não expressa por completo: construtor com nomes de parâmetros, valores padrão e tipos não nulos. O módulo Kotlin resolve isso. Segundo o README do projeto, se um null é desserializado quando o tipo é não nulo, é lançada uma exceção, em vez de o null entrar no objeto. Isso preserva a garantia de null safety na borda do sistema. O README também recomenda habilitar FAIL_ON_NULL_FOR_PRIMITIVES, porque um null para um primitivo pode virar zero sem aviso, e a aula viu que na versão testada essa proteção já vem ligada por padrão. Sem o módulo, o Jackson não conhece essas particularidades das data classes.

4. os mesmos casos nas duas bibliotecas

Para comparar, as duas bibliotecas leram as mesmas entradas, com classes de campos idênticos: id, cliente obrigatório, observacao nulável e cupom com valor padrão "NENHUM". O programa do Jackson usa JsonMapper com KotlinModule. O programa do kotlinx.serialization usa decodeFromString, com o plugin de serialização. A classe Pedido do Jackson tem observacao sem valor padrão, e a classe PedidoK do kotlinx tem = null. A diferença foi mantida de propósito, porque é uma diferença real de classe que muda o resultado.

O primeiro programa usa o Jackson 3.1.5 com o módulo Kotlin. Ele lê seis entradas, depois uma classe com observacao sem valor padrão, e depois duas configurações do mapper para o caso de primitivo recebendo null:

package aula.teste.v54

import tools.jackson.databind.DeserializationFeature
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.kotlin.KotlinModule

data class Pedido(val id: Int, val cliente: String, val observacao: String?, val cupom: String = "NENHUM")

// sem valor padrão no campo nulável
data class PedidoSemDefault(val id: Int, val cliente: String, val observacao: String?)

data class Primitivo(val id: Int, val quantidade: Int)

fun leitura(mapper: JsonMapper, json: String, tipo: Class<*>): String =
    try {
        mapper.readValue(json, tipo).toString()
    } catch (e: Exception) {
        "ERRO ${e::class.simpleName}: ${e.message?.lineSequence()?.first()}"
    }

fun main() {
    val mapper = JsonMapper.builder().addModule(KotlinModule.Builder().build()).build()
    val casos = listOf(
        "todos os campos" to """{"id":1,"cliente":"Ana","observacao":"frágil","cupom":"BEM10"}""",
        "nulo ausente" to """{"id":2,"cliente":"Bia"}""",
        "nulo explícito" to """{"id":3,"cliente":"Caio","observacao":null}""",
        "default ausente" to """{"id":4,"cliente":"Dani","observacao":null}""",
        "não nulo ausente" to """{"id":5,"observacao":null}""",
        "campo extra" to """{"id":6,"cliente":"Edu","observacao":null,"extra":1}""",
    )
    for ((nome, json) in casos) {
        println("[$nome] " + leitura(mapper, json, Pedido::class.java))
    }
    println("escrita: " + mapper.writeValueAsString(Pedido(7, "Fabi", null)))
    println("--- extras ---")
    println("[sem default, nulo ausente] " + leitura(mapper, """{"id":8,"cliente":"Gil"}""", PedidoSemDefault::class.java))
    println("[primitivo null, padrao] " + leitura(mapper, """{"id":9,"quantidade":null}""", Primitivo::class.java))
    val estrito = JsonMapper.builder()
        .addModule(KotlinModule.Builder().build())
        .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
        .build()
    println("[primitivo null, FAIL_ON_NULL_FOR_PRIMITIVES] " + leitura(estrito, """{"id":9,"quantidade":null}""", Primitivo::class.java))
    val permissivo = JsonMapper.builder()
        .addModule(KotlinModule.Builder().build())
        .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
        .build()
    println("[primitivo null, FAIL_ON_NULL_FOR_PRIMITIVES desligada] " + leitura(permissivo, """{"id":9,"quantidade":null}""", Primitivo::class.java))
    println("[campo extra, padrao do Boot-like] " + leitura(mapper, """{"id":10,"cliente":"Hugo","observacao":null,"extra":1}""", Pedido::class.java))
}
[todos os campos] Pedido(id=1, cliente=Ana, observacao=frágil, cupom=BEM10)
[nulo ausente] Pedido(id=2, cliente=Bia, observacao=null, cupom=NENHUM)
[nulo explícito] Pedido(id=3, cliente=Caio, observacao=null, cupom=NENHUM)
[default ausente] Pedido(id=4, cliente=Dani, observacao=null, cupom=NENHUM)
[não nulo ausente] ERRO KotlinInvalidNullException: Instantiation of [simple type, class aula.teste.v54.Pedido] value failed for JSON property cliente due to missing (therefore NULL) value for creator parameter cliente which is a non-nullable type
[campo extra] Pedido(id=6, cliente=Edu, observacao=null, cupom=NENHUM)
escrita: {"id":7,"cliente":"Fabi","observacao":null,"cupom":"NENHUM"}
--- extras ---
[sem default, nulo ausente] PedidoSemDefault(id=8, cliente=Gil, observacao=null)
[primitivo null, padrao] ERRO MismatchedInputException: Cannot map `null` into type `int` (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)
[primitivo null, FAIL_ON_NULL_FOR_PRIMITIVES] ERRO MismatchedInputException: Cannot map `null` into type `int` (set `DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES` to 'false' to allow)
[primitivo null, FAIL_ON_NULL_FOR_PRIMITIVES desligada] Primitivo(id=9, quantidade=0)
[campo extra, padrao do Boot-like] Pedido(id=10, cliente=Hugo, observacao=null, cupom=NENHUM)

O segundo programa usa o kotlinx.serialization 1.11.0 com as mesmas entradas, duas configurações de Json e o caso de nulável sem default e primitivo:

package aula.teste.v54

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class PedidoK(val id: Int, val cliente: String, val observacao: String? = null, val cupom: String = "NENHUM")

@Serializable
data class PedidoKSemDefault(val id: Int, val cliente: String, val observacao: String?)

@Serializable
data class PrimitivoK(val id: Int, val quantidade: Int)

fun leituraK(j: Json, json: String, serializer: kotlinx.serialization.KSerializer<*>): String =
    try {
        j.decodeFromString(serializer, json).toString()
    } catch (e: Exception) {
        "ERRO ${e::class.simpleName}: ${e.message?.lineSequence()?.first()}"
    }

fun main() {
    val padrao = Json
    val tolerante = Json { ignoreUnknownKeys = true }
    val casos = listOf(
        "todos os campos" to """{"id":1,"cliente":"Ana","observacao":"frágil","cupom":"BEM10"}""",
        "nulo ausente" to """{"id":2,"cliente":"Bia"}""",
        "nulo explícito" to """{"id":3,"cliente":"Caio","observacao":null}""",
        "default ausente" to """{"id":4,"cliente":"Dani","observacao":null}""",
        "não nulo ausente" to """{"id":5,"observacao":null}""",
        "campo extra" to """{"id":6,"cliente":"Edu","observacao":null,"extra":1}""",
    )
    for ((nome, json) in casos) {
        for ((modo, j) in listOf("padrao" to padrao, "ignoreUnknownKeys" to tolerante)) {
            println("[$nome | $modo] " + leituraK(j, json, PedidoK.serializer()))
        }
    }
    println("escrita padrao: " + padrao.encodeToString(PedidoK.serializer(), PedidoK(7, "Fabi", null)))
    println("escrita encodeDefaults=true: " + Json { encodeDefaults = true }.encodeToString(PedidoK.serializer(), PedidoK(7, "Fabi", null)))
    println("--- extras ---")
    println("[sem default, nulo ausente | padrao] " + leituraK(padrao, """{"id":8,"cliente":"Gil"}""", PedidoKSemDefault.serializer()))
    println("[primitivo null | padrao] " + leituraK(padrao, """{"id":9,"quantidade":null}""", PrimitivoK.serializer()))
}
[todos os campos | padrao] PedidoK(id=1, cliente=Ana, observacao=frágil, cupom=BEM10)
[todos os campos | ignoreUnknownKeys] PedidoK(id=1, cliente=Ana, observacao=frágil, cupom=BEM10)
[nulo ausente | padrao] PedidoK(id=2, cliente=Bia, observacao=null, cupom=NENHUM)
[nulo ausente | ignoreUnknownKeys] PedidoK(id=2, cliente=Bia, observacao=null, cupom=NENHUM)
[nulo explícito | padrao] PedidoK(id=3, cliente=Caio, observacao=null, cupom=NENHUM)
[nulo explícito | ignoreUnknownKeys] PedidoK(id=3, cliente=Caio, observacao=null, cupom=NENHUM)
[default ausente | padrao] PedidoK(id=4, cliente=Dani, observacao=null, cupom=NENHUM)
[default ausente | ignoreUnknownKeys] PedidoK(id=4, cliente=Dani, observacao=null, cupom=NENHUM)
[não nulo ausente | padrao] ERRO MissingFieldException: Field 'cliente' is required for type with serial name 'aula.teste.v54.PedidoK', but it was missing at path: $
[não nulo ausente | ignoreUnknownKeys] ERRO MissingFieldException: Field 'cliente' is required for type with serial name 'aula.teste.v54.PedidoK', but it was missing at path: $
[campo extra | padrao] ERRO JsonDecodingException: Unexpected JSON token at offset 43: Encountered an unknown key 'extra' at path: $
[campo extra | ignoreUnknownKeys] PedidoK(id=6, cliente=Edu, observacao=null, cupom=NENHUM)
escrita padrao: {"id":7,"cliente":"Fabi"}
escrita encodeDefaults=true: {"id":7,"cliente":"Fabi","observacao":null,"cupom":"NENHUM"}
--- extras ---
[sem default, nulo ausente | padrao] ERRO MissingFieldException: Field 'observacao' is required for type with serial name 'aula.teste.v54.PedidoKSemDefault', but it was missing at path: $
[primitivo null | padrao] ERRO JsonDecodingException: Unexpected JSON token at offset 21: Unexpected symbol 'n' in numeric literal at path: $.quantidade

A figura abaixo organiza a leitura lado a lado. Cada linha é uma entrada, e cada coluna é um leitor. As setas verdes indicam que o leitor aceitou a entrada, e as rosas indicam erro.

Mesma entrada, dois leitores de JSON. Sem o campo cliente → Jackson 3 (lê); Sem o campo cliente → kotlinx (lê); Campo extra → Jackson 3 (lê)…
figura 1 · Mesma entrada, dois leitores de JSON

A figura mostra o que cada biblioteca faz com a mesma entrada. Na primeira linha, o campo obrigatório cliente está ausente: as duas bibliotecas recusam, com exceções diferentes. Na segunda, o campo extra é aceito pelo Jackson e rejeitado pelo kotlinx, que só o aceita com ignoreUnknownKeys. Na terceira, observacao está ausente e a classe não tem valor padrão para ela. O Jackson devolve null, e o kotlinx exige o campo. Essa terceira diferença é a que mais pesa em contratos com campos opcionais: ela só aparece quando a classe não declara um padrão para o campo nulável.

Três resultados ficam para guardar. A ausência de um campo nulável sem valor padrão é aceita pelo Jackson e recusada pelo kotlinx. Um campo extra é ignorado pelo Jackson e recusado pelo kotlinx, a menos que ignoreUnknownKeys esteja ligado. Na escrita, o Jackson grava "observacao":null, e o kotlinx omite campos com valor igual ao padrão.

Pergunta de entrevistaComo o kotlinx.serialization se compara ao Jackson, e qual deles o Spring usa nas requisições HTTP?

O kotlinx.serialization é uma biblioteca da JetBrains que funciona em várias plataformas e tem versionamento próprio, independente do Kotlin. Marco as classes com @Serializable, e o formato JSON fica no módulo kotlinx-serialization-json. O objeto Json define o comportamento com nulos e campos ausentes, e na configuração padrão o ignoreUnknownKeys é false. Numa aplicação Spring, o Jackson continua sendo o conversor HTTP, mesmo quando o módulo do kotlinx.serialization está presente e o bean Json existe. Para usar o kotlinx.serialization nas requisições, preciso de configuração adicional, que a aula não testou. Num projeto Spring comum eu ficaria com o Jackson e reservaria o kotlinx.serialization para código multiplataforma.

5. detalhes e bordas

A ausência de um campo nulável é a borda mais sutil. O teste mostra que o resultado depende de a classe declarar um valor padrão. Com observacao: String? = null, o kotlinx aceita a ausência, e com observacao: String? sem padrão ele recusa. A regra segura é declarar o padrão explicitamente quando a ausência é esperada, e testar o caso em vez de confiar na leitura de que o campo é opcional.

O campo desconhecido é a borda mais perigosa em uma API que evolui. Se o Jackson ignora campos novos, um cliente antigo recebe a resposta sem problema. Se o kotlinx rejeita campos novos, um cliente novo que envia um campo que o servidor ainda não conhece recebe erro. A escolha depende de quem envia e de quem recebe.

Os primitivos merecem atenção à parte. O teste mostrou que, com o Jackson 3.1.5 e o módulo Kotlin, null para Int falha com MismatchedInputException, tanto com a flag ligada quanto com o padrão, e que desligar a flag produz 0 sem aviso. Ou seja, o valor zero silencioso só aparece quando alguém desliga explicitamente a proteção. O kotlinx também rejeita o null para Int, com JsonDecodingException. A recomendação do README do módulo, que fala em ativar a flag, descreve a proteção que já vem ligada nesta versão.

Esta verificação não passou pelo HttpMessageConverter do Spring. Os testes usaram JsonMapper e Json diretamente. A aplicação de verificação tem endpoints com @RequestBody, como o NovoPedido de /teste/corpo, mas a aula não enviou os casos acima como corpos de requisição. Por isso, a afirmação de que o conversor HTTP se comporta igual fica como inferência, e não como observação.

6. erros comuns e boas práticas

O erro mais frequente é misturar as duas bibliotecas no mesmo contrato sem decidir qual vale. Um endpoint que lê JSON pelo Jackson e um cliente que serializa pelo kotlinx.serialization podem concordar em quase todos os casos e discordar em campos desconhecidos ou em nulos sem padrão. A decisão deve ser escrita: qual biblioteca é a fonte do contrato, e como o outro lado trata os casos que a primeira rejeita.

O segundo erro é tratar null e campo ausente como a mesma coisa. Em uma atualização parcial, um null pode significar “apagar este valor”, e a ausência do campo pode significar “não mexa”. Nos testes desta aula, a ausência e o null explícito produziram o mesmo objeto Kotlin com o padrão declarado, e uma API que só recebe esse objeto perde a informação de qual dos dois o cliente enviou. Quando essa distinção importa, o campo precisa de um tipo próprio que a represente, e a aula não cobre essa modelagem.

O terceiro erro é deixar campos sensíveis saírem na serialização por padrão. Uma data class com um campo senha serializa esse campo se nada impedir. As duas bibliotecas oferecem formas de excluir um campo da saída, e a aula não as testou. A boa prática é tratar a saída como parte do contrato, com um DTO de resposta separado da entidade, para que a exclusão não dependa de uma anotação esquecida.

O quarto erro é aceitar ignoreUnknownKeys sem pensar. Ela resolve o problema de clientes com campos extras, mas também esconde erros de digitação no nome de um campo, que passam a ser ignorados sem aviso. A escolha deve ser consciente e registrada.

A boa prática é escolher uma biblioteca como padrão do projeto, escrever testes com JSON de entrada que cubram ausência, nulo com e sem padrão, primitivo nulo e campo extra, e revisar esses testes quando a biblioteca mudar de versão. A aula data class como entidade mostra como data class e entidades se misturam, e o mesmo cuidado de contrato vale para o JSON.

7. exercícios

  1. Adicione um campo prioridade: Int sem valor padrão à classe Pedido e rode os casos com o Jackson. Explique a mensagem de erro para o caso em que o campo falta.
  2. Troque observacao: String? por observacao: String? = null no Jackson e no kotlinx, e rode o caso “nulo ausente” de novo em cada um. Explique por que o resultado do kotlinx mudou.
  3. Escreva um JSON com "cupom": null e rode nas duas bibliotecas. Anote o resultado e diga se ele seria o esperado pela regra de negócio.

8. glossário

Serialização: conversão de um objeto em texto, geralmente JSON. A desserialização é o caminho inverso.

Jackson: biblioteca de JSON padrão do Spring Boot 4, que usa o pacote tools.jackson na versão 3.

Módulo Kotlin: extensão do Jackson que entende construtores, valores padrão e nulabilidade de classes Kotlin.

kotlinx.serialization: biblioteca de serialização da JetBrains, que gera código em tempo de compilação para classes marcadas com @Serializable.

Plugin de serialização: plugin do compilador Kotlin que processa as classes @Serializable.

ignoreUnknownKeys: opção do Json do kotlinx.serialization que aceita campos que a classe não declara.

Campo ausente: campo que não aparece no JSON. Não é o mesmo que um campo com valor null, e a leitura de cada biblioteca depende de a classe declarar um padrão.

fontes

Consultadas em 20/10/2026:

Verificações locais, feitas em 20/10/2026 com Temurin 21.0.8: os dois programas foram compilados com o Kotlin 2.3.21 e executados com jackson-databind 3.1.5, jackson-module-kotlin 3.1.5, kotlinx-serialization-json 1.11.0 e kotlin-reflect 2.3.21. O bean Json foi verificado com a aplicação de verificação, que recebeu o módulo spring-boot-kotlinx-serialization-json e um endpoint que injeta ObjectProvider<Json>. As saídas são as reais.

Não verificado nesta aula: o comportamento dentro do HttpMessageConverter do Spring com corpos JSON reais, o efeito de spring.http.converters.preferred-json-mapper (citado da documentação, não testado), as demais opções do objeto Json, e o comportamento de ObjectProvider<Json> quando o módulo não está no classpath.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?