2020 SpringCloud实战企业级项目

951 阅读12分钟

特点

  • springboot2、springcloud
  • 高可用注册中心nacos、nacos负载均衡算法
  • 多实例服务:gateway、backend
  • 熔断机制sentinel
  • 服务链路追踪zipkin
  • 服务监测actuator
  • docker-stack一键启动mysql主备
  • redis集群
  • 高可用nginx
  • docker-compose一键启动elk
  • rabbitmq实现消息百分百投递成功、幂等性消费消息
  • 统一异常处理、统一返回值、统一日志管理
  • 基于jwt的登陆登出机制
  • 基于注解的接口权限控制
  • 实现自己的spring-boot-starter
  • 设计模式的使用
  • ......

下载&&运行

项目地址

先启动nacos,再启动ci-backend、ci-gateway

一、项目骨架搭建

创建一个简单的maven工程作为我们的父项目,删除掉src等多余目录,只保留pom.xml,项目目录为:

完善pom.xml

<!-- 项目基础配置 -->
<groupId>com.cmsr</groupId>
<artifactId>ci-parent</artifactId>
<version>1.0.0</version>

<packaging>pom</packaging>

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.6.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <!-- 注册中心nacos -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <!--spring cloud版本管理-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Greenwich.SR1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!--spring cloud alibaba版本管理-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>2.1.0.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

然后创建一个父module:ci-backend,同样也是一个简单的maven项目,同样删除掉src等多余目录,只保留pom.xml

右键项目(如上图的ci-sample),New -> Module,填好ArtifactId(ci-backend)、Module name (ci-backend),点击Finish。

接着右键刚创建好的module,以同样的方法创建两个子module:ci-backend-api、ci-backend-service,此时的项目架构图如下:

创建子module的时候,ArtifactId和Module name都保持一致,分别都是ci-backend-api、ci-backend-service。ci-backend-api模块是后期各个微服务之间调用时使用的,当我们引入Feign的时候才会用到该模块。

现在我们将ci-backend-service模块改成springboot项目,首先添加一个启动类BackendLauncher.java

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

接着添加配置文件application.yml

server:
  port: 9000

spring:
  application:
    name: cmsr-backend

接着在pom.xml添加springmvc依赖,编写一个测试接口,测试我们的项目是否搭建成功

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>
@RestController
@RequestMapping("/test")
public class TestController {
    @GetMapping
    public String test() {
        return "hello springboot";
    }
}

启动我们的项目,在页面上访问localhost:9000/test,如果页面返回hello springboot,则表示我们的项目启动成功了。

此时我们的项目结构是这样的:

启动项目的时候,如果控制台报了异常:java.lang.IllegalArgumentException: no server available at com.alibaba.nacos.client.naming.net.NamingProxy.reqAPI,这是因为我们本地还没有启动注册中心,下面我们就将ci-backend-service项目注册到nacos上。

nacos详细安装步骤

启动nacos后,可以登陆nacos的管理界面看看:localhost:8848/nacos

nacos的管理界面的登陆名密码都是nacos

接着我们需要在ci-backend-service里整合nacos。整合nacos很简单,只需在application.yml里配置一下即可

spring:
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848

最后再次启动ci-backend-service,就会在nacos管理界面上看到我们注册的cmsr-backend服务了

二、搭建网关

网关是基于SpringCloud Gateway来搭建的。所有的请求都要先经过网关,然后由网关路由到各个微服务上。

首先在父模块下创建新的module:ci-gateway,然后在pom.xml里添加SpringCloud Gateway依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

接着添加项目的启动类和配置文件application.yml

@SpringBootApplication
public class GatewayLauncher {
    public static void main(String[] args) {
        SpringApplication.run(GatewayLauncher.class, args);
    }
}
server:
  port: 8000

spring:
  application:
    name: cmsr-gateway
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    gateway:
      discovery:
        locator:
          enabled: true # 让gateway通过服务发现组件找到其他的微服务

启动项目后,ci-gateway也会将自己注册到nacos上

此时项目的结构是这样的:

