【良心推荐】客户管理系统ServiceComb微服务化实战-PartII

点击蓝字关注这个神奇的公众号~

在前一篇博文[良心推荐] 客户管理系统ServiceComb微服务化实战-PartI中,详细介绍了如何使用 http://start.servicecomb.io 脚手架快速构建微服务项目、使用领域驱动设计(Domain-Driven Design,DDD)设计地产CRM系统、使用Edge Service构建统一认证边缘服务等内容。本篇将继续搭建剩余的模块,以及演示实现如何在边缘服务上利用动态路由轻松实现灰度发布特性。

01

快速实现客户关系管理系统的支付服务

第一步

创建微服务项目

使用Apache ServiceComb 

http://start.servicecomb.io/ 

创建支付微服务,创建完毕后使用IDEA或Eclipse打开项目,我们删掉HelloImpl和HelloConsumer,之后添加自己的实现。

第二步

使用MySQL持久化支付信息

支付微服务需要持久化支付信息,我们使用MySQL数据库,ORM框架方面可以使用Spring Data JPA:

引入依赖:

<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

模拟与银行成功完成一笔支付后需要将支付的信息保存下来(可以理解为刷卡后的签名单),定义存储Payment信息的实体PaymentEntity:

@Entity
@Table(name = "T_Payment")
public class PaymentEntity {
  @Id
  @GeneratedValue(strategy = GenerationType.AUTO)
  private long id;

  private String transactionId;

  private String userName;

  private String bankName;

  private String cardNumber;

  private double amount;

  private Date time;

  private String type;

  public long getId() {
    return id;
  }

  public void setId(long id) {
    this.id = id;
  }

  public String getTransactionId() {
    return transactionId;
  }

  public void setTransactionId(String transactionId) {
    this.transactionId = transactionId;
  }

  public String getUserName() {
    return userName;
  }

  public void setUserName(String userName) {
    this.userName = userName;
  }

  public String getBankName() {
    return bankName;
  }

  public void setBankName(String bankName) {
    this.bankName = bankName;
  }

  public String getCardNumber() {
    return cardNumber;
  }

  public void setCardNumber(String cardNumber) {
    this.cardNumber = cardNumber;
  }

  public double getAmount() {
    return amount;
  }

  public void setAmount(double amount) {
    this.amount = amount;
  }

  public Date getTime() {
    return time;
  }

  public void setTime(Date time) {
    this.time = time;
  }

  public String getType() {
    return type;
  }

  public void setType(String type) {
    this.type = type;
  }

  public PaymentEntity() {
  }

  public PaymentEntity(String transactionId, String userName, String bankName, String cardNumber,
      double amount, Date time, String type) {
    this.transactionId = transactionId;
    this.userName = userName;
    this.bankName = bankName;
    this.cardNumber = cardNumber;
    this.amount = amount;
    this.time = time;
    this.type = type;
  }
}

在CodeFist模式下,会在数据库中自动创建T_Payment表与此实体映射,id通过

@GeneratedValue(strategy = GenerationType.AUTO)标记为自增长字段,userName存储支付用户,bankName是付款银行,cardNumber是付款银行卡号,amount是支付金额,time保存支付时间,type是交易的类型。

public class PaymentType {
  //支付定金
  public static final String DEPOSIT = "deposit";

  //交易
  public static final String TRADE = "trade";
}

然后我们继承JPA的PagingAndSortingRepository来实现ORM操作:

@Repository
@EnableTransactionManagement
public interface PaymentRepository extends PagingAndSortingRepository<PaymentEntity, Long> {
  List<PaymentEntity> findByUserName(String userName);

  PaymentEntity findByTransactionId(String TransactionId);
}

同样,用户支付完定金后,需要保存已支付的定金总额,定义存储Deposit信息的实体DepositEntity:

@Entity
@Table(name = "T_Deposit")
public class DepositEntity {
  @Id
  private String userName;

  private double amount;

  public String getUserName() {
    return userName;
  }

  public void setUserName(String userName) {
    this.userName = userName;
  }

