增添行為


對 Java 來說,只要類別定義完成,就語法上來說,沒有方式可以動態地增加或修補行為,對類別的實例也是如此,有些語言支援 Mixin、物件個體化(Object individualization)、開放類別等,然而,Java 要達到類似功能,會是麻煩許多。

舉例來說,若有個類別的方法,原先在定義時並沒有檢查傳入的引數是否為 null,而且你拿不到它的原始碼,在這樣的情況下,又想為它增加 enabledisable 的行為,以決定要不要在每個方法執行前,檢查傳入引數是否為 null

一個簡單的想法是先定義介面,例如:

package cc.openhome.proxy;

public interface Nullable {
    void enable();
    void disable();
    boolean isEnabled();
}

如果想令〈動態代理〉中,AccountDAO 的實現,可以有檢查 null 的新行為,可以實作一個包裹器(Wrapper):

public class NullableAccountDAOProxy implements Nullable, AccountDAO {
    private boolean enabled;
    private AccountDAO accountDAO;

    public NullableProxy(AccountDAO accountDAO) {
        this.accountDAO = accountDAO;
    }

    public void enable() {
        enabled = true;
    }

    public void disable() {
        enabled = false;
    }

    public boolean isEnabled() {
        return enabled;
    }

    public void accountByEmail(String email) {
        if(!isEnabled() && email == null) {
            throw new IllegalArgumentException("argument of accountByEmail cannot be null");
        }
        accountDAO.accountByEmail(email);
    }

    ...略
}

NullableAccountDAOProxy 同時實現了 NullableAccountDAO,在每個 AccountDAO 的方法實作中進行傳入引數的 null 檢查,使用 AccountDAO 的客戶端,注入的實例會像是 new NullableAccountDAOProxy(new AccountDAOJdbcImpl(dataSource)),因此可以如下:

... 
((Nullable) accountDAO).enable(); // 必須 enable() 才允許傳入 null
accountDAO.accountByEmail(null);
...

當然,如上的 NullableAccountDAOProxy 專用於 AccountDAO,而且逐一實現介面定義的每個方法也是個麻煩;類似地,可以試著使用動態代理的方式來解決這個需求:

package cc.openhome.proxy;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.Arrays;

public class NullableProxy implements Nullable, InvocationHandler {
    private boolean enabled; 
    private Object target;

    public NullableProxy(Object target) {
        this.target = target;
    }

    @Override
    public void enable() {
        enabled = true; 
    }

    @Override
    public void disable() {
        enabled = false;        
    }

    public boolean isEnabled() {
        return enabled;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String methodName = method.getName();
        if(methodName.equals("enable") || methodName.equals("disable")) {
            return method.invoke(this, args);
        }

        if(!isEnabled() && Arrays.stream(args).anyMatch(arg -> arg == null)) {
            throw new IllegalArgumentException(
                String.format("argument(s) of %s.%s cannot be null", 
                    target.getClass().getName(), 
                    method.getName()
                )
            );
        }

        return method.invoke(target, args);
    }
}

只有在 enabledtrue 的情況下,才會允許呼叫目標物件,類似地,可以透過 Proxy.newProxyInstance 來建立實作指定介面的實例:

package cc.openhome;

...略

@Configuration
@ComponentScan
public class AppConfig {     
    ...略

    @Bean
    public AccountDAO accountDAO() {
        List<Class<?>> interfaces = new ArrayList<>(
            Arrays.asList(accountDAO.getClass().getInterfaces())
        );
        interfaces.add(Nullable.class);
        return (AccountDAO) Proxy.newProxyInstance( 
                accountDAO.getClass().getClassLoader(), 
                interfaces.toArray(new Class[interfaces.size()]), 
                new NullableProxy(accountDAO)
            ); 
    }

    ...略
}

藉由測試來驗證一下想法是否行得通:

package test.cc.openhome;

...略

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = AppConfig.class)
public class MainTest {
    @Autowired
    private AccountDAO accountDAO;

    @Test(expected = IllegalArgumentException.class)
    public void testNotNullable() {
        accountDAO.accountByEmail(null);
    }

    @Test public void testNullable() {
        ((Nullable) accountDAO).enable();
        assertTrue(accountDAO.accountByEmail(null).isEmpty());
    }
}

當然,想實現這樣的過程稍微複雜了一些,也容易模糊掉關切的要點,Spring AOP 實際上也提供這類的功能,之後再來看看是否可以省點功夫。

你可以在 BeforeAOP3 找到以上的範例專案。