Reputation: 946
I am on Spring Boot 2.0.6, where an entity pet
do have a Lazy many-to-one relationship to another entity owner
Pet entity
@Entity
@Table(name = "pets")
public class Pet extends AbstractPersistable<Long> {
@NonNull
private String name;
private String birthday;
@JsonIdentityInfo(generator=ObjectIdGenerators.PropertyGenerator.class, property="id")
@JsonIdentityReference(alwaysAsId=true)
@JsonProperty("ownerId")
@ManyToOne(fetch=FetchType.LAZY)
private Owner owner;
But while submitting a request like /pets
through a client(eg: PostMan), the controller.get() method run into an exception as is given below:-
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class java.lang.Long and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->com.petowner.entity.Pet["ownerId"])
at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77) ~[jackson-databind-2.9.7.jar:2.9.7]
at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1191) ~[jackson-databind-2.9.7.jar:2.9.7]
Controller.get implementation
@GetMapping("/pets")
public @ResponseBody List<Pet> get() {
List<Pet> pets = petRepository.findAll();
return pets;
}
My observations
Tried to invoke explicitly the getters within owner
through pet
to force the lazy-loading from the javaassist proxy object of owner
within the pet
. But did not work.
@GetMapping("/pets")
public @ResponseBody List<Pet> get() {
List<Pet> pets = petRepository.findAll();
pets.forEach( pet -> pet.getOwner().getId());
return pets;
}
Tried as suggested by this stackoverflow answer at https://stackoverflow.com/a/51129212/5107365 to have controller call to delegate to a service bean within the transaction scope to force lazy-loading. But that did not work too.
@Service
@Transactional(readOnly = true)
public class PetServiceImpl implements PetService {
@Autowired
private PetRepository petRepository;
@Override
public List<Pet> loadPets() {
List<Pet> pets = petRepository.findAll();
pets.forEach(pet -> pet.getOwner().getId());
return pets;
}
}
It works when Service/Controller returning a DTO created out from the entity. Obviously, the reason is JSON serializer get to work with a POJO instead of an ORM entity without any mock objects in it.
Changing the entity fetch mode to FetchType.EAGER would solve the problem, but I did not want to change it.
I am curious to know why it is thrown the exception in case of (1) and (2). Those should have forced the explicit loading of lazy objects.
Probably the answer might be connected to the life and scope of that javassist objects got created to maintain the lazy objects. Yet, wondering how would Jackson serializer not find a serializer for a java wrapper type like java.lang.Long
. Please do rememeber here that the exception thrown did indicate that Jackson serializer got access to owner.getId
as it recognised the type of the property ownerId
as java.lang.Long
.
Any clues would be highly appreciated.
The edited part from the accepted answer explains the causes. Suggestion to use a custom serializer is very useful one in case if I don't need to go in DTO's path.
I did a bit of scanning through the Jackson sources to dig down to the root causes. Thought to share that too.
Jackson caches most of the serialization metadata on first use. Logic related to the use case in discussion starts at this method com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serializeContents(Collection<?> value, JsonGenerator g, SerializerProvider provider)
. And, the respective code snippet is:-
The statement serializer = _findAndAddDynamic(serializers, cc, provider)
at Line #140 trigger the flow to assign serializers for pet
-level properties while skipping ownerId
to be later processed through serializer.serializeWithType
at line #147.
Assigning of serializers is done at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.resolve(SerializerProvider provider)
method. The respective snippet is shown below:-
Serializers are assigned at line #340 only for those properties which are confirmed as final
through the check at line #333.
When owner
comes here, its proxied properties are found to be of type com.fasterxml.jackson.databind.type.SimpleType
. Had this associated entity been loaded eagerly
, the proxied properties obviously won't be there. Instead, original properties would be found with the values that are typed with final classes like Long, String, etc. (just like the pet
properties).
Wondering why can't Jackson address this from their end by using the getter's type instead of using that of the proxied property. Anyway, that could be a different topic to discuss :-)
Upvotes: 0
Views: 3548
Reputation: 2246
This has to do with the way that Hibernate (internally what spring boot uses for JPA by default) hydrates objects. A lazy object is not loaded until some parameter of the object is requested. Hibernate returns a proxy which delegates to the dto after firing queries to hydrate the objects.
In your scenario, loading OwnerId does not help because it is the key via which you are referencing the owner object i.e. the OwnerId is already present in the Pet object, so the hydration will not take place.
In both 1 and 2, you have not actually loaded the owner object, so when Jackson tries to serialize it at the controller level it fails. In 3 and 4, the owner object has been loaded explicitly, which is why Jackson does not run into any issues.
If you want 2 to work then load some parameter of owner, other than id, and hibernate will hydrate the object, and then jackson will be able to serialize it.
Edited Answer
The problem here is with the default Jackson serializer. This inspects the class returned and fetches the value of each attribute via reflection. In the case of hibernate entities, the object returned is a delegator proxy class in which all parameters are null, but all getters are redirected to the contained instance. When the object is inspected, the values of each attribute are still null, which is defaulted to an error as explained here
So basically, you need to tell jackson how to serialize this object. You can do so by creating a serializer class
public class OwnerSerializer extends StdSerializer<Owner> {
public OwnerSerializer() {
this(null);
}
public OwnerSerializer(Class<Owner> t) {
super(t);
}
@Override
public void serialize(Owner value, JsonGenerator jgen, SerializerProvider provider)
throws IOException, JsonProcessingException {
jgen.writeStartObject();
jgen.writeNumberField("id", value.getId());
jgen.writeStringField("firstName", value.getFirstName());
jgen.writeStringField("lastName", value.getLastName());
jgen.writeEndObject();
}
}
And setting it as the default serializer for the object
@JsonSerialize(using = OwnerSerializer.class)
public class Owner extends AbstractPersistable<Long> {
Alternatively, you can create a new Object of type Owner from the proxy class, manually populate it and set it in the response.
It is a little roundabout, but as a general practice you should not expose your DTO's externally anyway. The controller/domain should be decoupled from the storage layer.
Upvotes: 1