Статьи

Простой против простого: написание универсального кода для избежания дублирования (представление данных для импорта)

В наших пакетных заданиях для импорта данных у нас было много похожих классов для хранения импортируемых данных. Технически они все разные, с разными полями, но концептуально они все одинаковые. Я нахожу это концептуальное дублирование неприятным и написал один, более общий класс, чтобы заменить их всех.

Рефакторинг был вдохновлен 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 более простым, чем один универсальный класс. Один универсальный класс, безусловно, является более сложным, чем примитивная структура данных, но в целом решение является менее сложным, потому что меньше частей, и один фрагмент используется везде одинаково, поэтому результирующая когнитивная нагрузка меньше. Первый код более «легок» для понимания, а второй, в целом, «проще».

Принципы СУХОЙ