朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)

本文会以一个简单而完整的业务来阐述Spring Cloud Finchley.RELEASE版本常用组件的使用。如下图所示,本文会覆盖的组件有:

  1. Spring Cloud Netflix Zuul网关服务器
  2. Spring Cloud Netflix Eureka发现服务器
  3. Spring Cloud Netflix Turbine断路器监控
  4. Spring Cloud Sleuth + Zipkin服务调用监控
  5. Sping Cloud Stream + RabbitMQ做异步消息
  6. Spring Data JPA做数据访问

本文的例子使用的依赖版本是:

  1. Spring Cloud - Finchley.RELEASE
  2. Spring Data - Lovelace-RELEASE
  3. Spring Cloud Stream - Fishtown.M3
  4. Spring Boot - 2.0.5.RELEASE

各项组件详细使用请参见官网,Spring组件版本变化差异较大,网上代码复制粘贴不一定能够适用,最最好的资料来源只有官网+阅读源代码,直接给出地址方便你阅读本文的时候阅读官网的文档:

  1. 全链路监控:http://cloud.spring.io/spring-cloud-static/spring-cloud-sleuth/2.0.1.RELEASE/single/spring-cloud-sleuth.html
  2. 服务发现、网关、断路器:http://cloud.spring.io/spring-cloud-static/spring-cloud-netflix/2.0.1.RELEASE/single/spring-cloud-netflix.html
  3. 服务调用:http://cloud.spring.io/spring-cloud-static/spring-cloud-openfeign/2.0.1.RELEASE/single/spring-cloud-openfeign.html
  4. 异步消息:https://docs.spring.io/spring-cloud-stream/docs/Fishtown.M3/reference/htmlsingle/
  5. 数据访问:https://docs.spring.io/spring-data/jpa/docs/2.1.0.RELEASE/reference/html/

如下贴出所有基础组件(除数据库)和业务组件的架构图,箭头代表调用关系(实现是业务服务调用、虚线是基础服务调用),蓝色框代表基础组件(服务器)
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第1张图片
这套架构中有关微服务以及消息队列的设计理念,请参考我之前的《朱晔的互联网架构实战心得》系列文章。下面,我们开始此次Spring Cloud之旅,Spring Cloud内容太多,本文分上下两节,并且不会介绍太多理论性的东西,这些知识点可以介绍一本书,本文更多的意义是给出一个可行可用的实际的示例代码供你参考。

业务背景

本文我们会做一个相对实际的例子,来演示互联网金融业务募集项目和放款的过程。三个表的表结构如下:

  1. project表存放了所有可募集的项目,包含项目名称、总的募集金额、剩余可以募集的金额、募集原因等等
  2. user表存放了所有的用户,包括借款人和投资人,包含用户的可用余额和冻结余额
  3. invest表存放了投资人投资的信息,包含投资哪个project,投资了多少钱、借款人是谁
