功能

  • SaaS 应用
  • 管理员 Admin 管理公司和排班
  • 雇员 Worker 管理个人信息

    重点

  • 微服务和云原生架构

  • Spring Boot + K8s 应用

    系统架构设计和技术栈选型

    微服务架构

    为何采用微服务架构

    观点一 单块优先

    截屏2021-11-30 上午10.48.21.png
    微服务引入时机
    截屏2021-11-30 上午10.48.56.png

    观点二 微服务优先

    微服务架构技术门槛减低,可以一开始就采用微服务架构,方便以后的拓展
    截屏2021-11-30 上午10.50.22.png

    总体架构设计

    截屏2021-11-30 下午12.39.05.png
    设计思想:

  • 分而治之

  • 单一职责
  • 关注分离

截屏2021-11-30 下午2.08.33.png

账户数据模型

截屏2021-11-30 下午2.20.16.png

服务接口

账户服务
截屏2021-11-30 下午2.26.51.png
公司服务
截屏2021-11-30 下午2.37.59.png
admin 服务
截屏2021-11-30 下午2.42.09.png
员工目录服务
截屏2021-11-30 下午2.42.20.png
Team 服务
截屏2021-11-30 下午2.42.41.png
雇员 worker 服务
截屏2021-11-30 下午2.43.21.png
任务 job 服务
截屏2021-11-30 下午2.43.33.png
班次 shift 服务
截屏2021-11-30 下午2.44.05.png

技术选型

微服务公共关注点
截屏2021-11-30 下午2.49.36.png
截屏2021-11-30 下午2.57.09.png
截屏2021-11-30 下午3.12.27.png

中台体系

截屏2021-11-30 下午3.33.20.png

Mono-Repo(单体仓库)

截屏2021-11-30 下午7.33.17.png

项目实践

项目地址:https://github.com/spring2go/staffjoy

代码组织

  1. <modules>
  2. <module>common-lib</module>
  3. <module>account-svc</module>
  4. <module>account-api</module>
  5. <module>company-api</module>
  6. <module>company-svc</module>
  7. <module>mail-api</module>
  8. <module>mail-svc</module>
  9. <module>sms-svc</module>
  10. <module>sms-api</module>
  11. <module>bot-api</module>
  12. <module>bot-svc</module>
  13. <module>ical-svc</module>
  14. <module>whoami-api</module>
  15. <module>whoami-svc</module>
  16. <module>web-app</module>
  17. <module>faraday</module>
  18. </modules>

截屏2021-11-30 下午7.24.09.png

自定义注解

先看某个 java bean

public class NewDirectoryEntry {
    @NotBlank
    private String companyId;
    @Builder.Default
    private String name = "";
    @Email
    private String email;
    @PhoneNumber
    private String phoneNumber;
    @Builder.Default
    private String internalId = "";
}

其中注解 @PhoneNumber为自定义注解,实现的功能为验证该字符串必须由 0-9 数字组成,且长度在 8-14 之间。
首先自定义一个注解类,用 interface修饰:

