spring-bootからNeo4jを利用する場合、spring-data-neo4j(neo4j-ogm)と言ったライブラリでアクセスする事になります。
その際、バグと思われる現象に遭遇しましたので、忘れないように記しておきます。
概要
ValueObjectの配列またはリストをフィールドにもつEntityを作成します。
Neo4jにValueObjectをNodeとして登録させたくない為、フィールドをコンバータで文字列シリアライズしSaveを行いました。
Saveは正常に行われ、Neo4jへのストアされた内容も想定通りのものでしたが、このエンティティをLoadすると、コンバータの処理で型キャストエラーが発生しLoadに失敗します。
詳細
- LoadできるEntity
下記のEntityはSave、Loadが行えます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | @Data @NoArgsConstructor public class EntityA { @GraphId private Long id; private String name; private List<String> stringList = new ArrayList<>(); private List<Integer> integerList = new ArrayList<>(); public EntityA(String name) { super(); this.name = name; } public EntityA addStringList(String val) { this.stringList.add(val); return this; } public EntityA addIntegerList(Integer val) { this.integerList.add(val); return this; } } |
- LoadできないEntity
下記のEntityはSaveできるが、Load時にエラーが発生する
ValueObjectのListフィールドを追加する。フィールドをコンバーターにより文字列(Json)に変換する
1 2 | @Convert(VoListConverter.class) private List<ValueObject> objectList = new ArrayList<>(); |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | public abstract class ListValuesConverter<T> implements AttributeConverter<List<T>, String> { protected ObjectMapper graphNodeConverter = new ObjectMapper(); private final Class<?> type; public ListValuesConverter() { super(); this.type = elementType(); } /** * {@inheritDoc} */ @Override public String toGraphProperty(List<T> value) { try { return graphNodeConverter.writeValueAsString(value); } catch (JsonProcessingException e) { throw new RuntimeException(e); } } /** * {@inheritDoc} */ @Override public List<T> toEntityAttribute(String value) { try { return graphNodeConverter.readValue(value, entityAttributeType()); } catch (IOException e) { throw new RuntimeException(e); } } protected JavaType entityAttributeType(){ return graphNodeConverter.getTypeFactory().constructCollectionLikeType(List.class, type); } protected Class<?> elementType(){ return GenericTypeResolver.resolveTypeArgument(getClass(), ListValuesConverter.class); } } |
1 2 3 | public class VoListConverter extends ListValuesConverter<ValueObject> { } |
このエンティティをSave後にLoadを実行すると、下記のような例外が発生し失敗します。スタックトレースから、キャストエラーが確認できます。
1 2 3 4 5 6 7 | Caused by: java.lang.ClassCastException: java.util.ArrayList cannot be cast to java.lang.String at jp.co.seamark.neo4j.converter.ListValuesConverter.toEntityAttribute(ListValuesConverter.java:1) at org.neo4j.ogm.entity.io.FieldWriter.write(FieldWriter.java:60) at org.neo4j.ogm.context.GraphEntityMapper.writeProperty(GraphEntityMapper.java:225) at org.neo4j.ogm.context.GraphEntityMapper.setProperties(GraphEntityMapper.java:185) at org.neo4j.ogm.context.GraphEntityMapper.mapNodes(GraphEntityMapper.java:152) at org.neo4j.ogm.context.GraphEntityMapper.mapEntities(GraphEntityMapper.java:136) |
ここで発生している例外がListからStringへの変換例外だった為、なぜ発生するのか原因がわかりませんでした。Load時に行う処理は文字列(JSON)からValueObjectへの変換のはずです。なぜListを文字列にCastしようとしているのか、、、
原因
スタックトレースを追っていくと、GraphEntityMapperクラスに問題がありそうです。
当該箇所を確認してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | private void writeProperty(ClassInfo classInfo, Object instance, Property<?, ?> property) { FieldInfo writer = classInfo.getFieldInfo(property.getKey().toString()); if (writer == null) { logger.debug("Unable to find property: {} on class: {} for writing", property.getKey(), classInfo.name()); } else { Object value = property.getValue(); // merge iterable / arrays and co-erce to the correct attribute type <span style="color: #ff0000"> if (writer.type().isArray() || Iterable.class.isAssignableFrom(writer.type())) { FieldInfo reader = classInfo.getFieldInfo(property.getKey().toString()); if (reader != null) { Class<?> paramType = writer.type(); Class elementType = underlyingElementType(classInfo, property.getKey().toString()); if (paramType.isArray()) { value = EntityAccessManager.merge(paramType, value, new Object[] {}, elementType); } else { value = EntityAccessManager.merge(paramType, value, Collections.emptyList(), elementType); } } }</span> writer.write(instance, value); } } |
配列、Listの場合の分岐があります。どうやら、デシリアライズ先のフィールドがList(配列)の場合のみここで型の変換を行っています。
Neo4jのNodeは、プロパティに数値、文字列、真偽値またはそれらの配列を持つ事ができます。そのため為のロジックだと思いますが、コンバータの有無を確認せずに型変換を行っています。Writerの中でCallされるコンバータは文字列を期待ていますがListが渡される為、文字列にキャストを行おうとし例外となりました。
対応
色々試行錯誤した結果、当該部分を削除する対応を取りました。
テストして見る限り、動作に変わりは無いようなのですが、
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | private void writeProperty(ClassInfo classInfo, Object instance, Property<?, ?> property) { PropertyWriter writer = EntityAccessManager.getPropertyWriter(classInfo, property.getKey().toString()); if (writer == null) { logger.debug("Unable to find property: {} on class: {} for writing", property.getKey(), classInfo.name()); } else { Object value = property.getValue(); // merge iterable / arrays and co-erce to the correct attribute type <span style="color: #ff0000">/* if (writer.type().isArray() || Iterable.class.isAssignableFrom(writer.type())) { PropertyReader reader = EntityAccessManager.getPropertyReader(classInfo, property.getKey().toString()); if (reader != null) { Object currentValue = reader.readProperty(instance); Class<?> paramType = writer.type(); Class elementType = underlyingElementType(classInfo, property.getKey().toString()); if (paramType.isArray()) { value = EntityAccess.merge(paramType, value, (Object[]) currentValue, elementType); } else { value = EntityAccess.merge(paramType, value, (Collection) currentValue, elementType); } } }*/</span> writer.write(instance, value); } } |
まとめ
spring-data-neo4jでList(配列)フィールドにコンバーターを設定する場合は、本現象が発生します。
また、本現象ですが、キャッシュオブジェクトが利用されると再現しませんのでご注意ください。凄くハマりました(–;