Java SpringBoot Kubernetes 云原生 微服务

案例需求介绍

staffjoy 公司背景

  1. 硅谷初创公司(2015~2017)
  2. 工时排班( Scheduling ) saaS服务
  3. 开源
    1. https://github.com/staffjoy/v2
    2. 原版Golang
    3. 课程教学版Java/Spring

      Dubbo、SpringCloud和K8s该如何选型?

      微服务公共关注点

      Spring Boot与Kubernetes云原生微服务实践 - 图1

      Dubbo、SpringCloud和K8s横向对比

      |
      | Dubbo | SpringCloud | K8s | | —- | —- | —- | —- | | 服务发现和LB | ZK/Nacos+Client | Eureka+Ribbon | Service | | API网关 | NA | Zuul | Ingress | | 配置管理 | Diamond | Spring Cloud Config | ConfigMaps/Secrets | | 容错限流 | Sentinel | Hystrix | HealthCheck/Probe/ServiceMesh | | 日志监控 | ELK | ELK | EFK | | Metrics监控 | Dubbo Admin/Monitor | Actuator/MicroMeter+Prometheus | Heapster+Prometheus | | 调用链监控 | NA | SpringCloud Sleuth/Zipkin | Jaeger/Zipkin |

Dubbo SpringCloud K8s
应用打包 Jar/War Uber Jar/War Docker Image/Helm
服务框架 Dubbo RPC Spring(Boot) REST 框架无关
发布和调度 NA NA Scheduler
自动伸缩和自愈 NA NA Scheduler/AutoScaler
进程隔离 NA NA Docker/Pod
环境管理 NA NA Namespace/Authorization
资源配额 NA NA CPU/Mem Limit,Namespace Quotas
流量治理 ZK+Client NA ServiceMesh

优劣比对


Dubbo SpringCloud K8s
亮点 阿里背书
成熟稳定
RPC高性能
流量治理
Netflix/Pivotal背书
社区活跃
开发体验好
抽象组件化好
谷歌背书
平台抽象
全面覆盖微服务关注点(发布)
语言栈无关
社区活跃
不足 技术较老
耦合性高
JVM Only
国外社区小
JVM Only
运行耗资源
偏DevOps和运维
重量复杂
技术门槛高

技术中台

阿里巴巴中台体系

Spring Boot与Kubernetes云原生微服务实践 - 图2

eBay中台架构

image.png

拍拍贷中台架构

image.png

单体仓库(Mono-Repo)

多仓库和单体仓库对比

Spring Boot与Kubernetes云原生微服务实践 - 图5

谁在用单体应用仓库

Google

https://bazel.build/

FaceBook

https://buck.build/

项目架构设计

接口参数校验