@Documented
@Constraint(validatedBy = PhoneNumberValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface PhoneNumber {
    String message() default "Invalid phone number";
    Class[] groups() default {};
    Class[] payload() default {};
}

其中
@Documented表明这个注解应该被 javadoc 工具记录
@Constraint用来限定自定义注解的方法,这里的类是下面真正实现验证功能的类
@Target用来说明该注解可以被声明在哪些元素之前,有以下参数:

  • ElementType.TYPE:说明该注解只能被声明在一个类前
  • ElementType.FIELD:说明该注解只能被声明在一个类的字段前
  • ElementType.METHOD:说明该注解只能被声明在一个类的方法前
  • ElementType.PARAMETER:说明该注解只能被声明在一个方法参数前
  • ElementType.CONSTRUCTOR:说明该注解只能声明在一个类的构造方法前
  • ElementType.LOCAL_VARIABLE:说明该注解只能声明在一个局部变量前
  • ElementType.ANNOTATION_TYPE:说明该注解只能声明在一个注解类型前
  • ElementType.PACKAGE:说明该注解只能声明在一个包名前

@Retention用来说明该注解类的生命周期,有以下三个参数:

  1. RetentionPolicy.SOURCE : 注解只保留在源文件中
  2. RetentionPolicy.CLASS : 注解保留在class文件中,在加载到JVM虚拟机时丢弃
  3. RetentionPolicy.RUNTIME : 注解保留在程序运行期间,此时可以通过反射获得定义在某个类上的所有注解

具体的验证类为:

public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {

    @Override
    public boolean isValid(String phoneField, ConstraintValidatorContext context) {
        if (phoneField == null) return true; // can be null
        return phoneField != null && phoneField.matches("[0-9]+")
                && (phoneField.length() > 8) && (phoneField.length() < 14);
    }
}

其中的 ConstraintValidator校验器类一般就是和@Constraint搭配进行验证功能的自定义注解编写

统一异常处理

截屏2021-11-30 下午9.47.49.png
对系统的各种异常进行统一的处理,需要进行以下工作:

  • 自定义返回体的结构

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    public class BaseResponse {
      private String message;
      @Builder.Default
      private ResultCode code = ResultCode.SUCCESS;
    
      public boolean isSuccess() {
          return code == ResultCode.SUCCESS;
      }
    }
    
  • 通过枚举类定义返回值和对应的 code 信息

    @Getter
    @AllArgsConstructor
    public enum ResultCode {
      SUCCESS(HttpServletResponse.SC_OK, "Operation is Successful"),
    
      FAILURE(HttpServletResponse.SC_BAD_REQUEST, "Biz Exception"),
    
      UN_AUTHORIZED(HttpServletResponse.SC_UNAUTHORIZED, "Request Unauthorized"),
    
      NOT_FOUND(HttpServletResponse.SC_NOT_FOUND, "404 Not Found"),
    
      MSG_NOT_READABLE(HttpServletResponse.SC_BAD_REQUEST, "Message Can't be Read"),
    
      METHOD_NOT_SUPPORTED(HttpServletResponse.SC_METHOD_NOT_ALLOWED, "Method Not Supported"),
    
      MEDIA_TYPE_NOT_SUPPORTED(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, "Media Type Not Supported"),
    
      REQ_REJECT(HttpServletResponse.SC_FORBIDDEN, "Request Rejected"),
    
      INTERNAL_SERVER_ERROR(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Internal Server Error"),
    
      PARAM_MISS(HttpServletResponse.SC_BAD_REQUEST, "Missing Required Parameter"),
    
      PARAM_TYPE_ERROR(HttpServletResponse.SC_BAD_REQUEST, "Parameter Type Mismatch"),
    
      PARAM_BIND_ERROR(HttpServletResponse.SC_BAD_REQUEST, "Parameter Binding Error"),
    
      PARAM_VALID_ERROR(HttpServletResponse.SC_BAD_REQUEST, "Parameter Validation Error");
    
      final int code;
    
      final String msg;
    }
    
  • 自定义异常

通常要根据具体业务的情况自定义一些服务异常,需要继承RuntimeException

public class ServiceException extends RuntimeException {
    private static final long serialVersionUID = 2359767895161832954L;

    @Getter
    private final ResultCode resultCode;

    public ServiceException(String message) {
        super(message);
        this.resultCode = ResultCode.FAILURE;
    }

    public ServiceException(ResultCode resultCode) {
        super(resultCode.getMsg());
        this.resultCode = resultCode;
    }

    public ServiceException(ResultCode resultCode, String msg) {
        super(msg);
        this.resultCode = resultCode;
    }

    public ServiceException(ResultCode resultCode, Throwable cause) {
        super(cause);
        this.resultCode = resultCode;
    }

    public ServiceException(String msg, Throwable cause) {
        super(msg, cause);
        this.resultCode = ResultCode.FAILURE;
    }

    /**
     * for better performance
     *
     * @return Throwable
     */
    @Override
    public Throwable fillInStackTrace() {
        return this;
    }

    public Throwable doFillInStackTrace() {
        return super.fillInStackTrace();
    }
}
  • 通过 @RestControllerAdvice来捕获全局异常

@RestControllerAdvice对 Controller 进行增强,可以全局捕获 spring mvc 抛的异常,与@ExceptionHandler(value=Exception.class)搭配使用,用来捕获特定的异常

@RestControllerAdvice
public class GlobalExceptionTranslator {

    static final ILogger logger = SLoggerFactory.getLogger(GlobalExceptionTranslator.class);

    @ExceptionHandler(MissingServletRequestParameterException.class)
    public BaseResponse handleError(MissingServletRequestParameterException e) {
        logger.warn("Missing Request Parameter", e);
        String message = String.format("Missing Request Parameter: %s", e.getParameterName());
        return BaseResponse
                .builder()
                .code(ResultCode.PARAM_MISS)
                .message(message)
                .build();
    }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public BaseResponse handleError(MethodArgumentTypeMismatchException e) {
        logger.warn("Method Argument Type Mismatch", e);
        String message = String.format("Method Argument Type Mismatch: %s", e.getName());
        return BaseResponse
                .builder()
                .code(ResultCode.PARAM_TYPE_ERROR)
                .message(message)
                .build();
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public BaseResponse handleError(MethodArgumentNotValidException e) {
        logger.warn("Method Argument Not Valid", e);
        BindingResult result = e.getBindingResult();
        FieldError error = result.getFieldError();
        String message = String.format("%s:%s", error.getField(), error.getDefaultMessage());
        return BaseResponse
                .builder()
                .code(ResultCode.PARAM_VALID_ERROR)
                .message(message)
                .build();
    }

    @ExceptionHandler(BindException.class)
    public BaseResponse handleError(BindException e) {
        logger.warn("Bind Exception", e);
        FieldError error = e.getFieldError();
        String message = String.format("%s:%s", error.getField(), error.getDefaultMessage());
        return BaseResponse
                .builder()
                .code(ResultCode.PARAM_BIND_ERROR)
                .message(message)
                .build();
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public BaseResponse handleError(ConstraintViolationException e) {
        logger.warn("Constraint Violation", e);
        Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
        ConstraintViolation<?> violation = violations.iterator().next();
        String path = ((PathImpl) violation.getPropertyPath()).getLeafNode().getName();
        String message = String.format("%s:%s", path, violation.getMessage());
        return BaseResponse
                .builder()
                .code(ResultCode.PARAM_VALID_ERROR)
                .message(message)
                .build();
    }

    @ExceptionHandler(NoHandlerFoundException.class)
    public BaseResponse handleError(NoHandlerFoundException e) {
        logger.error("404 Not Found", e);
        return BaseResponse
                .builder()
                .code(ResultCode.NOT_FOUND)
                .message(e.getMessage())
                .build();
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public BaseResponse handleError(HttpMessageNotReadableException e) {
        logger.error("Message Not Readable", e);
        return BaseResponse
                .builder()
                .code(ResultCode.MSG_NOT_READABLE)
                .message(e.getMessage())
                .build();
    }

    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public BaseResponse handleError(HttpRequestMethodNotSupportedException e) {
        logger.error("Request Method Not Supported", e);
        return BaseResponse
                .builder()
                .code(ResultCode.METHOD_NOT_SUPPORTED)
                .message(e.getMessage())
                .build();
    }

    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
    public BaseResponse handleError(HttpMediaTypeNotSupportedException e) {
        logger.error("Media Type Not Supported", e);
        return BaseResponse
                .builder()
                .code(ResultCode.MEDIA_TYPE_NOT_SUPPORTED)
                .message(e.getMessage())
                .build();
    }

    @ExceptionHandler(ServiceException.class)
    public BaseResponse handleError(ServiceException e) {
        logger.error("Service Exception", e);
        return BaseResponse
                .builder()
                .code(e.getResultCode())
                .message(e.getMessage())
                .build();
    }

    @ExceptionHandler(PermissionDeniedException.class)
    public BaseResponse handleError(PermissionDeniedException e) {
        logger.error("Permission Denied", e);
        return BaseResponse
                .builder()
                .code(e.getResultCode())
                .message(e.getMessage())
                .build();
    }

    @ExceptionHandler(Throwable.class)
    public BaseResponse handleError(Throwable e) {
        logger.error("Internal Server Error", e);
        return BaseResponse
                .builder()
                .code(ResultCode.INTERNAL_SERVER_ERROR)
                .message(e.getMessage())
                .build();
    }
}

以上是http请求的异常处理,对于spring mvc 异常页面的处理为:

@Controller
@SuppressWarnings(value = "Duplicates")
public class GlobalErrorController implements ErrorController {

    static final ILogger logger = SLoggerFactory.getLogger(GlobalErrorController.class);

    @Autowired
    ErrorPageFactory errorPageFactory;
    @Autowired
    SentryClient sentryClient;
    @Autowired
    StaffjoyProps staffjoyProps;
    @Autowired
    EnvConfig envConfig;

    @Override
    public String getErrorPath() {
        return "/error";
    }

    @RequestMapping("/error")
    public String handleError(HttpServletRequest request, Model model) {

        Object statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
        Object exception = request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);

        ErrorPage errorPage = null;
        if (statusCode != null && (Integer)statusCode == HttpStatus.NOT_FOUND.value()) {
            errorPage = errorPageFactory.buildNotFoundPage();
        } else {
            errorPage = errorPageFactory.buildInternalServerErrorPage();
        }

        if (exception != null) {
            if (envConfig.isDebug()) {  // no sentry aop in debug mode
                logger.error("Global error handling", exception);
            } else {
                sentryClient.sendException((Exception)exception);
                UUID uuid = sentryClient.getContext().getLastEventId();
                errorPage.setSentryErrorId(uuid.toString());
                errorPage.setSentryPublicDsn(staffjoyProps.getSentryDsn());
                logger.warn("Reported error to sentry", "id", uuid.toString(), "error", exception);
            }
        }

        model.addAttribute(Constant.ATTRIBUTE_NAME_PAGE, errorPage);

        return "error";
    }

}

DTO 和 DMO 为什么要互转

DTO: data transfer object,进行传输的对象,引用与 API 接口,输出输出端
DMO: data model object,用来表达应用的业务模型,一般需要持久化
在业务流程和传输过程中会根据需要裁减掉一部分的字段,或者增加一部分的字段
在转换时使用 ModelMapper工具:
链接:https://github.com/modelmapper/modelmapper

private Account convertToModel(AccountDto accountDto) {
    return modelMapper.map(accountDto, Account.class);
}

实现强类型接口设计

截屏2021-12-01 下午7.16.03.png
基于 Spring Feign,本质上是一种动态代理机制。
截屏2021-12-01 下午7.24.18.png
其中最重要的是 DecoderEncoder,也就是序列化反序列化过程,是考验框架性能的关键。

强类型接口设计

强弱类型接口区别

传统的 RPC 服务一般是强类型的,RPC 通常采用订制的二进制协议对消息进行编码和解码,采用 TCP 传输消息。RPC 服务通常有严格的契约(contract),开发服务器前先要定义 IDL(Interface Definition Language),用 IDL 来定义契约,再通过契约自动生成强类型的服务端和客户端的接口。服务调用的时候直接使用强类型客户端,不需要手动进行消息的编码和解码,gRPC 和 Apache Thrift 是目前两款主流的 RPC 框架。
而现在的大部分 Restful 服务通常是弱类型的,Rest 通常采用 Json 作为传输消息,使用 HTTP 作为传输协议,Restful 服务通常没有严格的契约的概念,使用普通的 HTTP Client 就可以调用,但是调用方通常需要对 Json 消息进行手动编码和解码的工作。在现实世界当中,大部分服务框架都是弱类型 Restful 的服务框架,比方说 Java 生态当中的 SpringBoot 可以认为是目前主流的弱类型 Restful 框架之一。

强弱类型接口优劣

强类型服务接口的好处是:接口规范、自动代码生成、自动编码解码、编译期自动类型检查。强类型接口的好处也带来不利的一面:首先是客户端和服务端强耦合,任何一方升级改动可能会造成另一方 break,另外自动代码生成需要工具支持,而开发这些工具的成本也比较高。其次强类型接口开发测试不太友好,一般的浏览器、Postman 这样的工具无法直接访问强类型接口。
弱类型服务接口的好处是客户端和服务器端不强耦合,不需要开发特别的代码生成工具,一般的 HTTP Client就可以调用,开发测试友好,不同的浏览器、Postman 可以轻松访问。弱类型服务接口的不足是需要调用方手动编码解码消息、没有自动代码的生成、没有编译器接口类型检查、代码不容易规范、开发效率相对低,而且容易出现运行期的错误。

折中做法

在 Spring Rest 弱类型接口的基础上借助 Spring Feign 支持的强类型接口特性实现强类型 Rest 接口的调用机制,同时兼备强弱类型接口的好处。
截屏2021-12-01 下午7.53.24.png
系统中有很多的 Response类型,它们都继承自BaseResponse类,如果有异常响应,会直接体现在 BaseResponse对象中,子类特有的信息是空的,能够正常绑定。如果响应正常,那么直接绑定成对应的子类对象即可。
如果使用 Spring Feign 则不能使用泛型来设计返回体,因为泛型信息在运行中会被擦除,无法正常反序列化,无法正常使用强类型接口。

@FeignClient(name = AccountConstant.SERVICE_NAME, path = "/v1/account", url = "${staffjoy.account-service-endpoint}")
// TODO Client side validation can be enabled as needed
// @Validated
public interface AccountClient {

    @PostMapping(path = "/create")
    GenericAccountResponse createAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid CreateAccountRequest request);

    @PostMapping(path = "/track_event")
    BaseResponse trackEvent(@RequestBody @Valid TrackEventRequest request);

    @PostMapping(path = "/sync_user")
    BaseResponse syncUser(@RequestBody @Valid SyncUserRequest request);

    @GetMapping(path = "/list")
    ListAccountResponse listAccounts(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam int offset, @RequestParam @Min(0) int limit);

    // GetOrCreate is for internal use by other APIs to match a user based on their phonenumber or email.
    @PostMapping(path= "/get_or_create")
    GenericAccountResponse getOrCreateAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid GetOrCreateRequest request);

    @GetMapping(path = "/get")
    GenericAccountResponse getAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam @NotBlank String userId);

    @PutMapping(path = "/update")
    GenericAccountResponse updateAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid AccountDto newAccount);

    @GetMapping(path = "/get_account_by_phonenumber")
    GenericAccountResponse getAccountByPhonenumber(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam @PhoneNumber String phoneNumber);

    @PutMapping(path = "/update_password")
    BaseResponse updatePassword(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid UpdatePasswordRequest request);

    @PostMapping(path = "/verify_password")
    GenericAccountResponse verifyPassword(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid VerifyPasswordRequest request);

    // RequestPasswordReset sends an email to a user with a password reset link
    @PostMapping(path = "/request_password_reset")
    BaseResponse requestPasswordReset(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid PasswordResetRequest request);

    @PostMapping(path = "/request_email_change")
    BaseResponse requestEmailChange(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid EmailChangeRequest request);

    // ChangeEmail sets an account to active and updates its email. It is
    // used after a user clicks a confirmation link in their email.
    @PostMapping(path = "/change_email")
    BaseResponse changeEmail(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid EmailConfirmation request);
}

