一次springboot+dubbo的项目实战

Mon, Apr 5, 2021 阅读时间 5 分钟

最近在从零开始实现一个服务端在线接口项目,项目选型为springboot + dubbo。由于是新项目,没有历史包袱,所以给了自己一次从头开始按照想法进行最优编码实践的机会。

1. 功能实现

项目使用springboot-web暴露网关接口,内部通过dubbo调用服务,比如用户服务。主要组件基本包括两块:gateway和service-provider。其中gateway对外暴露web端口,对内为dubbo的consumer。 通用的启动程序:

@SpringBootApplication
@EnableAspectJAutoProxy
@EnableDubbo
@Slf4j
public class MainApplication {
    public static void main(String[] args) {
        SpringApplication.run(MainApplication.class, args);
    }

    @Bean
    public CommandLineRunner commandLineRunner(ApplicationContext ctx) {
        return (args) -> log.info("==============================================");
    }
}

1.1 配置文件加载

spring boot对配置有着相当完善的支持。除了默认使用的spring.application.name、server.port等配置以及dubbo附加支持的dubbo.protocol.name、dubbo.registry.address等配置外,还可以直接添加自己的配置并在项目中直接使用。

配置可以直接写在默认配置文件application.properties或者application.yaml中,也可以写入自己的properties配置文件中然后通过spring加载解析,或者干脆放到一个文件目录中由自己加载。

关于application.properties和application.yaml需要注意的一个区别是,在spring boot 中application.properties是以iso8859-1编码读取的,application.yaml是以utf-8编码读取的。这意味着如果application.properties中存在中文配置的话,一定要记得编码转换:

String loadingValue = "some string loaded from properties with spring boot";
String correctValue = new String(loadingValue.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);

如果是直接写入application.properties的自定义配置,可以直接使用@Value注解在代码中进行使用:

@Service
public class ServiceImpl implements IService {

    @Value("${user.extra.config:default_val}")
    private String exteaConfig;
}

如果是使用了新的配置文件,则需要使用@PropertySource注解指定该配置文件。另外,我们也可以直接使用@ConfigurationProperties注解将配置属性绑定到bean上:

@Data
@Component
@ConfigurationProperties(prefix = "user.extra")
@PropertySource("classpath:config/extra.properties")
public class UserExtraConfiguration {

    private String extraValue;
}

关于@Value和@ConfigurationProperties区别,网上有人做了测试和总结如下:

如果是自己加载配置文件,可以使用springboot自带的ResourceUtils工具,支持从classpath下读取配置文件:

File file = ResourceUtils.getFile(filepath);
String configuration = new String(FileCopyUtils.copyToByteArray(keyFile));

需要注意的是,如果filepath是以classpath:开头,这时使用File的方式是读取不到打包在jar包中的资源文件的,需要以Stream的方式读取:

URL url = ResourceUtils.getURL(filepath);
String configuration = FileCopyUtils.copyToString(new InputStreamReader(url.openStream())); 

1.2 鉴权

业务的每个接口都需要做鉴权逻辑。项目中通用的鉴权逻辑是hmac-auth。由于鉴权逻辑通用,考虑利用切面或者拦截器的方式统一处理。具体实现方式大概有如下几种:

  • java servlet中的filter。spring boot web底层也是基于servlet技术,所以这个拦截比较底层,而且直接拦截的是原始的HttpServletRequest和HttpServletResponse对象。
  • spring mvc中的Interceptor。和filter类似,也能基于原始请求对象进行处理和拦截。
  • @RestControllerAdvice。这个是spring提供的用于全局拦截@RequestMapping的注解。主要提供了@ExceptionHandler、@InitBinder、@ModelAttribute三个处理入口。本项目中使用此种方式对全局的异常进行了统一处理和拦截,后面如果有非预期的错误出现,直接抛出相关异常即可:
@Slf4j
@RestControllerAdvice(basePackages = {"org.xxx..."})
public class GlobalExceptionHandler {

    @ExceptionHandler(value = UnAuthorizedException.class)
    public BizResponse unAuthorizedExceptionHandle(UnAuthorizedException e) {
        return new BizResponse(RetCode.AUTHFAILEDERR);
    }
 
