一、代码组织模式
1、多仓库
-
优点
- 每一个服务都有一个独立的仓库,职责单一
- 代码量和复杂性受控,服务由不同的团队独立维护、边界清晰。
- 单个服务也易于自治开发测试部署和扩展,不需要集中管理集中协调。
-
缺点
- 项目代码不容易规范。每个团队容易各自为政,随意引入依赖,code review 无法集中开展,代码风格各不相同。
- 项目集成和部署会比较麻烦。虽然每个项目服务易于集成和部署,但是整个应用集成和部署的时候由于仓库分散就需要集中的管理和协调。
- 开发人员缺乏对整个项目的整体认知。开发人员一般只关心自己的服务代码,看不到项目整体,造成缺乏对项目整体架构和业务目标整体性的理解。
- 项目间冗余代码多。每个服务一个服务一个仓库,势必造成团队在开发的时候走捷径,不断地重复造轮子而不是去优先重用其他团队开发的代码。
2、单体仓库
-
知名企业案例
-
优点
- 易于规范代码。所有的代码在一个仓库当中就可以标准化依赖管理,集中开展 code review,规范化代码的风格。
- 于集成和部署。所有的代码在一个仓库里面,配合自动化构建工具,可以做到一键构建、一键部署,一般不需要特别的集中管理和协调。
- 易于理解项目整体。开发人员可以把整个项目加载到本地的 IDE 当中,进行 code review,也可以直接在本地部署调试,方便开发人员把握整体的技术架构和业务目标。
- 易于重用。所有的代码都在一个仓库中,开发人员开发的时候比较容易发现和重用已有的代码,而不是去重复造轮子,开发人员(通过 IDE 的支持)容易对现有代码进行重构,可以抽取出一些公共的功能进一步提升代码的质量和复用度。
-
缺点
- 随着公司业务团队规模的变大,单一的代码库会变得越来越庞大复杂性也呈极度的上升
- 一般都有独立的代码管理和集成团队进行支持
- 有配套的自动化构建工具来支持
二、编程规约(参考《阿里 Java 开发手册》)
1、命名风格
2、常量定义
3、代码格式
4、OOP 规约
5、日期时间
6、集合处理
7、并发处理
8、控制语句
9、注释规约
10、前后端规约
11、其它
三、异常日志(参考《阿里 Java 开发手册》)
1、错误码
2、异常处理
3、日志规约
四、单元测试(参考《阿里 Java 开发手册》)
五、安全规约(参考《阿里 Java 开发手册》)
六、MySQL数据库(参考《阿里 Java 开发手册》)
1、建表规约
2、SQL语句
3、ORM 映射
4、索引规约
七、工程结构(参考《阿里 Java 开发手册》)
1、应用分层
2、二方库依赖
3、服务器
八、设计规约(参考《阿里 Java 开发手册》)
九、服务开发框架
1、接口参数校验
- 技术方案
- Hibernate Validator
- 自定义注解
- 全局异常处理
Hibernate Validator 是 SpringBoot 内置的校验框架,只要集成了 SpringBoot 就自动集成了它,我们可以通过在对象上面使用它提供的注解来完成参数校验。 ?
常用注解:
- @Null:被注释的属性必须为null;
- @NotNull:被注释的属性不能为null;
- @AssertTrue:被注释的属性必须为true;
- @AssertFalse:被注释的属性必须为false;
- @Min:被注释的属性必须大于等于其value值;
- @Max:被注释的属性必须小于等于其value值;
- @Size:被注释的属性必须在其min和max值之间;
- @Pattern:被注释的属性必须符合其regexp所定义的正则表达式;
- @NotBlank:被注释的字符串不能为空字符串;
- @NotEmpty:被注释的属性不能为空;
- @Email:被注释的属性必须符合邮箱格式。
有时候框架提供的校验注解并不能满足我们的需要,此时我们就需要自定义校验注解。比如还是上面的添加品牌,此时有个参数 showStatus,我们希望它只能是 0 或者 1,不能是其他数字,此时可以使用自定义注解来实现该功能。 ?
@GetMapping(path = "/list")
ListAccountResponse listAccounts(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestParam int offset, @RequestParam @Min(0) int limit);
@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);
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class AccountDto {
@NotBlank
private String id;
private String name;
@Email(message = "Invalid email")
private String email;
private boolean confirmedAndActive;
@NotNull
private Instant memberSince;
private boolean support;
@PhoneNumber
private String phoneNumber;
@NotEmpty
private String photoUrl;
}
@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 {};
}
public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
@Override
public boolean isValid(String phoneField, ConstraintValidatorContext context) {
if (phoneField == null) return true;
return phoneField != null && phoneField.matches("[0-9]+")
&& (phoneField.length() > 8) && (phoneField.length() < 14);
}
}
2、统一异常处理
使用全局异常处理来处理校验逻辑的思路很简单,首先我们需要通过 @ControllerAdvice 注解定义一个全局异常的处理类,然后自定义一个校验异常,当我们在 Controller 中校验失败时,直接抛出该异常,这样就可以达到校验失败返回错误信息的目的了 ?
使用到的注解:
- @ControllerAdvice:类似于@Component 注解,可以指定一个组件,这个组件主要用于增强@Controller注解修饰的类的功能,比如说进行全局异常处理。
- @ExceptionHandler:用来修饰全局异常处理的方法,可以指定异常的类型。
?
@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();
}
}
@ExceptionHandler(ServiceException.class)
public BaseResponse handleError(ServiceException e) {
logger.error("Service Exception", 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();
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BaseResponse {
private String message;
@Builder.Default
private ResultCode code = ResultCode.SUCCESS;
public boolean isSuccess() {
return code == ResultCode.SUCCESS;
}
}
@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()) {
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";
}
}
3、DTO 和 DMO 互转
DTO:
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class AccountDto {
@NotBlank
private String id;
private String name;
@Email(message = "Invalid email")
private String email;
private boolean confirmedAndActive;
@NotNull
private Instant memberSince;
private boolean support;
@PhoneNumber
private String phoneNumber;
@NotEmpty
private String photoUrl;
}
DMO:
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class Account {
@Id
@GenericGenerator(name = "system-uuid", strategy = "uuid")
@GeneratedValue(generator = "system-uuid")
private String id;
private String name;
private String email;
private boolean confirmedAndActive;
private Instant memberSince;
private boolean support;
private String phoneNumber;
private String photoUrl;
}
DTO 和 DMO 互转:
public AccountDto update(AccountDto newAccountDto) {
Account newAccount = this.convertToModel(newAccountDto);
Account existingAccount = accountRepo.findAccountById(newAccount.getId());
if (existingAccount == null) {
throw new ServiceException(ResultCode.NOT_FOUND, String.format("User with id %s not found", newAccount.getId()));
}
entityManager.detach(existingAccount);
if (!serviceHelper.isAlmostSameInstant(newAccount.getMemberSince(), existingAccount.getMemberSince())) {
throw new ServiceException(ResultCode.REQ_REJECT, "You cannot modify the member_since date");
}
if (StringUtils.hasText(newAccount.getEmail()) && !newAccount.getEmail().equals(existingAccount.getEmail())) {
Account foundAccount = accountRepo.findAccountByEmail(newAccount.getEmail());
if (foundAccount != null) {
throw new ServiceException(ResultCode.REQ_REJECT, "A user with that email already exists. Try a password reset");
}
}
if (StringUtils.hasText(newAccount.getPhoneNumber()) && !newAccount.getPhoneNumber().equals(existingAccount.getPhoneNumber())) {
Account foundAccount = accountRepo.findAccountByPhoneNumber(newAccount.getPhoneNumber());
if (foundAccount != null) {
throw new ServiceException(ResultCode.REQ_REJECT, "A user with that phonenumber already exists. Try a password reset");
}
}
if (AuthConstant.AUTHORIZATION_AUTHENTICATED_USER.equals(AuthContext.getAuthz())) {
if (!existingAccount.isConfirmedAndActive() && newAccount.isConfirmedAndActive()) {
throw new ServiceException(ResultCode.REQ_REJECT, "You cannot activate this account");
}
if (existingAccount.isSupport() != newAccount.isSupport()) {
throw new ServiceException(ResultCode.REQ_REJECT, "You cannot change the support parameter");
}
if (!existingAccount.getPhotoUrl().equals(newAccount.getPhotoUrl())) {
throw new ServiceException(ResultCode.REQ_REJECT, "You cannot change the photo through this endpoint (see docs)");
}
if (!existingAccount.getEmail().equals(newAccount.getEmail())) {
this.requestEmailChange(newAccount.getId(), newAccount.getEmail());
newAccount.setEmail(existingAccount.getEmail());
}
}
newAccount.setPhotoUrl(Helper.generateGravatarUrl(newAccount.getEmail()));
try {
accountRepo.save(newAccount);
} catch (Exception ex) {
String errMsg = "Could not update the user account";
serviceHelper.handleException(logger, ex, errMsg);
throw new ServiceException(errMsg, ex);
}
serviceHelper.syncUserAsync(newAccount.getId());
LogEntry auditLog = LogEntry.builder()
.authorization(AuthContext.getAuthz())
.currentUserId(AuthContext.getUserId())
.targetType("account")
.targetId(newAccount.getId())
.originalContents(existingAccount.toString())
.updatedContents(newAccount.toString())
.build();
logger.info("updated account", auditLog);
if (newAccount.isConfirmedAndActive() &&
StringUtils.hasText(newAccount.getPhoneNumber()) &&
!newAccount.getPhoneNumber().equals(existingAccount.getPhoneNumber())) {
serviceHelper.sendSmsGreeting(newAccount.getId());
}
this.trackEventWithAuthCheck("account_updated");
AccountDto accountDto = this.convertToDto(newAccount);
return accountDto;
}
public void updatePassword(String userId, String password) {
String pwHash = passwordEncoder.encode(password);
int affected = accountSecretRepo.updatePasswordHashById(pwHash, userId);
if (affected != 1) {
throw new ServiceException(ResultCode.NOT_FOUND, "user with specified id not found");
}
LogEntry auditLog = LogEntry.builder()
.authorization(AuthContext.getAuthz())
.currentUserId(AuthContext.getUserId())
.targetType("account")
.targetId(userId)
.build();
logger.info("updated password", auditLog);
this.trackEventWithAuthCheck("password_updated");
}
private AccountDto convertToDto(Account account) {
return modelMapper.map(account, AccountDto.class);
}
private Account convertToModel(AccountDto accountDto) {
return modelMapper.map(accountDto, Account.class);
}
4、强类型接口设计
- 强弱兼备
- Spring Feign
- 序列化及反序列化
Account Client:
@FeignClient(name = AccountConstant.SERVICE_NAME, path = "/v1/account", url = "${staffjoy.account-service-endpoint}")
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);
@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);
@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);
@PostMapping(path = "/change_email")
BaseResponse changeEmail(@RequestHeader(AuthConstant.AUTHORIZATION_HEADER) String authz, @RequestBody @Valid EmailConfirmation request);
}
继承关系:
客户端调用范例:
GenericAccountResponse genericAccountResponse = null;
try {
genericAccountResponse = this.accountClient.getAccount(AuthConstant.AUTHORIZATION_WHOAMI_SERVICE, userId);
} catch (Exception ex) {
String errMsg = "unable to get account";
handleErrorAndThrowException(ex, errMsg);
}
if (!genericAccountResponse.isSuccess()) {
handleErrorAndThrowException(genericAccountResponse.getMessage());
}
AccountDto account = genericAccountResponse.getAccount();
封装消息+捎带:
5、分环境配置
环境定义:
public class EnvConstant {
public static final String ENV_DEV = "dev";
public static final String ENV_TEST = "test";
public static final String ENV_UAT = "uat";
public static final String ENV_PROD = "prod";
}
环境配置:
@Data
@Builder
public 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);
envConfig = EnvConfig.builder().name(EnvConstant.ENV_UAT)
.debug(true)
.externalApex("dusan-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("dunsan.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
@Slf4j
public class SentryClientAspect {
@Autowired
EnvConfig envConfig;
@Around("execution(* io.sentry.SentryClient.send*(..))")
public void around(ProceedingJoinPoint joinPoint) throws Throwable {
if (envConfig.isDebug()) {
log.debug("no sentry logging in debug mode");
return;
}
joinPoint.proceed();
}
}
Sentry 是统一的异常管理平台,支持异常事件的收集、展示、告警等功能。
6、异步调用处理
ThreadPoolTaskExecutor:
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();
executor.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");
}
线程上下文拷贝:
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();
}
};
}
}
@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();
executor.setTaskDecorator(new ContextCopyingDecorator());
executor.setCorePoolSize(3);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setThreadNamePrefix("AsyncThread-");
executor.initialize();
return executor;
}
}
7、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("com.7d.PmsBrand.controller"))
.paths(PathSelectors.any())
.build()
.apiInfo(apiEndPointsInfo())
.useDefaultResponseMessages(false);
}
private ApiInfo apiEndPointsInfo() {
return new ApiInfoBuilder().title("PmsBrand REST API")
.description("7d Account REST API")
.contact(new Contact("7d", "https://zuozewei.blog.csdn.net", "zuozewei@hotmail.com"))
.license("The MIT License")
.licenseUrl("https://opensource.org/licenses/MIT")
.version("V2")
.build();
}
}
给 Controller 添加 Swagger 注解:
@Api(tags = "PmsBrandController", description = "商品品牌管理")
@Controller
@RequestMapping("/brand")
public class PmsBrandController {
@Autowired
private PmsBrandService brandService;
private static final Logger LOGGER = LoggerFactory.getLogger(PmsBrandController.class);
@ApiOperation("获取所有品牌列表")
@RequestMapping(value = "listAll", method = RequestMethod.GET)
@ResponseBody
public CommonResult<List<PmsBrand>> getBrandList() {
return CommonResult.success(brandService.listAllBrand());
}
@ApiOperation("添加品牌")
@RequestMapping(value = "/create", method = RequestMethod.POST)
@ResponseBody
public CommonResult createBrand(@RequestBody PmsBrand pmsBrand) {
CommonResult commonResult;
int count = brandService.createBrand(pmsBrand);
if (count == 1) {
commonResult = CommonResult.success(pmsBrand);
LOGGER.debug("createBrand success:{}", pmsBrand);
} else {
commonResult = CommonResult.failed("操作失败");
LOGGER.debug("createBrand failed:{}", pmsBrand);
}
return commonResult;
}
@ApiOperation("更新指定id品牌信息")
@RequestMapping(value = "/update/{id}", method = RequestMethod.POST)
@ResponseBody
public CommonResult updateBrand(@PathVariable("id") Long id, @RequestBody PmsBrand pmsBrandDto, BindingResult result) {
CommonResult commonResult;
int count = brandService.updateBrand(id, pmsBrandDto);
if (count == 1) {
commonResult = CommonResult.success(pmsBrandDto);
LOGGER.debug("updateBrand success:{}", pmsBrandDto);
} else {
commonResult = CommonResult.failed("操作失败");
LOGGER.debug("updateBrand failed:{}", pmsBrandDto);
}
return commonResult;
}
@ApiOperation("删除指定id的品牌")
@RequestMapping(value = "/delete/{id}", method = RequestMethod.GET)
@ResponseBody
public CommonResult deleteBrand(@PathVariable("id") Long id) {
int count = brandService.deleteBrand(id);
if (count == 1) {
LOGGER.debug("deleteBrand success :id={}", id);
return CommonResult.success(null);
} else {
LOGGER.debug("deleteBrand failed :id={}", id);
return CommonResult.failed("操作失败");
}
}
@ApiOperation("分页查询品牌列表")
@RequestMapping(value = "/list", method = RequestMethod.GET)
@ResponseBody
public CommonResult<CommonPage<PmsBrand>> listBrand(@RequestParam(value = "pageNum", defaultValue = "1")
@ApiParam("页码") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "3")
@ApiParam("每页数量") Integer pageSize) {
List<PmsBrand> brandList = brandService.listBrand(pageNum, pageSize);
return CommonResult.success(CommonPage.restPage(brandList));
}
@ApiOperation("获取指定id的品牌详情")
@RequestMapping(value = "/{id}", method = RequestMethod.GET)
@ResponseBody
public CommonResult<PmsBrand> brand(@PathVariable("id") Long id) {
return CommonResult.success(brandService.getBrand(id));
}
}
修改 MyBatis Generator 注释的生成规则:
CommentGenerator为MyBatis Generator的自定义注释生成器,修改addFieldComment方法使其生成Swagger的@ApiModelProperty注解来取代原来的方法注释,添加addJavaFileComment方法,使其能在import中导入@ApiModelProperty,否则需要手动导入该类,在需要生成大量实体类时,是一件非常麻烦的事。
public class CommentGenerator extends DefaultCommentGenerator {
private boolean addRemarkComments = false;
private static final String EXAMPLE_SUFFIX="Example";
private static final String API_MODEL_PROPERTY_FULL_CLASS_NAME="io.swagger.annotations.ApiModelProperty";
@Override
public void addConfigurationProperties(Properties properties) {
super.addConfigurationProperties(properties);
this.addRemarkComments = StringUtility.isTrue(properties.getProperty("addRemarkComments"));
}
@Override
public void addFieldComment(Field field, IntrospectedTable introspectedTable,
IntrospectedColumn introspectedColumn) {
String remarks = introspectedColumn.getRemarks();
if(addRemarkComments&&StringUtility.stringHasValue(remarks)){
if(remarks.contains("\"")){
remarks = remarks.replace("\"","'");
}
field.addJavaDocLine("@ApiModelProperty(value = \""+remarks+"\")");
}
}
private void addFieldJavaDoc(Field field, String remarks) {
field.addJavaDocLine("/**");
String[] remarkLines = remarks.split(System.getProperty("line.separator"));
for(String remarkLine:remarkLines){
field.addJavaDocLine(" * "+remarkLine);
}
addJavadocTag(field, false);
field.addJavaDocLine(" */");
}
@Override
public void addJavaFileComment(CompilationUnit compilationUnit) {
super.addJavaFileComment(compilationUnit);
if(!compilationUnit.isJavaInterface()&&!compilationUnit.getType().getFullyQualifiedName().contains(EXAMPLE_SUFFIX)){
compilationUnit.addImportedType(new FullyQualifiedJavaType(API_MODEL_PROPERTY_FULL_CLASS_NAME));
}
}
}
运行代码生成器重新生成 mbg 包中的代码:
运行com.7d.mall.tiny.mbg.Generator 的 main方法,重新生成 mbg 中的代码,可以看到 PmsBrand 类中已经自动根据数据库注释添加了@ApiModelProperty注解
?
8、前后端分离跨域
CORS全称Cross-Origin Resource Sharing,意为跨域资源共享。当一个资源去访问另一个不同域名或者同域名不同端口的资源时,就会发出跨域请求。如果此时另一个资源不允许其进行跨域资源访问,那么访问的那个资源就会遇到跨域问题。
覆盖默认的CorsFilter
添加GlobalCorsConfig配置文件来允许跨域访问。
设置 SpringSecurity 允许 OPTIONS 请求访问
在SecurityConfig类的configure(HttpSecurity httpSecurity) 方法中添加如下代码。
.antMatchers(HttpMethod.OPTIONS)
.permitAll()
9、统一访问日志记录
AOP 通过在 controller 层建一个切面来实现接口访问的统一日志记录。 ?
添加日志信息封装类 WebLog
用于封装需要记录的日志信息,包括操作的描述、时间、消耗时间、url、请求参数和返回结果等信息。
public class WebLog {
private String description;
private String username;
private Long startTime;
private Integer spendTime;
private String basePath;
private String uri;
private String url;
private String method;
private String ip;
private Object parameter;
private Object result;
}
添加切面类 WebLogAspect:
定义了一个日志切面,在环绕通知中获取日志需要的信息,并应用到controller层中所有的public方法中去。
@Aspect
@Component
@Order(1)
public class WebLogAspect {
private static final Logger LOGGER = LoggerFactory.getLogger(WebLogAspect.class);
@Pointcut("execution(public * com.dunsan.mall.tiny.controller.*.*(..))")
public void webLog() {
}
@Before("webLog()")
public void doBefore(JoinPoint joinPoint) throws Throwable {
}
@AfterReturning(value = "webLog()", returning = "ret")
public void doAfterReturning(Object ret) throws Throwable {
}
@Around("webLog()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
WebLog webLog = new WebLog();
Object result = joinPoint.proceed();
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
if (method.isAnnotationPresent(ApiOperation.class)) {
ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
webLog.setDescription(apiOperation.value());
}
long endTime = System.currentTimeMillis();
String urlStr = request.getRequestURL().toString();
webLog.setBasePath(StrUtil.removeSuffix(urlStr, URLUtil.url(urlStr).getPath()));
webLog.setIp(request.getRemoteUser());
webLog.setMethod(request.getMethod());
webLog.setParameter(getParameter(method, joinPoint.getArgs()));
webLog.setResult(result);
webLog.setSpendTime((int) (endTime - startTime));
webLog.setStartTime(startTime);
webLog.setUri(request.getRequestURI());
webLog.setUrl(request.getRequestURL().toString());
LOGGER.info("{}", JSONUtil.parse(webLog));
return result;
}
private Object getParameter(Method method, Object[] args) {
List<Object> argList = new ArrayList<>();
Parameter[] parameters = method.getParameters();
for (int i = 0; i < parameters.length; i++) {
RequestBody requestBody = parameters[i].getAnnotation(RequestBody.class);
if (requestBody != null) {
argList.add(args[i]);
}
RequestParam requestParam = parameters[i].getAnnotation(RequestParam.class);
if (requestParam != null) {
Map<String, Object> map = new HashMap<>();
String key = parameters[i].getName();
if (!StringUtils.isEmpty(requestParam.value())) {
key = requestParam.value();
}
map.put(key, args[i]);
argList.add(map);
}
}
if (argList.size() == 0) {
return null;
} else if (argList.size() == 1) {
return argList.get(0);
} else {
return argList;
}
}
}
10、打包方式
?
服务配置文件处理方式: 对于各个项目分环境部署,最麻烦的就是配置文件的问题,不同的环境需要加载不同的配置,好在 Spring Boot 框架加载配置是非常方便的,我们可以针对不同的环境分别配置不同的配置文件,这里有两个地方要注意一下:
- 构建镜像的时候,尽量实现一个镜像支持所有环境(即所有配置都打到一个镜像里面去),在容器启动时指定加载哪个环境配置即可,例如:在部署容器时指定 args: ["–spring.profiles.active=prod"] 参数启动。
- 尽量不要每个环境打出来一个镜像版本,传统方式在构建的时候指定 -D prod 配置 Profile 来指定加载哪个配置,来生成不同的产物 jar,容器化部署后不需要这样,那样后期控制各镜像版本发布会比较麻烦。
镜像可以分为基础镜像和应用镜像: 基础镜像要求体积尽量小,方便拉取,同时安装一些必要的软件,方便后期进入容器内排查问题,我们需要准备好服务运行的底层系统镜像,比如 Centos、Ubuntu 等常见 Linux 操作系统,然后基于该系统镜像,构建服务运行需要的环境镜像,比如一些常见组合:Centos + Jdk、Centos + Jdk + Tomcat、Centos + nginx 等,由于不同的服务运行依赖的环境版本不一定一致,所以还需要制作不同版本的环境镜像,例如如下基础镜像版本。
- Centos6.5 + Jdk1.8: registry.docker.com/baseimg/centos-jdk:6.5_1.8
- Centos7.5 + Jdk1.8: registry.docker.com/baseimg/centos-jdk:7.5_1.8
- Centos7.5 + Jdk1.7: registry.docker.com/baseimg/centos-jdk:7.5_1.7
- Centos7 + Tomcat8 + Jdk1.8: registry.docker.com/baseimg/centos-tomcat-jdk:7.5_8.5_1.8
- Centos7 + Nginx: registry.docker.com/baseimg/centos-tomcat-jdk:7.5_1.10.2
- …
这样,就可以标识该基础镜像的系统版本及软件版本,方便后边选择对应的基础镜像来构建应用镜像 ?
有了上边的基础镜像后,就很容易构建出对应的应用镜像了,例如一个简单的应用镜像 Dockerfile 如下:
FROM registry.docker.com/baseimg/centos-jdk:7.5_1.8
COPY app-name.jar /opt/project/app.jar
EXPOSE 8080
ENTRYPOINT ["/java", "-jar", "/opt/project/app.jar"]
当然,这里我建议使用另一种方式来启动服务,将启动命令放在统一 shell 启动脚本执行,例如如下Dockerfile 示例:
FROM registry.docker.com/baseimg/centos-jdk:7.5_1.8
COPY app-name.jar /opt/project/app.jar
COPY entrypoint.sh /opt/project/entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/bin/sh", "/opt/project/entrypoint.sh"]
将服务启动命令配置到 entrypoint.sh,这样我们可以扩展做很多事情,比如启动服务前做一些初始化操作等,还可以向容器传递参数到脚本执行一些特殊操作,而且这里变成脚本来启动,这样后续构建镜像基本不需要改 Dockerfile 了。
#!/bin/bash
java -jar $JAVA_OPTS /opt/project/app.jar $1 > /dev/null 2>&1
上边示例中,我们就注入 $JAVA_OPTS 环境变量,来优化 JVM 参数,还可以传递一个变量,这个变量大家应该就猜到了,就是服务启动加载哪个配置文件参数,例如:–spring.profiles.active=prod
十、技术选型(参考)
1、代码生成工具
MyBatis Generator 是 MyBatis 的代码生成器,支持为 MyBatis 的所有版本生成代码。非常容易及快速生成 Mybatis 的Java POJO文件及数据库 Mapping 文件。
2、核心框架
SpringBoot 它使用“习惯优于配置”(项目中存在大量的配置,此外还内置一个习惯性的配置,让你无须手动进行配置)的理念让 Java 项目快速运行起来。使用 SpringBoot 很容易创建一个独立运行(运行 Jar ,内嵌 Servlet 容器)、准生产级别的基于 Spring 的框架项目,使用 SpringBoot 你可以不用或者只需要很少的 Spring 配置。 用白话来理解,就是 SpringBoot 其实不是什么新框架,它默认配置了很多框架的使用方式,就像 Maven 整合了所有的 Jar 包,SpringBoot 整合了几乎所有的框架。
官网:https://spring.io/projects/spring-boot
3、日志框架
LogBack 是 Log4j 的改良版本,比 Log4j 拥有更多的特性,同时也带来很大性能提升,同时天然支持SLF4J。 LogBack 官方建议配合 Slf4j 使用,这样可以灵活地替换底层日志框架。
官网:http://logback.qos.ch/
4、持久层框架
MyBatis 是一款优秀的持久层框架,它支持定制化 SQL、存储过程以及高级映射。MyBatis 避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。MyBatis 可以使用简单的 XML 或注解来配置和映射原生类型、接口和 Java 的 POJO(Plain Old Java Objects,普通老式 Java 对象)为数据库中的记录。
5、连接池
Druid 是一个关系型数据库连接池,它是阿里巴巴的一个开源项目。Druid 支持所有 JDBC 兼容数据库,包括了Oracle、MySQL、PostgreSQL、SQL Server、H2等。 Druid 在监控、可扩展性、稳定性和性能方面具有明显的优势。通过 Druid 提供的监控功能,可以实时观察数据库连接池和SQL查询的工作情况。使用 Druid 连接池在一定程度上可以提高数据访问效率。
官网:https://druid.apache.org/
6、SQL拦截工具
p6spy 是一个开源项目,通常使用它来跟踪数据库操作,查看程序运行过程中执行的sql语句。
官网:https://github.com/p6spy/p6spy
7、多数据源启动器
- dynamic-datasource-spring-boot-starter
dynamic-datasource-spring-boot-starter 是一个基于 springboot 的快速集成多数据源的启动器。 其支持 Jdk 1.7+, SpringBoot 1.4.x、1.5.x、 2.0.x。
官网:https://github.com/baomidou/dynamic-datasource-spring-boot-starter
8、分页插件
MyBatis PageHelper 实现了通用的分页查询,其支持的数据有,mysql、Oracle、DB2、PostgreSQL等主流的数据库。
?
github: https://github.com/pagehelper/Mybatis-PageHelper ?
PageHelper.startPage(pageNum, pageSize);
List<PmsBrand> brandList = brandMapper.selectByExample(new PmsBrandExample());
PageInfo<PmsBrand> pageInfo = new PageInfo<PmsBrand>(list);
9、API文档
Swagger是一款Restful 接口的文档在线自动生成、功能测试框架。一个规范和完整的框架,用于生成、描述、调用和可视化Restful 风格的Web服务,加上Swagger-UI,可以有很好的呈现。
十一、开发环境(推荐)
1、开发插件
Lombok 项目是一个 Java 库,它会自动插入您的编辑器和构建工具中,从而使您的Java更加生动有趣。 永远不要再写另一个 getter 或 equals 方法,带有一个注释的您的类有一个功能全面的生成器,自动化您的日志记录变量等等。
官网:https://projectlombok.org/
Hutool 是一个小而全的Java工具类库,它帮助我们简化每一行代码,避免重复造轮子。如果你有需要用到某些工具类的时候,不妨在 Hutool 里面找找。 ?
官网:https://www.hutool.cn/
2、JDK
3、构建工具
Maven 作为一个构建工具,不仅能帮我们自动化构建,还能够抽象构建过程,提供构建任务实现;它跨平台,对外提供了一致的操作接口,这一切足以使它成为优秀的、流行的构建工具。 Maven 不仅是构建工具,还是一个依赖管理工具和项目管理工具,它提供了中央仓库,能帮助我们自动下载构件。
官网:https://maven.apache.org/
4、Git 不限
5、数据库
- MySQL 5.7及以上
- Navicat Premium 11.2.7及以上
MySQL是一个关系型数据库管理系统,由瑞典 MySQL AB 公司开发,目前属于 Oracle 旗下产品。MySQL 是最流行的关系型数据库管理系统之一,在 WEB 应用方面,MySQL是最好的 RDBMS (Relational Database Management System,关系数据库管理系统) 应用软件之一。 MySQL是一种关系数据库管理系统,关系数据库将数据保存在不同的表中,而不是将所有数据放在一个大仓库内,这样就增加了速度并提高了灵活性。 MySQL所使用的 SQL 语言是用于访问数据库的最常用标准化语言。MySQL 软件采用了双授权政策,分为社区版和商业版,由于其体积小、速度快、总体拥有成本低,尤其是开放源码这一特点,一般中小型网站的开发都选择 MySQL 作为网站数据库。 ----- 摘抄自百度百科
官网:https://www.mysql.com/
6、IDE
- IntelliJ IDEA 2020.1
- 推荐插件:
- Free MyBatis plugin:对MyBatis的xml具有强大的提示功能,同时可以关联mapper接口和mapper.xml中的sql实现。
- Lombok plugin:Lombok为Java语言添加了非常有趣的附加功能,你可以不用再为实体类手写getter,setter等方法,通过一个注解即可拥有。
- MyBatis Log Plugin:把Mybatis输出的SQL日志还原成完整的SQL语句。
- RestfulToolkit:一套Restful服务开发辅助工具集,提供了项目中的接口概览信息,可以根据URL跳转到对应的接口方法中去,内置了HTTP请求工具,对请求方法做了一些增强功能。
- GsonFormat:这款插件可以把JSON格式的字符串转化为实体类,当我们要根据JSON字符串来创建实体类的时候用起来很方便。
- Grep Console:一款帮你分析控制台日志的插件,可以对不同级别的日志进行不同颜色的高亮显示,还可以用来按关键字搜索日志内容。
- Alibaba Java Coding Guidelines:阿里巴巴《Java 开发手册》配套插件,可以实时检测代码中不符合手册规约的地方,助你码出高效,码出质量。
- Maven Helper:解决Maven依赖冲突的好帮手,可以快速查找项目中的依赖冲突,并予以解决
- Statistic:一款代码统计工具,可以用来统计当前项目中代码的行数和大小。
- Vue.js:Vue.js支持插件,可以根据模板创建.vue文件,也可以对Vue相关代码进行智能提示。
- element:Element-UI支持插件,可以对Element-UI中的标签进行智能提示,有了它就不用盲写相关代码了!
7、其他工具
- Postman:API接口调试工具。
- PowerDesigner:数据库设计工具,平时用来设计数据库表,设计完成之后可以直接导出数据库表
- RedisDesktop:Redis可视化工具,平时用来查看和管理Redis缓存中的数据,有时候需要清空缓存的时候就用到它了。
- Robomongo:MongoDB可视化工具,平时用来查看和管理MongoDB中的数据。
- X-shell:一款强大的安全终端模拟软件,可以用来连接和管理远程Linux服务器。-
- ProcessOn:作图工具,可以用来制作思维导图和流程图。
- Snipaste:一款好用的截屏工具。
十二、代码提交规范
1、基本原则
- Git 代码完整提交正确姿势,建议先 Commit,再 Pull,最后 Push;
- 代码提交前,保证本地编译通过;
- 代码提交时,保证代码、文件完整提交,不要把本地测试代码、配置提交上去了;
- 代码每次独立的功能、模块修改,都 Commit 到本地(不急每次都 Push);
- 创建本地开发分支,完成后合并到特性分支,特性分支不可 push。
2、提交注释规则
格式:[type: description] #[相关任务编号]
2.1、type
- fix: 修复bug
- add: 新功能
- update: 更新
- style : 代码格式改变
- test: 增加测试代码
- revert: 撤销上一次的commit
- build: 构建工具或构建过程等的变动,如:gulp 换成了 webpack,webpack 升级等
2.2、description
- description 是对本次提交的简短描述;
- 不超过50个字符;
- 推荐以动词开头,如: 设置、修改、增加、删减、撤销等。
3、示例
fix:修复登录正确提示不准确缺陷 #demo-1243
add:添加登录拦截校验功能 #demo-1240
update:删除登陆弹出框提示 #demo-1241
test:增加控制接口测试用例 #demo-1242
关联任务单/缺陷单编号,例如:“demo-124”;
?
《java开发手册》v1.7.0 嵩山版:
- https://github.com/zuozewei/blog-example/blob/master/Java-api-test/12-Alibaba%20Java%20Coding%20Guidelines/%E3%80%8Ajava%E5%BC%80%E5%8F%91%E6%89%8B%E5%86%8C%E3%80%8Bv1.7.0%20%E5%B5%A9%E5%B1%B1%E7%89%88.pdf
?
参考资料:
- [1]:《java开发手册》v1.7.0 嵩山版》
- [2]:《Spring Boot & Kubernetes 云原生微服务实践》
?
|