其中
注解@FeignClient表示 spring 会扫描这个类并装配成强类型客户端
Spring Feign 笔记

分环境配置问题

public class EnvConstant {
    public static final String ENV_DEV = "dev";
    public static final String ENV_TEST = "test";
    public static final String ENV_UAT = "uat"; // similar to staging
    public static final String ENV_PROD = "prod";
}

定义每种环境的配置

static {
        map = new HashMap<String, EnvConfig>();
        EnvConfig envConfig = EnvConfig.builder().name(EnvConstant.ENV_DEV)
                .debug(true)
                .externalApex("staffjoy-v2.local")
                .internalApex(EnvConstant.ENV_DEV)
                .scheme("http")
                .build();
        map.put(EnvConstant.ENV_DEV, envConfig);

        envConfig = EnvConfig.builder().name(EnvConstant.ENV_TEST)
                .debug(true)
                .externalApex("staffjoy-v2.local")
                .internalApex(EnvConstant.ENV_DEV)
                .scheme("http")
                .build();
        map.put(EnvConstant.ENV_TEST, envConfig);

        // for aliyun k8s demo, enable debug and use http and staffjoy-uat.local
        // in real world, disable debug and use http and staffjoy-uat.xyz in UAT environment
        envConfig = EnvConfig.builder().name(EnvConstant.ENV_UAT)
                .debug(true)
                .externalApex("staffjoy-uat.local")
                .internalApex(EnvConstant.ENV_UAT)
                .scheme("http")
                .build();
        map.put(EnvConstant.ENV_UAT, envConfig);

        envConfig = EnvConfig.builder().name(EnvConstant.ENV_PROD)
                .debug(false)
                .externalApex("staffjoy.com")
                .internalApex(EnvConstant.ENV_PROD)
                .scheme("https")
                .build();
        map.put(EnvConstant.ENV_PROD, envConfig);
    }

