1. 项目背景

    随着公司业务的划分,到家场景下,发展出了主要的3个战略级客户:联华、振华、爱婴室。
前期阶段,由各自维护了lhjx-server,zh-server,ays-server三个组件。以下统称为[app-server]
    此时一个存在一个最现实的问题,当一个功能需要同步给三个客户时,需要在三个地方维护代码,尤其当需求比较复杂时,很容易出现错漏,因而催生出了产生zl-app-service的诉求。

2. 需要解决的问题

    zl-app-service如何解决不同用户存在相同需求,以及不同用户存在不同需求的问题。
举例说明,有以下场景:
    1、联华使用的[会员、券、促销]三部分功能是知而行的,振华和爱婴室使用的是鼎力云及spms。
    2、为联华开发的拼团功能振华也想用。
    以上是两个最典型的场景,如何支撑这种场景是zl-app-service首先需要解决的问题。

3. 设计方案

3.1. 适配器

zl-app-service的第一个概念,适配器[IAdapter接口]。我们认为每个服务领域可能有多个服务提供方[供应商vendor],同一个服务领域在同一个客户场景下只会有一个服务提供方。

每一个服务领域为一个adapter,要求每一个adapter需要继承IAdapter接口[该接口为一个空接口,只为标识意图]。
每一个adapter可以有多个实现。例如促销适配器[IPmsAdapter],有SpmsPmsAdapter和ZexPmsAdapter两个实现。适配器定位于将服务提供方的数据模型转换为zl-app-service标准数据模型。

3.2. @AppAdapterComponent

为了可以使用@Autoware注解进行注入,要求同一个适配器只会注入一个bean实例,此处使用@AppAdapterComponent注解。 @AppAdapterComponent为自定义注解,该注解利用spring的Conditional功能进行bean注入配置,已达到上述目的,至此解决了zl-app-service的一个也是最主要的问题。

/**自定义注解*/
@Retention(RetentionPolicy.RUNTIME)
@Target({
    ElementType.TYPE })
@Documented
@Component
@Conditional(OnAppAdapterCondition.class)
public @interface AppAdapterComponent {
  /***
   * 适配器接口名
   */
  AppAdapter adapter();

  /**
   * 适配器供应商
   */
  AppAdapterVendor[] vendor() default {
      AppAdapterVendor.defaults };
}

/**AppAdapterComponent 注解解释器*/
public class OnAppAdapterCondition extends SpringBootCondition{
 @Override
  public ConditionOutcome getMatchOutcome(ConditionContext context,
      AnnotatedTypeMetadata metadata) {
    //bean加载实现
  }
}

/**知而行促销*/
@AppAdapterComponent(adapter = AppAdapter.pms, vendor = {
    AppAdapterVendor.zex, AppAdapterVendor.defaults })
public class ZexPmsAdapter implements IPmsAdapter {

}

4. 代码开发统一

由于老app-server的开发来自于各个开发组,各自的代码习惯迥异,使用的工具也千奇百怪,导致其维护成本高,故zl-app-service对部分领域的技术进行规范化。

4.1. http调用工具的统一

  • 由于FeignClient的编程的可读优越性,及eureka的使用,故zl-app-service使用FeignClient作为http调用的统一工具。

  • 关于FeignClient日志

    在生产实际中,由于feign的日志打印模式为每个请求头,一行日志,导致一次请求的日志打印次数可能有10多次。一方面一个外部请求的日志链路会非常的长,导致排查的成本上升,另一方面当业务并发度过高时,io的交互会极大地降低业务系统的性能。为此对feign的日志打印进行了改造。通过阅读源码发现,feign为每一个@FeignClient标注的接口生成了一个Logger类,为此我们对feign的日志工厂进行重写。重新实现FeignLoggerFactory及feign.Logger类,重写feign.Logger类中的log、logRequest、logAndRebufferResponse三个方法。分别对应打印日志格式,打印请求体,打印响应体。
/**日志工厂bean*/
public class AppFeignLoggerFactory implements FeignLoggerFactory {
  @Override
  public Logger create(Class<?> type) {
    return new AppFeignLogger(type);
  }
}

public class AppFeignLogger extends feign.Logger {

  private final Logger logger;

  public AppFeignLogger() {
    this(feign.Logger.class);
  }

  public AppFeignLogger(Class<?> clazz) {
    this(LoggerFactory.getLogger(clazz));
  }

  public AppFeignLogger(String name) {
    this(LoggerFactory.getLogger(name));
  }

  AppFeignLogger(Logger logger) {
    this.logger = logger;
  }

  @Override
  protected void log(String configKey, String format, Object... args) {
    if (logger.isDebugEnabled()) {
      logger.debug(String.format(methodTag(configKey) + format, args));
    }
  }

  @Override
  protected void logRequest(String configKey, Level logLevel, Request request) {
    if (logLevel.ordinal() >= Level.HEADERS.ordinal()) {
      String logString = buildRequestLog(request);
      log(configKey, "%s", logString);
    }
  }

