从0开始搭建一个微服务后端系统-基础入门篇

前言

今天开始,我和大家一起,从0开始,基于Spring Cloud Alibaba,搭建一套基本的微服务架构的项目。

主要用到下面的知识内容

  • JDK8/IDEA/Maven
  • Spring/SpringBoot/Spring Cloud Alibaba
  • Dubbo/openfeign:服务间调用
    • 这里之所以引入了两种调用方式,是为了方便根据接口的实际情况,选择合适的调用方式
  • Seata:分布式事务
  • MySQL
  • Redis
  • RabbitMQ/RocketMQ
  • RBAC:这里,我不打算使用常见的 Spring Security/Spring Security OAuth2.0/Shiro等常见的认证授权技术,我打算自己手动撸一套简易的认证授权体系出来

之所以这么干,我还是考虑到一个定制化和灵活性的问题,在真正的大企业中,其认证授权体系是非常个性化的,如果用了某项技术,往往会制约这种个性化的实现。好吧,我这里主要想吐槽的就是 Spring Security太复杂了,做一个简单的功能,要理解的概念太多了。既然RBAC理论和OAuth2.0协议本身已经非常成熟了,我们根据自己的需要,手撸一个也不是什么大问题

理论:微服务与Spring Cloud

什么是微服务

首先,微服务本身,是一种软件设计,软件架构的思想,并不是具体的某一种技术。可千万别把微服务和Spring Cloud给划上等号。

  • 单体应用

我们先来说说传统的单体应用,也就是在一个项目中,把所有的代码都写在一个应用中。

这么做其实在后续功能不断迭代之后,在软件管理和设计上,是会带来种种问题的。

1、应用整体挂机的概率增强

我们写代码的时候要有一个底线思维,就是要把情况往糟糕的情况下去考虑,然后给出解决方案。

只有这样严格要求自己,我们的代码才会更加健壮。

但即使是这样,只要是人,就会犯错,并且,一个项目中的代码,迭代几轮之后,不知道经了多少人的手。

保不齐就有个小伙伴埋了个雷,一旦这个雷爆发,那么我们整个系统也就挂了。

2、技术受限制

当我们在已有的应用中添加功能代码的时候,是需要受当前应用的限制的。

比如说,我举个极端的例子,这个项目比较老了,使用的是Spring2.5,而现在我们做开发一般至少都用JDK8.

那么很不幸,Spring2.5和JDK8是不兼容的。于是我们就没法使用JDK1.8提供的各种实用的特性。

3、部署复杂

这个部署复杂,是针对代码量急剧增加之后来说的。

随着代码量越来越庞大,不管是启动还是测试,上线部署,都将变得越来越慢。

有时候明明改的是一个很小的功能,但是没办法,我们就是需要整个项目都部署一遍

  • 分布式

与单体应用相对的,就是分布式系统。

所谓分布式,就是说我们把一个项目,按照一定的划分原则,将各个功能的实现拆开来,部署的时候分开部署。

应用之间通过约定的方式调用。一般是http协议或者rpc协议(如 gRPC、thrift、Dubbo等)。

当我们按照功能拆开后进行部署和开发,带来的好处是很明显的,至少把上面讲的,单体应用的几个缺点,是都给解决掉了。

但是同时,也引入了新的问题

1、事务怎么办,在单体应用中,一个进程中操作一个数据库,回滚是非常方便的,直接使用数据库的特性就可以实现回滚

2、基础功能重复开发怎么办?即如何复用?各个模块中有些功能可能是公用的,一旦拆开后,怎么复用这些代码?

3、测试困难,原先只要启动一个应用就够了,现在如果一个功能的测试,依赖于多个模块,那么相关模块就都要运行起来

但是,后端开发进入现在这个阶段,我们对大数据量高并发的要求,相对于分布式系统的一点点缺点,其带来的好处是不言而喻的。

所以,现在基本上一家企业如果已经过了市场验证阶段,没理由继续使用单体应用,肯定都是要改造成分布式系统的。

  • 微服务

那么什么是微服务呢?

微服务肯定是分布式的。那么微服务和分布式系统又有什么区别呢?

以我自己不成熟的看法来说的话,所谓微服务,除了解决分布式系统领域的问题之外,其特点,重在一个微。

怎么样的一个服务,可以称之为微服务呢?这个才是难点。一个系统中,我们怎么拆分,不管是从业务的角度还是技术的角度,各个应用单元之间如何协作,这个才是微服务设计中最最复杂的部分,技术上的难点,其实互联网上的大厂,基本上都已经有解决方案了。

image-20200827150328805

微服务设计6大原则

  • 微服务和单体应用的选择

既然微服务有这么多优点,单体应用有那么多缺点,我们在实际项目中到底选择使用哪种开发模式呢?

这个其实和公司当前的实际情况是挂钩的,我们做开发的,一定要理清楚一点,技术,是为业务服务的,或者说是为了需求服务的。