具体应用:
在 Debug 环境下不允许生成日志:

@Aspect
@Slf4j
public class SentryClientAspect {

    @Autowired
    EnvConfig envConfig;

    @Around("execution(* io.sentry.SentryClient.send*(..))")
    public void around(ProceedingJoinPoint joinPoint) throws Throwable {
        // no sentry logging in debug mode
        if (envConfig.isDebug()) {
            log.debug("no sentry logging in debug mode");
            return;
        }
        joinPoint.proceed();
    }
}

异步调用处理

截屏2021-12-02 下午6.45.00.png
在 Spring boot 中配置线程池:

@Configuration
@EnableAsync
@Import(value = {StaffjoyRestConfig.class})
@SuppressWarnings(value = "Duplicates")
public class AppConfig {

    public static final String ASYNC_EXECUTOR_NAME = "asyncExecutor";

    @Bean(name=ASYNC_EXECUTOR_NAME)
    public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // for passing in request scope context
        executor.setTaskDecorator(new ContextCopyingDecorator());
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(100);
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setThreadNamePrefix("AsyncThread-");
        executor.initialize();
        return executor;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

如果某个方法需要用到线程池异步操作,只需要加上 @Async注解,并标注线程池的名称即可。

@Async(AppConfig.ASYNC_EXECUTOR_NAME)
public void trackEventAsync(String userId, String event) {
    TrackEventRequest trackEventRequest = TrackEventRequest.builder()
            .userId(userId).event(event).build();
    BaseResponse baseResponse = null;
    try {
        baseResponse = accountClient.trackEvent(trackEventRequest);
    } catch (Exception ex) {
        String errMsg = "fail to trackEvent through accountClient";
        logException(logger, ex, errMsg);
    }
    if (!baseResponse.isSuccess()) {
        logError(logger, baseResponse.getMessage());
    }
}

线程上下文拷贝

自定义一个异步任务装饰器,实现 TaskDecorator接口,并重写decorate方法

public class ContextCopyingDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        // 获取主线程中的请求信息(我们的用户信息也放在这里)
        RequestAttributes context = RequestContextHolder.currentRequestAttributes();
        return () -> {
            try {
                // 将主线程的请求信息,设置到子线程中
                RequestContextHolder.setRequestAttributes(context);
                // 执行子线程
                runnable.run();
            } finally {
                // 线程结束,清空这些信息,否则可能造成内存泄漏
                RequestContextHolder.resetRequestAttributes();
            }
        };
    }
}

这样,就能将主线程的信息传递给子线程,并且子线程在执行完毕之后清除掉这些信息再放回线程池中。

使用异步处理的注意点

  1. 异步处理的方法需要写在另外一个单独的 Bean 中,然后引用它,不能出现在相同的 Bean 中
  2. 为了不丢失主线程的上下文信息,需要自定义装饰器进行线程上下文拷贝

    集成 Swagger 接口文档

    引入依赖

    <!-- Swagger -->
    <dependency>
     <groupId>io.springfox</groupId>
     <artifactId>springfox-swagger2</artifactId>
     <version>2.9.2</version>
    </dependency>
    <dependency>
     <groupId>io.springfox</groupId>
     <artifactId>springfox-swagger-ui</artifactId>
     <version>2.9.2</version>
    </dependency>
    

    自定义配置项

    @Configuration
    @EnableSwagger2
    public class SwaggerConfig {
     @Bean
     public Docket api() {
         return new Docket(DocumentationType.SWAGGER_2)
                 .select()
                 .apis(RequestHandlerSelectors.basePackage("xyz.staffjoy.account.controller"))
                 .paths(PathSelectors.any())
                 .build()
                 .apiInfo(apiEndPointsInfo())
                 .useDefaultResponseMessages(false);
     }
    
     private ApiInfo apiEndPointsInfo() {
         return new ApiInfoBuilder().title("Account REST API")
                 .description("Staffjoy Account REST API")
                 .contact(new Contact("bobo", "https://github.com/jskillcloud", "bobo@jskillcloud.com"))
                 .license("The MIT License")
                 .licenseUrl("https://opensource.org/licenses/MIT")
                 .version("V2")
                 .build();
     }
    }
    

    可编程微服务网关

    Gateway 是整个微服务应用集中的入口,是前后端分离的关键。同时网关的可编程性对系统的升级和灵活性非常重要。

    发展历程

    阶段一 完成单体应用的解耦拆分,无线应用还没有起步,还是比较传统的服务器端 web 应用
    通过 Nginx 作为网关和反向代理提供服务
    截屏2021-12-02 下午10.31.55.png
    阶段二 移动互联网兴起,通过 Nginx 直接将服务暴露给移动客户端
    截屏2021-12-02 下午11.48.59.png
    有如下几个问题:

  3. 移动端 App 与服务是强耦合的关系,包括接口耦合和域名耦合

  4. 每个暴露的服务都需要新的域名,开销问题
  5. 内网服务都暴露在公网上面有安全问题
  6. 移动端 App 需要进行聚合裁减和适配逻辑

