1. 程式人生 > >如何優雅地執行dubbo"單測"

如何優雅地執行dubbo"單測"

很多小夥伴所在的公司是基於Dubbo來構建技術棧的,日常開發中必不可少要寫dubbo單測(單元測試),如果單測資料依賴已有的外部dubbo服務,一般是mock資料,如果資料比較複雜,其實mock資料也是一個不小的工作量。那有沒有更好的單測方式來代替我們完成”mock“資料功能呢,這時可以藉助dubbo telnet功能,獲取真實資料用在單測中使用。

本文會先討論如何使用基於dubbo telnet的代理工具類(DubboTelnetProxy),然後再討論下mockito+DubboTelnetProxy如何進行多層次的單測,最後分析下如何讓單測變得更加智慧(比如自動注入等)。(ps:關於dubbo和mockito這裡就不展開討論了,具體可以參考對應資料~

1 Dubbo單測現狀

dubbo單測其實和非dubbo單測的流程是一樣的,初始化待測試類和單測上下文,打樁然後呼叫,最後檢查返回結果。比如我們常用mockito來跑單測,其簡單的示例如下:

public class DubboAppContextFilterTest extends BaseTest {
    private DubboAppContextFilter filter = new DubboAppContextFilter();

    @Before
    public void setUp() {
        cleanUpAll();
    }

    @After
    public void cleanUp() {
        cleanUpAll();
    }

    @Test
    public void testInvokeApplicationKey() {
        Invoker invoker = mock(Invoker.class);
        Invocation invocation = mock(Invocation.class);
        URL url = URL.valueOf("test://test:111/test?application=serviceA");
        when(invoker.getUrl()).thenReturn(url);

        filter.invoke(invoker, invocation);
        verify(invoker).invoke(invocation);

        String application = RpcContext.getContext().getAttachment(DubboUtils.SENTINEL_DUBBO_APPLICATION_KEY);
        assertEquals("serviceA", application);
    }
}

上面程式碼copy於sentinel的單元測試程式碼。

2 DubboTelnetProxy

在dubbo服務機器上,我們可以使用telnet連線dubbo服務,然後執行invoke命令來手動呼叫dubbo介面並獲取結果,DubboTelnetProxy就是將這一系列的手動操作按照dubbo telnet格式固化到程式碼中。在具體討論DubboTelnetProxy之前,先看下其有哪些功能,DubboTelnetProxy特點:

  • 基於telnet的dubbo代理工具類,可用於本地單測中;
  • 直接使用telnet指定ip+port進行連線,無需更多的dubbo相關配置,使用便捷;
  • 可動態配置ip+port資訊。

話不多說,先看下DubboTelnetProxy程式碼實現:

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class DubboTelnetProxy implements MethodInterceptor {

    private String ip;
    private Integer port;

    @Override
    public Object intercept(Object obj, Method method, Object[] params, MethodProxy proxy) throws Throwable {
        if ("toString".equals(method.getName())) {
            return obj.getClass().getName();
        }

        TelnetClient telnetClient = new TelnetClient();
        telnetClient.setConnectTimeout((int) TimeUnit.SECONDS.toMillis(5));
        telnetClient.connect(ip, port);
        try {
            InputStream in = telnetClient.getInputStream();
            PrintStream out = new PrintStream(telnetClient.getOutputStream());

            // 1. 傳送dubbo telnet請求
            StringBuffer request = new StringBuffer("invoke ");
            request.append(method.getDeclaringClass().getTypeName()).append(".");
            request.append(method.getName()).append("(");
            request.append(StringUtils.join(Arrays.stream(params).map(JSON::toJSONString).collect(Collectors.toList()), ",")).append(")");
            out.println(request.toString());
            out.flush();

            // 2. 結果處理
            int len = 0;
            byte[] buffer = new byte[512];
            String result = "";
            while (!result.contains(StringUtils.LF) && (len = in.read(buffer)) > 0) {
                result += new String(ArrayUtils.subarray(buffer, 0, len));
            }
            result = StringUtils.substringBefore(result, StringUtils.LF);
            if (StringUtils.isBlank(result) || !result.startsWith("{")) {
                throw new RuntimeException(result);
            }

            // 3. 反序列化
            return JSON.parseObject(result, method.getGenericReturnType());
        } finally {
            telnetClient.disconnect();
        }
    }

    /**
     * mockDubboIpPortFormat:配置格式為  -Dmock.dubbo.%s=127.0.0.1:8080,%s為當前dubbo介面的名字,class.getSimpleName()
     */
    private final static String mockDubboIpPortPrefix = "mock.dubbo.";
    public final static String mockDubboIpPortFormat = mockDubboIpPortPrefix + "%s";

    /**
     * dubbo telnet建造者
     */
    public static class Builder {
        final static String DEFAULT_IP = "127.0.0.1";
        final static Integer DEFAULT_PORT = 20880;

        /**
         * 建立dubbo telnet代理
         */
        public static <T> T enhance(Class<T> clazz) {
            return enhance(clazz, null, null);
        }
        public static <T> T enhance(Class<T> clazz, String ip) {
            return enhance(clazz, ip, null);
        }
        public static <T> T enhance(Class<T> clazz, Integer port) {
            return enhance(clazz, null, port);
        }

        @SuppressWarnings("unchecked")
        public static <T> T enhance(Class<T> object, String ip, Integer port) {
            // 優先嚐試從properties解析ip:port配置
            String ipPort = System.getProperties().getProperty(String.format(mockDubboIpPortFormat, object.getSimpleName()));
            if (StringUtils.isNotEmpty(ipPort)) {
                String[] array = StringUtils.split(ipPort, ",");
                ip = array[0];
                port = Integer.valueOf(array[1]);
            }

            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(object);
            enhancer.setCallback(new DubboTelnetProxy(ObjectUtils.defaultIfNull(ip, DEFAULT_IP), ObjectUtils.defaultIfNull(port, DEFAULT_PORT)));
            return (T) enhancer.create();
        }
    }
}

DubboTelnetProxy的實現原理是使用cglib生成dubbo facade介面代理類,然後在代理類按照dubbo telnet格式拼接請求引數,最後獲取返回結果並反序列化返回給應用程式。上述程式碼不足點是:目前每次dubbo呼叫都會新建telnet連線,對於單測來說是OK的,後續如果用於本地壓測或者呼叫頻繁測試場景,考慮複用連線或者使用netty client bootstrap方式避免每次都新建連線。

2.1 使用示例

手動/自動指定dubbo服務IP地址:

@Test
public void test() {
  // OrderQueryService為dubbo服務的一個API介面
  System.setProperty("mock.dubbo.OrderQueryService", "127.0.0.1:20880");

  OrderQueryService orderQueryService1 = DubboTelnetProxy.Builder.enhance(OrderQueryService.class);
  OrderQueryService orderQueryService2 = DubboTelnetProxy.Builder.enhance(OrderQueryService.class, "127.0.0.1");
  OrderQueryService orderQueryService3 = DubboTelnetProxy.Builder.enhance(OrderQueryService.class, "127.0.0.1", 20880);

  OrderDTO result = orderQueryService1.query("訂單號");
  System.out.println(result);
}

3 DubboTelnetProxy + mockito自動注入

日常開發中,可以使用mockito進行單測,保證程式碼質量。在mockito中,如果想讓某個DubboTelnetProxy代理類注入到待測試中,可使用FieldUtils工具類進行屬性注入。

使用DubboTelnetProxy + mockito示例如下:

@RunWith(MockitoJUnitRunner.class)
public class DemoServiceClientTest {
    @InjectMocks
    DemoServiceClient demoServiceClient;

    @Before
    public void before() throws IllegalAccessException {
        FieldUtils.writeField(demoServiceClient, "demoServiceFacade",
                DubboTelnetProxy.Builder.enhance(DemoServiceFacade.class), true);
    }

    @Test
    public void hello() throws IllegalAccessException {
        // 呼叫遠端服務,DubboTelnetProxy方式
        demoServiceClient.hello("world");

        // 如果需要打樁,則使用Mock類
        DemoServiceFacade demoServiceFacade = Mockito.mock(DemoServiceFacade.class);
        Mockito.when(demoServiceFacade.hello("world")).thenReturn("zzz");
        FieldUtils.writeField(demoServiceClient, "demoServiceFacade", demoServiceFacade, true);
        Assert.assertEquals(demoServiceClient.hello("world"), "zzz");
    }
}

@Component
public class DemoServiceClient {
    @Resource
    private DemoServiceFacade demoServiceFacade;

    public String hello(String world) {
        return demoServiceFacade.hello(world);
    }
}

// dubbo api
public interface DemoServiceFacade {
    String hello(String world);
}

3.1 如何自動注入

要實現DubboTelnetProxy的自動注入,首先判斷出來待測試類中的哪些屬性需要構造DubboTelnetProxy或者對應例項,一般情況下如果屬性是非本工程內的介面型別,就可以認為是dubbo api介面,進行構造DubboTelnetProxy並注入;如果屬性是本工程內的介面型別,則在本工程內查詢對應的實現類進行反射方式的屬性注入(可使用org.reflections包中的Reflections工具類來獲取介面下所有實現類);如果屬性是普通類,則直接反射構建物件注入即可,虛擬碼如下:

/**
 * 預設的dubbo屬性構造器,如果是非本工程內屬性型別並且是介面型別,直接進行DubboTelnetProxy構建
 */
public static Function<Field, Object> DEFAULT_DUBBO_FC = field -> {
    try {
        assert Objects.nonNull(targetContext.get());
        Class fieldClass = field.getType();
        if (fieldClass.isInterface()) {
            // 本工程內的載入其實現類,非本工程內的按照DubboTelnetProxy構建
            if (!isSameProjectPath(targetContext.get().getClass(), fieldClass)) {
                return DubboTelnetProxy.Builder.enhance(fieldClass);
            } else if (fieldClass.getSimpleName().endsWith("Dao")) {
                return Mockito.mock(fieldClass);
            } else {
                String packagePath = fieldClass.getPackage().getName() + ".impl.";
                return Class.forName(packagePath + fieldClass.getSimpleName() + "Impl").newInstance();
            }
        } else if (isSameProjectPath(targetContext.get().getClass(), fieldClass)) {
            return fieldClass.newInstance();
        } else {
            // 非工程內的類直接mock掉
            return Mockito.mock(fieldClass);
        }
    } catch (Exception e) {
        System.err.println("DEFAULT_DUBBO_FC 發生異常 field=" + field);
        e.printStackTrace();
        System.exit(-1);
        return null;
    }
};

針對待注入類有多個層次,比如測試類A中屬性b型別是B,B中屬性c型別是C等,那麼在自動注入類A的所有屬性時,需要遞迴進行,直至所有子型別的屬性都構建完畢,示例虛擬碼如下:

void doWithFieldsInternal(@NonNull Object target, @Nullable Function<Field, Object> fc, @Nullable Boolean recursive) {
    assert !(target instanceof Class);
    // 預設fc回撥直接呼叫預設無參構造方法
    fc = ObjectUtils.defaultIfNull(fc, DEFAULT_FC);
    recursive = ObjectUtils.defaultIfNull(recursive, false);

    List<Object> fieldList = new ArrayList<>();
    do {
        Object finalTarget = target;
        Function<Field, Object> finalFc = fc;
        ReflectionUtils.doWithFields(finalTarget.getClass(), field -> {
            Object value = finalFc.apply(field));
            DubboReflectionUtils.setField(finalTarget, field, value);
            if (Objects.nonNull(value) && DEFAULT_FF.matches(field)) {
                fieldList.add(value);
            }
        }, filterField -> {
            // 預設只注入非基本型別並且為null的屬性
            return DEFAULT_FF.matches(filterField) && DubboReflectionUtils.isNullFieldValue(finalTarget, filterField);
        });
    } while (recursive && !fieldList.isEmpty() && Objects.nonNull(target = fieldList.remove(0)));
}