统一异常处理

  1. @RestControllerAdvice
  2. public class GlobalExceptionTranslator {
  3. static final ILogger logger = SLoggerFactory.getLogger(GlobalExceptionTranslator.class);
  4. @ExceptionHandler(MissingServletRequestParameterException.class)
  5. public BaseResponse handleError(MissingServletRequestParameterException e) {
  6. logger.warn("Missing Request Parameter", e);
  7. String message = String.format("Missing Request Parameter: %s", e.getParameterName());
  8. return BaseResponse
  9. .builder()
  10. .code(ResultCode.PARAM_MISS)
  11. .message(message)
  12. .build();
  13. }
  14. @ExceptionHandler(MethodArgumentTypeMismatchException.class)
  15. public BaseResponse handleError(MethodArgumentTypeMismatchException e) {
  16. logger.warn("Method Argument Type Mismatch", e);
  17. String message = String.format("Method Argument Type Mismatch: %s", e.getName());
  18. return BaseResponse
  19. .builder()
  20. .code(ResultCode.PARAM_TYPE_ERROR)
  21. .message(message)
  22. .build();
  23. }
  24. @ExceptionHandler(MethodArgumentNotValidException.class)
  25. public BaseResponse handleError(MethodArgumentNotValidException e) {
  26. logger.warn("Method Argument Not Valid", e);
  27. BindingResult result = e.getBindingResult();
  28. FieldError error = result.getFieldError();
  29. String message = String.format("%s:%s", error.getField(), error.getDefaultMessage());
  30. return BaseResponse
  31. .builder()
  32. .code(ResultCode.PARAM_VALID_ERROR)
  33. .message(message)
  34. .build();
  35. }
  36. @ExceptionHandler(BindException.class)
  37. public BaseResponse handleError(BindException e) {
  38. logger.warn("Bind Exception", e);
  39. FieldError error = e.getFieldError();
  40. String message = String.format("%s:%s", error.getField(), error.getDefaultMessage());
  41. return BaseResponse
  42. .builder()
  43. .code(ResultCode.PARAM_BIND_ERROR)
  44. .message(message)
  45. .build();
  46. }
  47. @ExceptionHandler(ConstraintViolationException.class)
  48. public BaseResponse handleError(ConstraintViolationException e) {
  49. logger.warn("Constraint Violation", e);
  50. Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
  51. ConstraintViolation<?> violation = violations.iterator().next();
  52. String path = ((PathImpl) violation.getPropertyPath()).getLeafNode().getName();
  53. String message = String.format("%s:%s", path, violation.getMessage());
  54. return BaseResponse
  55. .builder()
  56. .code(ResultCode.PARAM_VALID_ERROR)
  57. .message(message)
  58. .build();
  59. }
  60. @ExceptionHandler(NoHandlerFoundException.class)
  61. public BaseResponse handleError(NoHandlerFoundException e) {
  62. logger.error("404 Not Found", e);
  63. return BaseResponse
  64. .builder()
  65. .code(ResultCode.NOT_FOUND)
  66. .message(e.getMessage())
  67. .build();
  68. }
  69. @ExceptionHandler(HttpMessageNotReadableException.class)
  70. public BaseResponse handleError(HttpMessageNotReadableException e) {
  71. logger.error("Message Not Readable", e);
  72. return BaseResponse
  73. .builder()
  74. .code(ResultCode.MSG_NOT_READABLE)
  75. .message(e.getMessage())
  76. .build();
  77. }
  78. @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
  79. public BaseResponse handleError(HttpRequestMethodNotSupportedException e) {
  80. logger.error("Request Method Not Supported", e);
  81. return BaseResponse
  82. .builder()
  83. .code(ResultCode.METHOD_NOT_SUPPORTED)
  84. .message(e.getMessage())
  85. .build();
  86. }
  87. @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
  88. public BaseResponse handleError(HttpMediaTypeNotSupportedException e) {
  89. logger.error("Media Type Not Supported", e);
  90. return BaseResponse
  91. .builder()
  92. .code(ResultCode.MEDIA_TYPE_NOT_SUPPORTED)
  93. .message(e.getMessage())
  94. .build();
  95. }
  96. @ExceptionHandler(ServiceException.class)
  97. public BaseResponse handleError(ServiceException e) {
  98. logger.error("Service Exception", e);
  99. return BaseResponse
  100. .builder()
  101. .code(e.getResultCode())
  102. .message(e.getMessage())
  103. .build();
  104. }
  105. @ExceptionHandler(PermissionDeniedException.class)
  106. public BaseResponse handleError(PermissionDeniedException e) {
  107. logger.error("Permission Denied", e);
  108. return BaseResponse
  109. .builder()
  110. .code(e.getResultCode())
  111. .message(e.getMessage())
  112. .build();
  113. }
  114. @ExceptionHandler(Throwable.class)
  115. public BaseResponse handleError(Throwable e) {
  116. logger.error("Internal Server Error", e);
  117. return BaseResponse
  118. .builder()
  119. .code(ResultCode.INTERNAL_SERVER_ERROR)
  120. .message(e.getMessage())
  121. .build();
  122. }
  123. }

DTO和DMO互转