阶段三 为了解决上述问题,引入无线 BFF
截屏2021-12-02 下午11.59.12.png
BFF 可以理解为代理适配服务,将后端微服务进行适配,聚合裁减,给无线设备提供统一 API,方便无线设备接入访问。

  1. 移动端和后端微服务不强耦合,可以各自独立变化
  2. 移动 App 只需要知道无线 BFF 的域名,不需要知道微服务的域名
  3. 无线 BFF 只需要一个新域名,开销小
  4. 微服务躲在 BFF 后面,不会暴露在公网上面,安全风险小

阶段四 将单体 BFF 拆分成集群,并引入单独的无线网关
截屏2021-12-03 上午10.56.46.png

  1. BFF 根据团队或者业务线进行解耦拆分
  2. 无线网关主要关注跨横切面的功能,比如:路由、认证鉴权、监控、限流熔断、防爬虫等

阶段五 废弃Nginx,引入统一的可编程网关层,提供更灵活的前后端能力
截屏2021-12-03 上午11.08.38.png

  1. 引入开放平台专用网关,供第三方应用开发
  2. 支持H5应用
  3. 由网关统一负责路由、负载均衡等功能,去除 Nginx 集群

    反向代理和网关的关系

    网关和反向代理

    网关设计

    截屏2021-12-03 下午12.35.20.png
    其中的 <>符号标识key-value映射关系
    在该项目中不引入外部网关,选择实际开发一个轻量级网关

  4. 当有 http 请求时,取出其中的 Host 域名信息,通过查询路由映射表找到目标服务,包括服务名、地址、配置信息等,然后交付给路由解析模块

  5. 请求转发器接收到服务信息,通过查询 HttpClient 映射表,找到对应的 HttpClient,就可以将请求转发到目标服务,并且接收服务产生的响应,并最终将响应返回给调用方
  6. 如果网关开启了负载均衡的能力,请求转发器还要根据规则得到实际的服务地址再进行转发
  7. 在请求的链路上面,还可以自定义添加一些请求截获器和响应截获器,可以对请求响应加一些定制化处理,以此来拓展网关的能力

静态路由配置如下:
截屏2021-12-03 下午1.56.22.png

网关具体实现

路由解析模块

首先实现一个抽象类MappingsProvider,这就是路由映射表,其中有两个方法已经编写好:

public MappingProperties resolveMapping(String originHost, HttpServletRequest request) {
    if (shouldUpdateMappings(request)) {
        updateMappings();
    }
    List<MappingProperties> resolvedMappings = mappings.stream()
            .filter(mapping -> originHost.toLowerCase().equals(mapping.getHost().toLowerCase()))
            .collect(Collectors.toList());
    if (isEmpty(resolvedMappings)) {
        return null;
    }
    return resolvedMappings.get(0);
}

@PostConstruct
protected synchronized void updateMappings() {
    List<MappingProperties> newMappings = retrieveMappings();
    mappingsValidator.validate(newMappings);
    mappings = newMappings;
    httpClientProvider.updateHttpClients(mappings);
    log.info("Destination mappings updated", mappings);
}
  1. 其中resolveMapping方法的作用就是,传入一个主机头,首先判断是否需要在映射表中更新映射信息,返回值为路由映射MappingProperties,具体结构为: ```java public class MappingProperties { /**
    • Name of the mapping / private String name; /*
    • Path for mapping incoming HTTP requests URIs. / private String host = “”; /*
    • List of destination hosts where HTTP requests will be forwarded. / private List destinations = new ArrayList<>(); /*
    • Properties responsible for timeout while forwarding HTTP requests. */ private TimeoutProperties timeout = new TimeoutProperties();
/**
 * Custom properties placeholder.
 */
private Map<String, Object> customConfiguration = new HashMap<>();

2. 还实现了更新路由表的方法,首先获取到实际的路由表,然后去更新 HttpClient 映射表

需要具体的映射表子类做的就是自定义两个方法,是否需要更新映射表以及获取当前的映射表。子类有两个,一个是根据静态配置,一个是可编程配置
<a name="yjhgf"></a>
##### 静态配置类
不需要动态更新映射表,所以`shouldUpdateMappings`方法返回false
```java
public class ConfigurationMappingsProvider extends MappingsProvider {
    ...

    @Override
    protected boolean shouldUpdateMappings(HttpServletRequest request) {
        return false;
    }

    @Override
    protected List<MappingProperties> retrieveMappings() {
        return faradayProperties.getMappings().stream()
                .map(MappingProperties::copy)
                .collect(Collectors.toList());
    }
}

其中的 FaradayProperties类是通过配置文件加载进来的:

/**
 * Faraday configuration properties
 */