千万不要为了技术而技术。如果一家公司,刚起步,人手不足、业务量也不大、技术实力也不够雄厚,这个时候贸贸然跟风上微服务,这不就是自己给自己找事儿做吗?

当公司业务上来了,团队规模也起来了,人员配备足够覆盖微服务本身的规模,能够驾驭了,这个时候,才能够切实感受到微服务对整个系统带来的好处。

Spring Cloud和微服务是什么关系

Spring Cloud,就是针对微服务的一套实现。

比较著名的所有 Spring Cloud Netflix和Spring Cloud Alibaba

小结

以上讲的都是一些概念性质的东西,具体怎么开发已经详细的理论,我会在后续实战部分,逐个进行讲解。

理论:微服务软件架构设计

我们先来对这一整个微服务体系,进行整体设计。

1598161368461

第一张图,是我在网上找的一个关于微服务架构的社交

第二张图,我重新精简了一下,基本上就划分为网关、认证中心、其他业务模块这几种类型。

实战:搭建项目骨架

  • chan
    • chan-dep:全局版本依赖管理
    • chan-common:通用工具类
    • chan-auth:认证授权模块
    • chan-user:用户管理模块
    • chan-log:日志模块
    • chan-gateway:网关
    • …:其他业务模块

我们创建了一个项目来进行依赖管理,其他大多数pom关系不大,

不过,对于Spring Cloud、Spring Cloud Alibaba、SpringBoot,这三者之间的版本,是有关联的。

Spring Cloud Alibaba的版本与Spring Boot一致

参见:https://start.spring.io/actuator/info

https://gitee.com/test-qqqq/spring-cloud-alibaba

Spring Cloud的版本与Spring Boot 一致

参考:https://spring.io/projects/spring-cloud/#overview

Spring Cloud Alibaba及其组件(Dubbo、Seata、Sentinel、Nacos),他们之间的版本也是有关联的。

我们在配置这几个组件的版本的时候,最好先去网上查一下,他们是怎么搭配的。

一般这种配置,除非是大版本的更新,否则一旦配置好后,就不要去修改了,避免引起不必要麻烦

理论:注册中心之Nacos

多个服务之间在相互调用的时候,是需要建立连接的。

那么他们怎么知道对方部署时的ip和port是多少呢?

  • 代码中写死?
    • 重启怎么办?
    • 扩容怎么办?

这个时候,就要用到注册中心。

  • 在微服务中,注册中心是个什么概念

注册中心主要是用来服务注册和发现的

实战:服务间简单调用

Nacos环境搭建

参考

git clone https://gitee.com/test-qqqq/nacos-docker.git

我这里就直接把nacos-docker clone到chan项目了,但是,我把nacos-docker加入了git忽略列表,不提交。

之所以将nacos-docker放到chan中,只是为了将所有与项目有关的物料,都放在此。

cd nacos-docker
# 启动单机版,用于开发测试足矣,生产上如果是部署在阿里云环境,可以购买服务,也不需要自己构建
docker-compose -f example/standalone-mysql-5.7.yaml up -d
# 停止
docker-compose -f example/standalone-mysql-5.7.yaml down

不过,这个standalone-mysql-5.7.yaml,里面的镜像速度超有点慢,我把他们都上传到自己的镜像仓库了,然后修改yaml文件如下

version: "2"
services:
  nacos:
    image: registry.cn-hangzhou.aliyuncs.com/sherry/nacos-server:latest
    container_name: nacos-standalone-mysql
    env_file:
      - ../env/nacos-standlone-mysql.env
    volumes:
      - ./standalone-logs/:/home/nacos/logs
      - ./init.d/custom.properties:/home/nacos/init.d/custom.properties
    ports:
      - "8848:8848"
      - "9555:9555"
    depends_on:
      - mysql
    restart: on-failure
  mysql:
    container_name: mysql
    image: registry.cn-hangzhou.aliyuncs.com/sherry/nacos-mysql:5.7
    env_file:
      - ../env/mysql.env
    volumes:
      - ./mysql:/var/lib/mysql
    ports:
      - "3306:3306"
  prometheus:
    container_name: prometheus
    image: registry.cn-hangzhou.aliyuncs.com/sherry/prometheus:latest
    volumes:
      - ./prometheus/prometheus-standalone.yaml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"
    depends_on:
      - nacos
    restart: on-failure
  grafana:
    container_name: grafana
    image: registry.cn-hangzhou.aliyuncs.com/sherry/grafana:latest
    ports:
      - 3000:3000
    restart: on-failure

1598028159897

  • 验证

http://localhost:8848/nacos

默认账号密码均为nacos

image-20200822004419179

OpenFeign

  • 创建chan-auth和chan-user,通过OpenFeign,chan-auth调用chan-user

1598177656244

  • chan-user模块

1、编写一个正常的接口

@RequestMapping("/user")
@Slf4j
@RestController
public class UserController {

    @GetMapping(params = "userId")
    public R userInfo(@RequestParam String userId) {
        return R.ok("返回id为" + userId + "的用户信息");
    }

}

2、在api模块中暴露此接口