  private String buildRequestLog(Request request) {
    StringBuilder sb = new StringBuilder(
        String.format("request %s %s \n", request.httpMethod().name(), request.url()));
    sb.append("headers:\n");
    for (String field : request.headers().keySet()) {
      for (String value : valuesOrEmpty(request.headers(), field)) {
        sb.append(field).append("=").append(value).append("\n");
      }
    }
    sb.append("\n");
    int bodyLength = 0;
    if (request.requestBody().asBytes() != null) {
      bodyLength = request.requestBody().asBytes().length;
      String bodyText = request.charset() != null
          ? new String(request.requestBody().asBytes(), request.charset())
          : null;
      sb.append("body:\n");
      sb.append(bodyText != null ? bodyText : "Binary data");
      sb.append("\n");
    }
    sb.append("bodyLength:").append(bodyLength);
    return sb.toString();
  }

  @Override
  protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response,
      long elapsedTime) throws IOException {
    StringBuilder sb = new StringBuilder();
    // response
    String reason = response.reason() != null && logLevel.compareTo(Level.NONE) > 0
        ? " " + response.reason()
        : "";
    int status = response.status();
    sb.append(String.format("response %s%s (%sms)", status, reason, elapsedTime));
    if (elapsedTime > 5000L) {
      sb.append(" >5000ms");
    } else if (elapsedTime > 3000L) {
      sb.append(" >3000ms");
    } else if (elapsedTime > 1000L) {
      sb.append(" >1000ms");
    }
    sb.append("\n");
    // headers
    sb.append("headers:\n");
    for (String field : response.headers().keySet()) {
      for (String value : valuesOrEmpty(response.headers(), field)) {
        sb.append(field).append("=").append(value).append("\n");
      }
    }
    // body
    int bodyLength = 0;
    if (response.body() != null && !(status == 204 || status == 205)) {
      byte[] bodyData = Util.toByteArray(response.body().asInputStream());
      bodyLength = bodyData.length;
      sb.append("body:\n");
      sb.append(decodeOrDefault(bodyData, UTF_8, "Binary data"));
      sb.append("\n");
      sb.append("bodyLength:").append(bodyLength);
      log(configKey, "%s", sb.toString());
      return response.toBuilder().body(bodyData).build();
    } else {
      sb.append("bodyLength:").append(bodyLength);
      log(configKey, "%s", sb.toString());
    }
    return response;
  }
}

4.2. 关于数据模型转换

  • 考查到老app-server对于数据模型转换存在多种操作方法,有BeanUtils.copyProperties,手写converter,rumba-converter等多种方案,对此我们需要进行统一。

    BeanUtils.copyProperties方法进行模型转换。首先该方法的提供方有两个,spring以及apache,其方法签名是相反的,以及实现上对链式模型的支持差异,以及可读性,可调试性,转换效率上,都是最差的,因此我们禁止在zl-app-service上使用该方式进行数据模型转换。

4.3. 关于lombok

  • 禁止使用@Accessors(chain = true)。他会导致rumba的converter异常。

  • 有类继承时,禁止使用@Data注解。

4.4. 关于日志监控

  • 日志作为我们排查线上问题的主要手段之一,对此进行了一部分设计。 由于zl-app-service的tps较高,从设计上我们应当在保证在有足够排查问题的基础上减少日志的产生。为此我们设计了ControllerMethodLogAdvice的aop接口,并提供AppLogger及LogScopeEnum进行场景化配置,对于例如商品查询等某种程度上幂等的接口,我们无需打印响应体,对于会员注册类型的接口,我们需要明确知道当次请求的结果,因此我们通过@AppLogger对接口进行标注用以实现响应诉求。