    @ExceptionHandler(value = Exception.class)
    public BizResponse exceptionHandle(Exception e) {
        log.error("unknown error", e);
        return new BizResponse(RetCode.UNKNOWNERR);
    }
}
  • aspectJ切面拦截。这种方式可以指定对某些方法(比如Controller方法)进行拦截。

本项目中采用aspectJ进行统一的hmac-auth鉴权逻辑的开发。使用aspectJ的一大问题在于无法获取到原始请求的header等信息,而这些信息鉴权中是必须的。好在可以通过spring web提供的RequestContextHolder来解决这个问题。RequestContextHolder本质上是 一个ThreadLocal对象,它存储了此次处理的原始请求request和session信息:

@Aspect
@Component
@Slf4j
public class AuthAspect {

    @Pointcut(value = "execution(* org.controller..*(..))")
    private void pointcut() {
    }

    @Before(value = "pointcut()")
    public void doBefore() {

        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        if (attributes == null) {
            throw new UnAuthException();
        }
        ContentCachingRequestWrapper request = (ContentCachingRequestWrapper) attributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
        //do session check...
        signature.check(request);
    }
}

其中,request是ContentCachingRequestWrapper类型,该类型提供了一种能够反复读取body内容的方法。原始的request是HttpServletRequest类型,该类型body只能读取一次,满足不了鉴权需要读取一次以及业务处理需要读取一次的需求。因此需要依赖于filter对原始请求做一次拦截转换:

@Component
@Slf4j
public class RequestFilter extends OncePerRequestFilter implements Filter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        filterChain.doFilter(new ContentCachingRequestWrapper(request), response);
    }
}

1.3 第三方调用

对于dubbo service来说,通过网络调用第三方的接口(比如统一账号系统)再正常不过。项目中在调用第三方系统时在中间加了一层接口,调用层和实现层通过接口分割开。这样一来,满足了依赖倒置原则,添加不同的第三方系统实现就很方便了。当然,更多的策略比如说第三方系统调用负载均衡、降级熔断等都可以在这一层实现。目前项目没有这些需求,暂时只是做了一层接口隔离。

1.4 测试

此前一直还在纠结”测试“这个方面到底算是功能还是非功能。最后我认为还是作为功能的一部分比较好。虽然测试代码不直接运行于生产环境中,但是它对于生产环境的代码的质量影响还是非常重要的,写测试也应该作为正常编码工作的一部分重要内容,不容忽视。

项目中我采用了spring-boot-test + Mockito的测试工具。Mockito对于bean的mock功能支持的也比较好:

@SpringBootTest
public class SignatureServiceTest {

    @Resource
    private Signature signature;

    @MockBean(answer = Answers.RETURNS_DEEP_STUBS)
    private RedisTemplate redisTemplate;    

    @Test
    public void testCheck1() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {

        Mockito.when(stringRedisTemplate.opsForValue()
                       .setIfAbsent(Mockito.anyString(), Mockito.anyString(), Mockito.any(Duration.class)))
                .thenReturn(null).thenReturn(true).thenReturn(false);

        Method m = signature.getClass().getDeclaredMethod("check", String.class);
        m.setAccessible(true);
        Result r1 = (Result) m.invoke(signature, "1");
        Result r2 = (Result) m.invoke(signature, "1");
        Result r3 = (Result) m.invoke(signature, "1");

        Mockito.verify(redisTemplate.opsForValue(), Mockito.times(3))
               .setIfAbsent(Mockito.anyString(), Mockito.anyString(), Mockito.any(Duration.class));
        Assertions.assertTrue(r1.isPass());
        Assertions.assertTrue(r2.isPass());
        Assertions.assertFalse(r3.isPass());
    }
}

在测试代码中,使用反射方式调用那些私有的方法;使用@MockBean注解处理那些需要mock的bean;另外可以使用Answers.RETURNS_DEEP_STUBS来处理那些需要mock链式调用的情形。