现在有了网关,我们就不用直接访问cmsr-backend了,而是通过访问网关,让网关路由到cmsr-backend上,而实现这一步只需在application.yml上配置一下即可

spring:
  cloud:
    gateway:
      routes:
        - id: cmsr-backend
          uri: lb://cmsr-backend
          predicates:
            - Path=/backend/**
          filters:
            - StripPrefix=1

这段配置的意思是当访问以/backend打头的url都负载均衡路由到cmsr-backend服务上

配置好后重启一下ci-gateway,在浏览器上访问localhost:8000/backend/test时,页面也会返回hello springboot

三、ci-backend多实例的负载均衡

假设我们的ci-backend部署了多个实例,那么网关要怎么路由到哪个实例上呢?是轮训、随机还是什么?肯定不是的!假如一个实例部署在北京,一个实例部署在上海,上海的用户请求ci-backend服务不可能路由到北京的那个实例上吧,那得走多远啊!此时应该有一个负载均衡算法来帮网关决定应该路由到哪个实例上。我们可以借助nacos来实现。

nacos领域模型有集群的概念,我们把部署在上海的ci-gateway和ci-backend实例放到nacos的一个集群名为shanghai的集群里,把部署在北京的ci-gateway和ci-backend实例放到nacos的一个集群名为beijing的集群里。

当一个上海用户使用上海的ci-gateway发起请求,那么这个请求就优先被转发到nacos集群名为shanghai集群的ci-backend上,当该集群里没有ci-backend服务的时候,才会跨集群调用nacos里集群名为beijing集群的ci-backend。

当集群名为shanghai集群有多个ci-backend,那么可以为每个ci-backend设置一个权重,权重高的部署在好的服务器上,权重低的部署在相对较好的服务器上。

好了,知道了负载均衡的思路后,让我们来coding这个负载均衡算法吧!

在ci-gateway项目下创建一个包:loadBalanced,在该目录下实现负载均衡算法

/**
 * 基于nacos同一集群下的权重的负载均衡算法
 */
public class NacosSameClusterWeightRule extends AbstractLoadBalancerRule {

    @Autowired
    private NacosDiscoveryProperties nacosDiscoveryProperties;

    private static final Logger log = LoggerFactory.getLogger(NacosSameClusterWeightRule.class);

    @Override
    public void initWithNiwsConfig(IClientConfig iClientConfig) {

    }

    @Override
    public Server choose(Object key) {

        try {
            // 获取配置文件中nacos的集群名称
            String clusterName = nacosDiscoveryProperties.getClusterName();

            BaseLoadBalancer baseLoadBalancer = (BaseLoadBalancer) this.getLoadBalancer();

            // 获取想要请求的微服务名称
            String name = baseLoadBalancer.getName();
            NamingService namingService = nacosDiscoveryProperties.namingServiceInstance();

            // 获取想要请求服务的所有实例
            List<Instance> instances = namingService.selectInstances(name, true);

            if (CollectionUtils.isEmpty(instances)) {
                return null;
            }

            // 获取指定集群下的所有实例
            List<Instance> sameClusterInstall = instances.stream().filter(instance -> {
                return Objects.equals(instance.getClusterName(), clusterName);
            }).collect(Collectors.toList());

            List<Instance> instancesToBeChosen;
            if (CollectionUtils.isEmpty(sameClusterInstall)) {
                instancesToBeChosen = instances;
                log.warn("发生跨集群调用, name = {}, clusterName = {}", clusterName, instances);
            } else {
                instancesToBeChosen = sameClusterInstall;
            }

            Instance instance = ExtendBalancer.getHostByRandomWeight2(instancesToBeChosen);
            log.info("选择的实例是:port = {}, instance = {}", instance.getPort(), instance);

            return new NacosServer(instance);
        } catch (NacosException e) {
            log.error("异常:", e);
            return null;
        }
    }
}

class ExtendBalancer extends Balancer {
    public static Instance getHostByRandomWeight2(List<Instance> hosts) {
        return getHostByRandomWeight(hosts);
    }
}

接着配置该负载均衡规则