@FeignClient(name = "chan-user-svc")
public interface UserServiceApi {

    /**
     * 查询
     *
     * @param userId
     * @return
     */
    @GetMapping("/user")
    R user(@RequestParam(name = "userId") String userId);
}
  • chan-auth

1、引入user-api模块

        <dependency>
            <groupId>com.zhangln</groupId>
            <artifactId>chan-user-api</artifactId>
            <version>${project.parent.version}</version>
        </dependency>

2、auth模块调用user

1598177809150

3、auth模块中打开Feign支持

1598177840197

测试

  • 依次启动ChanAuthApplication和ChanUserApplication

image-20200823184318116

  • 调用接口

image-20200823184306702

实战:配置中心之Nacos

实战:网关

跨域问题

package com.zhangln.chan.gateway.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.util.pattern.PathPatternParser;

@Configuration
public class CorsConfig {
    @Bean
    public CorsWebFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedMethod("*");
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
        source.registerCorsConfiguration("/**", config);

        return new CorsWebFilter(source);
    }

路由规则配置

spring:
  application:
    name: chan-gateway-svc
  main:
    allow-bean-definition-overriding: true
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    gateway:
      discovery:
        locator:
          enabled: true
      routes:
        - id: auth_route
          uri: lb://chan-auth-svc
          predicates:
            - Path=/auth/**
        - id: user_route
          uri: lb://chan-user-svc
          predicates:
            - Path=/user/**

全局日志

/**
 * 日志记录
 *
 * @author sherry
 * @description
 * @date Create in 2020/8/22
 * @modified By:
 */
@Component
@Slf4j
public class LogFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        GatewayLogDto gatewayLogDto = new GatewayLogDto();
        gatewayLogDto.setId(UUID.randomUUID().toString().replace("-", ""));
        gatewayLogDto.setReqTime(LocalDateTime.now());

        log.info("{},日志过滤器--start", gatewayLogDto.getId());


        ServerHttpRequest serverHttpRequest = exchange.getRequest();
        ServerHttpResponse serverHttpResponse = exchange.getResponse();

        gatewayLogDto.setUri(serverHttpRequest.getURI().getRawPath());
        HttpHeaders headers = serverHttpRequest.getHeaders();
        gatewayLogDto.setHeaderMap(headers);

        gatewayLogDto.setHost(headers.getHost().getHostName());


        MultiValueMap<String, String> queryParams = serverHttpRequest.getQueryParams();
        gatewayLogDto.setParamMap(queryParams);

        String method = serverHttpRequest.getMethodValue();
        gatewayLogDto.setMethod(method);
        String contentType = serverHttpRequest.getHeaders().getFirst("Content-Type");

        //向headers中放文件,记得build
        ServerHttpRequest host = null;
        try {

            URI newUri = null;
//            对URL参数进行URL编码
            if (!Objects.isNull(queryParams)) {
                StringBuilder query = new StringBuilder();
                Set<String> keySet = queryParams.keySet();
                for (Iterator<String> iterator = keySet.iterator(); iterator.hasNext(); ) {
                    String k = iterator.next();
                    String v = java.net.URLEncoder.encode(queryParams.getFirst(k), "UTF-8");
                    query.append(k + "=" + v + "&");
                }
//                添加额外的param参数
                newUri = UriComponentsBuilder.fromUri(serverHttpRequest.getURI())
                        .replaceQuery(query.toString())
                        .build(true)
                        .toUri();
//                添加额外的请求头
                host = exchange.getRequest().mutate()
                        .uri(newUri)
                        .header("x-forwarded-for", Objects.isNull(gatewayLogDto.getUri()) ? "" : URLEncoder.encode(gatewayLogDto.getUri(), "UTF-8"))
                        .build();
            } else {
                host = exchange.getRequest().mutate()
                        .header("x-forwarded-for", Objects.isNull(gatewayLogDto.getUri()) ? "" : URLEncoder.encode(gatewayLogDto.getUri(), "UTF-8"))
                        .build();
            }


        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        //将现在的request 变成 change对象
        ServerWebExchange.Builder builder = exchange.mutate().request(host);
        ServerHttpResponseDecorator decoratedResponse = getServerHttpResponseDecorator(gatewayLogDto, serverHttpResponse);
        ServerWebExchange build = builder.response(decoratedResponse).build();

        log.info("{},日志过滤器--end", gatewayLogDto.getId());
        return chain.filter(build);
    }

    /**
     * 处理返回
     *
     * @param gatewayLogDto
     * @param serverHttpResponse
     * @return
     */
    private ServerHttpResponseDecorator getServerHttpResponseDecorator(GatewayLogDto gatewayLogDto, ServerHttpResponse serverHttpResponse) {
        DataBufferFactory bufferFactory = serverHttpResponse.bufferFactory();

        return new ServerHttpResponseDecorator(serverHttpResponse) {
            @Override
            public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
                if (body instanceof Flux) {
                    Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body;
                    return super.writeWith(fluxBody.map(dataBuffer -> {
                        // probably should reuse buffers
                        byte[] content = new byte[dataBuffer.readableByteCount()];
                        dataBuffer.read(content);
                        //释放掉内存
                        DataBufferUtils.release(dataBuffer);

//                        返回二进制流  二进制与普通json要分开处理
                        if (Objects.equals("/auth/v1/auth/get_validate_code", gatewayLogDto.getUri())) {
                            printGatewayLog(gatewayLogDto);
                            return bufferFactory.wrap(content);
                        } else {
//                            返回普通json
                            String resStr = new String(content, Charset.forName("UTF-8"));
                            gatewayLogDto.setResBody(resStr);
                            gatewayLogDto.setResTime(LocalDateTime.now());
                            byte[] uppedContent = resStr.getBytes();
                            printGatewayLog(gatewayLogDto);
                            return bufferFactory.wrap(uppedContent);
                        }
                    }));
                }
                // if body is not a flux. never got there.
                return super.writeWith(body);
            }
        };
    }

    /**
     * 处理网关日志,一般简单打印或发送MQ
     *
     * @param gatewayLogDto
     */
    private void printGatewayLog(GatewayLogDto gatewayLogDto) {
        log.info("{},{},{},网关日志:{},耗时约:{} 毫秒", gatewayLogDto.getId(), gatewayLogDto.getMethod(), gatewayLogDto.getUri(), gatewayLogDto, (Duration.between(gatewayLogDto.getReqTime(), gatewayLogDto.getResTime())).toMillis());
    }

    @Override
    public int getOrder() {
//        注意order要小于-1,就能查看服务端响应的值了
        return -99;
    }
}