3.2 如何讓自動注入更易用

上述示例中的自動注入是程式會遞迴注入待測試類中的所有屬性,但還是需要在程式碼中先呼叫要"自動注入"的程式碼,為了更易用,可以使用註解方式來自動注入被註解修飾的所有類或者屬性,類似於在Spring中對類屬性配置了@Resource之後,Spring在容器啟動過程中會自動對該屬性注入對應示例,開發者無需關注。

關於如何實現mockito+DubboTelnetProxy的註解方式自動注入,筆者就不在贅述,感興趣的小夥伴可以參考3.1中的實現思路自行實現。

說道註解,其實想實現針對某些註解執行一些特定邏輯(比如執行自動注入),可以在兩種階段對其處理,如下所示:

  • 編譯處理階段:比如設定Java的註解處理器,一般是繼承AbstractProcessor來實現特定業務邏輯,其主要的處理邏輯就是掃描、評估和處理註解的程式碼,以及生產 Java 檔案。比如lombok中的@Setter註解就是要產生對應屬性的setter方法;
  • 容器啟動階段:這裡的容器是業務程式自己定義的容器,比如Spring的IoC容器,在容器啟動過程中針對註解進行處理,首先獲取註解對應的屬性,然後從容器中獲取屬性對應的例項通過反射將其注入即可。

以上兩種自動注入方式在實現都是OK的,前者在編譯階段後者在執行時,不過後者由於在執行時起作用,因此靈活性更大。

 

 推薦閱讀 

  • Java nio 空輪詢bug到底是什麼

  • 程式設計師必看| mockito原理淺析

  • Java常見幾種動態代理的對比


歡迎小夥伴關注【TopCoder】閱讀更多精彩好文。

&n