@ConfigurationProperties("faraday")
public class FaradayProperties {
    /**
     * Faraday servlet filter order.
     */
    private int filterOrder = HIGHEST_PRECEDENCE + 100;
    /**
     * Enable programmatic mapping or not,
     * false only in dev environment, in dev we use mapping via configuration file
     */
    private boolean enableProgrammaticMapping = true;
    /**
     * Properties responsible for collecting metrics during HTTP requests forwarding.
     */
    @NestedConfigurationProperty
    private MetricsProperties metrics = new MetricsProperties();
    /**
     * Properties responsible for tracing HTTP requests proxying processes.
     */
    @NestedConfigurationProperty
    private TracingProperties tracing = new TracingProperties();
    /**
     * List of proxy mappings.
     */
    @NestedConfigurationProperty
    private List<MappingProperties> mappings = new ArrayList<>();

@NestedConfigurationProperty:当配置类的数据结构比较复杂时,比如说一层嵌套一层,或者有List,Map这种结构的,需要使用@NestedConfigurationProperty注解完成配置。
对应的配置文件为:

faraday:
  enable_programmatic_mapping: false
  tracing:
    enabled: false
  mappings:
    -
      name: faraday_route
      host: faraday.staffjoy-v2.local
      destinations: httpbin.org
    -
      name: account_route
      host: account.staffjoy-v2.local
      destinations: localhost:8081
    -
      name: company_route
      host: company.staffjoy-v2.local
      destinations: localhost:8082
    -
      name: ical_route
      host: ical.staffjoy-v2.local
      destinations: localhost:8083
    -
      name: whoami_route
      host: whoami.staffjoy-v2.local
      destinations: localhost:8084
      timeout:
        connect: 10000
        read: 10000
    -
      name: superpowers_route
      host: superpowers.staffjoy-v2.local
      destinations: localhost:8085
    -
      name: www_route
      host: www.staffjoy-v2.local
      destinations: localhost:8086
    -
      name: myaccount_route
      host: myaccount.staffjoy-v2.local
      destinations: localhost:9000
    -
      name: app_route
      host: app.staffjoy-v2.local
      destinations: localhost:9001

以及 mapping 的嵌套结构为:

public class MappingProperties {
    /**
     * Name of the mapping
     */
    private String name;
    /**
     * Path for mapping incoming HTTP requests URIs.
     */
    private String host = "";
    /**
     * List of destination hosts where HTTP requests will be forwarded.
     */
    private List<String> destinations = new ArrayList<>();

可编程配置类
public class ProgrammaticMappingsProvider extends MappingsProvider {
    protected final EnvConfig envConfig;

    ...
    ...

    @Override
    protected boolean shouldUpdateMappings(HttpServletRequest request) {
        return false;
    }

    @Override
    protected List<MappingProperties> retrieveMappings() {
        List<MappingProperties> mappings = new ArrayList<>();
        Map<String, Service> serviceMap = ServiceDirectory.getMapping();
        for(String key : serviceMap.keySet()) {
            String subDomain = key.toLowerCase();
            Service service = serviceMap.get(key);
            MappingProperties mapping = new MappingProperties();
            mapping.setName(subDomain + "_route");
            mapping.setHost(subDomain + "." + envConfig.getExternalApex());
            // No security on backend right now :-(
            String dest = "http://" + service.getBackendDomain();
            mapping.setDestinations(Arrays.asList(dest));
            mappings.add(mapping);
        }
        return mappings;
    }
}

与静态配置方式不同的是,配置项都是通过编程的方式配置:

public class ServiceDirectory {

    private static Map<String, Service> serviceMap;

    static {

        Map<String, Service> map = new TreeMap<>();

        Service service = Service.builder()
                .security(SecurityConstant.SEC_AUTHENTICATED)
                .restrictDev(false)
                .backendDomain("account-service")
                .build();
        map.put("account", service);

        service = Service.builder()
                .security(SecurityConstant.SEC_AUTHENTICATED)
                .restrictDev(false)
                .backendDomain("app-service")
                .noCacheHtml(true)
                .build();
        map.put("app", service);

        service = Service.builder()
                .security(SecurityConstant.SEC_AUTHENTICATED)
                .restrictDev(false)
                .backendDomain("company-service")
                .build();
        map.put("company", service);

        service = Service.builder()
                // Debug site for faraday proxy
                .security(SecurityConstant.SEC_PUBLIC)
                .restrictDev(true)
                .backendDomain("httpbin.org")
                .build();
        map.put("faraday", service);

更新 HttpClientMapper

无论是静态配置类还是可编程配置类,如果路由表有更新都要同步去更新 servive<>HttpClient 表。

public class HttpClientProvider {
    protected Map<String, RestTemplate> httpClients = new HashMap<>();

    public void updateHttpClients(List<MappingProperties> mappings) {
        httpClients = mappings.stream().collect(toMap(MappingProperties::getName, this::createRestTemplate));
    }

    public RestTemplate getHttpClient(String mappingName) {
        return httpClients.get(mappingName);
    }

    protected RestTemplate createRestTemplate(MappingProperties mapping) {
        CloseableHttpClient client = createHttpClient(mapping).build();
        HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(client);
        requestFactory.setConnectTimeout(mapping.getTimeout().getConnect());
        requestFactory.setReadTimeout(mapping.getTimeout().getRead());
        return new RestTemplate(requestFactory);
    }

    protected HttpClientBuilder createHttpClient(MappingProperties mapping) {
        return create().useSystemProperties().disableRedirectHandling().disableCookieManagement();
    }
}

通过 Spring 的 RestTemplate来访问 Http 服务

请求转发模块

使用 RequestForwarder类中的方法完成请求转发功能:

public ResponseEntity<byte[]> forwardHttpRequest(RequestData data, String traceId, MappingProperties mapping) {
    // 由路由解析模块的结果获取到目标地址
    ForwardDestination destination = resolveForwardDestination(data.getUri(), mapping);
    prepareForwardedRequestHeaders(data, destination);
    traceInterceptor.onForwardStart(traceId, destination.getMappingName(),
            data.getMethod(), data.getHost(), destination.getUri().toString(),
            data.getBody(), data.getHeaders());
    RequestEntity<byte[]> request = new RequestEntity<>(data.getBody(), data.getHeaders(), data.getMethod(), destination.getUri());

    //发送请求
    ResponseData response = sendRequest(traceId, request, mapping, destination.getMappingMetricsName(), data);

    log.debug(String.format("Forwarded: %s %s %s -> %s %d", data.getMethod(), data.getHost(), data.getUri(), destination.getUri(), response.getStatus().value()));

    traceInterceptor.onForwardComplete(traceId, response.getStatus(), response.getBody(), response.getHeaders());

    // 响应拦截器
    postForwardResponseInterceptor.intercept(response, mapping);
    prepareForwardedResponseHeaders(response);

    return status(response.getStatus())
            .headers(response.getHeaders())
            .body(response.getBody());

}

截屏2021-12-05 16.18.42.png
实际进行转发的操作是上图中的 **.exchange(request, byte[].class)**方法

请求响应流程

整个流程的入口在 ReverseProxyFilter中,主流程逻辑都在这里:

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

    ...

    // 调用路由解析模块
    MappingProperties mapping = mappingsProvider.resolveMapping(originHost, request);
    if (mapping == null) {
        traceInterceptor.onNoMappingFound(traceId, method, originHost, originUri, headers);

        log.debug(String.format("Forwarding: %s %s %s -> no mapping found", method, originHost, originUri));

        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        response.getWriter().println("Unsupported domain");
        return;
    } else {
        log.debug(String.format("Forwarding: %s %s %s -> %s", method, originHost, originUri, mapping.getDestinations()));
    }

    byte[] body = extractor.extractBody(request);
    addForwardHeaders(request, headers);

    // 请求截获器模块
    RequestData dataToForward = new RequestData(method, originHost, originUri, headers, body, request);
    preForwardRequestInterceptor.intercept(dataToForward, mapping);
    if (dataToForward.isNeedRedirect() && !isBlank(dataToForward.getRedirectUrl())) {
        log.debug(String.format("Redirecting to -> %s", dataToForward.getRedirectUrl()));
        response.sendRedirect(dataToForward.getRedirectUrl());
        return;
    }

    // 请求转发模块
    ResponseEntity<byte[]> responseEntity =
            requestForwarder.forwardHttpRequest(dataToForward, traceId, mapping);

    // 获取响应后,处理响应
    this.processResponse(response, responseEntity);
}

生产级网关需要哪些拓展

生产扩展点:

  • 限流熔断
  • 动态路由和负载均衡
  • 基于 Path 的路由
    • api.xxx.com/pathx
  • 截获器链
  • 日志采集和 Metrics 埋点
  • 响应流优化
  • 。。。

    主流开源网关概览

    截屏2021-12-05 16.33.20.png

    安全认证框架

    截屏2021-12-08 22.13.11.png
    本实践中使用基于 JWT 的无状态认证模式。由 WWW Web MVC App 提供 Auth Server 服务,总体沿用的是 V3.6 架构。
    安全认证框架设计
    截屏2021-12-08 23.17.37.png

    代码实现

    首先在 common-lib 模块引入 jwt 依赖
    <dependency>
      <groupId>com.auth0</groupId>
      <artifactId>java-jwt</artifactId>
      <version>3.6.0</version>
    </dependency>
    
    JWT 生成算法 ```java private static Map algorithmMap = new HashMap<>();

public static String generateSessionToken(String userId, String signingToken, boolean support, long duration) { if (StringUtils.isEmpty(signingToken)) { throw new ServiceException(“No signing token present”); } Algorithm algorithm = getAlgorithm(signingToken); String token = JWT.create() .withClaim(CLAIM_USER_ID, userId) .withClaim(CLAIM_SUPPORT, support) .withExpiresAt(new Date(System.currentTimeMillis() + duration)) .sign(algorithm); return token; }

private static Algorithm getAlgorithm(String signingToken) { Algorithm algorithm = algorithmMap.get(signingToken); if (algorithm == null) { synchronized (algorithmMap) { algorithm = algorithmMap.get(signingToken); if (algorithm == null) { algorithm = Algorithm.HMAC512(signingToken); algorithmMap.put(signingToken, algorithm); } } } return algorithm; }

登录 Login 种 Cookie
```java
public static void loginUser(String userId,
                             boolean support,
                             boolean rememberMe,
                             String signingSecret,
                             String externalApex,
                             HttpServletResponse response) {
    long duration;
    int maxAge;

    if (rememberMe) {
        // "Remember me"
        duration = LONG_SESSION;
    } else {
        duration = SHORT_SESSION;
    }
    maxAge = (int) (duration / 1000);

    String token = Sign.generateSessionToken(userId, signingSecret, support, duration);

    Cookie cookie = new Cookie(AuthConstant.COOKIE_NAME, token);
    cookie.setPath("/");
    cookie.setDomain(externalApex);
    cookie.setMaxAge(maxAge);
    cookie.setHttpOnly(true);
    response.addCookie(cookie);
}

从 Cookie 种取出 JWT 令牌

public static String getToken(HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();
    if (cookies == null || cookies.length == 0) return null;
    Cookie tokenCookie = Arrays.stream(cookies)
            .filter(cookie -> AuthConstant.COOKIE_NAME.equals(cookie.getName()))
            .findAny().orElse(null);
    if (tokenCookie == null) return null;
    return tokenCookie.getValue();
}

JWT 校验和取出用户会话数据,在网关模块中的 AuthRequestInterceptor 拦截器中完成该功能

private Session getSession(HttpServletRequest request) {
    String token = Sessions.getToken(request);
    if (token == null) return null;
    try {
        DecodedJWT decodedJWT = Sign.verifySessionToken(token, signingSecret);
        String userId = decodedJWT.getClaim(Sign.CLAIM_USER_ID).asString();
        boolean support = decodedJWT.getClaim(Sign.CLAIM_SUPPORT).asBoolean();
        Session session = Session.builder().userId(userId).support(support).build();
        return session;
    } catch (Exception e) {
        log.error("fail to verify token", "token", token, e);
        return null;
    }
}

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
private static class Session {
    private String userId;
    private boolean support;
}

网关传递认证授权信息

private String setAuthHeader(RequestData data, MappingProperties mapping) {
    // default to anonymous web when prove otherwise
    String authorization = AuthConstant.AUTHORIZATION_ANONYMOUS_WEB;
    HttpHeaders headers = data.getHeaders();
    Session session = this.getSession(data.getOriginRequest());
    if (session != null) {
        if (session.isSupport()) {
            authorization = AuthConstant.AUTHORIZATION_SUPPORT_USER;
        } else {
            authorization = AuthConstant.AUTHORIZATION_AUTHENTICATED_USER;
        }

        this.checkBannedUsers(session.getUserId());

        headers.set(AuthConstant.CURRENT_USER_HEADER, session.getUserId());
    } else {
        // prevent hacking
        headers.remove(AuthConstant.CURRENT_USER_HEADER);
    }
    headers.set(AuthConstant.AUTHORIZATION_HEADER, authorization);

    return authorization;
}

登出操作

public static void logout(String externalApex, HttpServletResponse response) {
    Cookie cookie = new Cookie(AuthConstant.COOKIE_NAME, "");
    cookie.setPath("/");
    cookie.setMaxAge(0);
    cookie.setDomain(externalApex);
    response.addCookie(cookie);
}

认证上下文助手类
用于获取 userId

public class AuthContext {

