Статьи

Не допускайте этой ошибки при разработке SPI

Большая часть вашего кода является частной, внутренней, частной и никогда не будет открыта для общественности. Если это так, вы можете расслабиться — вы можете реорганизовать все свои ошибки, в том числе те, которые влекут за собой серьезные изменения API.

Если вы используете публичный API, это не так. Если вы поддерживаете общедоступные SPI ( интерфейсы сервис-провайдеров ), то все становится еще хуже.

H2 Trigger SPI

В недавнем вопросе о переполнении стека о том, как реализовать триггер базы данных H2 с помощью jOOQ , я снова столкнулся с SPI org.h2.api.Trigger — простым и простым в реализации SPI, который реализует семантику триггера. Вот как работают триггеры в базе данных H2:

Используйте триггер

1
2
3
4
5
CREATE TRIGGER my_trigger
BEFORE UPDATE
ON my_table
FOR EACH ROW
CALL "com.example.MyTrigger"

Реализуйте триггер

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
public class MyTrigger implements Trigger {
 
    @Override
    public void init(
        Connection conn,
        String schemaName,
        String triggerName,
        String tableName,
        boolean before,
        int type
    )
    throws SQLException {}
 
    @Override
    public void fire(
        Connection conn,
        Object[] oldRow,
        Object[] newRow
    )
    throws SQLException {
        // Using jOOQ inside of the trigger, of course
        DSL.using(conn)
           .insertInto(LOG, LOG.FIELD1, LOG.FIELD2, ..)
           .values(newRow[0], newRow[1], ..)
           .execute();
    }
 
    @Override
    public void close() throws SQLException {}
 
    @Override
    public void remove() throws SQLException {}
}

Весь SPI H2 Trigger на самом деле довольно элегантный, и обычно вам нужно всего лишь реализовать метод fire() .

Итак, как же этот SPI не так?

Это неправильно очень тонко. Рассмотрим метод init() . Он имеет boolean флаг, указывающий, должен ли триггер срабатывать до или после инициирующего события, т.е. UPDATE . Что если вдруг H2 также поддержит триггеры INSTEAD OF ? В идеале этот флаг должен быть заменен enum :

1
2
3
4
5
public enum TriggerTiming {
    BEFORE,
    AFTER,
    INSTEAD_OF
}

Но мы не можем просто ввести этот новый тип enum потому что метод init() не должен изменяться несовместимым образом, нарушая весь реализующий код! С Java 8 мы могли бы по крайней мере объявить перегрузку следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
default void init(
        Connection conn,
        String schemaName,
        String triggerName,
        String tableName,
        TriggerTiming timing,
        int type
    )
    throws SQLException {
        // New feature isn't supported by default
        if (timing == INSTEAD_OF)
            throw new SQLFeatureNotSupportedException();
 
        // Call through to old feature by default
        init(conn, schemaName, triggerName,
             tableName, timing == BEFORE, type);
    }

Это позволило бы новым реализациям обрабатывать триггеры INSTEAD_OF то время как старые реализации все еще будут работать. Но это кажется волосатым, не так ли?

Теперь представьте, что мы также поддерживаем предложения ENABLE / DISABLE и хотим передать эти значения в метод init() . Или, может быть, мы хотим справиться FOR EACH ROW . В настоящее время нет способа сделать это с этим SPI. Таким образом, мы получим все больше и больше этих перегрузок, которые очень сложно реализовать. И фактически это уже произошло, так как есть также org.h2.tools.TriggerAdapter , который избыточен с (но слегка отличается от) Trigger .

Что будет лучшим подходом, тогда?

Идеальный подход для поставщика SPI заключается в предоставлении «объектов аргументов», например:

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
public interface Trigger {
    default void init(InitArguments args)
        throws SQLException {}
    default void fire(FireArguments args)
        throws SQLException {}
    default void close(CloseArguments args)
        throws SQLException {}
    default void remove(RemoveArguments args)
        throws SQLException {}
 
    final class InitArguments {
        public Connection connection() { ... }
        public String schemaName() { ... }
        public String triggerName() { ... }
        public String tableName() { ... }
        /** use #timing() instead */
        @Deprecated
        public boolean before() { ... }
        public TriggerTiming timing() { ... }
        public int type() { ... }
    }
 
    final class FireArguments {
        public Connection connection() { ... }
        public Object[] oldRow() { ... }
        public Object[] newRow() { ... }
    }
 
