-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support Java Records when present in JVM. #2197
Conversation
Fixes #1794 Added a TypeAdapterFactory that deals specifcally with Java 17 Records. It uses reflection to detect if the JVM supports records, and from there accesses the RecordComponent array to serialize and deserialize objects. This new TypeAdapterFactory will only be added when Records are actually supported on the JVM.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for doing this! It's great to have it.
Ideally we would use a multi-release jar file and some build magic, so you wouldn't have to do all that reflection. But that supposes quite a lot of mechanism that we don't have. Meanwhile the reflective approach is way better than what we have, which is nothing.
I have a lot of detailed comments, mostly minor. Some high-level comments:
- Could you run google-java-format over
RecordTypeAdapterFactory
? That's much easier than me quibbling over the various divergences from Google Java Style. You might want to make the change I requested to use<ul>
in a javadoc comment first, because otherwise g-j-f will make a mess of that comment. - Ideally we'd have some sort of test. Perhaps it could do
assumeTrue(RecordTypeAdapterFactory.SUPPORTS_RECORD_TYPES)
and then use reflection to make an instance ofUnixDomainPrincipal
, which seems to be the only record type in the Java 17 API. If we can serialize and deserialize that, that's at least basic test coverage. Later we can think about ways to test more things, like@SerializedName
, but let's at least do theUnixDomainPrincipal
thing here.
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks also from my side!
My initial approach would have been to 'hack' this into ReflectiveTypeAdapterFactory
to avoid duplicating much of its logic, but on the other hand that might have been very messy. However, I had not actually tried that yet and I think your approach is fine as well. We just need to pay attention in the future that ReflectiveTypeAdapterFactory
and this newly added RecordTypeAdapterFactory
don't diverge.
I am not a direct member of this project so feel free to consider my comments mostly suggestions (though I hope @eamonnmcmanus agrees with most of them).
Feel free to work on them one by one; you don't have to resolve all at once.
|
||
/** | ||
* Internal helper class to manage access to Class.isRecord, and RecordComponent instances. Since this compiles | ||
* on Java 8, we need to use reflection to handle RecordComponent instances. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gson currently compiles against Java 7. However, maybe it would be best to reword it to prevent it from becoming outdated again. Maybe something like this?
* on Java 8, we need to use reflection to handle RecordComponent instances. | |
* on Java versions which don't have records, we need to use reflection to handle RecordComponent instances. |
private final Method getAnnotation; | ||
|
||
/** | ||
* Create a new RecordHelper to handle reflection for RecordComponent on Java 17. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
* Create a new RecordHelper to handle reflection for RecordComponent on Java 17. | |
* Create a new RecordHelper to handle reflection for RecordComponent. |
import com.google.gson.stream.JsonWriter; | ||
|
||
/** | ||
* {@link RecordFieldFactory} to create adapters for Java 17 records. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
* {@link RecordFieldFactory} to create adapters for Java 17 records. | |
* {@link RecordFieldFactory} to create adapters for Java 16 records. |
(see https://www.oracle.com/java/technologies/javase/16-relnote-issues.html#JDK-8246771)
Records were already available as preview feature in Java 15; not sure if that requires any special handling here, or if we just assume that either:
- no one is using Java 15 anymore since it is not LTS
- users who enable preview features are aware that they might not work properly with libraries (such as Gson)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the code here doesn't work with Java 15 and --enable-preview
, I think that is absolutely fine. People shouldn't be using that configuration in production.
|
||
boolean isRecord(TypeToken<?> type) { | ||
try { | ||
return isRecord != null && Boolean.TRUE.equals(isRecord.invoke(type)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The null
check seems redundant; the constructor already made sure it is not null
return isRecord != null && Boolean.TRUE.equals(isRecord.invoke(type)); | |
return Boolean.TRUE.equals(isRecord.invoke(type)); |
} | ||
} | ||
|
||
boolean isRecord(TypeToken<?> type) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't the parameter type be Class
? Otherwise the invoke
call below will fail.
} | ||
} | ||
|
||
public static class DeSerializeAccessException extends RuntimeException { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if it is a good idea to introduce these exception types here. Arguably Gson has a bad exception hierarchy, but creating separate specific exceptions here might not make it better. TypeAdapterReflectionException
is fine though in my opinion.
I think for the case where this exception is currently thrown (constructor.newInstance
), it would also be acceptable to throw a TypeAdapterReflectionException
instead.
gson/src/main/java/com/google/gson/internal/bind/RecordTypeAdapterFactory.java
Outdated
Show resolved
Hide resolved
@@ -332,9 +333,17 @@ public Gson() { | |||
this.jsonAdapterFactory = new JsonAdapterAnnotationTypeAdapterFactory(constructorConstructor); | |||
factories.add(jsonAdapterFactory); | |||
factories.add(TypeAdapters.ENUM_FACTORY); | |||
|
|||
// If we are on Java 17, we want to include the RecordTypeAdapterFactory before the ReflectiveTypeAdapterFactory, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// If we are on Java 17, we want to include the RecordTypeAdapterFactory before the ReflectiveTypeAdapterFactory, | |
// If we are on Java 16 or newer, we want to include the RecordTypeAdapterFactory before the ReflectiveTypeAdapterFactory, |
// If we are on Java 17, we want to include the RecordTypeAdapterFactory before the ReflectiveTypeAdapterFactory, | ||
// so that we intercept all Record types first. | ||
if (RecordTypeAdapterFactory.SUPPORTS_RECORD_TYPES) { | ||
factories.add(new RecordTypeAdapterFactory(this.excluder, constructorConstructor, jsonAdapterFactory)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For consistency:
factories.add(new RecordTypeAdapterFactory(this.excluder, constructorConstructor, jsonAdapterFactory)); | |
factories.add(new RecordTypeAdapterFactory(excluder, constructorConstructor, jsonAdapterFactory)); |
JsonAdapter jsonAdapter = RECORD_HELPER.getAnnotation(recordComponent, JsonAdapter.class); | ||
TypeAdapter<?> typeAdapter; | ||
if (jsonAdapter == null) { | ||
typeAdapter = gson.getAdapter(fieldType); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to ReflectiveTypeAdapterFactory
this should probably wrap the adapter in a TypeAdapterRuntimeTypeWrapper
.
} | ||
|
||
private RecordField createRecordField(Field field, Object recordComponent) { | ||
TypeToken<?> fieldType = TypeToken.get(RECORD_HELPER.getGenericType(recordComponent)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably has to resolve the field type against the parameterized record type (in case the record type is generic), see ReflectiveTypeAdapterFactory
code:
gson/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java
Line 205 in 2154e46
Type fieldType = $Gson$Types.resolve(type.getType(), raw, field.getGenericType()); |
// present, we need to respect it. This might also mean that multiple keys acts as aliases | ||
// when reading Json. | ||
SerializedName serializedNameAnnotation = | ||
RECORD_HELPER.getAnnotation(recordComponent, SerializedName.class); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if that actually works. SerializedName
does not have RECORD_COMPONENT
as target (and cannot have it as target if we want to still compile against Java 7).
However, you can obtain the annotation from the private field because it is propagated, see Java Language Specification:
A component field is annotated with the annotations, if any, that appear on the corresponding record component and whose annotation interfaces are applicable in the field declaration context
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, that's unfortunate. It's still worth adding support for records, but I suppose we won't be able to serialize their members using a name other than their own. One possible workaround would be to look for an annotation on the property method:
record Foo(int bar) {
@SerializedName("baz")
int bar() {
return bar;
}
}
Another possibility would be to remove the @Target
restriction on @SerializedName
, but that would be a bit sad.
I'm not sure I understand the comment about obtaining the annotation from the private field. If we could get @SerializedName
onto the record component then it would indeed be copied onto the field, but how would we do that?
Anyway, I'm fine with this initial version not supporting names other than the Java name of the record component.
This is all making me think that we should have tests that run on Java ≥16, presumably using Maven profiles. Again that doesn't have to be in this PR, though perhaps you could add a test and then just exclude it for now. The exclusion could look like this and go here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we could get
@SerializedName
onto the record component then it would indeed be copied onto the field, but how would we do that?
Java allows to place @SerializedName
on record components because it has FIELD
as target. It won't be available on the component using reflection then, but on the backing field.
Just try it out (e.g. with jshell
):
import java.lang.annotation.*;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@interface MyAnnotation {}
record R(@MyAnnotation int i) {}
R.class.getRecordComponents()[0].getAnnotations() // empty array
R.class.getDeclaredFields()[0].getAnnotations() // Annotation[1] { @MyAnnotation() }
The annotation is also propagated to the accessor method if it has METHOD
as target, however only if the accessor is implicit. If the user explicitly defines the accessor method the annotation won't be propagated. So the more reliable approach would be to obtain the annotation from the field.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, well that's OK. We should do that.
I've now configured the GitHub actions so that builds are done on both JDK 11 and JDK 17. So if we add a reflection-based test it should actually run on JDK 17. Ideally we would instead add a test that has actual records in it, and arrange for that to be excluded on Java 11. That would allow us to test the @SerializedName
logic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Later if desired some of the tests from my gson-record-type-adapter-factory
can be used. Though not all test cases are relevant for this implementation here.
String serializedName; | ||
String[] deserializedNames; | ||
if (serializedNameAnnotation == null) { | ||
serializedName = RECORD_HELPER.getName(recordComponent); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe this should pass the private field to the FieldNamingStrategy
to behave similarly to ReflectiveTypeAdapterFactory
.
// Yes -> Not supported, we return null as per the TypeAdapterFactory contract | ||
return null; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably this should also check the ReflectionAccessFilter
:
gson/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java
Lines 104 to 108 in 2154e46
FilterResult filterResult = ReflectionAccessFilterHelper.getFilterResult(reflectionFilters, raw); | |
if (filterResult == FilterResult.BLOCK_ALL) { | |
throw new JsonIOException("ReflectionAccessFilter does not permit using reflection for " | |
+ raw + ". Register a TypeAdapter for this type or adjust the access filter."); | |
} |
For example a user might only permit reflection based serialization of classes in a model
package but disallow (accidental) serialization of classes in an implementation
package.
// There is no method in the Java API to do the equivialent, instead we rely on the constructor and | ||
// recordComponent order to be the same. This construct matches the StackOverflow answer here: | ||
// https://stackoverflow.com/a/67127067 | ||
Constructor<T> recordConstructor = (Constructor<T>) type.getRawType().getConstructor(componentTypes); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The implicit canonical constructor has the same visibility as the record class, see https://docs.oracle.com/javase/specs/jls/se17/html/jls-8.html#jls-8.10.4.
So maybe it would make sense to try make the constructor accessible to support non-public records as well without requiring the user to specify an explicit public
constructor. Possibly in a similar way to how ConstructorConstructor.newDefaultConstructor
does it (with the ReflectionAccessFilter
checks).
|
||
private RecordField createRecordField(Field field, Object recordComponent) { | ||
TypeToken<?> fieldType = TypeToken.get(RECORD_HELPER.getGenericType(recordComponent)); | ||
JsonAdapter jsonAdapter = RECORD_HELPER.getAnnotation(recordComponent, JsonAdapter.class); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to https://github.com/google/gson/pull/2197/files#r978712348, I think the annotation has to be obtained from the private field instead of the component.
Ran Google code format on RecordTypeAdapterFactory so that it now conforms to Google style. Added primitive default values for primitive fields, to avoid NPE in the constructor.
I think this class is small enough to not impose any costs. Multi release-jars are a bit of a pain IMHO., harder to onboard new developers with multiple source trees, harder to reason about when files are spread out, and one more consideration for anyone consuming the library, including if they update their own build from for example Java 11 to 17.
I have fixed this and your other comments, as well as run Google code format.
I will look into this. But this would require to run the build on Java 17 unless I am mistaken. There is something called toolchains for Maven that supports different compilers, and could be set up to use Java 17 on tests. However, the plugin does not support download, and this would require additional configuration on each user by adding a toolchain.xml. to their .m2 folder. This is actually a bit easier on Gradle (where I have done something similar), as it can actually download the required jdks from public sources. |
I have created a new pull request with that solution instead: #2201 I have not looked at your code comments yet, but will get to those later in this pull request. |
Thanks! That pull request looks a bit more promising in my opinion because it avoids duplicating If you only want to continue working on the new pull request, it might be good at the end to have a look at the review comments here again nonetheless; maybe some of them also apply to the new pull request. |
// might differ from the source. | ||
Integer fieldIndex = recordFieldIndex.get(fieldName); | ||
if (fieldIndex != null) { | ||
values[fieldIndex] = recordFields[fieldIndex].readValueForKey(in); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi, I was also working on a record type adapter when I came across this PR.
After playing around a bit with it, I think there is a missing null check here:
values[fieldIndex] = recordFields[fieldIndex].readValueForKey(in); | |
Object value = recordFields[fieldIndex].readValueForKey(in); | |
if(value != null){ | |
values[fieldIndex] = value | |
} |
Without this, the default values for primitives is overwritten with null.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possibly the null
check should only be applied if the component type is a primitive to allow duplicate non-primitive members to overwrite each others values, to be consistent with ReflectiveTypeAdapterFactory
's behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you only want to continue working on the new pull request, it might be good at the end to have a look at the review comments here again nonetheless; maybe some of them also apply to the new pull request.
I think the new pull request is the way to go, as it is a bit more integrated with the normal reflection flow. I will still check these reviews to see if any apply. Thanks for the input.
Closing this in favor of #2201 - which is a bit more integrated with the rest of the Gson codebase. |
Fixes #1794
Added a TypeAdapterFactory that deals specifcally with Java 17 Records. It uses reflection to detect if the JVM supports records, and from there accesses the RecordComponent array to serialize and deserialize objects.
This new TypeAdapterFactory will only be added when Records are actually supported on the JVM.