    private static String getRequetHeader(String headerName) {
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes instanceof ServletRequestAttributes) {
            HttpServletRequest request = ((ServletRequestAttributes)requestAttributes).getRequest();
            String value = request.getHeader(headerName);
            return value;
        }
        return null;
    }

    public static String getUserId() {
        return getRequetHeader(AuthConstant.CURRENT_USER_HEADER);
    }

    public static String getAuthz() {
        return getRequetHeader(AuthConstant.AUTHORIZATION_HEADER);
    }

}

Feign 客户端传递用户认证信息
在上面获取的 userId 通过这种方式向后传

public class FeignRequestHeaderInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate requestTemplate) {
        String userId = AuthContext.getUserId();
        if (!StringUtils.isEmpty(userId)) {
            requestTemplate.header(AuthConstant.CURRENT_USER_HEADER, userId);
        }
    }
}

所以整体的链路就是,FARADAY 网关负责解析认证,并获取到 userId 向后传,后面的服务通过认证上下文助手类从请求上下文中获取到 userId,进行各自的业务流程。

服务间调用鉴权

利用控制器授权截获器
首先自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Authorize {
    // allowed consumers
    String[] value();
}
public class AuthorizeInterceptor extends HandlerInterceptorAdapter {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Authorize authorize = handlerMethod.getMethod().getAnnotation(Authorize.class);
        if (authorize == null) {
            return true; // no need to authorize
        }

        String[] allowedHeaders = authorize.value();
        String authzHeader = request.getHeader(AuthConstant.AUTHORIZATION_HEADER);

        if (StringUtils.isEmpty(authzHeader)) {
            throw new PermissionDeniedException(AuthConstant.ERROR_MSG_MISSING_AUTH_HEADER);
        }

        if (!Arrays.asList(allowedHeaders).contains(authzHeader)) {
            throw new PermissionDeniedException(AuthConstant.ERROR_MSG_DO_NOT_HAVE_ACCESS);
        }

        return true;
    }
}

Controller 端调用代码

@PutMapping(path = "/update")
@Authorize(value = {
        AuthConstant.AUTHORIZATION_WWW_SERVICE,
        AuthConstant.AUTHORIZATION_COMPANY_SERVICE,
        AuthConstant.AUTHORIZATION_AUTHENTICATED_USER,
        AuthConstant.AUTHORIZATION_SUPPORT_USER,
        AuthConstant.AUTHORIZATION_SUPERPOWERS_SERVICE
})
public GenericAccountResponse updateAccount(@RequestBody @Valid AccountDto newAccountDto) {
    this.validateAuthenticatedUser(newAccountDto.getId());
    this.validateEnv();

    AccountDto accountDto =  accountService.update(newAccountDto);

    GenericAccountResponse genericAccountResponse = new GenericAccountResponse(accountDto);
    return genericAccountResponse;
}

微服务测试设计

使用 Mock 技术隔离依赖,简化测试

三种测试方法截屏2021-12-10 20.04.16.png

截屏2021-12-10 20.04.46.png

截屏2021-12-10 20.07.07.png
涵盖范围广,工具多比较多,Junit5,mockito,mockSpringmvc,springtest 等等
但是只能保证每个层次独立工作的逻辑性,不能保证层次间协作工作的逻辑性,更不能保证整个系统的逻辑性
截屏2021-12-10 20.12.18.png
IT 主要针对组件之间协作的测试
截屏2021-12-10 20.14.28.png
将一个微服务看做一个黑箱,只针对暴露的接口进行测试

端到端测试

截屏2021-12-10 20.37.20.png
截屏2021-12-10 20.43.02.png

Mock vs Spy

Mock 针对的是接口,Spy 可以针对类的方法。
在 staffjoy 项目中有相应的 spy 实践,在文件 ServiceHelperTest 中。

分环境配置

截屏2021-12-16 19.51.31.png
为不同的环境编写各自的配置(以 account-svc 为例):
截屏2021-12-16 19.52.26.png
其中 application.yml是公共配置,下面的四个是分环境的配置,各个环境中的项目是对公共配置的重载。

动态配置

截屏2021-12-16 20.31.12.png
在配置中心中修改了配置项后,会将改变交付给应用,需要应用进行配合及时修改配置项。
几种产品的区别:
截屏2021-12-16 20.32.30.png

调用链监控

微服务应用,不同服务之间的依赖关系错综复杂,为了高效地发现问题,调用链监控是非常重要的。
截屏2021-12-16 21.00.47.png

截屏2021-12-16 21.09.14.png
staffjoy 项目经过埋点监控后的示意图:
image.png

结构化日志

字面意思,日志是有结构的,便于分析和处理。使用到的中间件为 StructLog4j
截屏2021-12-16 21.36.42.png
具体用法,在 account-svc 模块中的类 AccountService,首先引入依赖

static final ILogger logger = SLoggerFactory.getLogger(ServiceHelper.class);

在需要记录日志的地方:

LogEntry auditLog = LogEntry.builder()
        .authorization(AuthContext.getAuthz())
        .currentUserId(AuthContext.getUserId())
        .targetType("account")
        .targetId(account.getId())
        .updatedContents(account.toString())
        .build();

logger.info("created account", auditLog);

集中异常监控(Sentry)

是一个日志异常监控的工具,可以在 web 端看到比较细粒度的信息。
在 staffjoy 的配置类 StaffjoyConfig中生成 bean:

@Bean
public SentryClient sentryClient() {

    SentryClient sentryClient = Sentry.init(staffjoyProps.getSentryDsn());
    sentryClient.setEnvironment(activeProfile);
    sentryClient.setRelease(staffjoyProps.getDeployEnv());
    sentryClient.addTag("service", appName);

    return sentryClient;
}

集中异常处理:

public void handleError(ILogger log, String errMsg) {
    log.error(errMsg);
    if (!envConfig.isDebug()) {
        sentryClient.sendMessage(errMsg);
    }
}

public void handleException(ILogger log, Exception ex, String errMsg) {
    log.error(errMsg, ex);
    if (!envConfig.isDebug()) {
        sentryClient.sendException(ex);
    }
}

截屏2021-12-16 23.29.55.png
fluentd 收集各个 Docker 节点上的日志,通过 kafka 进行解耦,然后进行解析和过滤存入 ES,并且集中展示
截屏2021-12-16 23.35.05.png
截屏2021-12-16 23.38.06.png