由于是dubbo项目,在运行测试的过程中很尴尬的一点是启动spring环境时dubbo也随之启动了,哪怕真正的调用并没有去调用dubbo服务。没有找到相关关闭dubbo服务提供者和消费者的方法,只能在测试环境中禁止使用远程的配置中心和禁止远程调用。需要在test下的resources目录下的application.properties中进行如下配置:

dubbo.protocol.name=dubbo
dubbo.protocol.port=12345
dubbo.registry.address=N/A
dubbo.consumer.scope=local

2. 非功能需求

2.1 文档

对于一个接口项目来说,将文档和代码一起发布无疑是最好的选择。这样就不用担心文档过时缺失等问题了,也不用花很大力气去维护文档。项目中引入了swagger2来达到这个目的:

@Configuration
@EnableSwagger2
public class Swagger2Config {
    @Bean
    public Docket createRestApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("org.xxx.controller"))
                .paths(PathSelectors.any())
                .build();
    }

    // API的详细信息
    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("my_title") //标题
                .description("interface api docs") //描述
                .contact(new Contact("codeme", "", ""))
                .version("v1") //版本号
                .build();
    }
}

配置好如上bean后,直接在Controller接口层配置注解即可:

@RestController
@Slf4j
@Api(tags = "测试接口", value = "test")
public class TestController {
    @ApiOperation(value = "Hello", notes = "test hello API")
    @PostMapping(value = "/v1/hello")
    public GWResponse<?> hello(
            @ApiParam(value = "请求包", required = true)
            @RequestBody BizRequest bizRequest) {
            ...
    }
}

2.2 日志

如何记录日志我觉得是对于在线接口来说是非常重要的事,它是事后线上问题排查、数据统计等最为重要的途径。本项目使用slf4j的MDC进行统一的日志拦截和打印:

@Component
@Slf4j
@Aspect
public class LogAspect {
    @Pointcut(value = "execution(* org.xxx.controller..*(..))")
    private void pointcut() {
    }

    @Before(value = "pointcut()")
    public void doBefore() {
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        ContentCachingRequestWrapper request = (ContentCachingRequestWrapper) attributes.resolveReference(RequestAttributes.REFERENCE_REQUEST);
        MDC.put(Constants.TraceID,  genUUID());
        MDC.put(Constants.RemoteIP, stripRemoteIP(request));
        MDC.put(Constants.LocalIP, NetUtils.getLocalHost());
        MDC.put(Constants.ReqPkg, new String(request.getContentAsByteArray()));
        MDC.put(Constants.Ctm, logFormat.format(new Date()));
    }

    @AfterReturning(pointcut = "pointcut()", returning = "response")
    @SneakyThrows
    public void doAfterReturning(BizResponse<?> response) {
        Date ctm = format.parse(MDC.get(Constants.Ctm));
        Date etm = new Date();
        long utm = etm.getTime() - ctm.getTime();
        MDC.put(Constants.Etm, format.format(etm));
        MDC.put(Constants.Utm, String.valueOf(utm));
        MDC.put(Constants.ResPkg, objectMapper.writeValueAsString(response));
        log.info(objectMapper.writeValueAsString(MDC.getCopyOfContextMap()));
        MDC.clear();
    }


    @AfterThrowing(pointcut = "pointcut()", throwing = "throwable")
    @SneakyThrows
    public void doAfterThrowing(Throwable throwable) {
        Date ctm = format.parse(MDC.get(Constants.Ctm));
        Date etm = new Date();
        long utm = etm.getTime() - ctm.getTime();
        MDC.put(Constants.Etm, format.format(etm));
        MDC.put(Constants.Utm, String.valueOf(utm));
        MDC.put(Constants.Exception, throwable.toString());
        log.error(objectMapper.writeValueAsString(MDC.getCopyOfContextMap()));
        MDC.clear();
    }
}

其中,关于远程ip的获取是stripRemoteIP函数,该函数尽可能获取真实的远程IP:

private String stripRemoteIP(ContentCachingRequestWrapper request) {
    try {
        String forward = request.getHeader("X-Forwarded-For");
        if (StringUtils.isNotBlank(forward) && !"unknown".equalsIgnoreCase(forward)) {
            String[] ips = forward.split(",");
            for (String ip : ips) {
                if (StringUtils.isNotBlank(ip)) {
                    return ip;
                }
            }
        }
        String realIP = request.getHeader("X-Real-IP");
        if (StringUtils.isNotBlank(realIP) && !"unknown".equalsIgnoreCase(realIP)) {
            return realIP;
        }
        return request.getRemoteAddr();
    } catch (Exception e) {
        return null;
    }
}

关于获取本地IP的函数,直接使用的是dubbo提供的NetUtils工具类,核心代码如下:

public static String getLocalHost() {
    if (HOST_ADDRESS != null) {
        return HOST_ADDRESS;
    }

    InetAddress address = getLocalAddress();
    if (address != null) {
        return HOST_ADDRESS = address.getHostAddress();
    }
    return LOCALHOST_VALUE;
} 

public static InetAddress getLocalAddress() {
    if (LOCAL_ADDRESS != null) {
        return LOCAL_ADDRESS;
    }
    InetAddress localAddress = getLocalAddress0();
    LOCAL_ADDRESS = localAddress;
    return localAddress;
}

private static InetAddress getLocalAddress0() {
    InetAddress localAddress = null;

    // @since 2.7.6, choose the {@link NetworkInterface} first
    try {
        NetworkInterface networkInterface = findNetworkInterface();
        Enumeration<InetAddress> addresses = networkInterface.getInetAddresses();
        while (addresses.hasMoreElements()) {
            Optional<InetAddress> addressOp = toValidAddress(addresses.nextElement());
            if (addressOp.isPresent()) {
                try {
                    if (addressOp.get().isReachable(100)) {
                        return addressOp.get();
                    }
                } catch (IOException e) {
                    // ignore
                }
            }
        }
    } catch (Throwable e) {
        logger.warn(e);
    }

    try {
        localAddress = InetAddress.getLocalHost();
        Optional<InetAddress> addressOp = toValidAddress(localAddress);
        if (addressOp.isPresent()) {
            return addressOp.get();
        }
    } catch (Throwable e) {
        logger.warn(e);
    }


    return localAddress;
}


public static NetworkInterface findNetworkInterface() {

    List<NetworkInterface> validNetworkInterfaces = emptyList();
    try {
        validNetworkInterfaces = getValidNetworkInterfaces();
    } catch (Throwable e) {
        logger.warn(e);
    }

    NetworkInterface result = null;

    // Try to find the preferred one
    for (NetworkInterface networkInterface : validNetworkInterfaces) {
        if (isPreferredNetworkInterface(networkInterface)) {
            result = networkInterface;
            break;
        }
    }

    if (result == null) { // If not found, try to get the first one
        for (NetworkInterface networkInterface : validNetworkInterfaces) {
            Enumeration<InetAddress> addresses = networkInterface.getInetAddresses();
            while (addresses.hasMoreElements()) {
                Optional<InetAddress> addressOp = toValidAddress(addresses.nextElement());
                if (addressOp.isPresent()) {
                    try {
                        if (addressOp.get().isReachable(100)) {
                            result = networkInterface;
                            break;
                        }
                    } catch (IOException e) {
                        // ignore
                    }
                }
            }
        }
    }

    if (result == null) {
        result = first(validNetworkInterfaces);
    }

    return result;
}

其中getLocalAddress0会调用findNetworkInterface找到”合适的“网卡,然后遍历、验证其中可用的ip地址并返回。 findNetworkInterface先查找用户是否通过环境变量配置了想要使用的网卡,如果没有配置则验证返回第一个”可达“网卡。

在dubbo服务中的日志拦截类似,不同的是我这边在实现过程中将其放到了common项目中,该项目被所有provider引用。这样我就无法针对某个特殊包特殊类特殊方法进行拦截了,只能提供相关注解,由provider在需要记录日志的方法上使用注解进行注明:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Log {
}

@Component
@Slf4j
@Aspect
public class LogAspect {
    @Pointcut(value = "@annotation(org.xxx.aspect.Log)")
    private void pointcut() {
    }

    @Before(value = "pointcut()")
    public void doBefore(JoinPoint joinPoint) {
        ...
    }