  public double getAmount() {
    return amount;
  }

  public void setAmount(double amount) {
    this.amount = amount;
  }

  public DepositEntity() {
  }

  public DepositEntity(String userName, double amount) {
    this.userName = userName;
    this.amount = amount;
  }
}

在CodeFist模式下,会在数据库中自动创建T_Deposit表与此实体映射,userName是用户名,amount保存了已经支付的定金总额。然后我们继承JPA的PagingAndSortingRepository来实现ORM操作:

@Repository
@EnableTransactionManagement
public interface DepositRepository extends PagingAndSortingRepository<DepositEntity, String> {
  DepositEntity findByUserName(String userName);
}

配置数据库连接:

在项目的resources目录下新增application.properties文件,写入数据库连接信息:

spring.datasource.url=jdbc:mysql://localhost:3306/payment_db?useSSL=false
spring.datasource.username=root
spring.datasource.password=pwd
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect

第三步

实现支付服务

定义支付接口:

public interface PaymentService {
  //缴纳定金
  ResponseEntity<Boolean> deposit(PaymentDTO payment);
}

为了能够未来更好的扩展请求和响应的内容,我们使用PaymentDTO类包装参数。

实现支付接口:

创建PaymentServiceImpl,继承PaymentService接口:

@RestSchema(schemaId = "payment")
@RequestMapping(path = "/")
public class PaymentServiceImpl implements PaymentService {
  //信用额度
  private static final double CREDIT_LIMIT = 1000000;

  private final PaymentRepository paymentRepository;

  private final DepositRepository depositRepository;

  @Autowired
  public PaymentServiceImpl(PaymentRepository paymentRepository, DepositRepository depositRepository) {
    this.paymentRepository = paymentRepository;
    this.depositRepository = depositRepository;
  }

  @Override
  @PostMapping(path = "deposit")
  @Transactional(isolation = Isolation.SERIALIZABLE)
  public ResponseEntity<Boolean> deposit(@RequestBody PaymentDTO payment) {
    if (validatePayment(payment)) {
      if (checkBalance(payment)) {
        if (recordPayment(payment, PaymentType.DEPOSIT)) {
          if (recordDeposit(payment)) {
            if (cutWithBank(payment)) {
              return new ResponseEntity<>(true, HttpStatus.OK);
            }
            throw new InvocationException(BAD_REQUEST, "cut with bank failed");
          }
          throw new InvocationException(BAD_REQUEST, "record deposit failed");
        }
        throw new InvocationException(BAD_REQUEST, "record payment failed");
      }
      throw new InvocationException(BAD_REQUEST, "check balance failed");
    }
    throw new InvocationException(BAD_REQUEST, "incorrect payment request");
  }

  private boolean validatePayment(PaymentDTO payment) {
    if (StringUtils.isNotEmpty(payment.getUserName()) && payment.getAmount() > 0 && StringUtils
        .isNotEmpty(payment.getTransactionId())
        && StringUtils.isNotEmpty(payment.getBankName()) && StringUtils.isNotEmpty(payment.getCardNumber())) {
      //TransactionId需要不重复,未被使用过
      PaymentEntity pay = paymentRepository.findByTransactionId(payment.getTransactionId());
      return pay == null;
    }
    return false;
  }

  //检查用户的余额,这里我们假设每一个用户都有一百万授信
  private boolean checkBalance(PaymentDTO payment) {
    //我们先要查一下系统里面已经用了多少
    List<PaymentEntity> pays = paymentRepository.findByUserName(payment.getUserName());
    double used = 0;
    for (PaymentEntity pay : pays) {
      used += pay.getAmount();
    }
    //预估一下账户余额够不够
    return payment.getAmount() <= (CREDIT_LIMIT - used);
  }

  //本地记账保留扣款凭据
  private boolean recordPayment(PaymentDTO payment, String paymentType) {
    paymentRepository
        .save(new PaymentEntity(payment.getTransactionId(), payment.getUserName(), payment.getBankName(),
            payment.getCardNumber(), payment.getAmount(), new Date(), paymentType));
    return true;
  }

