功能
- SaaS 应用
- 管理员 Admin 管理公司和排班
-
重点
微服务和云原生架构
-
系统架构设计和技术栈选型
微服务架构
为何采用微服务架构
观点一 单块优先
观点二 微服务优先
微服务架构技术门槛减低,可以一开始就采用微服务架构,方便以后的拓展
总体架构设计
设计思想: 分而治之
- 单一职责
- 关注分离
账户数据模型
服务接口
账户服务
公司服务
admin 服务
员工目录服务
Team 服务
雇员 worker 服务
任务 job 服务
班次 shift 服务
技术选型
中台体系
Mono-Repo(单体仓库)
项目实践
项目地址:https://github.com/spring2go/staffjoy
代码组织
<modules>
<module>common-lib</module>
<module>account-svc</module>
<module>account-api</module>
<module>company-api</module>
<module>company-svc</module>
<module>mail-api</module>
<module>mail-svc</module>
<module>sms-svc</module>
<module>sms-api</module>
<module>bot-api</module>
<module>bot-svc</module>
<module>ical-svc</module>
<module>whoami-api</module>
<module>whoami-svc</module>
<module>web-app</module>
<module>faraday</module>
</modules>
自定义注解
先看某个 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
用来说明该注解类的生命周期,有以下三个参数:
- RetentionPolicy.SOURCE : 注解只保留在源文件中
- RetentionPolicy.CLASS : 注解保留在class文件中,在加载到JVM虚拟机时丢弃
- 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
搭配进行验证功能的自定义注解编写
统一异常处理
对系统的各种异常进行统一的处理,需要进行以下工作:
自定义返回体的结构
@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);
}
实现强类型接口设计
基于 Spring Feign,本质上是一种动态代理机制。
其中最重要的是 Decoder 和 Encoder,也就是序列化反序列化过程,是考验框架性能的关键。
强类型接口设计
强弱类型接口区别
传统的 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 接口的调用机制,同时兼备强弱类型接口的好处。
系统中有很多的 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();
}
}
异步调用处理
在 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();
}
};
}
}
这样,就能将主线程的信息传递给子线程,并且子线程在执行完毕之后清除掉这些信息再放回线程池中。
使用异步处理的注意点
- 异步处理的方法需要写在另外一个单独的 Bean 中,然后引用它,不能出现在相同的 Bean 中
为了不丢失主线程的上下文信息,需要自定义装饰器进行线程上下文拷贝
集成 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 作为网关和反向代理提供服务
阶段二 移动互联网兴起,通过 Nginx 直接将服务暴露给移动客户端
有如下几个问题:移动端 App 与服务是强耦合的关系,包括接口耦合和域名耦合
- 每个暴露的服务都需要新的域名,开销问题
- 内网服务都暴露在公网上面有安全问题
- 移动端 App 需要进行聚合裁减和适配逻辑
阶段三 为了解决上述问题,引入无线 BFF
BFF 可以理解为代理适配服务,将后端微服务进行适配,聚合裁减,给无线设备提供统一 API,方便无线设备接入访问。
- 移动端和后端微服务不强耦合,可以各自独立变化
- 移动 App 只需要知道无线 BFF 的域名,不需要知道微服务的域名
- 无线 BFF 只需要一个新域名,开销小
- 微服务躲在 BFF 后面,不会暴露在公网上面,安全风险小
阶段四 将单体 BFF 拆分成集群,并引入单独的无线网关
- BFF 根据团队或者业务线进行解耦拆分
- 无线网关主要关注跨横切面的功能,比如:路由、认证鉴权、监控、限流熔断、防爬虫等
阶段五 废弃Nginx,引入统一的可编程网关层,提供更灵活的前后端能力
- 引入开放平台专用网关,供第三方应用开发
- 支持H5应用
-
反向代理和网关的关系
网关设计
其中的<>
符号标识key-value
映射关系
在该项目中不引入外部网关,选择实际开发一个轻量级网关 当有 http 请求时,取出其中的 Host 域名信息,通过查询路由映射表找到目标服务,包括服务名、地址、配置信息等,然后交付给路由解析模块
- 请求转发器接收到服务信息,通过查询 HttpClient 映射表,找到对应的 HttpClient,就可以将请求转发到目标服务,并且接收服务产生的响应,并最终将响应返回给调用方
- 如果网关开启了负载均衡的能力,请求转发器还要根据规则得到实际的服务地址再进行转发
- 在请求的链路上面,还可以自定义添加一些请求截获器和响应截获器,可以对请求响应加一些定制化处理,以此来拓展网关的能力
网关具体实现
路由解析模块
首先实现一个抽象类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);
}
- 其中
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());
}
实际进行转发的操作是上图中的 **.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 埋点
- 响应流优化
- 。。。
主流开源网关概览
安全认证框架
本实践中使用基于 JWT 的无状态认证模式。由 WWW Web MVC App 提供 Auth Server 服务,总体沿用的是 V3.6 架构。
安全认证框架设计
代码实现
首先在 common-lib 模块引入 jwt 依赖
JWT 生成算法 ```java private static Map<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.6.0</version> </dependency>
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;
}
微服务测试设计
三种测试方法
涵盖范围广,工具多比较多,Junit5,mockito,mockSpringmvc,springtest 等等
但是只能保证每个层次独立工作的逻辑性,不能保证层次间协作工作的逻辑性,更不能保证整个系统的逻辑性
IT 主要针对组件之间协作的测试
将一个微服务看做一个黑箱,只针对暴露的接口进行测试
端到端测试
Mock vs Spy
Mock 针对的是接口,Spy 可以针对类的方法。
在 staffjoy 项目中有相应的 spy 实践,在文件 ServiceHelperTest
中。
分环境配置
为不同的环境编写各自的配置(以 account-svc 为例):
其中 application.yml
是公共配置,下面的四个是分环境的配置,各个环境中的项目是对公共配置的重载。
动态配置
在配置中心中修改了配置项后,会将改变交付给应用,需要应用进行配合及时修改配置项。
几种产品的区别:
调用链监控
微服务应用,不同服务之间的依赖关系错综复杂,为了高效地发现问题,调用链监控是非常重要的。
结构化日志
字面意思,日志是有结构的,便于分析和处理。使用到的中间件为 StructLog4j
具体用法,在 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);
}
}
fluentd 收集各个 Docker 节点上的日志,通过 kafka 进行解耦,然后进行解析和过滤存入 ES,并且集中展示