    // These currently don't have any properties
    final class CloseArguments {}
    final class RemoveArguments {}
}

Как видно из приведенного выше примера, Trigger.InitArguments был успешно разработан с соответствующими предупреждениями об устаревании. Никакой клиентский код не был нарушен, и новая функциональность готова к использованию, если это необходимо. Кроме того, close() и remove() готовы к будущим изменениям, даже если нам пока не нужны никакие аргументы.

Издержки этого решения — не более одного выделения объекта на вызов метода, что не должно повредить слишком сильно.

Другой пример: пользовательский тип Hibernate

К сожалению, эта ошибка случается слишком часто. Другим ярким примером является сложный для реализации SPI- org.hibernate.usertype.UserType :

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
public interface UserType {
    int[] sqlTypes();
    Class returnedClass();
    boolean equals(Object x, Object y);
    int hashCode(Object x);
 
    Object nullSafeGet(
        ResultSet rs,
        String[] names,
        SessionImplementor session,
        Object owner
    ) throws SQLException;
 
    void nullSafeSet(
        PreparedStatement st,
        Object value,
        int index,
        SessionImplementor session
    ) throws SQLException;
 
    Object deepCopy(Object value);
    boolean isMutable();
    Serializable disassemble(Object value);
    Object assemble(
        Serializable cached,
        Object owner
    );
    Object replace(
        Object original,
        Object target,
        Object owner
    );
}

SPI выглядит довольно сложно для реализации. Возможно, вы можете заставить что-то работать довольно быстро, но вы будете чувствовать себя непринужденно? Будете ли вы думать, что вы поняли это правильно? Несколько примеров:

  • Нет ли случая, когда вам нужна ссылка на owner также в nullSafeSet() ?
  • Что если ваш драйвер JDBC не поддерживает выборку значений по имени из ResultSet ?
  • Что если вам нужно использовать ваш тип пользователя в CallableStatement для хранимой процедуры?

Другим важным аспектом таких SPI является способ, которым разработчики могут предоставлять значения обратно в платформу. Обычно плохая идея иметь не пустые методы в SPI, так как вы никогда не сможете снова изменить тип возврата метода. В идеале у вас должны быть типы аргументов, которые принимают «результаты». Многие из вышеперечисленных методов могут быть заменены одним методом configuration() например так:

01
02
03
04
05
06
07
08
09
10
11
public interface UserType {
    default void configure(ConfigureArgs args) {}
 
    final class ConfigureArgs {
        public void sqlTypes(int[] types) { ... }
        public void returnedClass(Class<?> clazz) { ... }
        public void mutable(boolean mutable) { ... }
    }
 
    // ...
}

Другой пример, SAX ContentHandler

Посмотрите на этот пример здесь:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public interface ContentHandler {
    void setDocumentLocator (Locator locator);
    void startDocument ();
    void endDocument();
    void startPrefixMapping (String prefix, String uri);
    void endPrefixMapping (String prefix);
    void startElement (String uri, String localName,
                       String qName, Attributes atts);
    void endElement (String uri, String localName,
                     String qName);
    void characters (char ch[], int start, int length);
    void ignorableWhitespace (char ch[], int start, int length);
    void processingInstruction (String target, String data);
    void skippedEntity (String name);
}

Некоторые примеры недостатков этого SPI:

  • Что если вам нужны атрибуты элемента в endElement() ? Вы должны будете помнить их самостоятельно.
  • Что если вы хотите узнать endPrefixMapping() сопоставления префиксов в endPrefixMapping() ? Или на любом другом мероприятии?

Очевидно, что SAX был оптимизирован для скорости, и он был оптимизирован для скорости в то время, когда JIT и GC были еще слабыми. Тем не менее, реализация обработчика SAX не тривиальна. Частично это связано с трудностью реализации SPI.

Мы не знаем будущего

Как поставщики API или SPI, мы просто не знаем будущего. Прямо сейчас мы можем подумать, что определенного SPI достаточно, но мы сломаем его уже в следующем выпуске. Или мы не нарушаем его и не говорим нашим пользователям, что не можем реализовать эти новые функции.

Используя описанные выше приемы, мы можем продолжать развивать наш SPI без каких-либо серьезных изменений:

  • Всегда передавайте точно один аргумент объекта в методы.
  • Всегда возвращай void . Позвольте разработчикам взаимодействовать с состоянием SPI через объект аргумента.
  • Используйте default методы Java 8 или предоставьте «пустую» реализацию по умолчанию.