实战:Sentinel:熔断与流控

参考:https://github.com/alibaba/spring-cloud-alibaba/wiki/Sentinel

Sentinel控制台环境搭建

1、下载jar

https://github.com/alibaba/Sentinel/releases

2、找到下载地址

https://github.com/alibaba/Sentinel/releases/download/v1.8.0/sentinel-dashboard-1.8.0.jar

3、运行jar

#!/usr/bin/env bash
nohup java -Dserver.port=8888 -Dcsp.sentinel.dashboard.server=localhost:8888 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard-1.8.0.jar > Sentilel.log &

4、登陆

http://localhost:8888

登陆的用户名密码均为:sentinel

1598184832945

  • 其他启动参数
-Dsentinel.dashboard.auth.username=sentinel
-Dsentinel.dashboard.auth.password=sentinel
-Dsentinel.servlet.session.timeout=7200
指定SpringBoot服务端session的过期时间,单位秒
60m,则表示60分钟
默认值为30分钟
  • 扩展

如果使用jar启动麻烦,我就把他做成镜像供大家使用

FROM registry.cn-hangzhou.aliyuncs.com/sherry/java:8-jdk232-with-skywaling-agent
COPY ./sentinel-dashboard-1.8.0.jar /usr/app/sentinel-dashboard.jar

WORKDIR /usr/app

ENV TZ='Asia/Shanghai'
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' > /etc/timezone
ENV JAVA_OPTS='-Xms1024m -Xmx1024m'

# Skywalking相关配置
ENV SW_AGENT_NAME='sentinel-dashboard'
ENV SAMPLE_N_PER_3_SECS=1500
ENV SW_AGENT_OPEN_DEBUG=false
ENV SW_LOGGING_MAX_HISTORY_FILES=1

ENV JAVA_AGENT_OPTS='-javaagent:/skywalking/skywalking-agent.jar'

ENTRYPOINT java -Dserver.port=8888 -Dcsp.sentinel.dashboard.server=localhost:8888 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard.jar
# 打包
docker build -t registry.cn-hangzhou.aliyuncs.com/sherry/sentinel-dashboard:1.8.0 .

# 启动
docker run --restart=always -d -p 8888:8888 --name sentinel-dasbboard registry.cn-hangzhou.aliyuncs.com/sherry/sentinel-dashboard:1.8.0

Feign调用超时与降级

  • pom

在auth和user模块都加入pom

<dependency>
  <groupId>com.alibaba.cloud</groupId>
  <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

其实在微服务体系中,基本上所有应用都是Sentinel客户端,都加入依赖即可

  • yaml
spring:
  application:
    name: chan-auth-svc
  main:
    allow-bean-definition-overriding: true
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    sentinel:
      transport:
        dashboard: localhost:8888
server:
  port: 10001
management:
  #  端点检查(健康检查)
  endpoints:
    web:
      exposure:
        exclude: "*"
feign:
  sentinel:
    enabled: true

1598185155732

  • fallback
package com.zhangln.chan.user.api.fallback;

import com.zhangln.chan.common.vo.R;
import com.zhangln.chan.user.api.UserServiceApi;
import org.springframework.stereotype.Component;

/**
 * @author sherry
 * @description
 * @date Create in 2020/8/23
 * @modified By:
 */

@Component
public class UserServiceFallback implements UserServiceApi {
    @Override
    public R user(String userId) {
        return R.error("调用/user失败,参数 userId:" + userId);
    }