https://github.com/modelmapper/modelmapper

  1. private AccountDto convertToDto(Account account) {
  2. return modelMapper.map(account, AccountDto.class);
  3. }
  4. private Account convertToModel(AccountDto accountDto) {
  5. return modelMapper.map(accountDto, Account.class);
  6. }

设计基于Feign的强类型接口

Spring Feign

Spring Boot与Kubernetes云原生微服务实践 - 图6

强类型接口设计

Spring Boot与Kubernetes云原生微服务实践 - 图7

强类型接口定义

image.png
设置基础类Response,做统一处理,然后其他的Response继承基础类Response

  1. @Data
  2. @NoArgsConstructor
  3. @AllArgsConstructor
  4. @Builder
  5. public class BaseResponse {
  6. private String message;
  7. @Builder.Default
  8. private ResultCode code = ResultCode.SUCCESS;
  9. public boolean isSuccess() {
  10. return code == ResultCode.SUCCESS;
  11. }
  12. }
  1. @Getter
  2. @Setter
  3. @NoArgsConstructor
  4. @AllArgsConstructor
  5. @ToString(callSuper = true)
  6. @EqualsAndHashCode(callSuper = true)
  7. public class GenericAccountResponse extends BaseResponse {
  8. private AccountDto account;
  9. }
  1. @Getter
  2. @Setter
  3. @NoArgsConstructor
  4. @AllArgsConstructor
  5. @ToString(callSuper = true)
  6. @EqualsAndHashCode(callSuper = true)
  7. public class ListAccountResponse extends BaseResponse {
  8. private AccountList accountList;
  9. }

强类型接口的使用
  1. @FeignClient(name = AccountConstant.SERVICE_NAME, path = "/v1/account", url = "${staffjoy.account-service-endpoint}")
  2. // TODO Client side validation can be enabled as needed
  3. // @Validated
  4. public interface AccountClient {
  5. @PostMapping(path = "/create")
  6. GenericAccountResponse createAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid CreateAccountRequest request);
  7. @PostMapping(path = "/track_event")
  8. BaseResponse trackEvent(@RequestBody @Valid TrackEventRequest request);
  9. @PostMapping(path = "/sync_user")
  10. BaseResponse syncUser(@RequestBody @Valid SyncUserRequest request);
  11. @GetMapping(path = "/list")
  12. ListAccountResponse listAccounts(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam int offset, @RequestParam @Min(0) int limit);
  13. // GetOrCreate is for internal use by other APIs to match a user based on their phonenumber or email.
  14. @PostMapping(path= "/get_or_create")
  15. GenericAccountResponse getOrCreateAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid GetOrCreateRequest request);
  16. @GetMapping(path = "/get")
  17. GenericAccountResponse getAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam @NotBlank String userId);
  18. @PutMapping(path = "/update")
  19. GenericAccountResponse updateAccount(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid AccountDto newAccount);
  20. @GetMapping(path = "/get_account_by_phonenumber")
  21. GenericAccountResponse getAccountByPhonenumber(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam @PhoneNumber String phoneNumber);
  22. @PutMapping(path = "/update_password")
  23. BaseResponse updatePassword(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid UpdatePasswordRequest request);
  24. @PostMapping(path = "/verify_password")
  25. GenericAccountResponse verifyPassword(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid VerifyPasswordRequest request);
  26. // RequestPasswordReset sends an email to a user with a password reset link
  27. @PostMapping(path = "/request_password_reset")
  28. BaseResponse requestPasswordReset(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid PasswordResetRequest request);
  29. @PostMapping(path = "/request_email_change")
  30. BaseResponse requestEmailChange(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid EmailChangeRequest request);
  31. // ChangeEmail sets an account to active and updates its email. It is
  32. // used after a user clicks a confirmation link in their email.
  33. @PostMapping(path = "/change_email")
  34. BaseResponse changeEmail(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid EmailConfirmation request);
  35. }

