Java SpringBoot Kubernetes 云原生 微服务
案例需求介绍
staffjoy 公司背景
- 硅谷初创公司(2015~2017)
- 工时排班( Scheduling ) saaS服务
- 开源
- https://github.com/staffjoy/v2
- 原版Golang
- 课程教学版Java/Spring
Dubbo、SpringCloud和K8s该如何选型?
微服务公共关注点
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和运维 重量复杂 技术门槛高 |
技术中台
阿里巴巴中台体系
eBay中台架构
拍拍贷中台架构
单体仓库(Mono-Repo)
多仓库和单体仓库对比
谁在用单体应用仓库
项目架构设计
接口参数校验
统一异常处理
@RestControllerAdvicepublic 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();}}
DTO和DMO互转
https://github.com/modelmapper/modelmapper
private AccountDto convertToDto(Account account) {return modelMapper.map(account, AccountDto.class);}private Account convertToModel(AccountDto accountDto) {return modelMapper.map(accountDto, Account.class);}
设计基于Feign的强类型接口
Spring Feign
强类型接口设计
强类型接口定义

设置基础类Response,做统一处理,然后其他的Response继承基础类Response
@Data@NoArgsConstructor@AllArgsConstructor@Builderpublic class BaseResponse {private String message;@Builder.Defaultprivate ResultCode code = ResultCode.SUCCESS;public boolean isSuccess() {return code == ResultCode.SUCCESS;}}
@Getter@Setter@NoArgsConstructor@AllArgsConstructor@ToString(callSuper = true)@EqualsAndHashCode(callSuper = true)public class GenericAccountResponse extends BaseResponse {private AccountDto account;}
@Getter@Setter@NoArgsConstructor@AllArgsConstructor@ToString(callSuper = true)@EqualsAndHashCode(callSuper = true)public class ListAccountResponse extends BaseResponse {private AccountList accountList;}
强类型接口的使用
@FeignClient(name = AccountConstant.SERVICE_NAME, path = "/v1/account", url = "${staffjoy.account-service-endpoint}")// TODO Client side validation can be enabled as needed// @Validatedpublic 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);}
框架层考虑分环境配置
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 stagingpublic static final String ENV_PROD = "prod";}
@Data@Builderpublic class EnvConfig {private String name;private boolean debug;private String externalApex;private String internalApex;private String scheme;@Getter(AccessLevel.NONE)@Setter(AccessLevel.NONE)private static Map<String, EnvConfig> map;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 environmentenvConfig = 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_UAT)// .debug(false)// .externalApex("staffjoy-uat.xyz")// .internalApex(EnvConstant.ENV_UAT)// .scheme("https")// .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);}public static EnvConfig getEnvConfg(String env) {EnvConfig envConfig = map.get(env);if (envConfig == null) {envConfig = map.get(EnvConstant.ENV_DEV);}return envConfig;}}
然后在开发测试环境禁用Sentry异常日志
@Aspect@Slf4jpublic class SentryClientAspect {@AutowiredEnvConfig envConfig;@Around("execution(* io.sentry.SentryClient.send*(..))")public void around(ProceedingJoinPoint joinPoint) throws Throwable {// no sentry logging in debug modeif (envConfig.isDebug()) {log.debug("no sentry logging in debug mode");return;}joinPoint.proceed();}}
异步处理时复制上下文信息
AsyncExecutor配置
@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 contextexecutor.setTaskDecorator(new ContextCopyingDecorator());executor.setCorePoolSize(3);executor.setMaxPoolSize(5);executor.setQueueCapacity(100);executor.setWaitForTasksToCompleteOnShutdown(true);executor.setThreadNamePrefix("AsyncThread-");executor.initialize();return executor;}}
在异步的操作上面添加@Async标注
@Async(AppConfig.ASYNC_EXECUTOR_NAME)public void trackEventAsync(String userId, String eventName) {if (envConfig.isDebug()) {logger.debug("intercom disabled in dev & test environment");return;}Event event = new Event().setUserID(userId).setEventName("v2_" + eventName).setCreatedAt(Instant.now().toEpochMilli());try {Event.create(event);} catch (Exception ex) {String errMsg = "fail to create event on Intercom";handleException(logger, ex, errMsg);throw new ServiceException(errMsg, ex);}logger.debug("updated intercom");}
线程上下文拷贝
对于异常操作中线程切换,有时候需要的用户信息就没有了,所以需要处理线程上下文拷贝:
// https://stackoverflow.com/questions/23732089/how-to-enable-request-scope-in-async-task-executorpublic class ContextCopyingDecorator implements TaskDecorator {@Overridepublic Runnable decorate(Runnable runnable) {RequestAttributes context = RequestContextHolder.currentRequestAttributes();return () -> {try {RequestContextHolder.setRequestAttributes(context);runnable.run();} finally {RequestContextHolder.resetRequestAttributes();}};}}
Swagger接口文档
https://swagger.io/docs/specification/about/
引入pom依赖
<!-- 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>
配置Swagger的JavaConfig
@Configuration@EnableSwagger2public class SwaggerConfig {@Beanpublic 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();}}
Swagger JSON Doc
主流的服务框架对比
| 支持公司 | 编程风格 | 编程模型 | 支持语言 | 亮点 | |
|---|---|---|---|---|---|
| Spring(Boot) | Pivotal | REST | 代码优先 | Java | 社区生态好 |
| Dubbo | 阿里 | RPC/REST | 代码优先 | Java | 阿里背书+服务治理 |
| Motan | 新浪 | RPC | 代码优先 | Java为主 | 轻量版Dubbo |
| gRpc | 谷歌 | RPC | 契约优先 | 跨语言 | 谷歌背书+多语言支持+HTTP2支持 |