    @Override
    public R testSentinel(Integer timeout) {
        return R.error("调用/user/sentinel失败,参数 timeout:" + timeout);
    }
}

package com.zhangln.chan.user.api;

import com.zhangln.chan.common.vo.R;
import com.zhangln.chan.user.api.fallback.UserServiceFallback;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

/**
 * @author sherry
 * @description
 * @date Create in 2020/8/22
 * @modified By:
 */
@FeignClient(name = "chan-user-svc", fallback = UserServiceFallback.class)
public interface UserServiceApi {

    /**
     * 查询
     *
     * @param userId
     * @return
     */
    @GetMapping("/user")
    R user(@RequestParam(name = "userId") String userId);

    /**
     * 测试Sentinel
     *
     * @param timeout
     * @return
     */
    @GetMapping("/user/sentinel")
    R testSentinel(@RequestParam(name = "timeout") Integer timeout);
}

注意:UserServiceFallback编写后,要配置到spring.factories中,否则auth模块无法找到

1598188229617

  • 调用

我们找一个Feign调用的接口进行调用,然后就能在sentinel控制台看到我们的Feign客户端注册上来了

1598188081099

注意:一定要多调用几次接口,才能在控制台看到我们的应用

  • 验证熔断

1598189132850

这个是正常的

1598189162902

这个由于超时时间过长,就触发了熔断。

并且从user模块的日志来看,Feign在调用的时候应该是发起了超时重试的。

这里我猜测有一个默认配置,就是Feign调用默认超时时间为1秒,超时后重试1次

网关调用超时与降级

  • pom
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>
  • yaml

1598189961424

小结

Sentinel的详细使用与配置,后续再进行深入

实战:Dubbo基本使用

  • 为什么要引入Dubbo?

当微服务体系足够大了之后,除了对外暴露的服务,使用Http协议,内部服务间调用,最好还是使用性能更加高的。

而Dubbo,就是其中比较好的。

  • 案例:auth模块编写一个接口供gateway调用

pom

  • 在生产者和消费者中,都添加以下pom
        <!-- Apache Dubbo -->
        <dependency>
            <groupId>org.apache.dubbo</groupId>
            <artifactId>dubbo</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.nacos</groupId>
            <artifactId>nacos-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.dubbo</groupId>
            <artifactId>dubbo-registry-nacos</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.dubbo</groupId>
            <artifactId>dubbo-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.dubbo</groupId>
            <artifactId>dubbo-serialization-kryo</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>commons-logging</groupId>
                    <artifactId>commons-logging</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>log4j</groupId>
                    <artifactId>log4j</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.apache.dubbo</groupId>
                    <artifactId>dubbo-common</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>com.alibaba.spring</groupId>
            <artifactId>spring-context-support</artifactId>
        </dependency>

生产者

  • 编写一个普通接口
public interface AuthServiceDubbo {

    R getTime(String msg);

}
  • 实现这个接口
package com.zhangln.chan.auth.service;

import com.zhangln.chan.auth.api.AuthServiceDubbo;
import com.zhangln.chan.common.vo.R;
import org.apache.dubbo.config.annotation.Service;

import java.time.LocalDateTime;

/**
 * @author sherry
 * @description
 * @date Create in 2020/8/24
 * @modified By:
 */
@Service(version = "1.0.0")
public class AuthServiceDubboImpl implements AuthServiceDubbo {
    @Override
    public R getTime(String msg) {
        return R.ok("当前时间"+msg+":"+ LocalDateTime.now());
    }
}

并通过@Service注解暴露服务

  • yaml
dubbo:
  scan:
    base-packages: com.zhangln.chan.auth.service
  protocol:
    name: dubbo
    port: -1
    serialization: kryo
  registry:
    address: nacos://localhost:8848
  provider:
    loadbalance: roundrobin

消费者

  • 当做普通Service使用即可
package com.zhangln.chan.gateway.service;

import com.zhangln.chan.auth.api.AuthServiceDubbo;
import com.zhangln.chan.common.vo.R;
import org.apache.dubbo.config.annotation.Reference;
import org.springframework.stereotype.Service;

/**
 * @author sherry
 * @description
 * @date Create in 2020/8/24
 * @modified By:
 */
@Service
public class AccessService {
    @Reference(version = "1.0.0")
    private AuthServiceDubbo authServiceDubbo;

    public R getTime(String msg) {
        R r = authServiceDubbo.getTime(msg);
        return r;
    }
}

  • yaml
management:
  health:
    dubbo:
      status:
        defaults: memory
        extras: threadpool
dubbo:
  scan:
    base-packages: com.zhangln.chan.gateway.service
  protocol:
    name: dubbo
    port: -1
    serialization: kryo
  registry:
    address: nacos://localhost:8848

验证

image-20200824145650174

启动后,我们可以在Nacos中看到这两个服务

实战:Web开发基础

通用返回对象

全局异常

package com.zhangln.chan.common.web;