@Configuration
public class RibbonConfig {
    @Bean
    public IRule ribbonRule() {
        return new NacosSameClusterWeightRule();
    }
}
@Configuration
@RibbonClients(defaultConfiguration = RibbonConfig.class)
public class BackendRibbonConfig {
}

在ci-gateway项目的application.yml配置自己是属于哪个集群

spring:
  cloud:
    nacos:
      discovery:
        cluster-name: shanghai

在ci-backend-service项目的application.yml配置自己是属于哪个集群和权重

spring:
  cloud:
    nacos:
      discovery:
        cluster-name: shanghai
        weight: 10

启动ci-gateway和两个ci-backend-service来验证我们的负载均衡算法是否生效

idea启动多个实例方法,如下如,

然后然后勾选右上角的Allow parallel run后,修改ci-backend-service的端口号和nacos的权重,这样就可以启动两个不同的实例了

启动完成之后,我们去nacos的管理界面看下服务列表,可以发现有两个ci-backend-service实例

点击详情进去,就会发现有一个集群名为shanghai的集群,里面有个两个ci-backend-service实例,权重分别是在配置文件里配置的

接着我们在页面上请求多次localhost:8000/backend/test,然后对比下idea控制台上输出的日志,是否权重高的实例被访问的次数多于权重低的实例。

大家也可以启动多个ci-backend-service实例,但是集群名称不是shanghai,看看idea控制台会打印什么日志。

四、 搭建nacos集群

上面说的shanghai集群不是nacos集群,而是nacos的领域模型,这点要注意。

ci-gateway和ci-backend-service服务都需要注册到nacos上,当单机的nacos挂了那么所有的服务都不可用了,所以我们需要一个nacos集群。参考官方文档

首先我们需要一个mysql数据库,这里大家就自行安装一下吧

然后在nacos下载包找到 /nacos/conf/nacos-mysql.sql,将其导入到数据库中

同时在 /nacos/conf/application.properties 添加如下内容:

# 表明用MySQL作为后端存储
spring.datasource.platform=mysql

# 有几个数据库实例
db.num=1

# mysql实例的地址
db.url.0=jdbc:mysql://127.0.0.1:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true
db.user=root
db.password=123456

然后将下载的nacos包复制三份,分别将/nacos/conf/application.properties 里面server.port=8848分别改成

server.port=8848
server.port=8849
server.port=8850

分别启动这三个nacos,即可组成集群

sh /nacos/bin/start.sh 

修改ci-gateway和ci-backend-service配置文件,支持nacos集群

spring:
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848,localhost:8849,localhost:8850

我们可以手动关闭一个nacos,然后去访问接口,如果接口还能正常返回,表示我们的应用服务已经支持nacos集群了。

五、实现高可用网关ci-gateway

上面我们已经实现了服务ci-backend和nacos的高可用方案,而网关是所有流量的入口,所以我们也需要启动多个网关实例,然后通过nginx反向代理实现网关的高可用。

首先通过idea启动三个ci-gateway实例,端口分别是:8000、8001、8002

然后启动nginx,点我下载nginx

window操作系统直接双击nginx.exe即可;如果是linux或者mac系统的话可以参看我之前的文章:nginx-超详细配置及其优化

在nginx.conf文件的http段里添加一个server和upstream来实现反向代理

http {
    server {
        listen       7000;
        server_name  localhost;

        location / {
            proxy_pass http://gateway;
        }
    }

    upstream gateway {
        #网关实例地址
        server 127.0.0.1:8000 weight=2;
        server 127.0.0.1:8001 weight=2;
        server 127.0.0.1:8002 weight=2;
    }
}

这段配置的意思是:当我们访问localhost:7000/backend/test的时候,nginx会替换成http://gateway/backend/test,而gateway又被换成127.0.0.1:8000或127.0.0.1:8001再或者127.0.0.1:8002,最终结果请求都会路由到ci-backend-service上。如下图所示:

到这里我们通过nginx实现了网关ci-gateway的高可用。

六、高可用nginx

上面实现了网关的高可用,但是我们的nginx是单机的,也不能满足上生产环境的条件,所以我们需要一个高可用的nginx方案,我们借助keepalive封装好的VRRP来实现。

