502d0ba72ccdd0da2b77cfca67e4cc5e3c25a87c
[fanfix.git] / src / be / nikiroo / utils / serial / SerialUtils.java
1 package be.nikiroo.utils.serial;
2
3 import java.io.IOException;
4 import java.io.NotSerializableException;
5 import java.lang.reflect.Array;
6 import java.lang.reflect.Constructor;
7 import java.lang.reflect.Field;
8 import java.lang.reflect.Modifier;
9 import java.net.URL;
10 import java.util.ArrayList;
11 import java.util.HashMap;
12 import java.util.List;
13 import java.util.Map;
14 import java.util.UnknownFormatConversionException;
15
16 import be.nikiroo.utils.Image;
17
18 /**
19 * Small class to help with serialisation.
20 * <p>
21 * Note that we do not support inner classes (but we do support nested classes)
22 * and all objects require an empty constructor to be deserialised.
23 * <p>
24 * It is possible to add support to custom types (both the encoder and the
25 * decoder will require the custom classes) -- see {@link CustomSerializer}.
26 * <p>
27 * Default supported types are:
28 * <ul>
29 * <li>NULL (as a null value)</li>
30 * <li>String</li>
31 * <li>Boolean</li>
32 * <li>Byte</li>
33 * <li>Character</li>
34 * <li>Short</li>
35 * <li>Long</li>
36 * <li>Float</li>
37 * <li>Double</li>
38 * <li>Integer</li>
39 * <li>Enum (any enum whose name and value is known by the caller)</li>
40 * <li>java.awt.image.BufferedImage (as a {@link CustomSerializer})</li>
41 * <li>An array of the above (as a {@link CustomSerializer})</li>
42 * <li>URL</li>
43 * </ul>
44 *
45 * @author niki
46 */
47 public class SerialUtils {
48 private static Map<String, CustomSerializer> customTypes;
49
50 static {
51 customTypes = new HashMap<String, CustomSerializer>();
52
53 // Array types:
54 customTypes.put("[]", new CustomSerializer() {
55 @Override
56 protected String toString(Object value) {
57 String type = value.getClass().getCanonicalName();
58 type = type.substring(0, type.length() - 2); // remove the []
59
60 StringBuilder builder = new StringBuilder();
61 builder.append(type).append("\n");
62 try {
63 for (int i = 0; true; i++) {
64 Object item = Array.get(value, i);
65 // encode it normally if direct value
66 if (!SerialUtils.encode(builder, item)) {
67 try {
68 // use ZIP: if not
69 builder.append(new Exporter().append(item)
70 .toString(true));
71 } catch (NotSerializableException e) {
72 throw new UnknownFormatConversionException(e
73 .getMessage());
74 }
75 }
76 builder.append("\n");
77 }
78 } catch (ArrayIndexOutOfBoundsException e) {
79 // Done.
80 }
81
82 return builder.toString();
83 }
84
85 @Override
86 protected String getType() {
87 return "[]";
88 }
89
90 @Override
91 protected Object fromString(String content) throws IOException {
92 String[] tab = content.split("\n");
93
94 try {
95 Object array = Array.newInstance(
96 SerialUtils.getClass(tab[0]), tab.length - 1);
97 for (int i = 1; i < tab.length; i++) {
98 Object value = new Importer().read(tab[i]).getValue();
99 Array.set(array, i - 1, value);
100 }
101
102 return array;
103 } catch (Exception e) {
104 if (e instanceof IOException) {
105 throw (IOException) e;
106 }
107 throw new IOException(e.getMessage());
108 }
109 }
110 });
111
112 // URL:
113 customTypes.put("java.net.URL", new CustomSerializer() {
114 @Override
115 protected String toString(Object value) {
116 if (value != null) {
117 return ((URL) value).toString();
118 }
119 return null;
120 }
121
122 @Override
123 protected Object fromString(String content) throws IOException {
124 if (content != null) {
125 return new URL(content);
126 }
127 return null;
128 }
129
130 @Override
131 protected String getType() {
132 return "java.net.URL";
133 }
134 });
135
136 // Images (this is currently the only supported image type by default)
137 customTypes.put("be.nikiroo.utils.Image", new CustomSerializer() {
138 @Override
139 protected String toString(Object value) {
140 return ((Image) value).toBase64();
141 }
142
143 @Override
144 protected String getType() {
145 return "be.nikiroo.utils.Image";
146 }
147
148 @Override
149 protected Object fromString(String content) {
150 try {
151 return new Image(content);
152 } catch (IOException e) {
153 throw new UnknownFormatConversionException(e.getMessage());
154 }
155 }
156 });
157 }
158
159 /**
160 * Create an empty object of the given type.
161 *
162 * @param type
163 * the object type (its class name)
164 *
165 * @return the new object
166 *
167 * @throws ClassNotFoundException
168 * if the class cannot be found
169 * @throws NoSuchMethodException
170 * if the given class is not compatible with this code
171 */
172 public static Object createObject(String type)
173 throws ClassNotFoundException, NoSuchMethodException {
174
175 String desc = null;
176 try {
177 Class<?> clazz = getClass(type);
178 String className = clazz.getName();
179 List<Object> args = new ArrayList<Object>();
180 List<Class<?>> classes = new ArrayList<Class<?>>();
181 Constructor<?> ctor = null;
182 if (className.contains("$")) {
183 for (String parentName = className.substring(0,
184 className.lastIndexOf('$'));; parentName = parentName
185 .substring(0, parentName.lastIndexOf('$'))) {
186 Object parent = createObject(parentName);
187 args.add(parent);
188 classes.add(parent.getClass());
189
190 if (!parentName.contains("$")) {
191 break;
192 }
193 }
194
195 // Better error description in case there is no empty
196 // constructor:
197 desc = "";
198 String end = "";
199 for (Class<?> parent = clazz; parent != null
200 && !parent.equals(Object.class); parent = parent
201 .getSuperclass()) {
202 if (!desc.isEmpty()) {
203 desc += " [:";
204 end += "]";
205 }
206 desc += parent;
207 }
208 desc += end;
209 //
210
211 try {
212 ctor = clazz.getDeclaredConstructor(classes
213 .toArray(new Class[] {}));
214 } catch (NoSuchMethodException nsme) {
215 // TODO: it seems e do not always need a parameter for each
216 // level, so we currently try "ALL" levels or "FIRST" level
217 // only -> we should check the actual rule and use it
218 ctor = clazz.getDeclaredConstructor(classes.get(0));
219 Object firstParent = args.get(0);
220 args.clear();
221 args.add(firstParent);
222 }
223 desc = null;
224 } else {
225 ctor = clazz.getDeclaredConstructor();
226 }
227
228 ctor.setAccessible(true);
229 return ctor.newInstance(args.toArray());
230 } catch (ClassNotFoundException e) {
231 throw e;
232 } catch (NoSuchMethodException e) {
233 if (desc != null) {
234 throw new NoSuchMethodException("Empty constructor not found: "
235 + desc);
236 }
237 throw e;
238 } catch (Exception e) {
239 throw new NoSuchMethodException("Cannot instantiate: " + type);
240 }
241 }
242
243 /**
244 * Insert a custom serialiser that will take precedence over the default one
245 * or the target class.
246 *
247 * @param serializer
248 * the custom serialiser
249 */
250 static public void addCustomSerializer(CustomSerializer serializer) {
251 customTypes.put(serializer.getType(), serializer);
252 }
253
254 /**
255 * Serialise the given object into this {@link StringBuilder}.
256 * <p>
257 * <b>Important: </b>If the operation fails (with a
258 * {@link NotSerializableException}), the {@link StringBuilder} will be
259 * corrupted (will contain bad, most probably not importable data).
260 *
261 * @param builder
262 * the output {@link StringBuilder} to serialise to
263 * @param o
264 * the object to serialise
265 * @param map
266 * the map of already serialised objects (if the given object or
267 * one of its descendant is already present in it, only an ID
268 * will be serialised)
269 *
270 * @throws NotSerializableException
271 * if the object cannot be serialised (in this case, the
272 * {@link StringBuilder} can contain bad, most probably not
273 * importable data)
274 */
275 static void append(StringBuilder builder, Object o, Map<Integer, Object> map)
276 throws NotSerializableException {
277
278 Field[] fields = new Field[] {};
279 String type = "";
280 String id = "NULL";
281
282 if (o != null) {
283 int hash = System.identityHashCode(o);
284 fields = o.getClass().getDeclaredFields();
285 type = o.getClass().getCanonicalName();
286 if (type == null) {
287 // Anonymous inner classes support
288 type = o.getClass().getName();
289 }
290 id = Integer.toString(hash);
291 if (map.containsKey(hash)) {
292 fields = new Field[] {};
293 } else {
294 map.put(hash, o);
295 }
296 }
297
298 builder.append("{\nREF ").append(type).append("@").append(id)
299 .append(":");
300 if (!encode(builder, o)) { // check if direct value
301 try {
302 for (Field field : fields) {
303 field.setAccessible(true);
304
305 if (field.getName().startsWith("this$")
306 || field.isSynthetic()
307 || (field.getModifiers() & Modifier.STATIC) == Modifier.STATIC) {
308 // Do not keep this links of nested classes
309 // Do not keep synthetic fields
310 // Do not keep final fields
311 continue;
312 }
313
314 builder.append("\n");
315 builder.append(field.getName());
316 builder.append(":");
317 Object value;
318
319 value = field.get(o);
320
321 if (!encode(builder, value)) {
322 builder.append("\n");
323 append(builder, value, map);
324 }
325 }
326 } catch (IllegalArgumentException e) {
327 e.printStackTrace(); // should not happen (see
328 // setAccessible)
329 } catch (IllegalAccessException e) {
330 e.printStackTrace(); // should not happen (see
331 // setAccessible)
332 }
333 }
334 builder.append("\n}");
335 }
336
337 /**
338 * Encode the object into the given builder if possible and if supported.
339 * <p>
340 * A supported object in this context means an object we can directly
341 * encode, like an Integer or a String. Custom objects and arrays are also
342 * considered supported, but <b>compound objects are not supported here</b>.
343 * <p>
344 * For compound objects, you should use {@link Exporter}.
345 *
346 * @param builder
347 * the builder to append to
348 * @param value
349 * the object to encode (can be NULL, which will be encoded)
350 *
351 * @return TRUE if success, FALSE if not (the content of the builder won't
352 * be changed in case of failure)
353 */
354 static boolean encode(StringBuilder builder, Object value) {
355 if (value == null) {
356 builder.append("NULL");
357 } else if (value.getClass().getSimpleName().endsWith("[]")) {
358 // Simple name does support [] suffix and do not return NULL for
359 // inner anonymous classes
360 return customTypes.get("[]").encode(builder, value);
361 } else if (customTypes.containsKey(value.getClass().getCanonicalName())) {
362 return customTypes.get(value.getClass().getCanonicalName())//
363 .encode(builder, value);
364 } else if (value instanceof String) {
365 encodeString(builder, (String) value);
366 } else if (value instanceof Boolean) {
367 builder.append(value);
368 } else if (value instanceof Byte) {
369 builder.append(value).append('b');
370 } else if (value instanceof Character) {
371 encodeString(builder, "" + value);
372 builder.append('c');
373 } else if (value instanceof Short) {
374 builder.append(value).append('s');
375 } else if (value instanceof Integer) {
376 builder.append(value);
377 } else if (value instanceof Long) {
378 builder.append(value).append('L');
379 } else if (value instanceof Float) {
380 builder.append(value).append('F');
381 } else if (value instanceof Double) {
382 builder.append(value).append('d');
383 } else if (value instanceof Enum) {
384 String type = value.getClass().getCanonicalName();
385 builder.append(type).append(".").append(((Enum<?>) value).name())
386 .append(";");
387 } else {
388 return false;
389 }
390
391 return true;
392 }
393
394 /**
395 * Decode the data into an equivalent supported source object.
396 * <p>
397 * A supported object in this context means an object we can directly
398 * encode, like an Integer or a String. Custom objects and arrays are also
399 * considered supported, but <b>compound objects are not supported here</b>.
400 * <p>
401 * For compound objects, you should use {@link Importer}.
402 *
403 * @param encodedValue
404 * the encoded data, cannot be NULL
405 *
406 * @return the object (can be NULL for NULL encoded values)
407 *
408 * @throws IOException
409 * if the content cannot be converted
410 */
411 static Object decode(String encodedValue) throws IOException {
412 try {
413 String cut = "";
414 if (encodedValue.length() > 1) {
415 cut = encodedValue.substring(0, encodedValue.length() - 1);
416 }
417
418 if (CustomSerializer.isCustom(encodedValue)) {
419 // custom:TYPE_NAME:"content is String-encoded"
420 String type = CustomSerializer.typeOf(encodedValue);
421 if (customTypes.containsKey(type)) {
422 return customTypes.get(type).decode(encodedValue);
423 }
424 throw new IOException("Unknown custom type: " + type);
425 } else if (encodedValue.equals("NULL")
426 || encodedValue.equals("null")) {
427 return null;
428 } else if (encodedValue.endsWith("\"")) {
429 return decodeString(encodedValue);
430 } else if (encodedValue.equals("true")) {
431 return true;
432 } else if (encodedValue.equals("false")) {
433 return false;
434 } else if (encodedValue.endsWith("b")) {
435 return Byte.parseByte(cut);
436 } else if (encodedValue.endsWith("c")) {
437 return decodeString(cut).charAt(0);
438 } else if (encodedValue.endsWith("s")) {
439 return Short.parseShort(cut);
440 } else if (encodedValue.endsWith("L")) {
441 return Long.parseLong(cut);
442 } else if (encodedValue.endsWith("F")) {
443 return Float.parseFloat(cut);
444 } else if (encodedValue.endsWith("d")) {
445 return Double.parseDouble(cut);
446 } else if (encodedValue.endsWith(";")) {
447 return decodeEnum(encodedValue);
448 } else {
449 return Integer.parseInt(encodedValue);
450 }
451 } catch (Exception e) {
452 if (e instanceof IOException) {
453 throw (IOException) e;
454 }
455 throw new IOException(e.getMessage());
456 }
457 }
458
459 /**
460 * Return the corresponding class or throw an {@link Exception} if it
461 * cannot.
462 *
463 * @param type
464 * the class name to look for
465 *
466 * @return the class (will never be NULL)
467 *
468 * @throws ClassNotFoundException
469 * if the class cannot be found
470 * @throws NoSuchMethodException
471 * if the class cannot be created (usually because it or its
472 * enclosing class doesn't have an empty constructor)
473 */
474 static private Class<?> getClass(String type)
475 throws ClassNotFoundException, NoSuchMethodException {
476 Class<?> clazz = null;
477 try {
478 clazz = Class.forName(type);
479 } catch (ClassNotFoundException e) {
480 int pos = type.length();
481 pos = type.lastIndexOf(".", pos);
482 if (pos >= 0) {
483 String parentType = type.substring(0, pos);
484 String nestedType = type.substring(pos + 1);
485 Class<?> javaParent = null;
486 try {
487 javaParent = getClass(parentType);
488 parentType = javaParent.getName();
489 clazz = Class.forName(parentType + "$" + nestedType);
490 } catch (Exception ee) {
491 }
492
493 if (javaParent == null) {
494 throw new NoSuchMethodException(
495 "Class not found: "
496 + type
497 + " (the enclosing class cannot be created: maybe it doesn't have an empty constructor?)");
498 }
499 }
500 }
501
502 if (clazz == null) {
503 throw new ClassNotFoundException("Class not found: " + type);
504 }
505
506 return clazz;
507 }
508
509 @SuppressWarnings({ "unchecked", "rawtypes" })
510 private static Enum<?> decodeEnum(String escaped) {
511 // escaped: be.xxx.EnumType.VALUE;
512 int pos = escaped.lastIndexOf(".");
513 String type = escaped.substring(0, pos);
514 String name = escaped.substring(pos + 1, escaped.length() - 1);
515
516 try {
517 return Enum.valueOf((Class<Enum>) getClass(type), name);
518 } catch (Exception e) {
519 throw new UnknownFormatConversionException("Unknown enum: <" + type
520 + "> " + name);
521 }
522 }
523
524 // aa bb -> "aa\tbb"
525 private static void encodeString(StringBuilder builder, String raw) {
526 builder.append('\"');
527 for (char car : raw.toCharArray()) {
528 switch (car) {
529 case '\\':
530 builder.append("\\\\");
531 break;
532 case '\r':
533 builder.append("\\r");
534 break;
535 case '\n':
536 builder.append("\\n");
537 break;
538 case '"':
539 builder.append("\\\"");
540 break;
541 default:
542 builder.append(car);
543 break;
544 }
545 }
546 builder.append('\"');
547 }
548
549 // "aa\tbb" -> aa bb
550 private static String decodeString(String escaped) {
551 StringBuilder builder = new StringBuilder();
552
553 boolean escaping = false;
554 for (char car : escaped.toCharArray()) {
555 if (!escaping) {
556 if (car == '\\') {
557 escaping = true;
558 } else {
559 builder.append(car);
560 }
561 } else {
562 switch (car) {
563 case '\\':
564 builder.append('\\');
565 break;
566 case 'r':
567 builder.append('\r');
568 break;
569 case 'n':
570 builder.append('\n');
571 break;
572 case '"':
573 builder.append('"');
574 break;
575 }
576 escaping = false;
577 }
578 }
579
580 return builder.substring(1, builder.length() - 1);
581 }
582 }