框架层考虑分环境配置

  1. public class EnvConstant {
  2. public static final String ENV_DEV = "dev";
  3. public static final String ENV_TEST = "test";
  4. public static final String ENV_UAT = "uat"; // similar to staging
  5. public static final String ENV_PROD = "prod";
  6. }
  1. @Data
  2. @Builder
  3. public class EnvConfig {
  4. private String name;
  5. private boolean debug;
  6. private String externalApex;
  7. private String internalApex;
  8. private String scheme;
  9. @Getter(AccessLevel.NONE)
  10. @Setter(AccessLevel.NONE)
  11. private static Map<String, EnvConfig> map;
  12. static {
  13. map = new HashMap<String, EnvConfig>();
  14. EnvConfig envConfig = EnvConfig.builder().name(EnvConstant.ENV_DEV)
  15. .debug(true)
  16. .externalApex("staffjoy-v2.local")
  17. .internalApex(EnvConstant.ENV_DEV)
  18. .scheme("http")
  19. .build();
  20. map.put(EnvConstant.ENV_DEV, envConfig);
  21. envConfig = EnvConfig.builder().name(EnvConstant.ENV_TEST)
  22. .debug(true)
  23. .externalApex("staffjoy-v2.local")
  24. .internalApex(EnvConstant.ENV_DEV)
  25. .scheme("http")
  26. .build();
  27. map.put(EnvConstant.ENV_TEST, envConfig);
  28. // for aliyun k8s demo, enable debug and use http and staffjoy-uat.local
  29. // in real world, disable debug and use http and staffjoy-uat.xyz in UAT environment
  30. envConfig = EnvConfig.builder().name(EnvConstant.ENV_UAT)
  31. .debug(true)
  32. .externalApex("staffjoy-uat.local")
  33. .internalApex(EnvConstant.ENV_UAT)
  34. .scheme("http")
  35. .build();
  36. map.put(EnvConstant.ENV_UAT, envConfig);
  37. // envConfig = EnvConfig.builder().name(EnvConstant.ENV_UAT)
  38. // .debug(false)
  39. // .externalApex("staffjoy-uat.xyz")
  40. // .internalApex(EnvConstant.ENV_UAT)
  41. // .scheme("https")
  42. // .build();
  43. // map.put(EnvConstant.ENV_UAT, envConfig);
  44. envConfig = EnvConfig.builder().name(EnvConstant.ENV_PROD)
  45. .debug(false)
  46. .externalApex("staffjoy.com")
  47. .internalApex(EnvConstant.ENV_PROD)
  48. .scheme("https")
  49. .build();
  50. map.put(EnvConstant.ENV_PROD, envConfig);
  51. }
  52. public static EnvConfig getEnvConfg(String env) {
  53. EnvConfig envConfig = map.get(env);
  54. if (envConfig == null) {
  55. envConfig = map.get(EnvConstant.ENV_DEV);
  56. }
  57. return envConfig;
  58. }
  59. }

然后在开发测试环境禁用Sentry异常日志

  1. @Aspect
  2. @Slf4j
  3. public class SentryClientAspect {
  4. @Autowired
  5. EnvConfig envConfig;
  6. @Around("execution(* io.sentry.SentryClient.send*(..))")
  7. public void around(ProceedingJoinPoint joinPoint) throws Throwable {
  8. // no sentry logging in debug mode
  9. if (envConfig.isDebug()) {
  10. log.debug("no sentry logging in debug mode");
  11. return;
  12. }
  13. joinPoint.proceed();
  14. }
  15. }

异步处理时复制上下文信息

Spring Boot与Kubernetes云原生微服务实践 - 图9

AsyncExecutor配置

  1. @Configuration
  2. @EnableAsync
  3. @Import(value = {StaffjoyRestConfig.class})
  4. @SuppressWarnings(value = "Duplicates")
  5. public class AppConfig {
  6. public static final String ASYNC_EXECUTOR_NAME = "asyncExecutor";
  7. @Bean(name=ASYNC_EXECUTOR_NAME)
  8. public Executor asyncExecutor() {
  9. ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
  10. // for passing in request scope context
  11. executor.setTaskDecorator(new ContextCopyingDecorator());
  12. executor.setCorePoolSize(3);
  13. executor.setMaxPoolSize(5);
  14. executor.setQueueCapacity(100);
  15. executor.setWaitForTasksToCompleteOnShutdown(true);
  16. executor.setThreadNamePrefix("AsyncThread-");
  17. executor.initialize();
  18. return executor;
  19. }
  20. }