  //登记缴纳的定金
  private boolean recordDeposit(PaymentDTO payment) {
    DepositEntity deposit = depositRepository.findByUserName(payment.getUserName());
    if (deposit == null) {
      deposit = new DepositEntity(payment.getUserName(), payment.getAmount());
    } else {
      deposit.setAmount(deposit.getAmount() + payment.getAmount());
    }
    depositRepository.save(deposit);
    return true;
  }

  //走银行接口请求银行划账,Demo不对接直接返回为true
  private boolean cutWithBank(PaymentDTO payment) {
    return true;
  }
}

可以看到,支付定金会先后调用五个函数:

  1. validatePayment:首先我们需要检查一下调用入参是否正确;

  2. checkBalance:然后我们检查一下这个用户是否有足够的余额。模拟的逻辑比较简单,我们假设每一个用户都有一百万授信,扣掉在本系统中支付(包含支付定金)历史的总款项,如果大于需要支付的款项,那么我们认为有足够的余额;

  3. recordPayment:本地记录扣款凭据,注意:deposit方法标记了@Transactional,因此事务在成功返回前不会提交;

  4. recordDeposit:记录支付的定金,注意:deposit方法标记了@Transactional,因此事务在成功返回前不会提交;

  5. cutWithBank:请求银行划款(扣款),由于我们不走银行系统,直接返回true,代表扣款成功。如果将这一步的返回结果设置为false,则recordPayment会回滚不会写入数据库。

至此支付微服务就构建完毕了。

02

什么是灰度发布(金丝雀测试)

以前矿工下矿洞前,会先放一只金丝雀进去试探氧气含量以及是否有有毒气体,金丝雀测试由此得名。

生产环境中微服务都是多实例运行的,更新(发布)微服务,为避免风险,往往先更新1个实例,或一个很小的比例例如1%,承担很低的流量做验证,如果没有问题,则使用手工或自动的方式逐步更新剩余的实例,完成整体升级。

发布前:

 

发布中:

 

发布结束:

 

灰度路由策略

灰度发布需要边缘服务支持动态设置路由策略,常见的路由策略有:

  1. 基于匹配特定Header的策略 这个策略是通过匹配指定Header的值,决定转发微服务的版本;例如我们上线新版本后,用户界面提示是否尝试新版本,如果用户选择切换,则将特定的值写入请求的Header,此用户的所有后继请求都路由至新版本。这种方式给予了用户很大的灵活性和选择权,缺点是无法控制流量(因为完全要看大多数用户的选择)。

  2. .基于用户标识的策略 这个策略是通过匹配用户的标识例如ID,决定转发微服务的版本;例如我们上线新版本后,只将以Test开头的测试账号的请求路由至新版本。这种方式对客户端透明,也能够控制流量(限制了账号数),缺点是需要结合用户系统做一定的修改才能够支持,因为边缘服务无法直接获取用户信息。

  3. 基于流量控制的策略 这个策略是通过定制弹性伸缩(loadbalance)策略,决定转发微服务的版本;例如如果使用的是RoundRobin,则路由比例与版本实例数成正比,如果我们希望路由到新版本的流量为一个比较低的值例如1%但实例数比例远高于此值,则需要做定制。

  4. 基于匹配调用方IP的策略 这个策略是通过匹配调用发起方的IP,决定转发微服务的版本;例如在多地域多集群环境下,可以通过IP识别对方大致身份(例如所属省份、子系统等)。

本文将以第一种策略(基于匹配特定Header的策略)为例演示如何实现,我们先需要定义策略的格式:

参数名

类型

投稿

headerRules

User-Agent

Header匹配规则集合

defaultVersion

String

如果没有匹配任何规则,则默认版本(一般设置为老版本)

User-Agent

参数名

类型

投稿

 operator

User-Agent

匹配操作符,支持START_WITH、END_WITH、CONTAINS和EQUALS

value

String

匹配的值

version

String

如果匹配,值为路由版本

为了便于整体下发配置,使用JSON格式存储,例如:

{
  "headerRules": {
    "User-Agent": [
      {
        "operator": "CONTAINS",
        "value": "vip",
        "version": "0.0.2"
      }
    ]
  },
  "defaultVersion": "0.0.1"
}

此配置的含义为:如果存在User-Agent头并且其中的内容包含vip这个字符串,则路由到0.0.2版本的微服务实例,否则默认路由到0.0.1版本的微服务实例。

在边缘服务中实现灰度发布

增加灰度路由策略配置

在microservice.yaml中增加dark-launch-rules配置节点:

edge:
  routing-short-path:
    user: user-service
    payment: payment-service
  dark-launch-rules:
    payment-service: "{\"headerRules\":{\"User-Agent\":[{\"operator\":\"CONTAINS\",\"value\":\"vip\",\"version\":\"0.0.2\"}]},\"defaultVersion\":\"0.0.1\"}"

修改EdgeDispatcher增加灰度应用逻辑

public class EdgeDispatcher extends AbstractEdgeDispatcher {
  private static final Logger LOGGER = LoggerFactory.getLogger(EdgeDispatcher.class);

  private final List<EdgeFilter> filterChain;

  private static final ObjectMapper OBJ_MAPPER = new ObjectMapper();

  private final Map<String, DarkLaunchRule> darkLaunchRules = new ConcurrentHashMap<>();

  public EdgeDispatcher() {
    filterChain = SPIServiceUtils.getSortedService(EdgeFilter.class);
  }

  //此Dispatcher的优先级,Order级越小,路由策略优先级越高
  public int getOrder() {
    return 10000;
  }

  //初始化Dispatcher的路由策略
  public void init(Router router) {
    ///捕获 {ServiceComb微服务Name}/{服务路径&参数} 的URL
    String regex = "/([^\\\\/]+)/(.*)";
    router.routeWithRegex(regex).handler(CookieHandler.create());
    router.routeWithRegex(regex).handler(createBodyHandler());
    router.routeWithRegex(regex).failureHandler(this::onFailure).handler(this::onRequest);
  }

  //处理请求,请注意
  private void onRequest(RoutingContext context) {
    Map<String, String> pathParams = context.pathParams();
    //从匹配的param0拿到{ServiceComb微服务Name}
    final String service = pathParams.get("param0");
    //从匹配的param1拿到{服务路径&参数}
    String operationPath = "/" + pathParams.get("param1");

    final String serviceName = DynamicPropertyFactory.getInstance()
        .getStringProperty("edge.routing-short-path." + service, service).get();

    //获取对应微服务的灰度策略
    darkLaunchRules.computeIfAbsent(serviceName, s -> {
      DynamicStringProperty property = DynamicPropertyFactory.getInstance()
          .getStringProperty("edge.dark-launch-rules." + serviceName, "");
      return parseRule(property.getValue());
    });

    EdgeInvocation edgeInvocation = new EdgeInvocation();
    //设定灰度版本策略
    edgeInvocation.setVersionRule(darkLaunchRules.get(serviceName).matchVersion(context.request().headers().entries()));
    edgeInvocation.init(serviceName, context, operationPath, httpServerFilters);
    edgeInvocation.edgeInvoke();
  }

  private DarkLaunchRule parseRule(String config) {
    try {
      if (StringUtils.isNotEmpty(config)) {
        return OBJ_MAPPER.readValue(config, DarkLaunchRule.class);
      }
    } catch (IOException e) {
      LOGGER.error("parse rule failed", e);
    }
    return new DarkLaunchRule();
  }
}

03

实现VIP版本的支付服务用于演示灰度发布

场景

上面实现的支付服务做风控,授信额度CREDIT_LIMIT不能超过一百万,我们假设现在经济形势较好,信贷宽松,因此能尝试对VIP用户将授信额度提升为两百万,为稳妥起见,采用灰度发布逐步升级。

创建新的支付服务

payment-service-canary

为了能使两个版本的微服务能够同时调试,我们将payment-service中的代码和配置全部拷贝进入新的微服务payment-service-canary(实际的开发并不需要这么做,只需要切换版本并发布),并修改:

升级微服务版本

首先我们需要修改微服务的版本:

APPLICATION_ID: scaffold
service_description:
  name: payment-service
  #版本从0.0.1升级为0.0.2
  version: 0.0.2

放宽风控逻辑

//信用额度翻倍
private static final double CREDIT_LIMIT = 2000000;

可以看到,相比0.0.1版本中的CREDIT_LIMIT,授信额度审查已经被放宽到两百万。

04

使用支付服务验证灰度发布

我们启动边缘服务、用户服务和老版本的支付服务,使用zhengyangyong账户登录系统,然后做一笔一百万的支付:

 

支付成功,现在我们再来做一笔金额为888元的支付:

 

结果返回余额不足,因为老版本的支付服务只允许一百万额度,现在我们启动一个新版本的支付服务实例(模拟灰度发布),之后在Service Center UI中,我们已经可以看到新旧版本的支付系统并存:

 

再次请求:

仍然失败,我们在Header中增加User-Agent并设置值为vip:

 

返回请求处理成功,表明请求已经被路由至额度检查更宽松的0.0.2版本微服务实例。

05

结合使用配置中心实现平滑的灰度发布

上面我们已经演示了如何实现灰度发布,但缺点是我们需要在恢复发布的过程中修改本地microservice.yaml配置并重启边缘服务才能使灰度策略生效,那么是否可以结合使用配置中心的动态配置能力实现无需重启边缘服务平滑灰度发布?答案是肯定的。

ServiceComb目前支持对接Apollo作为配置中心,Apollo:https://github.com/ctripcorp/apollo 是携程开发的一个开源配置中心,ServiceComb已经做了良好的对接,如何使用可以参考此文    http://servicecomb.incubator.apache.org/cn/users/dynamic-config/

改进方案

现在我们详细描述改进方案:

01

首先我们将edge.dark-launch-rules.payment-service的配置从microservice.yaml本地配置中移动到Apollo配置中心:

 

初始配置的值是:

{"headerRules":{"User-Agent":[{"operator":"CONTAINS","value":"vip","version":"0.0.2"}]},"defaultVersion":"0.0.1"}

当前生效的路由为:

 

02

然后我们将0.0.2版本的新版支付服务上线,因为此时配置尚未更改并发布,边缘服务并不会将用户的请求路由到0.0.2版本的微服务实例上:

 

03

一切就绪后,我们在Apollo中修改edge.dark-launch-rules.payment-service的配置,使能灰度发布:

 

配置的值是:

{"headerRules":{"User-Agent":[{"operator":"CONTAINS","value":"vip","version":"0.0.2"}]},"defaultVersion":"0.0.1"}

将带有User-Agent Header并且值包含vip字符串的请求路由到0.0.2版本的微服务实例:

 

至此,只要接口不发生变化,新版本即可采用实例上线 -> 修改路由配置的方式实现灰度发布,无需修改任何代码和重启边缘服务。

修改边缘服务的实现

以支持方案

添加动态配置依赖

我们在边缘服务中添加如下依赖:

<!--增加使用Apollo作为配置中心-->
<dependency>
  <groupId>com.netflix.archaius</groupId>
  <artifactId>archaius-core</artifactId>
</dependency>
<dependency>
  <groupId>org.apache.servicecomb</groupId>
  <artifactId>config-apollo</artifactId>
</dependency>

在microservice.yaml中添加配置中心配置

edge:
  routing-short-path:
    user: user-service
    payment: payment-service
#  停用本地配置,使用apollo配置中心配置
#  dark-launch-rules:
#    payment-service: "{\"headerRules\":{\"User-Agent\":[{\"operator\":\"CONTAINS\",\"value\":\"vip\",\"version\":\"0.0.2\"}]},\"defaultVersion\":\"0.0.1\"}"