import com.zhangln.chan.common.vo.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * @author sherry
 * @description
 * @date Create in 2020/8/22
 * @modified By:
 */
@RestControllerAdvice
@Slf4j
public class ControllerCheckAdvice {

    /**
     * 全局异常兜底
     *
     * @param e
     * @return
     */
    @ExceptionHandler(Throwable.class)
    public R globalError(Throwable e) {
        log.error("全局异常:", e);
        return R.error(e.getMessage());
    }
}


org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.zhangln.chan.common.web.ControllerCheckAdvice

自定义异常

  • 异常类
public class ChanError extends Throwable {
    private Integer code;
    private String message;

    public ChanError(Integer code, String message) {
        super(message);
        this.code = code;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }
}
  • 全局捕获
@RestControllerAdvice
@Slf4j
public class ControllerCheckAdvice {

    /**
     * 全局异常兜底
     *
     * @param e 异常
     * @return R 返回
     */
    @ExceptionHandler(Throwable.class)
    public R globalError(Throwable e) {
        log.error("全局异常:", e);
        return R.error(e.getMessage());
    }

    @ExceptionHandler(ChanError.class)
    public R chanError(ChanError e) {
        log.error("全局异常:", e);
        return R.error(e);
    }
}

AOP日志

package com.zhangln.chan.common.web;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;

/**
 * @author sherry
 * @description
 * @date Create in 2020/8/22
 * @modified By:
 */
@Aspect
@Component
@Slf4j
public class ReqResAop {

    @Pointcut("execution(public * com.zhangln.chan.*.controller.*.*(..))")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint pjp) {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        String uri = request.getRequestURI();

        log.info("AOP拦截:{}", uri);

        Object proceed = null;
        try {
            proceed = pjp.proceed();
        } catch (Throwable e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException("AOP异常:" + e.getMessage());
        }
        return proceed;
    }
}


org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.zhangln.chan.common.web.ControllerCheckAdvice,\
  com.zhangln.chan.common.web.ReqResAop

配置文件

  • 假设在yaml文件中有这么几项要注入
chan-gateway:
  ignore:
    - /auth/pub/**
    - /user/pub/**
  • 在common中创建类
@Component
@Data
@Configuration
@ConfigurationProperties(prefix = "chan-gateway")
public class ChanGatewayConfig {

    private List<String> ignore;

}
  • 在需要用到的地方添加
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.zhangln.chan.common.prop.ChanGatewayConfig

包分层规则与领域对象

  • 每一个package下,都创建一个package-info.java,说明此package的作用
  • 开放接口层
    • Controller
    • Api/feign/dubbo
    • VO/BO/Query

如果是对外的,那一般就是Controller接口层;

如果是内部调用的,那一般是有一个单独的api包,通过feign或dubbo的方式,供内部系统调用;

内部系统引入对应api的pom后,像调用本地方法一样调用集群中其他应用的api

请求参数为Query对象

返回数据封装成VO对象(Controller接口);如果是Dubbo调用,即将Service暴露服务,则返回BO对象

bT8FKd

一个Service对应一个Controller,一个Manager,更多的是通用业务层的复用;

各个层级之间的领域对象传输类型,如箭头所示。

也不一定非要按照上述层级编写代码,最终目的就是让每一层级的代码各司其事,让代码更加简介易读有序,方便重构优化,便于小步快速迭代。

实战:基本权限系统设计

基本思路

  • 不打算使用Shiro或Spring Security框架
    • 理由一:自己写更加灵活
    • 理由二:Spring Security太复杂了,扩展不方便;Shiro在分布式系统中集成有点麻烦

总之,综合起来,还是自己实现一套吧,也不是什么特别复杂的东西。RBAC和OAuth2.0都是很成熟的协议

  • 目标

第一阶段:仅针对需要认证的接口校验token是否合法即可,至于token是否有权限访问,不管;仅账号密码模式,密码不加密

第二阶段:添加上token和接口之间的权限校验;其他更加特性的校验,如:token和访问目标租户之间的数据权限,放到具体接口中单独校验;支持多种认证模式;认证信息加密

不使用JWT传输数据

认证授权流程设计

1、请求到网关

2、网关通过Dubbo访问chan-auth-svc

3、chan-auth-svc判断本次请求是否允许

4、网关通过chan-auth-svc的请求结果决定是否正常路由

表结构设计

h4ZnFw

就设计了两张表,太简单了,以至于都不想讲了

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for t00_token_validate
-- ----------------------------
DROP TABLE IF EXISTS `t00_token_validate`;
CREATE TABLE `t00_token_validate` (
  `id` bigint(20) NOT NULL,
  `username` varchar(127) COLLATE utf8mb4_bin NOT NULL,
  `token` varchar(127) COLLATE utf8mb4_bin NOT NULL,
  `exp_time` datetime NOT NULL COMMENT '过期时间',
  `create_dt` datetime DEFAULT CURRENT_TIMESTAMP,
  `update_dt` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `t00_token_validate_pk_2` (`username`),
  UNIQUE KEY `t00_token_validate_token_uindex` (`token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='token校验';

-- ----------------------------
-- Table structure for t00_user_auth
-- ----------------------------
DROP TABLE IF EXISTS `t00_user_auth`;
CREATE TABLE `t00_user_auth` (
  `id` bigint(20) NOT NULL,
  `username` varchar(127) COLLATE utf8mb4_bin DEFAULT '' COMMENT '账号',
  `pwd` varchar(255) COLLATE utf8mb4_bin DEFAULT '' COMMENT '密码',
  `create_dt` datetime DEFAULT CURRENT_TIMESTAMP,
  `update_dt` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `t00_user_auth_username_uindex` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='用户认证信息';

SET FOREIGN_KEY_CHECKS = 1;

整合MyBatis-Plus

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
  • 执行自动代码生成工具,进行代码生成
  • 配置数据库连接信息
spring:
  application:
    name: chan-auth-svc
  main:
    allow-bean-definition-overriding: true
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
#      config:
#        server-addr: localhost:8848
    sentinel:
      transport:
        dashboard: localhost:8888
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
#    注意,生产环境要加密
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/chan_auth?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
    hikari:
      # 是否允许暂停连接池,暂停后getConnection方法都会被阻塞,用于模拟连接故障,生产环境使用默认配置false
      allow-pool-suspension: false
      #      空闲连接数
      minimum-idle: ${DB_IDLE:1}
      #      最大连接数
      maximum-pool-size: ${DB_POOLSIZE:5}
      connection-test-query: select 1
      #      一个连接idle状态的最大时长(毫秒),超时则被释放(retired),缺省:10分钟
      idle-timeout: 100000
      #      一个连接的生命时长(毫秒),超时而且没被使用则被释放(retired),缺省:30分钟,建议设置比数据库超时时长少30秒
      max-lifetime: 120000
      connection-timeout: 30000
      connection-init-sql: set names utf8mb4
      pool-name: DatebookHikariCP
  • MyBatis配置
package com.zhangln.chan.auth.config;

import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator;
import com.baomidou.mybatisplus.extension.plugins.OptimisticLockerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import com.zhangln.chan.common.id.SnowIdDto;
import com.zhangln.chan.common.id.SnowManager;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * @author sherry
 * @description
 * @date Create in 2020/8/3
 * @modified By:
 */