    @AfterReturning(pointcut = "pointcut()")
    public void doAfterReturning() {
        ....
    }

    @AfterThrowing(pointcut = "pointcut()", throwing = "throwable")
    public void doAfterThrowing(Throwable throwable) {
        ...
    }
}

另外,provider引用库时不会自动扫描其中的aspect路径,导致无法自动拦截打印日志。解决的方案有三种:

  • 一是手动在所有provider项目中配置@ComponentScan(“org.xxx.aspect”),这样需要手动写路径,没有采用;
  • 二是在common项目中额外提供EnableLog注解,所有provider项目启动类上配置@EnableLog,项目中采用该方法;
  • 三是写自己的starter,这个稍微有些复杂没有采用。

写EnableLog的方式如下:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Import(LogConfig.class)
public @interface EnableLog {
} 

@ComponentScan("org.xxx.aspect")
@Component
public class LogConfig {
}

这样就将@ComponentScan(“org.xxx.aspect”)这样的注解写到了common内部,封装性较好。

关于日志的打印位置也值得注意。一开始我将日志配置放到application.properties中:

logging.level.root=warn
logging.level.org.xxx.controller=debug
logging.level.org.xxx.aspect=info
logging.pattern.console=...

这样虽然可以不同的包分不同级别打印,但是无法分开打印到不同文件中。最后还是通过写logback-spring.xml解决这个问题:

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS}%-5level [%thread] [%logger{20}] - %msg%n</pattern>
        </encoder>
    </appender>
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>file.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>2</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%-5level] - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="WARN">
        <appender-ref ref="CONSOLE"/>
    </root>
    <logger name="org.xxx.controller" additivity="false" level="INFO">
        <appender-ref ref="CONSOLE"/>
    </logger>
    <logger name="org.xxx.aspect" additivity="false" level="INFO">
        <appender-ref ref="FILE"/>
    </logger>
</configuration> 

这样就将日志文件打印到了file.xxx.log中,而常规日志还是打印到控制台中。

2.3 调用链

由于一次请求会跨越多个微服务,所以有必要通过某种手段将所有调用串联起来。traceid是一个比较好的日志串联方案。对于网关来说,由于是服务入口,可以自己生成,但是dubbo服务则需要由调用者传递给提供者。我研究了一下skywalking的做法,它是对dubbo的MonitorFilter进行了拦截,将其作为入口将traceid以及其他信息附属在调用上进行传递。项目中没有采用skywalking因为其开销还是太大了,官方也建议开启采样,而业务日志最好是不采样比较好,所以我的实现还是自定义filter只传递traceid信息,做一次轻量的拦截。

在common模块中的resources下创建文件夹META-INF.dubbo,在此文件夹下创建文件org.apache.dubbo.rpc.Filter,并写入如下内容:

logConsumer=org.xxx.dubbo.filter.ConsumerFilter
logProvider=org.xxx.dubbo.filter.ProviderFilter 

该文件会在dubbo启动时自动扫描到,并加载其中的filter使之生效。

接下来实现这两个filter:

@Activate(group = CommonConstants.CONSUMER)
public class ConsumerFilter implements Filter {
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        RpcContext.getContext().setAttachment(Constants.TraceID, MDC.get(Constants.TraceID));
        return invoker.invoke(invocation);
    }
}

@Activate(group = CommonConstants.PROVIDER)
public class ProviderFilter implements Filter {
    @Override
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        String traceID = RpcContext.getContext().getAttachment(Constants.TraceID);
        MDC.put(Constants.TraceID, traceID);
        return invoker.invoke(invocation);
    }
}

2.4 监控

为了更好地对项目进行监控,项目后续会接入actuator暴露指标并由prometheus进行抓取监控。抓取的指标除了常规的进程、内存、cpu等信息外,还应该包括调用量、响应耗时、错误率等黄金指标。当前项目还未集成actuator ,暂不多数,后续有机会再补上。

3. 总结

经过以上的项目配置,我觉得一个基本可用的项目框架才算是搭建完成了。当然还应该有其他应当考虑的内容,比如自动化集成和功能回归测试,后续是否还有其他未考虑到的点,还需要看自己进一步的实践了。