@Target({
    ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface AppLogger {
  /**
   * 日志打印级别
   */
  LogLevel level() default LogLevel.INFO;

  /**
   * 日志输出范围,用于标记需要记录的日志信息范围,包含入参、返回值等。 ALL-入参和出参, BEFORE-入参, AFTER-出参
   */
  LogScopeEnum scope() default LogScopeEnum.ALL;
}

public enum LogScopeEnum {
  // ALL-入参和出参, BEFORE-入参, AFTER-出参
  ALL, BEFORE, AFTER;

  public boolean contains(LogScopeEnum scope) {
    if (this == ALL) {
      return true;
    } else {
      return this == scope;
    }
  }
}

4.5. 关于请求链路计时的需求

  • 由于zl-app-service的大多数接口是在请求第三方以及组装数据,我们需要监测各个环节的耗时情况。

    此处未做过多的设计,主要利用spring提供StopWatch类的基础上封装了Watcher类。
public class Watcher {
  private static final ThreadLocal<StopWatch> holder = new ThreadLocal<>();

  public static void createWatcher(String task) {
    holder.set(new StopWatch(task));
  }

  public static void watch(String taskName) {
    StopWatch stopwatch = holder.get();
    if (stopwatch == null) {
      holder.set(new StopWatch());
      stopwatch = holder.get();
    }
    if (stopwatch.isRunning()) {
      stopwatch.stop();
    }
    stopwatch.start(taskName);
  }

  public static String detail() {
    StopWatch stopwatch = holder.get();
    if (stopwatch.isRunning()) {
      stopwatch.stop();
    }
    return stopwatch.prettyPrint();
  }

  public static void clear() {
    holder.remove();
  }
}

日志打印结果如下

[ContentDeliveryControllerV4.queryContentSkus]--->param:
{"tenant":"lhprd","appId":"NewJingXuanAPp","shop":"100451","request":{"fetchDecorator":true,"fetchMbrPrice":true,"fetchPromPrice":true,"fetchStock":true,"fetchDelivery":false,"limitQty":10,"sortByStock":true,"hideSoldOut":true,"memberInfo":{"memberUuid":"12020201019002095","memberId":"MN1026105781","memberCardType":"eh","memberCardNumber":"MN1026105781","memberGrade":"0","birthdate":true,"scores":[{"category":"","value":0}]},"skuIds":["717647","948326","502649","108501","798607","794062","826335","134372","998468","6907992512761"]}}

[ContentDeliveryControllerV4.queryContentSkus]--->end:
StopWatch 'ContentDeliveryControllerV4.queryContentSkus': running time (millis) = 230
-----------------------------------------
ms     %     Task name
-----------------------------------------
00045  020%  默认
00035  015%  查询库存
00006  003%  查询角标
00024  010%  查询售价
00120  052%  查询促销价

4.6. 关于配置

  • 由于zl-app-service的设计方案导致了一个问题,他的yml配置是非常多的,如何解决其纷繁的配置,当前给出的设计方案如下:

    • 将配置划分为app级别和app-adapter级别,分别由AppProperties及AdapterProperties两个类进行管控,要求所有的业务性质的配置都由这两个类进行管控。

    • 使用spring-boot-configuration-processor对yml进行配置识别。

    • 不允许对该类型的配置使用@Value进行取值从而增加的维护配置的复杂度。

/**app级别配置*/
@Data
public class AppProperties {
  @NestedConfigurationProperty
  private AppCustomer user;
  @NestedConfigurationProperty
  private DataSourceProps datasource;
  @NestedConfigurationProperty
  private RedisProperties redis;
  @NestedConfigurationProperty
  private DftProperties dft;
  @NestedConfigurationProperty
  private GeerSignProperties geersign;
  // feign使用的log实现
  private String feignLog = "app";
  @NestedConfigurationProperty
  private MqProps mns;
}
/**适配器级别配置*/
@Data
public class AdapterProperties {
  public static final String ADAPTER_PROPERTY_PREFIX = "app-adapter";
  public static final String ADAPTER_PREFIX = ADAPTER_PROPERTY_PREFIX + ".";
  public static final String ADAPTER_SUFFIX = ".vendor";
  /** 地图服务 */
  @NestedConfigurationProperty
  private volatile MapProps map = new MapProps();
  /** 行政区服务 */
  @NestedConfigurationProperty
  private volatile AdapterBaseProps district = new AdapterBaseProps();
  /** 账户 */
  @NestedConfigurationProperty
  private volatile AccountProps account = new AccountProps();
  /** 券 */
  @NestedConfigurationProperty
  private volatile CouponProps coupon = new CouponProps();
  /** 会员 */
  @NestedConfigurationProperty
  private volatile MemberProps member = new MemberProps();
  /** 积分活动 */
  @NestedConfigurationProperty
  private volatile ScoreActivityProps scoreActivity = new ScoreActivityProps();
  /** 促销 */
  @NestedConfigurationProperty
  private volatile PmsProps pms = new PmsProps();
  /** 投放 */
  @NestedConfigurationProperty
  private volatile ContentProps cms = new ContentProps();
  /** 基础资料 */
  @NestedConfigurationProperty
  private volatile MetaDataProps metaData = new MetaDataProps();
  /** 营销 */
  @NestedConfigurationProperty
  private volatile MarketingProps marketing = new MarketingProps();
  /** 客服配置 */
  @NestedConfigurationProperty
  private volatile CustomerProps customer = new CustomerProps();
  /** 实体卡配置 */
  @NestedConfigurationProperty
  private volatile CardProps card = new CardProps();
  /** token签名配置 */
  @NestedConfigurationProperty
  private volatile TlspProps tlsp = new TlspProps();
  /** 短信消息配置 */
  @NestedConfigurationProperty
  private volatile AdapterBaseProps fms = new AdapterBaseProps();

  /** 抽奖配置 */
  @NestedConfigurationProperty
  private volatile LotteryProps lottery = new LotteryProps();
  /** 动态码 */
  @NestedConfigurationProperty
  private volatile PayCodeProps payCode = new PayCodeProps();
}