@EnableTransactionManagement
@Configuration
@MapperScan(basePackages = "com.zhangln.chan.**.mapper")
@Slf4j
public class MyBatisPlusConfig {

    @Value("${spring.application.name:chan-auth-svc}")
    private String appName;

    private final SnowManager snowManager;

    public MyBatisPlusConfig(SnowManager snowManager) {
        this.snowManager = snowManager;
    }

    /**
     * 乐观锁
     *
     * @return
     */
    @Bean
    public OptimisticLockerInterceptor optimisticLockerInterceptor() {
        return new OptimisticLockerInterceptor();
    }

    /**
     * 分页插件
     */
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
        return paginationInterceptor;
    }

    @Bean
    public IdentifierGenerator idGenerator() {
        SnowIdDto snowId = snowManager.getSnowId(appName);
        log.info("{} 使用雪花算法作为id生成策略:{}", appName, snowId);
        return new DefaultIdentifierGenerator(snowId.getWorkerId(), snowId.getDataId());
    }
}

  • 解决雪花算法可能导致的id重复问题

详见:SnowManager类

  • 测试方法
@SpringBootTest(classes = ChanAuthApplication.class)
@RunWith(SpringRunner.class)
public class T00UserAuthDaoTest {

    @Autowired
    private T00UserAuthDao t00UserAuthDao;

    /**
     * 测试新增
     */
    @Test
    public void testSaveUserAuth() {
        T00UserAuthEntity t00UserAuthEntity = new T00UserAuthEntity();
        t00UserAuthEntity.setUsername("admin");
        t00UserAuthEntity.setPwd("123456");
        t00UserAuthDao.save(t00UserAuthEntity);
    }
}

image-20200824174424722

核心代码逻辑

  • 获取token:供客户端调用,http协议
    @PostMapping(value = "/token", params = "type=get")
    public R getTokenByUsernameAndPassword(@Validated GetTokenQuery getTokenQuery) {
        TokenVo tokenVo = authService.getTokenByUsernameAndPassword(getTokenQuery.getUsername(), getTokenQuery.getPwd());
        return R.ok(tokenVo);
    }
  • 校验token:供网关调用,dubbo协议

public interface AuthServiceDubbo {

    /**
     * 校验token是否有效
     *
     * @param token
     * @param uri
     * @return
     */
    R<TokenCheckResultBo> checkToken(String token, String uri);

}

这里的校验比较简单,我只校验token本身是否合法

网关在校验之前,先判断当前uri是否允许匿名调用,

允许匿名调用的uri都配置在网关的yaml中

后续可以配置到数据库中,程序启动的时候加载进缓存

实战:分布式事务之Seata

前置准备

