JSON
코어는 이 프로젝트의 JSON 라이브러리인 silk-json으로 JSON을 만들고 파싱하며, 문서와 Java 타입 사이의 매핑은 직접 작성하는 코드이거나 빌드가 타입에서 생성하는 코드다.
라이터와 리더
JsonWriter<T>와 JsonReader<T>는 매핑에 이름을 붙여 재사용할 수 있게 한다.
두 인터페이스 모두 메서드가 하나뿐이므로 둘 다 람다다.
라이터는 건네받은 JsonOutput에 각 멤버를 곧바로 쓰고, 리더는 JsonInput에서 필요한 것을 꺼낸다:
static final JsonWriter<Deck> DECK = (deck, out) -> out.object()
.put("id", deck.id())
.put("name", deck.name())
.end();
static final JsonWriter<List<Deck>> DECKS = JsonWriter.list(DECK);
record NewDeck(String name) { }
static final JsonReader<NewDeck> NEW_DECK =
JsonReader.object(object -> new NewDeck(object.getString("name")));
app.get("/api/decks", req -> WebResponse.json(deckService.decks(), DECKS));
app.post("/api/decks", req -> {
Deck deck = deckService.create(req.bodyJson(NEW_DECK).name()); // no key -> 400
return WebResponse.json(deck, DECK).status(HttpStatus.CREATED);
});
라이터와 리더에는 리플렉션이 없다. 매핑은 직접 작성한 코드이므로, Java 필드 이름을 바꿔도 매핑을 함께 고쳐야만 와이어 포맷이 바뀐다. 생성되는 JSON 코덱은 매핑을 얻는 또 다른 길이다: 애너테이션 프로세서가 컴파일 시점에 타입 선언으로부터 같은 종류의 코덱을 써내고, 핸들러는 그것을 같은 방식으로 지명한다.
쓰기
JsonOutput은 라이터가 쓰는 곳이다: 중간에 트리 없이 버퍼에 곧바로 쓰이는 UTF-8 바이트다.
object()와 array()는 다음 값으로 컨테이너를 열고, end()는 가장 안쪽 컨테이너를 닫으며, 쉼표는 출력이 알아서 찍는다.
객체 안에서 멤버는 put(key, value)이거나, name(key) 뒤에 오는 값이다.
배열 안에서 각 원소는 value(…)이고, values(strings)는 이터러블의 문자열을 하나씩 더한다.
put과 value는 문자열, long, double, float, boolean, BigDecimal, BigInteger, JsonValue, 또는 자기 라이터를 가진 다른 값을 받으므로, 라이터는 서로 조합된다:
static final JsonWriter<Card> CARD = (card, out) -> out.object()
.put("id", card.id())
.put("deck", card.deck(), DECK)
.array("tags").values(card.tags()).end()
.put("due", card.due().toString())
.end();
null인 문자열은 JSON null로 쓰이고, 라이터와 함께 건네진 null 값도 마찬가지다.
double은 같은 double로 다시 읽히는 가장 짧은 십진수, 곧 Double.toString이 답하는 텍스트로 나가고, float은 Float.toString이 답하는 텍스트로 나간다.
0.1f는 0.1로 쓰이며, 넓혀진 double의 0.10000000149011612로 쓰이지 않는다.
NaN이거나 무한대인 double이나 float은 JsonException을 던지는데, JSON에는 둘 중 어느 것의 문법도 없기 때문이다.
객체 밖의 put이나 열린 것이 없는 end()처럼 자리에 맞지 않는 호출은 IllegalStateException을 던진다.
writer.toJson(value)와 writer.toJsonBytes(value)는 테스트, SSE 이벤트, 로그 한 줄을 위해 문서를 답한다.
WebResponse.json(value, writer)는 문서를 응답이 실어 나르는 바이트에 쓰며, 레코드 목록의 키처럼 문서 안에서 반복되는 키는 한 번만 인코딩한다.
Json.object()와 Json.array()로 트리를 만드는 편이 나은 라이터는 JsonWriter.tree(value → …)이며, 그 트리는 같은 출력을 통해 쓰인다.
읽기
JsonInput은 리더가 읽는 곳이다: 문서의 UTF-8 바이트를 한 번에 값 하나씩 읽으며, 문서의 문자열을 먼저 만들지 않는다.
JsonReader.object(fromObject)는 객체를 읽는 보통의 리더다.
이 리더는 객체를 트리로 파싱해 한 번 꺼내므로, 함수는 각 필드를 JsonObject에서 바로 읽는다:
static final JsonReader<CardDraft> CARD_DRAFT = JsonReader.object(object -> new CardDraft(
object.getString("text"),
object.getString("meaning", ""),
object.getString("tags", "")));
객체가 아닌 본문은 JsonException을 던지며, req.bodyJson(reader)는 이에 400으로 답한다.
JsonReader.list(element)는 JsonWriter.list가 라이터에 하듯 원소 하나의 리더로부터 JSON 배열의 리더를 만들며, 원소를 한 번에 하나씩 읽는다.
JsonReader.tree(fromTree)는 그 밖의 모양을 값 전체를 트리로 꺼내 읽는다.
입력 자체를 상대로 작성한 리더는 트리를 전혀 만들지 않으며, 생성된 코덱이 바로 그렇게 한다.
object()는 객체에 들어가고, nextKey()는 각 멤버로 나아가다 false를 답하며 객체를 벗어나고, keyIs(key)는 멤버 이름의 문자열을 만들지 않고 JsonKey와 맞춰 본다.
read 메서드들은 값을 꺼내고, readValue()는 값 하나를 통째로 트리로 꺼내고, readValueBytes()는 값 하나를 통째로 쓰인 그대로의 바이트로 꺼내며, skipValue()는 값 하나를 건너뛴다:
static final JsonKey ID = JsonKey.of("id");
static final JsonKey NAME = JsonKey.of("name");
static final JsonReader<Deck> DECK_IN = in -> {
in.object();
long id = 0;
String name = "";
while (in.nextKey()) {
if (in.keyIs(ID)) {
id = in.readLong();
} else if (in.keyIs(NAME)) {
name = in.readString();
} else {
in.skipValue();
}
}
return new Deck(id, name);
};
nextKeyIs(key)는 다음 멤버의 키가 그 키이고 JsonOutput이 쓰는 대로 키와 콜론 사이에 공백 없이 쓰였을 때만 그 멤버로 나아간다.
키가 시작하는 자리의 바이트를 콜론까지 포함해 키 자신의 바이트와 비교하며, 아무것도 스캔하지 않는다.
pretty printer나 Python의 json.dumps가 쓰듯 키 앞이나 쉼표 뒤에 공백이 있으면 먼저 건너뛴다.
다른 멤버라면 false를 답하고 아무 데도 움직이지 않는다.
그러면 nextKey()가 그 멤버를 읽는다.
생성된 코덱은 선언한 다음 키를 이렇게 먼저 시도하므로, 같은 선언으로 쓴 문서는 키를 하나도 스캔하지 않고 읽힌다.
keyHead()는 다음 멤버가 시작하는 자리의 4바이트를 첫 바이트가 가장 낮은 int로 답하고, 아무 데도 움직이지 않는다.
생성된 코덱은 이 값으로 switch해 nextKeyIs로 시도할 키를 고른다.
그러면 키를 다른 순서로 나열한 문서도 키 앞뒤에 공백이 없는 한 키를 제자리에서 찾는다.
enum 상수의 이름처럼 정해진 집합에서 오는 문자열도 JsonKey로 둔다.
JsonKey.names(Level.values())는 각 상수의 이름을 서수 순서대로 한 번씩 인코딩한다.
out.put(key, names[level.ordinal()])와 out.value(names[level.ordinal()])는 따옴표로 감싼 이름을 복사하고, in.readOneOf(names)는 다음에 오는 이름을 문자열을 만들지 않고 제자리에서 비교해 그 인덱스를 답한다.
어느 이름도 아닌 값은 JsonException으로 거부한다.
생성된 코덱은 enum이 어디에 있든 이렇게 쓰고 읽는다: 프로퍼티, Optional, 컬렉션 원소, 맵 값이다.
reader.fromJson(text)와 reader.fromJsonBytes(utf8)는 그 값 하나만 담고 뒤에 아무것도 없는 문서를 읽는다.
실패는 Near character 12: Not a JSON string: a number처럼 입력이 어디에 있는지 밝힌다.
readDouble()은 숫자의 텍스트에 Double.parseDouble이 답하는 double을 답하고, readFloat()는 Float.parseFloat이 답하는 float을 답한다.
둘 다 숫자를 제자리에서 변환하며, 문자열을 만들지 않고 한 번만 반올림한다.
(float) readDouble()은 두 번 반올림하므로, 1.00000017881393432617187499처럼 두 float의 한가운데에 조금 못 미치는 숫자에서 한 비트 어긋난다.
가장 큰 double이나 float을 넘는 숫자는 Number out of range로 거부되고, 가장 작은 값보다 0에 가까운 숫자는 부호를 지닌 0으로 읽힌다: -0은 -0.0으로 읽힌다.
값 읽기
getString은 키가 없거나 값의 타입이 틀리면 JsonException을 던지고, req.bodyJson(reader)는 이를 400으로 바꾼다.
메시지는 Not a JSON object: an array처럼 찾은 것의 종류를 밝히며, 본문에서는 짧은 스칼라 이상을 클라이언트에게 되돌려 인용하지 않는다.
JsonArray.get(index)도 배열 밖의 인덱스에 이 예외를 던지므로, 짧은 배열도 400이다.
핸들러는 값 전체를 받거나 아무것도 받지 않으며, 이는 pathParamLong과 같은 계약이다.
JsonException은 IllegalArgumentException이므로, 자체 검증 규칙 때문에 일반 IllegalArgumentException을 던지는 리더도 400이 된다.
실패한 LocalDate.parse처럼 리더에서 나온 DateTimeException도 400이 된다.
그 밖의 리더 예외는 서버 에러로 남는다.
bodyJson(reader)는 입력 거부를 HttpException으로 감싸므로, 이 에러를 직접 처리하려면 그 타입이나 400 상태 페이지를 대상으로 한다.
핸들러 코드가 직접 던진 JsonException은 감싸지지 않으므로, JsonException에 등록한 예외 핸들러가 그대로 받는다.
파서는 확장 없이 RFC 8259의 문법만 받아들이며, bodyJson은 그 밖의 입력에 400으로 답한다.
이 JVM이 디코딩하지 못하는 charset의 본문은 그 바이트가 파서에 닿지 못하므로 대신 415로 답한다.
01, +1, .5, 1. 같은 숫자는 잘못된 문법이고, 이스케이프 없이 문자열에 쓴 제어 문자도 마찬가지다.
문자열 안의 잘못된 UTF-8 바이트 열도 값 속의 대체 문자가 아니라 잘못된 문법이다.
asLong과 getLong은 소수부가 있는 숫자를 잘라내지 않고 거부한다: 1.5는 long이 아니지만, 2.0과 1e3은 정수이므로 long으로 읽는다.
이 변환은 가장 가까운 double이 아니라 숫자 자체의 자릿수를 읽으므로, 9007199254740993.0은 정확히 그 long으로 읽고 1.0000000000000001은 소수로 거부한다.
파싱한 숫자는 같은 자릿수로 다시 쓰이므로, WebResponse.json(req.bodyJson())처럼 트리를 그대로 넘기면 받은 숫자를 그대로 보낸다.
asDouble과 getDouble은 가장 가까운 double을 답하며, 이는 본래 근삿값이다.
12345678901234567890처럼 long 범위를 벗어난 정수는 asDouble은 읽고 asLong은 거부하는 숫자로 파싱된다.
없어도 되는 키에는 기본값을 받는 getString(key, defaultValue), getLong, getDouble, getBoolean이 대신 기본값을 답하며, 키가 없을 때와 명시적인 JSON null일 때 모두 그렇다.
getObjectOrNull과 getArrayOrNull은 같은 조건에서 null을 답하고, 키가 다른 타입의 값으로 존재하면 예외를 던진다.
이 이름들은 WebRequest가 따르는 규칙을 따른다: 기본 이름은 값을 요구하고, 마지막 인자로 기본값을 주면 선택이 되며, OrNull은 null로 답한다.
isString(), isNumber(), isBoolean()은 isNull()과 함께 원시 값이 무엇을 담고 있는지 알려 주므로, 문자열일 수도 숫자일 수도 있는 값을 읽기 전에 구분할 수 있다.
파싱된 배열은 for-each로 읽는다: for (JsonValue v : json.asArray()).
미리 알 수 없는 키
파싱된 객체도 for-each로 읽으며, 문서 순서대로 멤버마다 Map.Entry를 하나씩 내놓는다.
keys()는 그 순서대로 키를 나열하고 size()는 멤버 수를 세므로, 예를 들어 태그 이름별 개수를 담은 맵처럼 키가 데이터인 문서는 Map으로 읽는다:
Map<String, Long> counts = new LinkedHashMap<>();
for (var member : req.bodyJson().asObject()) {
counts.put(member.getKey(), member.getValue().asLong());
}
키 목록은 복사본이고 루프에 건네지는 멤버는 읽기 전용이므로, 어느 쪽을 통해 써도 객체에는 닿지 않는다.
JsonCodec<T>
JsonCodec<T>는 라이터이자 리더로, 양방향으로 오가는 타입에 쓴다.
JsonCodec.of(writer, reader)가 하나를 만들고, JsonCodec.list(codec)가 리스트 형태를 준다.
라이터와 리더가 별개의 인터페이스인 것은 대부분의 타입이 나가기만 하기 때문이며, 인터페이스가 하나라면 그런 타입에 구현할 수 없는 read가 남는다.
생성된 코덱은 JsonCodec이므로, 라이터나 리더가 가는 곳이면 어디든 간다.
이것으로 부족해지는 지점
WebResponse.json(value, writer)는 문서 전체를 메모리에 쓴 뒤에야 보내기 시작하는데, 십만 행짜리 응답은 이를 감당할 수 없다.
대용량 JSON과 NDJSON은 대신 값을 하나씩 쓰고, 본문도 같은 방식으로 읽는다.
매핑을 손으로 작성하거나 타입에서 생성하는 것은 코어가 코어를 위해 내린 결정이지, 애플리케이션을 위한 결정이 아니다.
소유하지 않은 와이어 포맷을 가진 애플리케이션은 나가는 쪽의 WebResponse.rawJson(String)과 들어오는 쪽의 req.bodyStream() 또는 req.bodyReader()를 통해 라이브러리로 객체를 바인딩할 수 있다: 외부 JSON 바인딩.
단독으로 쓰는 silk-json
이 페이지에서 req.bodyJson과 WebResponse.json을 뺀 모든 것은 silk-json이며, 이는 코어가 그 위에 지어진 JSON 라이브러리로서 실행 시점에 JDK 말고는 아무것도 필요로 하지 않는다.
그 타입들은 net.benelog.silkjson 패키지에 있으며, 코어를 가진 애플리케이션은 이 타입들도 함께 가진다.
Spider Silk 애플리케이션이 아닌 애플리케이션은 이 라이브러리만 가져다 쓴다:
dependencies {
implementation 'net.benelog.silkjson:silk-json:1.1.0'
}
<dependency>
<groupId>net.benelog.silkjson</groupId>
<artifactId>silk-json</artifactId>
<version>1.1.0</version>
</dependency>
생성되는 JSON 코덱은 그 프로세서인 silk-json-processor를 같은 방식으로 더한다.
이 라이브러리는 프레임워크와 함께, 프레임워크의 버전으로 릴리스되며, 독립된 라이브러리로 읽히도록 자신의 이름을 가진다.