如何使用 Retrofit2 和 GSON 转换器解决 "quirky" JSON API

How to work around a "quirky" JSON API with Retrofit2 and GSON converter

我正在为第 3 方 JSON REST API 实施客户端。在客户端(我的项目),我使用 Retrofit 和 GSON 作为库。服务器端似乎在 PHP 中实现并且超出了我的控制范围,即我无法轻易修复服务器错误。

服务器经常以不同的实际类型响应相同的正式类型。开箱即用,我在反序列化过程中遇到了很多 GSON parsing/conversion 异常,因为 GSON 对正确的类型非常挑剔。

在反序列化响应和类型转换方面,如何让 GSON 更健壮?我已经找到了注释 @JsonAdapter,它可能允许我解决服务器的那些怪癖。但由于这似乎是一个典型的 PHP 问题(示例如下),我认为可能已经有一个 GSON 的库或外观可以解决这些问题。

具体而言,服务器显示以下怪癖:

上面的列表只是我迄今为止遇到的怪事的开始。可能还有更多。这就是我犹豫是否为 GSON 编写可能拥有的适配器的方式,因为我担心列表很快就会变得无穷无尽。有没有已经为那些典型的 PHP 问题实现转换的库?

幸运的是,GSON 转换器只需要额外的反序列化鲁棒性,即如果来自服务器的响应被解析为 Java 对象。当 Java 对象被序列化为 JSON 并在请求中发送到服务器时,只要 PHP 能够将其转换为所需类型,服务器就会很乐意接受任何类型。


更新

我开始为普通的 Java 类型(intlongboolean) 及其 OO 对应部分 IntegerLong 等。 到目前为止这很容易。 棘手的部分是实现一个通用列表适配器,它涵盖第 3 项(见上文)中提到的怪癖,并且可以正确反序列化列表中的任何类型。 我的问题是 Java 的类型擦除。 也许有人可以解决以下问题?

假设 GSON 的以下带注释的 POJO serialiation/deserialization。

public class Container {
  @SerializeName("foos")
  @JsonAdapter( QuirkyListAdapter<Foo>.class )  // Note: No legal Java syntax
  public List<Foo> foos;
  
  @SerializeName("bars")
  @JsonAdapter( QuirkyListAdapter<Bar>.class )  // Note: No legal Java syntax
  public List<Bar> bars;
}

public class Foo {
  @SerializeName("str")
  public String str;
}

public class Bar {
  @SerializeName("n")
  public Integer n;
}

我的 QuirkyListAdapter 方法正确地将 JSON null{}""false 反序列化为一个空列表。

public class QuirkyListAdapter<T> extends TypeAdapter<List<T>> {
  @Override
  public void write( @NonNull JsonWriter out, List<T> listOfT ) throws IOException {
    final Gson gson = new Gson();
    final TypeAdapter<T> typeAdapter = gson.getAdapter( T.class );  // Note: No legal Java syntax
    out.beginArray();
    if( listOfT != null ) {
      for( final T val : listOfT ) {
        typeAdapter.write( out, val );
      }
    }
    out.endArray();
  }

  @Override
  @NonNull
  public List<T> read( @NonNull JsonReader in ) throws IOException {
    final List<T> result = new ArrayList<>();
    final JsonToken peekedToken = in.peek();

    if( peekedToken == JsonToken.NULL ) {
      in.nextNull();
      return result;
    }
    if( peekedToken == JsonToken.BOOLEAN ) {
      if( in.nextBoolean() )
        throw new IllegalStateException( "Expected an empty array encoded as boolean value \"FALSE\" but found \"true\"" );
      return result;
    }
    if( peekedToken == JsonToken.STRING ) {
      final String str = in.nextString();
      if( str.equals( "" ) )
        throw new IllegalStateException( "Expected an empty array encoded as an empty string value but found \"" + str + "\"" );
      return result;
    }
    if( peekedToken == JsonToken.BEGIN_OBJECT ) {
      in.beginObject();
      if( in.peek() != JsonToken.END_OBJECT)
        throw new IllegalStateException( "Expected an empty array encoded as an empty object \"{}\", but object has attributes" );
      in.endObject(); 
    }
    if( peekedToken != JsonToken.BEGIN_ARRAY )
      throw new IllegalStateException( "Expected BEGIN_ARRAY but was " + peekedToken );

    final Gson gson = new Gson();
    final TypeAdapter<T> typeAdapter = gson.getAdapter( T.class );  // Note: No legal Java syntax

    in.beginArray();
    while( in.hasNext() ) {
      result.add( typeAdapter.read( in ) );
    }
    in.endArray();
    return result;
  }
}

