В наших пакетных заданиях для импорта данных у нас было много похожих классов для хранения импортируемых данных. Технически они все разные, с разными полями, но концептуально они все одинаковые. Я нахожу это концептуальное дублирование неприятным и написал один, более общий класс, чтобы заменить их всех.
Рефакторинг был вдохновлен Clojure и его предпочтением нескольких общих структур, таких как карты с множеством функций, по сравнению с OO-способом многих структур данных (например, классов), как это объясняется, например, в этом интервью Rich Hickey , начиная с « ОО может серьезно помешать повторному использованию ».
Оригинальный код:
01
02
03
04
05
06
07
08
09
10
|
public interface LogEntry { Date getTimestamp(); /** For dumping into a .tsv file to be imported into Hadoop */ String toTabDelimitedString(); String getSearchTerms(); boolean hasSearchTerms(); String[][] getColumns(); String getKey(); void mergeWith(LogEntry entry); } |
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// Another impl. of LogEntry, to showcase the conceptual and, // to a degree, factual duplication public class PlayerEventLogEntry implements LogEntry { public static final String[][] COLUMNS = { ... } private String userId; private String contentId; private String sessionId; ... public PlayerEventLogEntry(...) { /* assign all fields ... */ } @Override public String[][] getColumns() { return COLUMNS; } ... @Override public String toTabDelimitedString() { StringBuilder sb= new StringBuilder(); sb.append(contentId); sb.append(FIELD_DELIMITER); sb.append(userId); sb.append(FIELD_DELIMITER); sb.append(sessionId); sb.append(FIELD_DELIMITER); ... return sb.toString(); } } |
01
02
03
04
05
06
07
08
09
10
11
|
// Called from a data import job to process individual log lines. // Some time later, the job will call toTabDelimitedString on it. public class PlayerEventsParser ... { @Override public LogEntry parse(String logLine) throws LogParseException { ... // tokenizing, processing etc. of the data ... return new PlayerEventLogEntry(userId, contentId, sessionId, ...); } } |
01
02
03
04
05
06
07
08
09
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
// One of 15 implementations of LogEntry for import of right granted event logs public class RightGrantedLogEntry implements LogEntry { private static final char FIELD_DELIMITER = '\t' ; public static final String[][] COLUMNS = { { "messageId" , "STRING" } { "userId" , "STRING" },{ "rightId" , "STRING" }, ... }; private String messageId; private Date timestamp; private String userId; private String rightId; ... public RightGrantedLogEntry(String messageId, Date timestamp, String userId, String rightId, ...) { this .messageId=messageId; this .timestamp=timestamp; this .userId=userId; this .rightId=rightId; ... } @Override public String[][] getColumns() { return RightGrantedLogEntry.COLUMNS; } @Override public String getKey() { return messageId; } @Override public String getSearchTerms() { return null ; } @Override public Date getTimestamp() { return timestamp; } @Override public boolean hasSearchTerms() { return false ; } @Override public void mergeWith(LogEntry arg0) {} @Override public String toTabDelimitedString() { StringBuilder sb= new StringBuilder(); sb.append(messageId); sb.append(FIELD_DELIMITER); sb.append(userId); sb.append(FIELD_DELIMITER); sb.append(rightId); sb.append(FIELD_DELIMITER); ... return sb.toString(); } } |
Реорганизованный код, в котором все реализации LogEntry были заменены на MapLogEntry :
1
2
3
|
public interface LogEntry { // same as before } |
01
02
03
04
05
06
07
08
09
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
|
// The generic LogEntry implementation // JavaDoc removed for the sake of brevity public class MapLogEntry implements LogEntry { private static final char FIELD_SEPARATOR = '\t' ; private Map<String, String> fields = new HashMap<String, String>(); private final String[][] columns; private final StringBuilder tabString = new StringBuilder(); private final Date timestamp; private String key; public MapLogEntry(Date timestamp, String[][] columns) { this .timestamp = checkNotNull(timestamp, "timestamp" ); this .columns = checkNotNull(columns, "columns" ); } @Override public String toTabDelimitedString() { return tabString.toString(); } public MapLogEntry addInOrder(String column, String value) { checkAndStoreColumnValue(column, value); appendToTabString(value); return this ; } public MapLogEntry validated() throws IllegalStateException { if (fields.size() != columns.length) { throw new IllegalStateException( "This entry doesn't contain values for all the columns " + "expected (" + columns.length + "). Actual values (" + fields.size() + "): " + toTabDelimitedString()); } return this ; } private void checkAndStoreColumnValue(String column, String value) { final int addedColumnIndex = fields.size(); checkElementIndex(addedColumnIndex, columns.length, "Cannot add more values, all " + columns.length + " columns already provided; column being added: " + column); String expectedColumn = columns[addedColumnIndex][ 0 ]; if (!column.equals(expectedColumn)) { throw new IllegalArgumentException( "Cannot store value for the column '" + column + "', the column expected at the current position " + addedColumnIndex + " is '" + expectedColumn + "'" ); } fields.put(column, value); } private void appendToTabString(String value) { if (tabString.length() > 0 ) { tabString.append(FIELD_SEPARATOR); } tabString.append(valOrNull(replaceFildSeparators(value))); } /** Encode value for outputting into a tab-delimited dump. */ Object valOrNull(Object columnValue) { if (columnValue == null ) { return HiveConstants.NULL_MARKER; } return columnValue; } @Override public Date getTimestamp() { return timestamp; } @Override public String getKey() { return key; } public void setKey(String key) { this .key = key; } public MapLogEntry withKey(String key) { setKey(key); return this ; } /**Utility method to simplify testing. */ public Map<String, String> asFieldsMap() { return fields; } ... } |
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
|
// Called from a data import job to process individual log lines. // Some time later, the job will call toTabDelimitedString on it. public class PlayerEventsParser ... { @Override public LogEntry parse(String logLine) throws LogParseException { ... // tokenizing, processing etc. of the data ... return new MapLogEntry(timestamp, getColumns()) .addInOrder( "userid" , userId) .addInOrder( "contentid" , contentId) .addInOrder( "sessionid" , sessionId) ... .validated(); } } |
Улучшения Мы заменили около 15 классов на один, позволили изменить способ преобразования данных в строку, разделенную табуляцией, в одном месте (DRY), и предоставили хороший, гибкий API, использование которого выглядит практически одинаково в каждом месте. Новый MapLogEntry также намного более удобен для тестирования (было бы кошмаром изменить все существующие классы для поддержки того, что делает MLE).
Возражения Кто-то может посчитать ряд примитивных классов POJO более простым, чем один универсальный класс. Один универсальный класс, безусловно, является более сложным, чем примитивная структура данных, но в целом решение является менее сложным, потому что меньше частей, и один фрагмент используется везде одинаково, поэтому результирующая когнитивная нагрузка меньше. Первый код более «легок» для понимания, а второй, в целом, «проще».
Принципы СУХОЙ