From 4925755ffa90aec75c116c6204688314121f71d9 Mon Sep 17 00:00:00 2001 From: jwilson Date: Sun, 17 Apr 2016 20:15:07 -1000 Subject: [PATCH] Optimize reading one of several expected values with Selection This isn't yet public API. This relies on an unreleased Okio API. This has a significant impact on performance. I measured parsing performance improve from 89k ops/sec to 140k ops/sec on one benchmark. --- .../moshi/BufferedSinkJsonWriter.java | 10 +- .../moshi/BufferedSourceJsonReader.java | 34 +++++ .../com/squareup/moshi/ClassJsonAdapter.java | 19 ++- .../java/com/squareup/moshi/JsonReader.java | 49 ++++++++ .../squareup/moshi/StandardJsonAdapters.java | 8 +- .../moshi/BufferedSourceJsonReaderTest.java | 116 ++++++++++++++++++ pom.xml | 2 +- 7 files changed, 228 insertions(+), 10 deletions(-) diff --git a/moshi/src/main/java/com/squareup/moshi/BufferedSinkJsonWriter.java b/moshi/src/main/java/com/squareup/moshi/BufferedSinkJsonWriter.java index 1ff6480..5a6be26 100644 --- a/moshi/src/main/java/com/squareup/moshi/BufferedSinkJsonWriter.java +++ b/moshi/src/main/java/com/squareup/moshi/BufferedSinkJsonWriter.java @@ -218,7 +218,7 @@ final class BufferedSinkJsonWriter extends JsonWriter { private void writeDeferredName() throws IOException { if (deferredName != null) { beforeName(); - string(deferredName); + string(sink, deferredName); deferredName = null; } } @@ -232,7 +232,7 @@ final class BufferedSinkJsonWriter extends JsonWriter { } writeDeferredName(); beforeValue(); - string(value); + string(sink, value); pathIndices[stackSize - 1]++; return this; } @@ -331,7 +331,11 @@ final class BufferedSinkJsonWriter extends JsonWriter { stackSize = 0; } - private void string(String value) throws IOException { + /** + * Writes {@code value} as a string literal to {@code sink}. This wraps the value in double quotes + * and escapes those characters that require it. + */ + static void string(BufferedSink sink, String value) throws IOException { String[] replacements = REPLACEMENT_CHARS; sink.writeByte('"'); int last = 0; diff --git a/moshi/src/main/java/com/squareup/moshi/BufferedSourceJsonReader.java b/moshi/src/main/java/com/squareup/moshi/BufferedSourceJsonReader.java index 327c06b..c51ea74 100644 --- a/moshi/src/main/java/com/squareup/moshi/BufferedSourceJsonReader.java +++ b/moshi/src/main/java/com/squareup/moshi/BufferedSourceJsonReader.java @@ -552,6 +552,23 @@ final class BufferedSourceJsonReader extends JsonReader { return result; } + @Override int selectName(Selection selection) throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + if (p != PEEKED_DOUBLE_QUOTED_NAME) { + return -1; + } + + int result = source.select(selection.doubleQuoteSuffix); + if (result != -1) { + peeked = PEEKED_NONE; + pathNames[stackSize - 1] = selection.strings[result]; + } + return result; + } + @Override public String nextString() throws IOException { int p = peeked; if (p == PEEKED_NONE) { @@ -579,6 +596,23 @@ final class BufferedSourceJsonReader extends JsonReader { return result; } + @Override int selectString(Selection selection) throws IOException { + int p = peeked; + if (p == PEEKED_NONE) { + p = doPeek(); + } + if (p != PEEKED_DOUBLE_QUOTED) { + return -1; + } + + int result = source.select(selection.doubleQuoteSuffix); + if (result != -1) { + peeked = PEEKED_NONE; + pathIndices[stackSize - 1]++; + } + return result; + } + @Override public boolean nextBoolean() throws IOException { int p = peeked; if (p == PEEKED_NONE) { diff --git a/moshi/src/main/java/com/squareup/moshi/ClassJsonAdapter.java b/moshi/src/main/java/com/squareup/moshi/ClassJsonAdapter.java index 7947e9f..2ce4f52 100644 --- a/moshi/src/main/java/com/squareup/moshi/ClassJsonAdapter.java +++ b/moshi/src/main/java/com/squareup/moshi/ClassJsonAdapter.java @@ -116,11 +116,14 @@ final class ClassJsonAdapter extends JsonAdapter { private final ClassFactory classFactory; private final Map> fieldsMap; private final FieldBinding[] fieldsArray; + private final JsonReader.Selection selection; ClassJsonAdapter(ClassFactory classFactory, Map> fieldsMap) { this.classFactory = classFactory; this.fieldsMap = new LinkedHashMap<>(fieldsMap); this.fieldsArray = fieldsMap.values().toArray(new FieldBinding[fieldsMap.size()]); + this.selection = JsonReader.Selection.of( + fieldsMap.keySet().toArray(new String[fieldsMap.size()])); } @Override public T fromJson(JsonReader reader) throws IOException { @@ -141,13 +144,19 @@ final class ClassJsonAdapter extends JsonAdapter { try { reader.beginObject(); while (reader.hasNext()) { - String name = reader.nextName(); - FieldBinding fieldBinding = fieldsMap.get(name); - if (fieldBinding != null) { - fieldBinding.read(reader, result); + int index = reader.selectName(selection); + FieldBinding fieldBinding; + if (index != -1) { + fieldBinding = fieldsArray[index]; } else { - reader.skipValue(); + String name = reader.nextName(); + fieldBinding = fieldsMap.get(name); + if (fieldBinding == null) { + reader.skipValue(); + continue; + } } + fieldBinding.read(reader, result); } reader.endObject(); return result; diff --git a/moshi/src/main/java/com/squareup/moshi/JsonReader.java b/moshi/src/main/java/com/squareup/moshi/JsonReader.java index 5cabc78..a152242 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonReader.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonReader.java @@ -17,7 +17,11 @@ package com.squareup.moshi; import java.io.Closeable; import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import okio.Buffer; import okio.BufferedSource; +import okio.ByteString; /** * Reads a JSON (RFC 7159) @@ -272,6 +276,12 @@ public abstract class JsonReader implements Closeable { */ public abstract String nextName() throws IOException; + /** + * If the next token is a {@linkplain Token#NAME property name} that's in {@code selection}, this + * consumes it and returns its index. Otherwise this returns -1 and no name is consumed. + */ + abstract int selectName(Selection selection) throws IOException; + /** * Returns the {@linkplain Token#STRING string} value of the next token, consuming it. If the next * token is a number, this method will return its string form. @@ -280,6 +290,12 @@ public abstract class JsonReader implements Closeable { */ public abstract String nextString() throws IOException; + /** + * If the next token is a {@linkplain Token#STRING string} that's in {@code selection}, this + * consumes it and returns its index. Otherwise this returns -1 and no string is consumed. + */ + abstract int selectString(Selection selection) throws IOException; + /** * Returns the {@linkplain Token#BOOLEAN boolean} value of the next token, consuming it. * @@ -347,6 +363,39 @@ public abstract class JsonReader implements Closeable { */ abstract void promoteNameToValue() throws IOException; + /** + * A set of strings to be chosen with {@link #selectName} or {@link #selectString}. This prepares + * the encoded values of the strings so they can be read directly from the input source. It cannot + * read arbitrary encodings of the strings: if any of a string's characters are unnecessarily + * escaped in the source JSON, that string will not be selected. Similarly, if the string is + * unquoted or uses single quotes in the source JSON, it will not be selected. Client code that + * uses this class should fall back to another mechanism to accommodate this possibility. + */ + static final class Selection { + final String[] strings; + final List doubleQuoteSuffix; + + public Selection(String[] strings, List doubleQuoteSuffix) { + this.strings = strings; + this.doubleQuoteSuffix = doubleQuoteSuffix; + } + + public static Selection of(String... strings) { + try { + ByteString[] result = new ByteString[strings.length]; + Buffer buffer = new Buffer(); + for (int i = 0; i < strings.length; i++) { + BufferedSinkJsonWriter.string(buffer, strings[i]); + buffer.readByte(); // Skip the leading double quote (but leave the trailing one). + result[i] = buffer.readByteString(); + } + return new Selection(strings.clone(), Arrays.asList(result)); + } catch (IOException e) { + throw new AssertionError(e); + } + } + } + /** * A structure, name, or value type in a JSON-encoded string. */ diff --git a/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapters.java b/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapters.java index 2029029..db08b58 100644 --- a/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapters.java +++ b/moshi/src/main/java/com/squareup/moshi/StandardJsonAdapters.java @@ -216,11 +216,13 @@ final class StandardJsonAdapters { private final Class enumType; private final Map nameConstantMap; private final String[] nameStrings; + private final T[] constants; + private final JsonReader.Selection selection; public EnumJsonAdapter(Class enumType) { this.enumType = enumType; try { - T[] constants = enumType.getEnumConstants(); + constants = enumType.getEnumConstants(); nameConstantMap = new LinkedHashMap<>(); nameStrings = new String[constants.length]; for (int i = 0; i < constants.length; i++) { @@ -230,12 +232,16 @@ final class StandardJsonAdapters { nameConstantMap.put(name, constant); nameStrings[i] = name; } + selection = JsonReader.Selection.of(nameStrings); } catch (NoSuchFieldException e) { throw new AssertionError("Missing field in " + enumType.getName(), e); } } @Override public T fromJson(JsonReader reader) throws IOException { + int index = reader.selectString(selection); + if (index != -1) return constants[index]; + String name = reader.nextString(); T constant = nameConstantMap.get(name); if (constant != null) return constant; diff --git a/moshi/src/test/java/com/squareup/moshi/BufferedSourceJsonReaderTest.java b/moshi/src/test/java/com/squareup/moshi/BufferedSourceJsonReaderTest.java index 913d4c0..742694f 100644 --- a/moshi/src/test/java/com/squareup/moshi/BufferedSourceJsonReaderTest.java +++ b/moshi/src/test/java/com/squareup/moshi/BufferedSourceJsonReaderTest.java @@ -33,6 +33,7 @@ import static com.squareup.moshi.JsonReader.Token.NUMBER; import static com.squareup.moshi.JsonReader.Token.STRING; import static com.squareup.moshi.TestUtil.newReader; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; public final class BufferedSourceJsonReaderTest { @@ -1770,6 +1771,121 @@ public final class BufferedSourceJsonReaderTest { } } + @Test public void selectName() throws IOException { + JsonReader.Selection abc = JsonReader.Selection.of("a", "b", "c"); + + JsonReader reader = newReader("{\"a\": 5, \"b\": 5, \"c\": 5, \"d\": 5}"); + reader.beginObject(); + assertEquals("$.", reader.getPath()); + + assertEquals(0, reader.selectName(abc)); + assertEquals("$.a", reader.getPath()); + assertEquals(5, reader.nextInt()); + assertEquals("$.a", reader.getPath()); + + assertEquals(1, reader.selectName(abc)); + assertEquals("$.b", reader.getPath()); + assertEquals(5, reader.nextInt()); + assertEquals("$.b", reader.getPath()); + + assertEquals(2, reader.selectName(abc)); + assertEquals("$.c", reader.getPath()); + assertEquals(5, reader.nextInt()); + assertEquals("$.c", reader.getPath()); + + // A missed selectName() doesn't advance anything, not even the path. + assertEquals(-1, reader.selectName(abc)); + assertEquals("$.c", reader.getPath()); + assertEquals(JsonReader.Token.NAME, reader.peek()); + + assertEquals("d", reader.nextName()); + assertEquals("$.d", reader.getPath()); + assertEquals(5, reader.nextInt()); + assertEquals("$.d", reader.getPath()); + + reader.endObject(); + } + + @Test public void selectString() throws IOException { + JsonReader.Selection abc = JsonReader.Selection.of("a", "b", "c"); + + JsonReader reader = newReader("[\"a\", \"b\", \"c\", \"d\"]"); + reader.beginArray(); + assertEquals("$[0]", reader.getPath()); + + assertEquals(0, reader.selectString(abc)); + assertEquals("$[1]", reader.getPath()); + + assertEquals(1, reader.selectString(abc)); + assertEquals("$[2]", reader.getPath()); + + assertEquals(2, reader.selectString(abc)); + assertEquals("$[3]", reader.getPath()); + + // A missed selectName() doesn't advance anything, not even the path. + assertEquals(-1, reader.selectString(abc)); + assertEquals("$[3]", reader.getPath()); + assertEquals(JsonReader.Token.STRING, reader.peek()); + + assertEquals("d", reader.nextString()); + assertEquals("$[4]", reader.getPath()); + + reader.endArray(); + } + + /** Select doesn't match unquoted strings. */ + @Test public void selectStringUnquoted() throws IOException { + JsonReader.Selection abc = JsonReader.Selection.of("a", "b", "c"); + + JsonReader reader = newReader("[a]"); + reader.setLenient(true); + reader.beginArray(); + assertEquals(-1, reader.selectString(abc)); + assertEquals("a", reader.nextString()); + reader.endArray(); + } + + /** Select doesn't match single quoted strings. */ + @Test public void selectStringSingleQuoted() throws IOException { + JsonReader.Selection abc = JsonReader.Selection.of("a", "b", "c"); + + JsonReader reader = newReader("['a']"); + reader.setLenient(true); + reader.beginArray(); + assertEquals(-1, reader.selectString(abc)); + assertEquals("a", reader.nextString()); + reader.endArray(); + } + + /** Select doesn't match unnecessarily-escaped strings. */ + @Test public void selectUnnecessaryEscaping() throws IOException { + JsonReader.Selection abc = JsonReader.Selection.of("a", "b", "c"); + + JsonReader reader = newReader("[\"\\u0061\"]"); + reader.beginArray(); + assertEquals(-1, reader.selectString(abc)); + assertEquals("a", reader.nextString()); + reader.endArray(); + } + + /** Select does match necessarily escaping. The decoded value is used in the path. */ + @Test public void selectNecessaryEscaping() throws IOException { + JsonReader.Selection selection = JsonReader.Selection.of("\n", "\u0000", "\""); + + JsonReader reader = newReader("{\"\\n\": 5,\"\\u0000\": 5, \"\\\"\": 5}"); + reader.beginObject(); + assertEquals(0, reader.selectName(selection)); + assertEquals(5, reader.nextInt()); + assertEquals("$.\n", reader.getPath()); + assertEquals(1, reader.selectName(selection)); + assertEquals(5, reader.nextInt()); + assertEquals("$.\u0000", reader.getPath()); + assertEquals(2, reader.selectName(selection)); + assertEquals(5, reader.nextInt()); + assertEquals("$.\"", reader.getPath()); + reader.endObject(); + } + private void assertDocument(String document, Object... expectations) throws IOException { JsonReader reader = newReader(document); reader.setLenient(true); diff --git a/pom.xml b/pom.xml index 611d418..66b64f0 100644 --- a/pom.xml +++ b/pom.xml @@ -28,7 +28,7 @@ 1.7 - 1.6.0 + 1.8.0-SNAPSHOT 4.12