如果我用 FooBar 分别替换通用 T,并实现两个独立的 QuirkyFooListAdapterQuirkyBarListAdapter,则一切正常. 即使将 T 替换为 Object 也不是解决方案,尽管它可以编译。 然而,棘手的行是 gson.getAdapter( .. ) 在我古怪的列表适配器中。 使用 gson.getAdapter( Object.class ) 显然不会 return 反序列化列表中正确对象所需的适配器。

您不需要那么多类型适配器,因为您可以将常见的反序列化逻辑合并到为从后端遇到的每个有问题的文字类型(就像您在问题中发布的那样)设计的类型适配器中。

独立创建的类型适配器,不在类型适配器工厂中,通常适用于简单的情况。工厂提供对共享 context Gson 实例(您配置然后使用的实例)的访问,并提供具体类型来构建类型适配器(这是“T.class" 可以变通)。

public abstract class AbstractQuirkyTypeAdapterFactory<T, C>
        implements TypeAdapterFactory {

    protected abstract boolean supports(@Nonnull TypeToken<?> typeToken);

    @Nullable
    protected abstract C createContext(@Nonnull TypeToken<?> typeToken);

    @Nullable
    protected abstract T read(@Nullable C context, @Nonnull TypeAdapter<? extends T> delegateAdapter, @Nonnull JsonReader in)
            throws IOException;

    @Override
    @Nullable
    public final <U> TypeAdapter<U> create(final Gson gson, final TypeToken<U> typeToken) {
        if ( !supports(typeToken) ) {
            return null;
        }
        @SuppressWarnings("unchecked")
        final TypeAdapter<T> delegateAdapter = (TypeAdapter<T>) gson.getDelegateAdapter(this, typeToken);
        @Nullable
        final C context = createContext(typeToken);
        final TypeAdapter<T> quirkyAdapter = new TypeAdapter<T>() {
            @Override
            public void write(final JsonWriter out, final T value)
                    throws IOException {
                delegateAdapter.write(out, value);
            }

            @Override
            public T read(final JsonReader in)
                    throws IOException {
                return AbstractQuirkyTypeAdapterFactory.this.read(context, delegateAdapter, in);
            }
        }
                .nullSafe();
        @SuppressWarnings("unchecked")
        final TypeAdapter<U> typeAdapter = (TypeAdapter<U>) quirkyAdapter;
        return typeAdapter;
    }

}
public final class QuirkyBooleanTypeAdapterFactory
        extends AbstractQuirkyTypeAdapterFactory<Boolean, Void> {

    private static final TypeAdapterFactory instance = new QuirkyBooleanTypeAdapterFactory();

    private QuirkyBooleanTypeAdapterFactory() {
    }

    public static TypeAdapterFactory getInstance() {
        return instance;
    }

    @Override
    protected boolean supports(@Nonnull final TypeToken<?> typeToken) {
        final Class<?> rawType = typeToken.getRawType();
        return rawType == boolean.class
                || rawType == Boolean.class;
    }

    @Nullable
    @Override
    protected Void createContext(@Nonnull final TypeToken<?> typeToken) {
        return null;
    }

    @Override
    @Nullable
    @SuppressWarnings("NestedSwitchStatement")
    protected Boolean read(@Nullable final Void context, @Nonnull final TypeAdapter<? extends Boolean> delegateAdapter, @Nonnull final JsonReader in)
            throws IOException {
        final JsonToken token = in.peek();
        switch ( token ) {
        case BOOLEAN:
            return delegateAdapter.read(in);
        case NUMBER:
            final int i = in.nextInt();
            switch ( i ) {
            case 0:
                return false;
            case 1:
                return true;
            default:
                throw new JsonSyntaxException("Unhandled integer: " + i);
            }
        case STRING:
            final String s = in.nextString();
            switch ( s ) {
            case "0":
            case "false":
            case "null":
                return false;
            case "1":
            case "true":
                return true;
            default:
                throw new JsonSyntaxException("Unhandled string: " + s);
            }
        case NULL:
            return null; // TODO or false?
        case BEGIN_ARRAY:
        case END_ARRAY:
        case BEGIN_OBJECT:
        case END_OBJECT:
        case NAME:
        case END_DOCUMENT:
            throw new JsonSyntaxException("Unhandled token: " + token);
        default:
            throw new AssertionError(token);
        }
    }

}
public final class QuirkyNumberTypeAdapterFactory
        extends AbstractQuirkyTypeAdapterFactory<Number, Number> {

    private static final TypeAdapterFactory instance = new QuirkyNumberTypeAdapterFactory();

    private static final Function<Class<?>, Number> getKnownZero = new ImmutableMap.Builder<Class<?>, Number>()
            .put(byte.class, (byte) 0)
            .put(Byte.class, (byte) 0)
            .put(short.class, (short) 0)
            .put(Short.class, (short) 0)
            .put(int.class, 0)
            .put(Integer.class, 0)
            .put(long.class, 0L)
            .put(Long.class, 0L)
            .put(float.class, 0F)
            .put(Float.class, 0F)
            .put(double.class, 0D)
            .put(Double.class, 0D)
            .put(BigInteger.class, BigInteger.ZERO)
            .put(BigDecimal.class, BigDecimal.ZERO)
            .build()
            ::get;

    private QuirkyNumberTypeAdapterFactory() {
    }

    public static TypeAdapterFactory getInstance() {
        return instance;
    }

    @Override
    @SuppressWarnings("OverlyComplexBooleanExpression")
    protected boolean supports(@Nonnull final TypeToken<?> typeToken) {
        final Class<?> rawType = typeToken.getRawType();
        return Number.class.isAssignableFrom(rawType)
                || rawType == byte.class
                || rawType == short.class
                || rawType == int.class
                || rawType == long.class
                || rawType == float.class
                || rawType == double.class;
    }

    @Nullable
    @Override
    protected Number createContext(@Nonnull final TypeToken<?> typeToken) {
        return getKnownZero.apply(typeToken.getRawType());
    }

    @Override
    @Nullable
    @SuppressWarnings("NestedSwitchStatement")
    protected Number read(@Nullable final Number knownZero, @Nonnull final TypeAdapter<? extends Number> delegateAdapter, @Nonnull final JsonReader in)
            throws IOException {
        final JsonToken token = in.peek();
        switch ( token ) {
        case NUMBER:
            return delegateAdapter.read(in);
        case STRING:
            final String s = in.nextString();
            switch ( s ) {
            case "null":
            case "false":
                if ( knownZero == null ) {
                    return delegateAdapter.read(new JsonReader(new StringReader("0"))); // TODO optimize "constant" reading or cache previously unknown zero
                }
                return knownZero;
            default:
                return delegateAdapter.fromJsonTree(new JsonPrimitive(s)); // TODO optimize bypassing the intermediate JSON element
            }
        case BOOLEAN:
            final boolean b = in.nextBoolean();
            if ( !b ) {
                if ( knownZero == null ) {
                    return delegateAdapter.read(new JsonReader(new StringReader("0"))); // TODO optimize "constant" reading or cache previously unknown zero
                }
                return knownZero;
            }
            throw new JsonSyntaxException("Unhandled boolean: " + b);
        case NULL:
            return null; // TODO or zero?
        case BEGIN_ARRAY:
        case END_ARRAY:
        case BEGIN_OBJECT:
        case END_OBJECT:
        case NAME:
        case END_DOCUMENT:
            throw new JsonSyntaxException("Unhandled token: " + token);
        default:
            throw new AssertionError(token);
        }
    }

}
public final class QuirkyCollectionTypeAdapterFactory
        extends AbstractQuirkyTypeAdapterFactory<Collection<?>, Void> {

    private static final TypeAdapterFactory instance = new QuirkyCollectionTypeAdapterFactory();

    private QuirkyCollectionTypeAdapterFactory() {
    }

    public static TypeAdapterFactory getInstance() {
        return instance;
    }

    @Override
    protected boolean supports(@Nonnull final TypeToken<?> typeToken) {
        return Collection.class.isAssignableFrom(typeToken.getRawType());
    }

    @Nullable
    @Override
    protected Void createContext(@Nonnull final TypeToken<?> typeToken) {
        return null;
    }

    @Override
    @Nullable
    protected Collection<?> read(@Nullable final Void context, @Nonnull final TypeAdapter<? extends Collection<?>> delegateAdapter,
            @Nonnull final JsonReader in)
            throws IOException {
        final JsonToken token = in.peek();
        switch ( token ) {
        case BEGIN_ARRAY:
            return delegateAdapter.read(in);
        case BOOLEAN:
            final boolean b = in.nextBoolean();
            if ( !b ) {
                return delegateAdapter.read(new JsonReader(new StringReader("[]"))); // TODO optimize "constant" reading (caching is not possible: collections are supposed be new and mutable)
            }
            throw new JsonSyntaxException("Unhandled boolean: " + b);
        case NULL:
            return null; // TODO or empty collection?
        case END_ARRAY:
        case BEGIN_OBJECT:
        case END_OBJECT:
        case NAME:
        case STRING:
        case NUMBER:
        case END_DOCUMENT:
            throw new JsonSyntaxException("Unhandled token: " + token);
        default:
            throw new AssertionError(token);
        }
    }

}

上述方法为所有三种情况实现了模板方法设计模式:布尔值、数字和数组(JSON 数组,但 Java 集合,我没有包括 Java 数组适配器为简洁起见)。

它们背后的共同逻辑如下:

  • 类型适配器工厂检查它是否可以处理给定的类型。
  • 如果可以,则向 Gson 请求委托类型适配器进行处理。
  • 创建一个泛型类型适配器,它只是将写入操作委托给原始类型适配器,但读取操作专门用于访问它的每个子类。
  • 每个读取操作都实施简单的 JSON 令牌查看,以根据您在问题中描述的怪癖来决定如何进一步进行。

具有以下JSON:

{
    "booleans": [
        true,
        false,
        0,
        1,
        "0",
        "1",
        "true",
        "false",
        null,
        "null"
    ],
    "numbers": [
        42,
        "42",
        null,
        "null",
        false,
        "false"
    ],
    "arrays": [
        [
            "foo",
            "bar"
        ],
        [],
        false,
        null
    ]
}

以下测试通过:

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@ToString
final class Data {

    @SerializedName("booleans")
    final List<Boolean> booleans;

    @SerializedName("numbers")
    final List<Number> numbers;

    @SerializedName("arrays")
    final List<List<String>> arrays;

}
public final class QuirksTest {

    private static final Gson gson = new GsonBuilder()
            .disableHtmlEscaping()
            .disableInnerClassSerialization()
            .registerTypeAdapterFactory(QuirkyBooleanTypeAdapterFactory.getInstance())
            .registerTypeAdapterFactory(QuirkyNumberTypeAdapterFactory.getInstance())
            .registerTypeAdapterFactory(QuirkyCollectionTypeAdapterFactory.getInstance())
            .create();

    @Test
    @SuppressWarnings("ReturnOfNull")
    public void test()
            throws IOException {
        try ( final JsonReader jsonReader = open("quirks.json") ) {
            final Data data = gson.fromJson(jsonReader, Data.class);
            Assertions.assertIterableEquals(
                    Arrays.asList(true, false, false, true, false, true, true, false, null, false),
                    data.booleans
            );
            Assertions.assertIterableEquals(
                    Arrays.asList(42, 42, null, 0, 0, 0),
                    data.numbers
                            .stream()
                            .map(n -> n != null ? n.intValue() : null)
                            .collect(Collectors.toList())
            );
            Assertions.assertIterableEquals(
                    Arrays.asList(
                            Arrays.asList("foo", "bar"),
                            Collections.emptyList(),
                            Collections.emptyList(),
                            null
                    ),
                    data.arrays
            );
        }
    }

}