#配置中心
apollo:
  config:
    #请修改为您的Apollo地址
    serverUri: http://192.168.99.100:8070
    #请修改为您的Apollo AppId
    serviceName: scaffold  #AppId in apollo
    env: DEV
    #default clusters name in apollo,if user create new clusters please replace this setting value
    #more detail can be found at https://github.com/ctripcorp/apollo/wiki
    clusters: default
    #default namespace name in apollo,if user create new namespace please replace this setting value
    #more detail can be found at https://github.com/ctripcorp/apollo/wiki/Apollo%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5%E4%B9%8B%E2%80%9CNamespace%E2%80%9D
    namespace: application
    #get token from Apollo web pages
    #请修改为您的Token
    token: d453c813eb2e0a83bd4532bca7b0c272bc3377c9

请按你本地Apollo的配置替换其中的配置项,如何获取这些配置请参考此文   http://servicecomb.incubator.apache.org/cn/users/dynamic-config/。

修改EdgeDispatcher,强化灰度策略执行效率

private void onRequest(RoutingContext context) {
  Map<String, String> pathParams = context.pathParams();
  //从匹配的param0拿到{ServiceComb微服务Name}
  final String service = pathParams.get("param0");
  //从匹配的param1拿到{服务路径&参数}
  String operationPath = "/" + pathParams.get("param1");

  //还记得我们之前说的做出一点点改进吗?引入一个自定义配置edge.routing-short-path.{简称},映射微服务名;如果简称没有配置,那么就认为直接是微服务的名
  final String serviceName = DynamicPropertyFactory.getInstance()
      .getStringProperty("edge.routing-short-path." + service, service).get();

  //检查灰度策略是否更新
  checkDarkLaunchRule(serviceName);

  //创建一个Edge转发
  EdgeInvocation edgeInvocation = new EdgeInvocation();
  //设定灰度版本策略
  edgeInvocation.setVersionRule(
      darkLaunchRules.get(serviceName).getRule().matchVersion(context.request().headers().entries()));
  edgeInvocation.init(serviceName, context, operationPath, httpServerFilters);
  edgeInvocation.edgeInvoke();
}

private void checkDarkLaunchRule(String serviceName) {
  final String config = DynamicPropertyFactory.getInstance()
      .getStringProperty("edge.dark-launch-rules." + serviceName, "").getValue();
  if (darkLaunchRules.containsKey(serviceName)) {
    DynamicDarkLaunchRule rule = darkLaunchRules.get(serviceName);
    if (!rule.getConfig().equals(config)) {
      darkLaunchRules.put(serviceName, new DynamicDarkLaunchRule(config, parseRule(config)));
    }
  } else {
    darkLaunchRules.computeIfAbsent(serviceName, s -> new DynamicDarkLaunchRule(config, parseRule(config)));
  }
}

private DarkLaunchRule parseRule(String config) {
  try {
    if (StringUtils.isNotEmpty(config)) {
      return OBJ_MAPPER.readValue(config, DarkLaunchRule.class);
    }
  } catch (IOException e) {
    LOGGER.error("parse rule failed", e);
  }
  return new DarkLaunchRule();
}

主要改动就是为了提升策略检查逻辑的性能,将解析完毕的DarkLaunchRule缓存起来,配置没有发生变化则直接使用缓存。

06

小结

作为“客户关系管理系统ServiceComb微服务化实战”系列的第二篇,本文阐述了如何开发客户关系管理中的支付服务并进行灰度发布。同时还演示了如何进一步通过ServiceComb对接的Apollo配置中心实现平滑的灰度发布。剩余的模块,我们将在接下来的部分继续展示如何开发。

07

ServiceComb相关资料

官方网站:

http://servicecomb.incubator.apache.org/

加入社区:

https://servicecomb.incubator.apache.org/cn/docs/join_the_community

JIRA:

https://issues.apache.org/jira/browse/SCB

ServiceComb Java-Chassis:https://github.com/apache/incubator-servicecomb-java-chassis

ServiceComb Saga:

https://github.com/apache/incubator-servicecomb-saga

ServiceComb Service-Center:

https://github.com/apache/incubator-servicecomb-service-center

长按“识别图中二维码”,加入ServiceComb讨论群

 点击左下角“阅读原文”,给SeriveComb加个Star吧 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值