既然是分布式事务,那么我们至少得有两个数据库,一个就是前面认证授权的chan_auth库,另一个我们就为chan_user创建一个库,并且建一张t00_user_info表

并且整合MyBatis-Plus

再创建一个chan_log应用和数据库

最终是,auth作为调用方调用user和log模块

部署Seata

这里就只讲操作,原理就不细究了,等下一章节再说

Tw0cSn

执行命令即可

docker-compose up -d

bYMVhu

启动成功

  • 在所有需要分布式事务的数据库上,都创建以下表
CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

Dubbo

  • Dubbo案例,auth正常调用user和auth异常调用user,触发回滚
  • 先用JDBC演示,再用MyBatis演示

1、编写Dubbo接口

2、配置Seata,即 file.conf和registry.conf

3、配置yml

4、配置SeataConfiguration(Seata参与者和管理者都要配置)

  • 参与者的SeataConfiguration
package com.zhangln.chan.log.config;

import com.zaxxer.hikari.HikariDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import io.seata.spring.annotation.GlobalTransactionScanner;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;

import javax.sql.DataSource;

@Configuration
public class SeataConfiguration {

    @Value("${spring.application.name}")
    private String appName;

    @Bean("hikariDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.hikari")
    public HikariDataSource hikariDataSource() {
        HikariDataSource hikariDataSource = new HikariDataSource();
        return hikariDataSource;
    }

    /**
     * 将本地数据源交给Seata进行管理
     *
     * @param hikariDataSource
     * @return
     */
    @Primary
    @Bean("dataSource")
    public DataSourceProxy dataSourceProxy(DataSource hikariDataSource) {
        return new DataSourceProxy(hikariDataSource);
    }

    /**
     * 因为数据源交给了Seata进行代理,所以和数据源相关的一系列对象,都需要自己进行构建,并且需要排除自动配置
     *
     * @param dataSource
     * @return
     */
    @Bean
    @ConditionalOnBean(DataSourceProxy.class)
    public JdbcTemplate jdbcTemplate(DataSourceProxy dataSource) {
        JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
        return jdbcTemplate;
    }

    @Bean
    public GlobalTransactionScanner globalTransactionScanner() {
        return new GlobalTransactionScanner(appName, "tx_group");
    }

}
  • 如果我们使用MyBatis操作数据库,而不是JDBCTemplate,则使用DataSourceProxy重新初始化一下SQLSessionFactory即可
    @Bean
    public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
        SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        factoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath*:/mapper/*.xml"));
        return factoryBean.getObject();
    }
  • 如果我们使用MyBatis-Plus,则更加方便,只需配置好DataSourceProxy即可

Feign

  • 发起者添加配置
package com.zhangln.seata.feign.main.config;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.context.annotation.Configuration;

import feign.RequestInterceptor;
import feign.RequestTemplate;
import io.seata.core.context.RootContext;

/**
 * 内部调用需要token校验,这边将页面端的token和平台属性转发给feign
 */
@Configuration
@Slf4j
public class FeignConfig implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {

        String xid = RootContext.getXID();
        if (StringUtils.isNotBlank(xid)) {
            log.info("feign 获得分布式事务xid:" + xid);
        }

        requestTemplate.header(RootContext.KEY_XID, xid);
    }
}
  • 写作者添加配置
package com.zhangln.seata.feign.db2.config;


import io.seata.core.context.RootContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;

import org.slf4j.Logger;

import org.slf4j.LoggerFactory;

import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;


import javax.servlet.FilterChain;

import javax.servlet.ServletException;

import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpServletResponse;

import java.io.IOException;

@Slf4j
@Component
public class FescarXidFilter extends OncePerRequestFilter {

    protected Logger logger = LoggerFactory.getLogger(FescarXidFilter.class);


    @Override

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String xid = RootContext.getXID();

        String restXid = request.getHeader(RootContext.KEY_XID);
        log.info("设置 Xid:{}",restXid);

        boolean bind = false;

        if (StringUtils.isBlank(xid) && StringUtils.isNotBlank(restXid)) {

            RootContext.bind(restXid);

            bind = true;

            if (logger.isDebugEnabled()) {

                logger.debug("bind[" + restXid + "] to RootContext");

            }

        }

        try {

            filterChain.doFilter(request, response);

        } finally {

            if (bind) {

                String unbindXid = RootContext.unbind();

                if (logger.isDebugEnabled()) {

                    logger.debug("unbind[" + unbindXid + "] from RootContext");

                }

                if (!restXid.equalsIgnoreCase(unbindXid)) {

                    logger.warn("xid in change during http rest from " + restXid + " to " + unbindXid);

                    if (unbindXid != null) {

                        RootContext.bind(unbindXid);

                        logger.warn("bind [" + unbindXid + "] back to RootContext");

                    }
                }
            }
        }
    }
}
  • 参考

https://www.zhangln.com/2020/05/14/fen-bu-shi-kai-fa-yu-wei-fu-wu/fen-bu-shi-shi-wu-jie-jue-fang-an-zhi-seata/


版权声明:本文为m0_37208669原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。