CREATE TABLE `invest` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `project_id` bigint(20) unsigned NOT NULL,
  `project_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `investor_id` bigint(20) unsigned NOT NULL,
  `investor_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `borrower_id` bigint(20) unsigned NOT NULL,
  `borrower_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `amount` decimal(10,2) unsigned NOT NULL,
  `status` tinyint(4) NOT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
)
CREATE TABLE `project` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `reason` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `borrower_id` bigint(20) unsigned NOT NULL,
  `total_amount` decimal(10,0) unsigned NOT NULL,
  `remain_amount` decimal(10,0) unsigned NOT NULL,
  `status` tinyint(3) unsigned NOT NULL COMMENT '1-募集中 2-募集完成 3-已放款',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`) USING BTREE
)
CREATE TABLE `user` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL,
  `available_balance` decimal(10,2) unsigned NOT NULL,
  `frozen_balance` decimal(10,2) unsigned NOT NULL,
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`) USING BTREE
)

我们会搭建四个业务服务,其中三个是被其它服务同步调用的服务,一个是监听MQ异步处理消息的服务:

  1. project service:用于处理project表做项目相关的查询和操作
  2. user service:用于操作user表做用户相关的查询和操作
  3. invest service:用于操作invest表做投资相关的查询和操作
  4. project listener:监听MQ中有关项目变化的消息,异步处理项目的放款业务
    整个业务流程其实就是初始化投资人、借款人和项目->项目投资(一个项目可以有多个投资人进行多笔投资)->项目全部募集完毕后把所有投资的钱放款给借款人的过程:
  5. 数据库中有id=1和2的user为投资人1和2,初始可用余额10000,冻结余额0
  6. 数据库中有id=3的user为借款人1,初始可用余额0,冻结余额0
  7. 数据库中有id=1的project为一个可以投资的项目,投资额度为1000元,状态为1募集中
  8. 初始情况下数据库中的invest表没记录
  9. 用户1通过invest service下单进行投资,每次投资100元投资5次,完成后invest表是5条记录,然后用户1的可用余额为9500,冻结余额为500,项目1的剩余可以投资额度为500元(在整个过程中invest service会调用project service和user service查询项目和用户的信息,以及更新项目和用户的资金)
  10. 用户2也是类似重复投资5次,完成后invest表应该是10条记录,然后用户2的可用余额为9500,冻结余额为500,项目1的剩余可以投资额度为0元
  11. 此时,project service把project项目状态改为2代表募集完成,然后发送一条消息到MQ服务器
  12. project listener收到这条消息后进行异步的放款处理,调用user service逐一根据10比投资订单的信息,把所有投资人冻结的钱转移到借款人,完成后投资人1和2可用余额为9500,冻结余额为0,借款人1可用余额为1000,冻结余额为0,随后把项目状态改为3放款完成
    除了业务服务还有三个基础服务(Ererka+Zuul+Turbine,Zipkin服务不在项目内,我们直接通过jar包启动),整个项目结构如下:

朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第2张图片
整个业务包含了同步服务调用和异步消息处理,业务简单而有代表性。但是在这里我们并没有演示Spring Cloud Config的使用,之前也提到过,国内开源的几个配置中心比Cloud Config功能强大太多太多,目前Cloud Config实用性不好,在这里就不纳入演示了。
下面我们来逐一实现每一个组件和服务。

基础设施搭建

我们先来新建一个父模块的pom:



    4.0.0

    me.josephzhu
    springcloud101
    pom
    1.0-SNAPSHOT
    
        springcloud101-investservice-api
        springcloud101-investservice-server
        springcloud101-userservice-api
        springcloud101-userservice-server
        springcloud101-projectservice-api
        springcloud101-projectservice-server
        springcloud101-eureka-server
        springcloud101-zuul-server
        springcloud101-turbine-server
        springcloud101-projectservice-listener
    

    
        org.springframework.boot
        spring-boot-starter-parent
        2.0.5.RELEASE
        
    

    
        UTF-8
        UTF-8
        1.8
    

    
        
            org.projectlombok
            lombok
            true
        
    

    
        
            
                org.springframework.cloud
                spring-cloud-dependencies
                Finchley.RELEASE
                pom
                import
            
            
                org.springframework.data
                spring-data-releasetrain
                Lovelace-RELEASE
                import
                pom
            
            
                org.springframework.cloud
                spring-cloud-stream-dependencies
                Fishtown.M3
                pom
                import
            
        
    


    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
            
        
    

    
        
            spring-milestones
            Spring Milestones
            https://repo.spring.io/libs-milestone
            
                false
            
        
    

Eureka

第一个要搭建的服务就是用于服务注册的Eureka服务器:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    spring101-eureka-server

    
        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-server
        
    

在resources文件夹下创建一个配置文件application.yml(对于Spring Cloud项目由于配置实在是太多,为了模块感层次感强一点,这里我们使用yml格式):

server:
  port: 8865

eureka:
  instance:
    hostname: localhost
  client:
    registry-fetch-interval-seconds: 5
    registerWithEureka: false
    fetchRegistry: false
    serviceUrl:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
  server:
    enable-self-preservation: true
    eviction-interval-timer-in-ms: 5000

spring:
  application:
    name: eurka-server

在这里,为了简单期间,我们搭建的是一个Standalone的注册服务(这里,我们注意到Eureka有一个自我保护的开关,默认开启,自我保护的意思是短时间大批节点和Eureka断开的话,这个一般是网络问题,自我保护会开启防止节点注销,在之后的测试过程中因为我们会经常重启调试服务,所以如果遇到节点不注销的问题可以暂时关闭这个功能),分配了8865端口(我们约定,基础组件分配的端口以88开头),随后建立一个主程序文件:

package me.josephzhu.springcloud101.eurekaserver;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {

    public static void main(String[] args) {
        SpringApplication.run( EurekaServerApplication.class, args );
    }
}

对于搭建Spring Cloud的一些基础组件的服务,往往就是三步,加依赖,加配置,加注解开关即可。

Zuul

Zuul是一个代理网关,具有路由和过滤两大功能。并且直接能和Eureka注册服务以及Sleuth链路监控整合,非常方便。在这里,我们会同时演示两个功能,我们会进行路由配置,使网关做一个反向代理,我们也会自定义一个前置过滤器做安全拦截。
首先,新建一个模块:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    springcloud101-zuul-server

    
        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-client
        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-zuul
        
        
            org.springframework.boot
            spring-boot-starter-actuator
        
        
            org.springframework.cloud
            spring-cloud-starter-sleuth
        
        
            org.springframework.cloud
            spring-cloud-starter-zipkin
        
    

随后加一个配置文件:

server:
  port: 8866

spring:
  application:
    name: zuulserver
  main:
    allow-bean-definition-overriding: true
  zipkin:
      base-url: http://localhost:9411
  sleuth:
    feign:
      enabled: true
    sampler:
      probability: 1.0

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8865/eureka/
    registry-fetch-interval-seconds: 5

zuul:
  routes:
    invest:
      path: /invest/**
      serviceId: investservice
    user:
      path: /user/**
      serviceId: userservice
    project:
      path: /project/**
      serviceId: projectservice
    host:
      socket-timeout-millis: 60000
      connect-timeout-millis: 60000


management:
  endpoints:
    web:
      exposure:
        include: "*"

  endpoint:
    health:
      show-details: always

Zuul网关我们这里使用8866端口,这里重点看一下路由的配置:

  1. 我们通过path来批量访问请求的路径,转发到指定的serviceId
  2. 我们延长了传输和连接的超时时间,以便调试时不超时
    对于其它的配置,之后会进行解释,下面我们通过编程实现一个前置过滤:
package me.josephzhu.springcloud101.zuul.server;

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_DECORATION_FILTER_ORDER;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;

@Component
public class TokenFilter extends ZuulFilter {
    @Override
    public String filterType() {
        return PRE_TYPE;
    }

    @Override
    public int filterOrder() {
        return PRE_DECORATION_FILTER_ORDER - 1;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();
        String token = request.getParameter("token");
        if(token == null) {
            ctx.setSendZuulResponse(false);
            ctx.setResponseStatusCode(401);
            try {
                ctx.getResponse().setCharacterEncoding("UTF-8");
                ctx.getResponse().getWriter().write("禁止访问");
            } catch (Exception e){}

            return null;
        }
        return null;
    }
}

这个前置过滤演示了一个授权校验的例子,检查请求是否提供了token参数,如果没有的话拒绝转发服务,返回401响应状态码和错误信息。
下面实现服务程序:

package me.josephzhu.springcloud101.zuul.server;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@SpringBootApplication
@EnableZuulProxy
@EnableDiscoveryClient
public class ZuulServerApplication {

    public static void main(String[] args) {
        SpringApplication.run( ZuulServerApplication.class, args );
    }
}

这里解释一下两个注解:

  1. @EnableZuulProxy vs @EnableZuulServer:@EnableZuulProxy不但可以开启Zuul服务器,而且直接启用更多的一些过滤器实现代理功能,而@EnableZuulServer只是启动一个空白的Zuul,功能上是@EnableZuulProxy的子集。在这里我们使用功能更强大的前者。
  2. @EnableDiscoveryClient vs @EnableEurekaClient:@EnableDiscoveryClient启用的是发现服务的客户端功能,支持各种注册中心,@EnableEurekaClient只支持Eureka,功能也是一样的。在这里我们使用通用型更强的前者。

Turbine

Turbine用于汇总Hystrix服务断路器监控流。Spring Cloud还提供了Hystrix的Dashboard,在这里我们把这两个功能集合在一个服务中运行。三部曲第一步依赖:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    springcloud101-turbine-server

    
        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-client
        
        
            org.springframework.boot
            spring-boot-starter-actuator
        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-hystrix
        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-hystrix-dashboard
        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-turbine
        
    

第二步配置:

server:
  port: 8867

spring:
  application:
    name: turbineserver

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8865/eureka/

management:
  endpoints:
    web:
      exposure:
        include: "*"

  endpoint:
    health:
      show-details: always

turbine:
  aggregator:
    clusterConfig: default
  clusterNameExpression: "'default'"
  combine-host: true
  instanceUrlSuffix:
    default: actuator/hystrix.stream
  app-config: investservice,userservice,projectservice,projectservice-listener

Turbine服务我们使用8867端口,这里重点看一下turbine下面的配置项:

  1. instanceUrlSuffix配置了默认情况下每一个实例监控数据流的拉取地址
  2. app-config配置了所有需要监控的应用程序

我们来看一下文首的架构图,这里的Turbine其实是从各个配置的服务读取监控流来汇总监控数据的,并不是像Zipkin这种由服务主动上报数据的方式。当然,我们还可以通过Turbine Stream的功能让客户端主动上报数据(通过消息队列),这里就不详细展开阐述了。下面是第三步:

package me.josephzhu.springcloud101.turbine.server;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.hystrix.EnableHystrix;
import org.springframework.cloud.netflix.hystrix.dashboard.EnableHystrixDashboard;
import org.springframework.cloud.netflix.turbine.EnableTurbine;

@SpringBootApplication
@EnableDiscoveryClient
@EnableHystrix
@EnableHystrixDashboard
@EnableCircuitBreaker
@EnableTurbine
public class TurbineServerApplication {

    public static void main(String[] args) {
        SpringApplication.run( TurbineServerApplication.class, args );
    }
}

之后会展示使用截图。

Zipkin

Zipkin用于收集分布式追踪信息(同时扮演了服务端以及查看后台的角色),搭建方式请参见官网https://github.com/openzipkin/zipkin ,最简单的方式是去https://dl.bintray.com/openzipkin/maven/io/zipkin/java/zipkin-server/直接下载jar包运行即可,在生产环境强烈建议配置后端存储为ES或Mysql等等,这里我们用于演示不进行任何其它配置了。我们直接启动即可,默认运行在9411端口:

朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第3张图片
之后我们展示全链路监控的截图。

用户服务搭建

我们先来新建一个被依赖最多的业务服务,每一个服务分两个项目,API定义和实现。Spring Cloud推荐API定义客户端和服务端分别自己定义,不共享API接口,这样耦合更低。我觉得互联网项目注重快速开发,服务多并且往往用于内部调用,还是共享接口方式更切实际,在这里我们演示的是接口共享方式的实践。首先新建API项目的模块:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    springcloud101-userservice-api

    
        
            org.springframework.cloud
            spring-cloud-starter-openfeign
        
    

API项目不包含任何服务端实现,因此这里只是引入了feign。在API接口项目中,我们一般定义两个东西,一是服务接口定义,二是传输数据DTO定义。用户DTO如下:

package me.josephzhu.springcloud101.userservice.api;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.util.Date;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
    private Long id;
    private String name;
    private BigDecimal availableBalance;
    private BigDecimal frozenBalance;
    private Date createdAt;
}

对于DTO我建议重新定义一份,不要直接使用数据库的Entity,前者用于服务之间对外的数据传输,后者用于服务内部和数据库进行交互,不能耦合在一起混为一谈,虽然这多了一些转化工作。
用户服务如下:

package me.josephzhu.springcloud101.userservice.api;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.math.BigDecimal;

public interface UserService {
    @GetMapping("getUser")
    User getUser(@RequestParam("id") long id) throws Exception;
    @PostMapping("consumeMoney")
    BigDecimal consumeMoney(@RequestParam("investorId") long investorId,
                            @RequestParam("amount") BigDecimal amount) throws Exception;
    @PostMapping("lendpayMoney")
    BigDecimal lendpayMoney(@RequestParam("investorId") long investorId,
                            @RequestParam("borrowerId") long borrowerId,
                            @RequestParam("amount") BigDecimal amount) throws Exception;
}

这里定义了三个服务接口,在介绍服务实现的时候再来介绍这三个接口。
API模块是会被服务实现的服务端和其它服务使用的客户端引用的,本身不具备独立使用功能,所以也就没有启动类。
下面我们实现用户服务服务端,首先是pom:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    springcloud101-userservice-server

    
        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-client
        
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            org.springframework.cloud
            spring-cloud-starter-openfeign
        
        
            org.springframework.boot
            spring-boot-starter-actuator
        
        
            org.springframework.cloud
            spring-cloud-starter-sleuth
        
        
            org.springframework.cloud
            spring-cloud-starter-zipkin
        
        
            org.springframework.boot
            spring-boot-starter-data-jpa
        
        
            mysql
            mysql-connector-java
        
        
            com.github.gavlyukovskiy
            p6spy-spring-boot-starter
            1.4.3
        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-hystrix
        
        
            org.redisson
            redisson-spring-boot-starter
            3.8.2
        

        
            me.josephzhu
            springcloud101-userservice-api
            1.0-SNAPSHOT
        
    

由于我们的服务具有发现、监控、数据访问、分布式锁全功能,所以引入的依赖比较多一点:

  1. spring-cloud-starter-netflix-eureka-client用于服务发现和注册
  2. spring-boot-starter-web用于服务承载(服务本质上是Spring MVC项目)
  3. spring-cloud-starter-openfeign用于声明方式调用其它服务,用户服务不会调用其它服务,但是为了保持所有服务端依赖统一,我们这里也启用这个依赖
  4. spring-boot-starter-actuator用于开启监控和打点等等功能,见此系列文章前面一篇
  5. spring-cloud-starter-sleuth用于全链路追踪基础功能,开启后可以在日志中看到traceId等信息,之后会演示
  6. spring-cloud-starter-zipkin用于全链路追踪数据提交到zipkin
  7. spring-boot-starter-data-jpa用于数据访问
  8. p6spy-spring-boot-starter是开源社区某人提供的一个包,用于显示JDBC的事件,并且可以和全链路追踪整合
  9. spring-cloud-starter-netflix-hystrix用于断路器功能
  10. redisson-spring-boot-starter用于在项目中方便使用Redisson提供的基于Redis的锁服务
  11. mysql-connector-java用于访问mysql数据库
  12. springcloud101-userservice-api是服务接口依赖

下面我们建立一个配置文件,这次我们建立的是properties格式(只是为了说明更方便一点,网上有工具可以进行properties和yml的转换):

  1. server.port=8761:服务的端口,业务服务我们以87开始。
  2. spring.application.name=userservice:服务名称,以后其它服务都会使用这个名称来引用到用户服务
  3. spring.datasource.url=jdbc:mysql://localhost:3306/p2p?useSSL=false:JDBC连接字符串
  4. spring.datasource.username=root:mysql帐号
  5. spring.datasource.password=root:mysql密码
  6. spring.datasource.driver-class-name=com.mysql.jdbc.Driver:mysql驱动
  7. spring.zipkin.base-url=http://localhost:9411:zipkin服务端地址
  8. spring.sleuth.feign.enabled=true:启用客户端声明方式访问服务集成全链路监控
  9. spring.sleuth.sampler.probability=1.0:全链路监控抽样概率100%(默认10%,丢数据太多不方便观察结果)
  10. spring.jpa.show-sql=true:显示JPA生成的SQL
  11. spring.jpa.hibernate.use-new-id-generator-mappings=false:禁用Hibernate ID生成映射表
  12. spring.redis.host=localhost:Redis地址
  13. spring.redis.pool=6379:Redis端口
  14. feign.hystrix.enabled=true:启用声明方式访问服务的断路器功能
  15. eureka.client.serviceUrl.defaultZone=http://localhost:8865/eureka/:注册中心地址
  16. eureka.client.registry-fetch-interval-seconds=5:客户端从注册中心拉取服务信息的间隔,我们为了测试方便,把这个时间设置了短一点
  17. management.endpoints.web.exposure.include=*:直接暴露actuator所有端口
  18. management.endpoint.health.show-details=always:展开显示actuator的健康信息

下面实现服务,首先定义数据库实体:

package me.josephzhu.springcloud101.userservice.server;

import lombok.Data;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.math.BigDecimal;
import java.util.Date;

@Data
@Entity
@Table(name = "user")
@EntityListeners(AuditingEntityListener.class)
public class UserEntity {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal availableBalance;
    private BigDecimal frozenBalance;
    @CreatedDate
    private Date createdAt;
    @LastModifiedDate
    private Date updatedAt;
}

没有什么特殊的,只是我们使用了@CreatedDate和@LastModifiedDate注解来生成记录的创建和修改时间。下面是数据访问资源库,一键实现增删改查:

package me.josephzhu.springcloud101.userservice.server;

import org.springframework.data.repository.CrudRepository;

public interface UserRepository extends CrudRepository {
}

服务实现如下:

package me.josephzhu.springcloud101.userservice.server;

import me.josephzhu.springcloud101.userservice.api.User;
import me.josephzhu.springcloud101.userservice.api.UserService;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;

@RestController
public class UserServiceController implements UserService {

    @Autowired
    UserRepository userRepository;
    @Autowired
    RedissonClient redissonClient;

    @Override
    public User getUser(long id) {
        return userRepository.findById(id).map(userEntity ->
                User.builder()
                        .id(userEntity.getId())
                        .availableBalance(userEntity.getAvailableBalance())
                        .frozenBalance(userEntity.getFrozenBalance())
                        .name(userEntity.getName())
                        .createdAt(userEntity.getCreatedAt())
                        .build())
                .orElse(null);
    }

    @Override
    public BigDecimal consumeMoney(long investorId, BigDecimal amount) {
        RLock lock = redissonClient.getLock("User" + investorId);
        lock.lock();
        try {
            UserEntity user = userRepository.findById(investorId).orElse(null);
            if (user != null && user.getAvailableBalance().compareTo(amount)>=0) {
                user.setAvailableBalance(user.getAvailableBalance().subtract(amount));
                user.setFrozenBalance(user.getFrozenBalance().add(amount));
                userRepository.save(user);
                return amount;
            }
            return null;
        } finally {
            lock.unlock();
        }
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public BigDecimal lendpayMoney(long investorId, long borrowerId, BigDecimal amount) throws Exception {
        RLock lock = redissonClient.getLock("User" + investorId);
        lock.lock();
        try {
            UserEntity investor = userRepository.findById(investorId).orElse(null);
            UserEntity borrower = userRepository.findById(borrowerId).orElse(null);

            if (investor != null && borrower != null && investor.getFrozenBalance().compareTo(amount) >= 0) {
                investor.setFrozenBalance(investor.getFrozenBalance().subtract(amount));
                userRepository.save(investor);
                borrower.setAvailableBalance(borrower.getAvailableBalance().add(amount));
                userRepository.save(borrower);
                return amount;
            }
            return null;
        } finally {
            lock.unlock();
        }
    }

}

这里实现了三个服务接口:

  1. getUser:根据用户ID查询用户信息
  2. consumeMoney:在用户投资的时候需要为用户扣款,这个时候需要把钱从可用余额扣走,加入冻结余额,为了避免并发问题(这还是很重要的一点,否则肯定会遇到BUG),我们引入了Redisson提供的基于Redis的分布式锁
  3. lendpayMoney:在完成募集进行放款的时候把钱从投资人的冻结余额转到借款人的可用余额,这里同时启用了分布式锁和Spring事务

这里我们看到由于我们的实现类直接实现了接口(共享Feign接口方式),在实现业务逻辑的时候不需要去考虑参数如何获取,接口暴露地址等事情。
最后实现主程序:

package me.josephzhu.springcloud101.userservice.server;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.hystrix.EnableHystrix;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableDiscoveryClient
@EnableJpaAuditing
@EnableHystrix
@EnableCircuitBreaker
@Configuration
public class UserServiceApplication {
    @Bean
    RedissonClient redissonClient() {
        return Redisson.create();
    }
    public static void main(String[] args) {
        SpringApplication.run( UserServiceApplication.class, args );
    }
}

所有服务我们都一视同仁,开启服务发现、断路器、断路器监控等功能。这里额外定义了一下Redisson的配置。

项目服务搭建

项目服务和用户服务比较类似,唯一区别是项目服务会用到外部其它服务(用户服务)。首先定义项目服务接口模块:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    springcloud101-projectservice-api

    
        
            org.springframework.cloud
            spring-cloud-starter-openfeign
        
    

接口中的DTO:

package me.josephzhu.springcloud101.projectservice.api;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.util.Date;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Project {
    private Long id;
    private BigDecimal totalAmount;
    private BigDecimal remainAmount;
    private String name;
    private String reason;
    private long borrowerId;
    private String borrowerName;
    private int status;
    private Date createdAt;
}

以及服务定义:

package me.josephzhu.springcloud101.projectservice.api;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.math.BigDecimal;

public interface ProjectService {
    @GetMapping("getProject")
    Project getProject(@RequestParam("id") long id) throws Exception;
    @PostMapping("gotInvested")
    BigDecimal gotInvested(@RequestParam("id") long id,
                            @RequestParam("amount") BigDecimal amount) throws Exception;
    @PostMapping("lendpay")
    BigDecimal lendpay(@RequestParam("id") long id) throws Exception;
}

不做过多说明了,直接来实现服务实现模块:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    springcloud101-projectservice-server

    
        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-client
        
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            org.springframework.cloud
            spring-cloud-starter-openfeign
        
        
            org.springframework.boot
            spring-boot-starter-data-jpa
        
        
            mysql
            mysql-connector-java
        
        
            org.springframework.boot
            spring-boot-starter-actuator
        
        
            org.springframework.cloud
            spring-cloud-starter-sleuth
        
        
            org.springframework.cloud
            spring-cloud-starter-zipkin
        
        
            com.github.gavlyukovskiy
            p6spy-spring-boot-starter
            1.4.3
        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-hystrix
        
        
            org.springframework.cloud
            spring-cloud-stream
        
        
            org.springframework.cloud
            spring-cloud-starter-stream-rabbit
        

        
            me.josephzhu
            springcloud101-projectservice-api
            1.0-SNAPSHOT
        
        
            me.josephzhu
            springcloud101-userservice-api
            1.0-SNAPSHOT
        
    

依赖和用户服务基本一致,只有几个区别:

  1. 引入了Spring Cloud Stream相关依赖,回顾一下文首的架构图,我们的项目服务在募集完成之后会发出一个MQ消息,通知消息关心着来进行项目的后续放款处理,这里我们的项目服务扮演的是一个MQ消息发送者,也就是Spring Cloud Stream中的Source角色。
  2. 除了引入项目服务接口依赖还引入了用户服务接口依赖,因为项目服务中会调用用户服务。

下面是配置:

server:
  port: 8762

spring:
  application:
    name: projectservice
  cloud:
    stream:
      bindings:
        output:
          destination: zhuye
  datasource:
    url: jdbc:mysql://localhost:3306/p2p?useSSL=false
    username: root
    password: root
    driver-class-name: com.mysql.jdbc.Driver
  zipkin:
    base-url: http://localhost:9411
  sleuth:
    feign:
      enabled: true
    sampler:
      probability: 1.0
  jpa:
    show-sql: true
    hibernate:
      use-new-id-generator-mappings: false
feign:
  hystrix:
    enabled: true

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8865/eureka/
    registry-fetch-interval-seconds: 5


management:
  endpoints:
    web:
      exposure:
        include: "*"
  endpoint:
    health:
      show-details: always

项目服务的配置直接把用户服务的配置拿来改一下即可,有几个需要改的地方:

  1. 对外端口地址
  2. 应用程序名称
  3. Spring Cloud的配置,这里定向了绑定的输出到RabbitMQ名为zhuye的交换机上,这里不对RabbitMQ做详细说明了,之后会给出演示的图

首先实现项目实体类:

package me.josephzhu.springcloud101.projectservice.server;

import lombok.Data;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.math.BigDecimal;
import java.util.Date;

@Data
@Entity
@Table(name = "project")
@EntityListeners(AuditingEntityListener.class)
public class ProjectEntity {
    @Id
    @GeneratedValue
    private Long id;
    private BigDecimal totalAmount;
    private BigDecimal remainAmount;
    private String name;
    private String reason;
    private long borrowerId;
    private int status;
    @CreatedDate
    private Date createdAt;
    @LastModifiedDate
    private Date updatedAt;
}

然后是数据访问增删改查Repository:

package me.josephzhu.springcloud101.projectservice.server;

import org.springframework.data.repository.CrudRepository;

public interface ProjectRepository extends CrudRepository {
}

然后是依赖的外部用户服务:

package me.josephzhu.springcloud101.projectservice.server;

import me.josephzhu.springcloud101.userservice.api.User;
import me.josephzhu.springcloud101.userservice.api.UserService;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;

@FeignClient(value = "userservice",fallback = RemoteUserService.Fallback.class)
public interface RemoteUserService extends UserService {
    @Component
    class Fallback implements RemoteUserService {

        @Override
        public User getUser(long id) throws Exception {
            return null;
        }

        @Override
        public BigDecimal consumeMoney(long id, BigDecimal amount) throws Exception {
            return null;
        }

        @Override
        public BigDecimal lendpayMoney(long investorId, long borrowerId, BigDecimal amount) throws Exception {
            return null;
        }
    }
}

这里我们需要声明@Feign注解根据服务名称来使用外部的用户服务,此外,我们还定义了服务熔断时的Fallback类,实现上我们给出了返回null的空实现。
最关键的服务实现如下:

package me.josephzhu.springcloud101.projectservice.server;

import lombok.extern.slf4j.Slf4j;
import me.josephzhu.springcloud101.projectservice.api.Project;
import me.josephzhu.springcloud101.projectservice.api.ProjectService;
import me.josephzhu.springcloud101.userservice.api.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.messaging.Source;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;

@RestController
@Slf4j
@EnableBinding(Source.class)
public class ProjectServiceController implements ProjectService {

    @Autowired
    ProjectRepository projectRepository;
    @Autowired
    RemoteUserService remoteUserService;

    @Override
    public Project getProject(long id) throws Exception {
        ProjectEntity projectEntity = projectRepository.findById(id).orElse(null);
        if (projectEntity == null) return null;
        User borrower = remoteUserService.getUser(projectEntity.getBorrowerId());
        if (borrower == null) return null;

        return Project.builder()
                .id(projectEntity.getId())
                .borrowerId(borrower.getId())
                .borrowerName(borrower.getName())
                .name(projectEntity.getName())
                .reason(projectEntity.getReason())
                .status(projectEntity.getStatus())
                .totalAmount(projectEntity.getTotalAmount())
                .remainAmount(projectEntity.getRemainAmount())
                .createdAt(projectEntity.getCreatedAt())
                .build();
    }

    @Override
    public BigDecimal gotInvested(long id, BigDecimal amount) throws Exception {
        ProjectEntity projectEntity = projectRepository.findById(id).orElse(null);
        if (projectEntity != null && projectEntity.getRemainAmount().compareTo(amount)>=0) {
            projectEntity.setRemainAmount(projectEntity.getRemainAmount().subtract(amount));
            projectRepository.save(projectEntity);

            if (projectEntity.getRemainAmount().compareTo(new BigDecimal("0"))==0) {
                User borrower = remoteUserService.getUser(projectEntity.getBorrowerId());
                if (borrower != null) {
                    projectEntity.setStatus(2);
                    projectRepository.save(projectEntity);
                    projectStatusChanged(Project.builder()
                            .id(projectEntity.getId())
                            .borrowerId(borrower.getId())
                            .borrowerName(borrower.getName())
                            .name(projectEntity.getName())
                            .reason(projectEntity.getReason())
                            .status(projectEntity.getStatus())
                            .totalAmount(projectEntity.getTotalAmount())
                            .remainAmount(projectEntity.getRemainAmount())
                            .createdAt(projectEntity.getCreatedAt())
                            .build());
                }
                return amount;
            }
            return amount;
        }
        return null;
    }

    @Override
    public BigDecimal lendpay(long id) throws Exception {
        Thread.sleep(5000);
        ProjectEntity project = projectRepository.findById(id).orElse(null);
        if (project != null) {
            project.setStatus(3);
            projectRepository.save(project);
            return project.getTotalAmount();
        }
        return null;
    }

    @Autowired
    Source source;

    private void projectStatusChanged(Project project){
        if (project.getStatus() == 2)
        try {
            source.output().send(MessageBuilder.withPayload(project).build());
        } catch (Exception ex) {
            log.error("发送MQ失败", ex);
        }
    }
}

三个方法的业务逻辑如下:

  1. getProject用于查询项目信息,在实现中我们会调用用户服务来查询借款人的信息
  2. gotInvested用于在投资人投资后更新项目的募集余额,当项目募集余额为0的时候,我们把项目状态改为2募集完成,然后发送MQ消息通知消息订阅者做后续异步处理
  3. 使用Spring Cloud Stream发送消息非常简单,这里我们扮演的是Source角色(消息来源),只要注入Source,然后构造一个Message调用source的output方法获取MessageChannel发出去消息即可
  4. lendpay用于在放款完成后更新项目状态为3放款完成

最后定义启动类:

package me.josephzhu.springcloud101.projectservice.server;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.hystrix.EnableHystrix;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@EnableJpaAuditing
@EnableHystrix
@EnableCircuitBreaker
public class ProjectServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run( ProjectServiceApplication.class, args );
    }
}

投资服务搭建

投资服务和前两个服务也是类似的,只不过它更复杂点,会依赖用户服务和项目服务。首先建立一个服务定义模块:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    springcloud101-investservice-api

    
        
            org.springframework.cloud
            spring-cloud-starter-openfeign
        
    

然后DTO:

package me.josephzhu.springcloud101.investservice.api;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.util.Date;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Invest {
    private Long id;
    private long investorId;
    private long borrowerId;
    private long projectId;
    private int status;
    private BigDecimal amount;
    private Date createdAt;
    private Date updatedAt;
}

以及接口定义:

package me.josephzhu.springcloud101.investservice.api;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.math.BigDecimal;
import java.util.List;

public interface InvestService {
    @PostMapping("createInvest")
    Invest createOrder(@RequestParam("userId") long userId,
                     @RequestParam("projectId") long projectId,
                     @RequestParam("amount") BigDecimal amount) throws Exception;
    @GetMapping("getOrders")
    List getOrders(@RequestParam("projectId") long projectId) throws Exception;
}

实现了定义模块后来实现服务模块:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    springcloud101-investservice-server

    
        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-client
        
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            org.springframework.cloud
            spring-cloud-starter-openfeign
        
        
            org.springframework.boot
            spring-boot-starter-data-jpa
        
        
            mysql
            mysql-connector-java
        
        
            org.springframework.boot
            spring-boot-starter-actuator
        
        
            org.springframework.cloud
            spring-cloud-starter-sleuth
        
        
            org.springframework.cloud
            spring-cloud-starter-zipkin
        
        
            com.github.gavlyukovskiy
            p6spy-spring-boot-starter
            1.4.3
        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-hystrix
        

        
            me.josephzhu
            springcloud101-investservice-api
            1.0-SNAPSHOT
        
        
            me.josephzhu
            springcloud101-userservice-api
            1.0-SNAPSHOT
        
        
            me.josephzhu
            springcloud101-projectservice-api
            1.0-SNAPSHOT
        
    

依赖使用和用户服务基本类似,只是多了几个外部服务接口的引入。
然后是配置:

server:
  port: 8763

spring:
  application:
    name: investservice
  datasource:
    url: jdbc:mysql://localhost:3306/p2p?useSSL=false
    username: root
    password: root
    driver-class-name: com.mysql.jdbc.Driver
  zipkin:
    base-url: http://localhost:9411
  sleuth:
    feign:
      enabled: true
    sampler:
      probability: 1.0
  jpa:
    show-sql: true
    hibernate:
      use-new-id-generator-mappings: false
feign:
  hystrix:
    enabled: true

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8865/eureka/
    registry-fetch-interval-seconds: 5


management:
  endpoints:
    web:
      exposure:
        include: "*"
  endpoint:
    health:
      show-details: always

和用户服务也是类似,只是修改了端口和程序名。
现在来创建数据实体:

package me.josephzhu.springcloud101.investservice.server;

import lombok.Data;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.math.BigDecimal;
import java.util.Date;

@Data
@Entity
@Table(name = "invest")
@EntityListeners(AuditingEntityListener.class)
public class InvestEntity {
    @Id
    @GeneratedValue
    private Long id;
    private long investorId;
    private long borrowerId;
    private long projectId;
    private String investorName;
    private String borrowerName;
    private String projectName;
    private BigDecimal amount;
    private int status;
    @CreatedDate
    private Date createdAt;
    @LastModifiedDate
    private Date updatedAt;
}

数据访问Repository:

package me.josephzhu.springcloud101.investservice.server;

import org.springframework.data.repository.CrudRepository;

import java.util.List;

public interface InvestRepository extends CrudRepository {
    List findByProjectIdAndStatus(long projectId, int status);
}

具备熔断Fallback的用户外部服务客户端:

package me.josephzhu.springcloud101.investservice.server;

import lombok.extern.slf4j.Slf4j;
import me.josephzhu.springcloud101.userservice.api.User;
import me.josephzhu.springcloud101.userservice.api.UserService;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;

@FeignClient(value = "userservice", fallback = RemoteUserService.Fallback.class)
public interface RemoteUserService extends UserService {
    @Component
    @Slf4j
    class Fallback implements RemoteUserService {

        @Override
        public User getUser(long id) throws Exception {
            log.warn("getUser fallback");
            return null;
        }

        @Override
        public BigDecimal consumeMoney(long id, BigDecimal amount) throws Exception {
            log.warn("consumeMoney fallback");
            return null;
        }

        @Override
        public BigDecimal lendpayMoney(long investorId, long borrowerId, BigDecimal amount) throws Exception {
            log.warn("lendpayMoney fallback");
            return null;
        }
    }
}

项目服务访问客户端:

package me.josephzhu.springcloud101.investservice.server;

import me.josephzhu.springcloud101.projectservice.api.Project;
import me.josephzhu.springcloud101.projectservice.api.ProjectService;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;

@FeignClient(value = "projectservice", fallback = RemoteProjectService.Fallback.class)
public interface RemoteProjectService extends ProjectService {
    @Component
    class Fallback implements RemoteProjectService {

        @Override
        public Project getProject(long id) throws Exception {
            return null;
        }

        @Override
        public BigDecimal gotInvested(long id, BigDecimal amount) throws Exception {
            return null;
        }

        @Override
        public BigDecimal lendpay(long id) throws Exception {
            return null;
        }
    }
}

服务接口实现:

package me.josephzhu.springcloud101.investservice.server;

import lombok.extern.slf4j.Slf4j;
import me.josephzhu.springcloud101.investservice.api.Invest;
import me.josephzhu.springcloud101.investservice.api.InvestService;
import me.josephzhu.springcloud101.projectservice.api.Project;
import me.josephzhu.springcloud101.userservice.api.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;

@RestController
@Slf4j
public class InvestServiceController implements InvestService {
    @Autowired
    InvestRepository investRepository;
    @Autowired
    RemoteUserService remoteUserService;
    @Autowired
    RemoteProjectService remoteProjectService;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public Invest createOrder(long userId, long projectId, BigDecimal amount) throws Exception {
        User investor = remoteUserService.getUser(userId);
        if (investor == null) throw new Exception("无效用户ID");
        if (amount.compareTo(investor.getAvailableBalance()) > 0) throw new Exception("用户余额不足");

        Project project = remoteProjectService.getProject(projectId);
        if (project == null) throw new Exception("无效项目ID");
        if (amount.compareTo(project.getRemainAmount()) > 0) throw new Exception("项目余额不足");
        if (project.getStatus() !=1) throw new Exception("项目不是募集中状不能投资");

        InvestEntity investEntity = new InvestEntity();
        investEntity.setInvestorId(investor.getId());
        investEntity.setInvestorName(investor.getName());
        investEntity.setAmount(amount);
        investEntity.setBorrowerId(project.getBorrowerId());
        investEntity.setBorrowerName(project.getBorrowerName());
        investEntity.setProjectId(project.getId());
        investEntity.setProjectName(project.getName());
        investEntity.setStatus(1);
        investRepository.save(investEntity);

        if (remoteUserService.consumeMoney(userId, amount) == null) throw new Exception("用户消费失败");
        if (remoteProjectService.gotInvested(projectId, amount) == null) throw new Exception("项目投资失败");

        return Invest.builder()
                .id(investEntity.getId())
                .amount(investEntity.getAmount())
                .borrowerId(investEntity.getBorrowerId())
                .investorId(investEntity.getInvestorId())
                .projectId(investEntity.getProjectId())
                .status(investEntity.getStatus())
                .createdAt(investEntity.getCreatedAt())
                .updatedAt(investEntity.getUpdatedAt())
                .build();
    }

    @Override
    public List getOrders(long projectId) throws Exception {
        return investRepository.findByProjectIdAndStatus(projectId,1).stream()
                .map(investEntity -> Invest.builder()
                        .id(investEntity.getId())
                        .amount(investEntity.getAmount())
                        .borrowerId(investEntity.getBorrowerId())
                        .investorId(investEntity.getInvestorId())
                        .projectId(investEntity.getProjectId())
                        .status(investEntity.getStatus())
                        .createdAt(investEntity.getCreatedAt())
                        .updatedAt(investEntity.getUpdatedAt())
                        .build())
                .collect(Collectors.toList());
    }
}

投资服务定义了两个接口:

  1. createOrder:先后调用外部服务获取投资人和项目信息,然后插入投资记录,然后调用用户服务去更新投资人的冻结账户余额,调用项目服务去更新项目余额。
  2. getOrders:根据项目ID查询所有状态为1的投资订单(在放款操作的时候需要用到)。

启动类如下:

package me.josephzhu.springcloud101.investservice.server;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.hystrix.EnableHystrix;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.ApplicationContext;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

import java.util.Arrays;
import java.util.stream.Stream;

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@EnableJpaAuditing
@EnableHystrix
@EnableCircuitBreaker
public class InvestServiceApplication implements CommandLineRunner{
    public static void main(String[] args) {
        SpringApplication.run( InvestServiceApplication.class, args );
    }

    @Autowired
    ApplicationContext applicationContext;

    @Override
    public void run(String... args) throws Exception {
        System.out.println("所有注解:");
        Stream.of(applicationContext.getBeanDefinitionNames())
                .map(applicationContext::getBean)
                .map(bean-> Arrays.asList(bean.getClass().getAnnotations()))
                .flatMap(a->a.stream())
                .filter(annotation -> annotation.annotationType().getName().startsWith("org.springframework.cloud"))
                .forEach(System.out::println);
    }
}

和其它几个服务一样没啥特殊的,只是这里多了个Runner,这个是我自己玩的,想输出一下Spring中的Bean上定义的和Spring Cloud相关的注解,和业务没有关系。

项目监听服务搭建

最后一个服务是监听MQ进行处理的项目(消息)监听服务。这个服务其实是可以和其它服务进行合并的,但是为了清晰我们还是分开做了一个模块:



    
        springcloud101
        me.josephzhu
        1.0-SNAPSHOT
    
    4.0.0

    springcloud101-projectservice-listener

    
        
            org.springframework.cloud
            spring-cloud-starter-netflix-eureka-client
        
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            org.springframework.cloud
            spring-cloud-starter-openfeign
        
        
            org.springframework.boot
            spring-boot-starter-actuator
        
        
            org.springframework.cloud
            spring-cloud-starter-sleuth
        
        
            org.springframework.cloud
            spring-cloud-starter-zipkin
        
        
            org.springframework.cloud
            spring-cloud-starter-netflix-hystrix
        
        
            org.springframework.cloud
            spring-cloud-stream
        
        
            org.springframework.cloud
            spring-cloud-starter-stream-rabbit
        
        
            com.fasterxml.jackson.core
            jackson-databind
            2.9.7
        

        
            me.josephzhu
            springcloud101-userservice-api
            1.0-SNAPSHOT
        
        
            me.josephzhu
            springcloud101-projectservice-api
            1.0-SNAPSHOT
        
        
            me.josephzhu
            springcloud101-investservice-api
            1.0-SNAPSHOT
        
    

引入了Stream相关依赖,去掉了数据访问相关依赖,因为这里我们只会调用外部服务,服务本身不会进行数据访问。
配置信息如下:

server:
  port: 8764

spring:
  application:
    name: projectservice-listener
  cloud:
    stream:
      bindings:
        input:
          destination: zhuye
  zipkin:
    base-url: http://localhost:9411
  sleuth:
    feign:
      enabled: true
    sampler:
      probability: 1.0

feign:
  hystrix:
    enabled: true

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8865/eureka/
    registry-fetch-interval-seconds: 5

management:
  endpoints:
    web:
      exposure:
        include: "*"
  endpoint:
    health:
      show-details: always

唯一值得注意的是,这里我们定义了Spring Cloud Input绑定到也是之前定义的Output的那个交换机zhuye上面,实现了MQ发送接受数据连通。
下面我们定义了三个外部服务客户端(代码和其它地方使用的一模一样。
投资服务:

package me.josephzhu.springcloud101.projectservice.listener;

import me.josephzhu.springcloud101.investservice.api.InvestService;
import org.springframework.cloud.openfeign.FeignClient;

@FeignClient(value = "investservice")
public interface RemoteInvestService extends InvestService {
}

用户服务:

package me.josephzhu.springcloud101.projectservice.listener;

import lombok.extern.slf4j.Slf4j;
import me.josephzhu.springcloud101.userservice.api.User;
import me.josephzhu.springcloud101.userservice.api.UserService;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;

@FeignClient(value = "userservice", fallback = RemoteUserService.Fallback.class)
public interface RemoteUserService extends UserService {
    @Component
    @Slf4j
    class Fallback implements RemoteUserService {

        @Override
        public User getUser(long id) throws Exception {
            log.warn("getUser fallback");
            return null;
        }

        @Override
        public BigDecimal consumeMoney(long id, BigDecimal amount) throws Exception {
            log.warn("consumeMoney fallback");
            return null;
        }

        @Override
        public BigDecimal lendpayMoney(long investorId, long borrowerId, BigDecimal amount) throws Exception {
            log.warn("lendpayMoney fallback");
            return null;
        }
    }
}

项目服务:

package me.josephzhu.springcloud101.projectservice.listener;

import me.josephzhu.springcloud101.projectservice.api.Project;
import me.josephzhu.springcloud101.projectservice.api.ProjectService;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;

@FeignClient(value = "projectservice", fallback = RemoteProjectService.Fallback.class)
public interface RemoteProjectService extends ProjectService {
    @Component
    class Fallback implements RemoteProjectService {

        @Override
        public Project getProject(long id) throws Exception {
            return null;
        }

        @Override
        public BigDecimal gotInvested(long id, BigDecimal amount) throws Exception {
            return null;
        }

        @Override
        public BigDecimal lendpay(long id) throws Exception {
            return null;
        }
    }
}

监听程序实现如下:

package me.josephzhu.springcloud101.projectservice.listener;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import me.josephzhu.springcloud101.projectservice.api.Project;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;
import org.springframework.stereotype.Component;

@Component
@EnableBinding(Sink.class)
@Slf4j
public class ProjectServiceListener {
    @Autowired
    RemoteUserService remoteUserService;
    @Autowired
    RemoteProjectService remoteProjectService;
    @Autowired
    RemoteInvestService remoteInvestService;

    static ObjectMapper objectMapper = new ObjectMapper();

    @StreamListener(Sink.INPUT)
    public void handleProject(Project project) {
        try {
            log.info("收到消息: " + project);
            if (project.getStatus() == 2) {
                remoteInvestService.getOrders(project.getId())
                        .forEach(invest -> {
                            try {
                                remoteUserService.lendpayMoney(invest.getInvestorId(), invest.getBorrowerId(), invest.getAmount());
                            } catch (Exception ex) {
                                try {
                                    log.error("处理放款的时候遇到异常:" + objectMapper.writeValueAsString(invest), ex);
                                } catch (JsonProcessingException e) {

                                }
                            }
                        });
                remoteProjectService.lendpay(project.getId());
            }
        } catch (Exception ex) {
            log.error("处理消息出现异常",ex);
        }
    }
}

我们通过@StreamListener方便实现消息监听,在收听到Project消息(其实最标准的应该为MQ消息定义一个XXNotification的DTO,比如ProjectStatusChangedNotification,这里我们偷懒直接使用了Project这个DTO)后:

  1. 判断项目状态是不是2募集完成,如果是的话
  2. 首先,调用投资服务getOrders接口获取项目所有投资信息
  3. 然后,逐一调用用户服务lendpayMoney接口为每一笔投资进行余额转移(把投资人冻结的钱解冻,转给借款人可用余额)
  4. 最后,调用项目服务lendpay接口更新项目状态为放款完成

这里可以看到,虽然lendpay接口耗时很久(里面休眠5秒)但是由于处理是异步的,不会影响投资订单这个操作,这是通过MQ进行异步处理的应用点之一。

演示和测试

激动人心的时刻来了,我们来通过演示看一下我们这套Spring Cloud微服务体系的功能。
先启动Eureka,然后依次启动所有的基础服务,最后依次启动所有的业务服务。
全部启动后,访问一下http://localhost:8865/来查看Eureka注册中心:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第4张图片
这里可以看到所有服务已经注册在线:

  1. 8866的Zuul
  2. 8867的Tubine
  3. 8761的用户服务
  4. 8762的项目服务
  5. 8763的投资服务

访问http://localhost:8761/getUser?id=1可以测试用户服务:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第5张图片
访问http://localhost:8762/getProject?id=2可以测试项目服务:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第6张图片
我们来初始化一下数据库,默认有一个项目信息:

还有两个投资人和一个借款人:


现在来通过网关访问http://localhost:8866/invest/createInvest投资服务(使用网关进行路由,我们配置的是匹配invest/**这个path路由到投资服务,直接访问服务的时候无需提供invest前缀)使用投资人1做一次投资:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第7张图片
在没有提供token的时候会出现错误,加上token后访问成功:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第8张图片
可以看到投资后投资人冻结账户为100,项目剩余金额为900,多了一条投资记录:

我们使用投资人1测试5次投资,使用投资人2测试5次投资,测试后可以看到项目状态变为了3放款完成:

数据库中有10条投资记录:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第9张图片
两个投资人的冻结余额都为0,可用余额分别少了500,借款人可用余额多了1000,说明放款成功了?:

同时可以在ProjectListner的日志中看到收到消息的日志:

我们可以访问http://localhost:15672打开RabbitMQ都是管理台看一下我们那条消息的情况:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第10张图片
可以看到在队列中的确有一条消息先收到然后不久后(大概是6秒后)得到了ack处理完毕。队列绑定到了zhuye这个交换机上:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第11张图片
至此,我们已经演示了Zuul、Eureka和Stream,现在我们来看一下断路器功能。
我们首先访问http://localhost:8867/hystrix:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第12张图片
然后输入http://localhost:8867/turbine.stream(Turbine聚合监控数据流)进入监控面板:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第13张图片
多访问几次投资服务接口可以看到每一个服务方法的断路器情况以及三套服务断路器线程池的情况,我们接下去关闭用户服务,再多访问几次投资服务接口,可以看到getUser断路器打开(getUser方法有个红点):
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第14张图片
同时在投资服务日志中可以看到断路器走了Fallback的用户服务:

最后,我们访问Zipkin来看一下服务链路监控的威力,访问http://localhost:9411/zipkin/然后点击按照最近排序可以看到有一条很长的链路:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第15张图片
点进去看看:
朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第16张图片
整个链路覆盖:

  1. 网关:
    朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第17张图片
  2. 断路器以及同步服务调用
    朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第18张图片
  3. 消息发送和接受的异步处理
    朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第19张图片
    整个过程一清二楚,只是这里没有Redis和数据库访问的信息,我们可以通过定义扩展实现,这里不展开阐述。还可以点击Zipkin的依赖链接分析服务之间的依赖关系:
    朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第20张图片
    点击每一个服务可以查看明细:
    朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第21张图片
    还记得我们引用了p6spy吗,我们来看一下投资服务的日志:
    朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子)_第22张图片
    方括号中的几个数据分别是appname,traceId,spanId,exportable(是否发送到zipkin)。
    随便复制一个traceId,粘贴到zipkin即可查看这个SQL的完整链路:

    演示到此结束。

总结

这是一篇超长的文章,在本文中我们以一个实际的业务例子介绍演示了如下内容:

  1. Eureka服务注册发现
  2. Feign服务远程调用
  3. Hystrix服务断路器
  4. Turbine断路器监控聚合
  5. Stream做异步处理
  6. Sleuth和Zipkin服务调用链路监控
  7. Zuul服务网关和自定义过滤器
  8. JPA数据访问和Redisson分布式锁
    虽然我们给出的是一个完整的业务例子,但是我们可以看到投资的时候三大服务是需要做事务处理的,这里因为是演示Spring Cloud,完全忽略了分布式事务处理,以后有机会会单独写文章来讨论这个事情。

总结一下我对Spring Cloud的看法:

  1. 发展超快,感觉Spring Cloud总是会先用开源的东西先纳入体系然后慢慢推出自己的实现,Feign、Gateway就是这样的例子
  2. 因为发展快,版本迭代快,所以网上的资料往往五花八门,各种配置不一定适用最新版本,还是看官方文档最好
  3. 但是官方文档有的时候也不全面,这个时候只能自己阅读相关源码
  4. 现在还不够成熟(可用,但用的不是最舒服,需要用好的话需要做很多定制),功能不是最丰富,属于凑活能用的阶段,照这个速度,1年后我们再看到时候可能就很爽了
  5. 期待Spring Cloud在配置服务、网关服务、全链路监控、一体化的配置后台方面继续加强
  6. 不管怎么说,如果只需要2小时就可以搭建一套微服务体系,具有服务发现+同步调用+异步调用+调用监控+熔断+网关的功能,还是很震撼的,小型创业项目用这套架构可以当天就起步项目
  7. 社区还提供了一个Admin项目功能比较丰富,你可以尝试搭建https://github.com/codecentric/spring-boot-admin,这里没有演示

希望本文对你有用,完整代码见https://github.com/JosephZhu1983/SpringCloud101。

你可能感兴趣的:(朱晔和你聊Spring系列S1E8:凑活着用的Spring Cloud(含一个实际业务贯穿所有组件的完整例子))