在异步的操作上面添加@Async标注

  1. @Async(AppConfig.ASYNC_EXECUTOR_NAME)
  2. public void trackEventAsync(String userId, String eventName) {
  3. if (envConfig.isDebug()) {
  4. logger.debug("intercom disabled in dev & test environment");
  5. return;
  6. }
  7. Event event = new Event()
  8. .setUserID(userId)
  9. .setEventName("v2_" + eventName)
  10. .setCreatedAt(Instant.now().toEpochMilli());
  11. try {
  12. Event.create(event);
  13. } catch (Exception ex) {
  14. String errMsg = "fail to create event on Intercom";
  15. handleException(logger, ex, errMsg);
  16. throw new ServiceException(errMsg, ex);
  17. }
  18. logger.debug("updated intercom");
  19. }

线程上下文拷贝

对于异常操作中线程切换,有时候需要的用户信息就没有了,所以需要处理线程上下文拷贝:

  1. // https://stackoverflow.com/questions/23732089/how-to-enable-request-scope-in-async-task-executor
  2. public class ContextCopyingDecorator implements TaskDecorator {
  3. @Override
  4. public Runnable decorate(Runnable runnable) {
  5. RequestAttributes context = RequestContextHolder.currentRequestAttributes();
  6. return () -> {
  7. try {
  8. RequestContextHolder.setRequestAttributes(context);
  9. runnable.run();
  10. } finally {
  11. RequestContextHolder.resetRequestAttributes();
  12. }
  13. };
  14. }
  15. }

image.png
这样即便线程切换了,但是上下文信息在其他线程中依然可见。

Swagger接口文档

https://swagger.io/docs/specification/about/

引入pom依赖

  1. <!-- Swagger -->
  2. <dependency>
  3. <groupId>io.springfox</groupId>
  4. <artifactId>springfox-swagger2</artifactId>
  5. <version>2.9.2</version>
  6. </dependency>
  7. <dependency>
  8. <groupId>io.springfox</groupId>
  9. <artifactId>springfox-swagger-ui</artifactId>
  10. <version>2.9.2</version>
  11. </dependency>

配置Swagger的JavaConfig

  1. @Configuration
  2. @EnableSwagger2
  3. public class SwaggerConfig {
  4. @Bean
  5. public Docket api() {
  6. return new Docket(DocumentationType.SWAGGER_2)
  7. .select()
  8. .apis(RequestHandlerSelectors.basePackage("xyz.staffjoy.account.controller"))
  9. .paths(PathSelectors.any())
  10. .build()
  11. .apiInfo(apiEndPointsInfo())
  12. .useDefaultResponseMessages(false);
  13. }
  14. private ApiInfo apiEndPointsInfo() {
  15. return new ApiInfoBuilder().title("Account REST API")
  16. .description("Staffjoy Account REST API")
  17. .contact(new Contact("bobo", "https://github.com/jskillcloud", "bobo@jskillcloud.com"))
  18. .license("The MIT License")
  19. .licenseUrl("https://opensource.org/licenses/MIT")
  20. .version("V2")
  21. .build();
  22. }
  23. }

Swagger JSON Doc

https://editor.swagger.io

主流的服务框架对比

支持公司 编程风格 编程模型 支持语言 亮点
Spring(Boot) Pivotal REST 代码优先 Java 社区生态好
Dubbo 阿里 RPC/REST 代码优先 Java 阿里背书+服务治理
Motan 新浪 RPC 代码优先 Java为主 轻量版Dubbo
gRpc 谷歌 RPC 契约优先 跨语言 谷歌背书+多语言支持+HTTP2支持