到此,项目从nginx到gateway到nacos到各个微服务都是高可用了!下面我们就一步一步把微服务ci-backend-service完善起来吧。

七、全局统一返回值

全局统一的配置需要写到一块,这样其他模块无需再重复配置,所以我们需要创建一个ci-common模块。然后在该模块coding我们的全局统一代码吧

首先添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

接着编写统一返回数据的结构体CommonResponse

public final class CommonResponse<T> {

    private int code;
    private String msg;
    private T data;

    public static CommonResponse success() {
        return new CommonResponse(200, "ok");
    }

    public static <T> CommonResponse<T> success(T result) {
        return new CommonResponse<>(200, "ok", result);
    }

    private CommonResponse(int code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    private CommonResponse(int errCode, String msg) {
        this.code = errCode;
        this.msg = msg;
        this.data = null;
    }
    
    // getter and setter
}

接着需要将业务的返回值拦截,封装成CommonResponse返回给前端

@RestControllerAdvice
public class CommonResponseAdvice implements ResponseBodyAdvice<Object> {

    // 所有的返回值都要封装成CommonResponse
    @Override
    public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
        return true;
    }

    // 将返回值封装成CommonResponse的具体逻辑
    @Override
    public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType,
                                  Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest,
                                  ServerHttpResponse serverHttpResponse) {

        CommonResponse response = CommonResponse.success();
        if (null == o) {
            return response;
        } else if (o instanceof byte[]){
            return o;
        } else if (o instanceof CommonResponse) {
            response = (CommonResponse) o;
        } else if (o instanceof String) {
            response.setData(o);
            return JSON.toJSONString(response);
        } else {
            response.setData(o);
        }
        return response;
    }
}

然后在ci-backend-service添加ci-common的依赖,因为ci-common已经有spring-boot-starter-web的依赖了,如果ci-backend-service也有其依赖,可以把其依赖删掉,保持代码简洁

<dependency>
    <groupId>com.cmsr</groupId>
    <artifactId>ci-common</artifactId>
    <version>1.0.0</version>
</dependency>

再然后在ci-backend-service的启动类上修改下配置,让其能扫描到ci-common下的组件

@SpringBootApplication(scanBasePackages = "com.cmsr")

最后重启下ci-backend验证下结果:

如果我们需要一些特定的接口不需要封装成CommonResponse,我们可以写一个注解,如果该接口上添加上了该注解,我们就忽略掉,具体实现如下:

/**
 * 忽略对返回值的封装
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IgnoreCommonResponseAdvice {
}

最后修改下CommonResponseAdvice的supports方法

@Override
    public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
        if (methodParameter.getDeclaringClass().isAnnotationPresent(IgnoreCommonResponseAdvice.class)) {
            return false;
        }
        return !Objects.requireNonNull(methodParameter.getMethod()).isAnnotationPresent(IgnoreCommonResponseAdvice.class);
    }

然后在我们不需要返回成CommonResponse格式的接口上加上该注解即可。

八、统一全局异常

在ci-common模块添加GlobalExceptionAdvice.java

@RestControllerAdvice
public class GlobalExceptionAdvice {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionAdvice.class);
    
    /**
     * request body为空时的异常处理
     */
    @ExceptionHandler(value = HttpMessageNotReadableException.class)
    public ResponseEntity<?> handleBusinessException(HttpMessageNotReadableException ex) {
        return new ResponseEntity<>(
                new CommonResponse<>(9999, "请求参数不能为空或格式化错误", null), HttpStatus.OK);
    }

    /**
     * 统一异常处理
     */
    @ExceptionHandler(value = Exception.class)
    public ResponseEntity<?> handleException(Exception ex) {
        log.error("统一异常处理切面捕获异常:", ex);
        return new ResponseEntity<>(
                new CommonResponse<>(500, "服务异常,请稍后再试", null), HttpStatus.OK);
    }
}

后期各个微服务的自定义业务异常也可以定义在ci-common里,然后完善下GlobalExceptionAdvice即可