JSON Javas
Sissejuhatus
Java standardteek ei toeta JSON-it. JSON-i lugemiseks ja kirjutamiseks on vaja välist teeki, mis tegeleb töötlemise ja objektide loomisega. Java ökosüsteemis domineerivad kaks teeki selleks:
- Jackson - Spring ökosüsteemi vaikevalik, hästi konfigureeritav, toetab lisaks JSON-ile ka muid andmetüüpe.
- Gson - Google poolt arendatud, lihtsa API ja kerge õpikõveraga.
Mõlemad toetavad objektide mappimist ehk JSON-i ja Java objektide automaatset teisendamist. Selles peatükis käsitleme mõlemat. Põhimõte on mõlemal sama, erineb ainult API.
Mapper
Mapper on komponent, mille ainus ülesanne on teisendada andmete ühte esitust teiseks - samad andmed, erinev kuju.
Siin peatükis toimiub mappimine kahel tasemel:
- JSON mapper tegeleb tehnilise teisendusega JSON-teksti ja Java objektide vahel.
- Domeeni mapper tegeleb teisendusega DTO ja domeeniobjekti vahel, mis rakendab ärireegleid.
Teek haldab esimest taset automaatselt.
Teise taseme puhul saab kasutada ObjectMapper.convertValue() meetodit, kui väljade nimed kattuvad ja konstruktoris puudub valideerimisloogika.
Samas kui argumente tulkeb valideerida või klass ise kasutab final-välju, tuleb teisendus ise kirjutada.
Sõltuvuse lisamine
Kuna kumbki teekidest pole Java standardteegi osa, tuleb need käsitsi lisada. Juhendi selleks asuvad JavaDocis:
- Väliste teekide lisamine (ITI0202 kursuse jaoks soovituslik)
- Maven
- Gradle
- Sõltuvuste haldamine
Miks domeeniobjekte ei saa sageli otse mappida
Nii Jackson kui ka Gson loovad Java objekte JSON-ist, kutsudes välja parameetriteta konstruktori ja määrates väljade väärtused otse. See töötab lihtsate klasside peal, millel on avalikud või ligipääsetavad väljad ning parameetriteta konstruktor.
Domeeniobjektid rikuvad seda sageli mitmel viisil:
finalvälju ei saa pärast konstrueerimist muuta- Valideerivad konstruktorid ei sobi parameetriteta lähenemisega
- Mõned väljad täidetakse alles pärast objekti loomist eraldi meetodikutsetega
- Konstruktori signatuur ei vasta JSON-i väljade nimedele
Lahenduseks on sisemine DTO - lihtne, map'itav klass, mida kasutatakse ainult serialiseerimiseks, ilma äriloogika, valideerimise ja final väljadeta.
Tekib nii-öelda vahekiht andmeliiklusesse:
DTO peatükk käsitleb seda mustrit põhjalikumalt.
Jackson
Peamine klass, mida Jacksoni puhul kasutama peaks on ObjectMapper.
Ühest ObjectMapper-i instantsist piisab.
Seda tuleks taaskasutada, mitte iga väljakutse jaoks uuesti luua.
Sisemine DTO
Esmalt tuleks ära määratleda mida JSON-ist Java objektiks teisendada. Tavaliselt luuakse selle jaoks sisemine DTO, millel on avalikud väljad ja konstruktor puudub. Jackson määrab iga välja väärtuse otse, sobitades JSON-võtme nime välja nimega.
public class BookRecord {
public int id;
public String isbn;
public String title;
public String author;
public double price;
}
Selle saavutamiseks kasutatakse võtet nimega reflektsioon, mille kohta tulevad peatükid hiljem.
Kui eelistad final-väljasid, siis Jackson ei saa neid peale instantseerimist enam määrata.
Sel juhul tuleb konstruktor märgistada annotatsiooniga @JsonCreator ja iga parameeter @JsonProperty annotatsiooniga, et suunata Jacksonit, milline JSON-võti millise parameetriga vastab:
public class BookRecord {
private final int id;
private final String isbn;
private final String title;
private final String author;
private final double price;
@JsonCreator
public BookRecord(
@JsonProperty("id") int id,
@JsonProperty("isbn") String isbn,
@JsonProperty("title") String title,
@JsonProperty("author") String author,
@JsonProperty("price") double price) {
this.id = id;
this.isbn = isbn;
this.title = title;
this.author = author;
this.price = price;
}
public int getId() { return id; }
public String getIsbn() { return isbn; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
public double getPrice() { return price; }
}
Mõlemad lähenemised annavad identse tulemuse. Erinevus seisneb ainult selles, kuidas Jackson väärtusi tagasi loeb.
JSON-faili sisse lugemine
ObjectMapper suudab lugeda otse File, Path, InputStream või String-ist.
Kui eesmärk on kogu fail korraga sisse lugeda, on File kasutamine kõige lihtsam variant.
JSON-i lugemiseks ja siis objektiks teisendamiseks kasutatakse readValue meetodit:
ObjectMapper mapper = new ObjectMapper();
File file = new File("books.json");
BookRecord[] records = mapper.readValue(file, BookRecord[].class);
Deserialiseerimine massiiviks (BookRecord[].class) on lihtsam kui üldtüübi List määramine.
List-iks on võimalik hiljem teisendada:
List<BookRecord> recordList = Arrays.asList(records);
Alternatiivina saab Jackson deserialiseerida otse List-iks, kasutades constructCollectionType, et mööda minna Java tüüpide kustutamisest (type erasure):
List<BookRecord> records = mapper.readValue(
file,
mapper.getTypeFactory().constructCollectionType(List.class, BookRecord.class)
);
JSON-faili kirjutamine
JSON-iks kirjutamiseks kasutatakse writeValue meetodit.
ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
mapper.writeValue(new File("books.json"), records);
SerializationFeature.INDENT_OUTPUT tagab, et väljund oleks ka inimloetav.
Selle alternatiiv on meetod writerWithDefaultPrettyPrinter(), mis töötab käsupõhiselt (ei sea vaikeväärtuseks):
mapper.writerWithDefaultPrettyPrinter().writeValue(new File("books.json"), records);
Mõlemad variandid annavad taandatud ja inimesele loetava väljundi. Ilma nendeta kirjutab Jackson kõik ühele reale. Tulemuseks on küll korrektne JSON, kuid seda on raskem lugeda ja võrrelda inimesel.
DTO ja domeeniobjekti vaheline teisendamine
// BookRecord -> Book
private Book toDomain(BookRecord record) {
return new Book(record.id, record.isbn, record.title, record.author, new BigDecimal(String.valueOf(record.price)));
}
// Book -> BookRecord
private BookRecord toRecord(Book book) {
BookRecord record = new BookRecord();
record.id = book.getId();
record.isbn = book.getIsbn();
record.title = book.getTitle();
record.author = book.getAuthor();
record.price = book.getPrice().doubleValue();
return record;
}
Kui sihtklassil pole final-välju ning sisaldab endas argumentideta konstruktor, saab selle asemel kasutada ObjectMapper.convertValue() meetodit:
private BookRecord toRecord(Book book) {
return mapper.convertValue(book, BookRecord.class);
}
convertValue() teisendab ühe objekti teiseks, kasutades Jacksoni serialiseerimis- ja deserialiseerimisloogikat - see vastab sisuliselt objekti JSON-iks serialiseerimisele ja seejärel uueks tüübiks deserialiseerimisele.
Kuna meie BookRecord kasutab lõplikke välju ja @JsonCreator konstruktorit, on käsitsi teisendamine siin vajalik.
Gson
Gsoni põhiklass on Gson,
Sarnaselt ObjectMapper-ile piisab siin ka ühe instantsi taaskasutamisest.
JSON-faili sisse lugemine
Gson gson = new Gson();
Type listType = new TypeToken<List<BookRecord>>() {}.getType();
try (Reader reader = Files.newBufferedReader(Path.of("books.json"))) {
List<BookRecord> records = gson.fromJson(reader, listType);
} catch (IOException e) {
throw new RuntimeException("Could not read JSON file", e);
}
TypeToken täidab sama eesmärki nagu Jacksoni constructCollectionType - see säilitab üldtüübi info käitusajal, et mööda minna tüüpide kustutamisest (type erasure).
JSON-faili kirjutamine
Gson gson = new GsonBuilder().setPrettyPrinting().create();
try (Writer writer = Files.newBufferedWriter(Path.of("books.json"))) {
gson.toJson(records, writer);
} catch (IOException e) {
throw new RuntimeException("Could not write JSON file", e);
}
GsonBuilder võimaldab Gson-i instantsi seadistada enne selle loomist.
setPrettyPrinting() lubab taandatud väljundit, mis on samaväärne Jacksoni writerWithDefaultPrettyPrinter()-iga.
Jackson vs Gson
Mõlemad teegid katavad selles lihtsamate rakenduse kasutusjuhte võrdselt hästi. Praktilised erinevused sellel tasemel on väikesed:
| Jackson | Gson | |
|---|---|---|
Otse File-ist lugemine/kirjutamine | Jah - mapper.readValue(file, ...) | Ei - vajab Reader/Writer-it |
| Üldtüübi kogumik | Massiiv (BookRecord[].class) või constructCollectionType | TypeToken |
| Pretty printing | SerializationFeature.INDENT_OUTPUT või writerWithDefaultPrettyPrinter() | GsonBuilder().setPrettyPrinting() |
final väljad DTO-s | @JsonCreator + @JsonProperty | Töötab vaikimisi |
| Vaikimisi Spring Bootis | Jah | Ei |
Kui kasutad edaspidi Spring Booti, puutud seal kokku Jacksoniga. Iseseisva failipõhise I/O jaoks on mõlemad teegid head valikud.
Lõpetuseks - miks kasutada teeke
Toome ühe väikse näite sellest, miks antud juhul on välise teegi kasutamine mõistlik. Järgnev kood loeb välja ühe raamatu pealkirja JSON-sõnest ilma ühegi teegita:
public static String extractTitle(String json) {
int keyStart = json.indexOf("\"title\"");
if (keyStart == -1) return null;
int colonPos = json.indexOf(':', keyStart);
int valueStart = json.indexOf('"', colonPos) + 1;
int valueEnd = valueStart;
while (valueEnd < json.length()) {
char c = json.charAt(valueEnd);
if (c == '\\') {
valueEnd += 2; // skip escaped character
} else if (c == '"') {
break;
} else {
valueEnd++;
}
}
return json.substring(valueStart, valueEnd);
}
See kood teeb ainult üht asja - leiab ühe string-välja väärtuse.
Arvude, massiivide, pesastatud objektide, Unicode escape-järjendite (\uXXXX), null-väärtuste ja mitmete muude JSON-spetsifikatsiooni nüansside jaoks tuleb kirjutada täiendavat koodi.
Täielik, korrektne JSON parser on mitu tuhat rida.
Kui andmeformaat on standardiseeritud (JSON, XML, CSV), kasuta alati olemasolevat teeki. Ise sellist tööriista tasub ainult õppe eesmärgil või väga spetsiifilistes olukordades kirjutada.