Steve's Blog

Talk is cheap, show me the code.

0%

如何实现分布式Session-下?

image-20220912000517236

上文简单介绍了Cookie和Session,以及为什么需要分布式Session,本文介绍具体实现。 image-20220916010212956

我们打算用docker-compose.yml实现以上服务端结构。为了实现分布式session,我们先需要完成以下工作:

  • SpringBoot通过redis保存session&登录功能
  • Dockerfile构建单个tomcat(SpringBoot)服务
  • 编写docker-compose.yml实现分布式session功能:编排nginx、tomcat和redis

1. SpringBoot通过redis保存session&登录功能

登录流程如下:

  1. 先创建SpringBoot空应用,其中添加一下要用到的功能:SpringWeb(页面登录)、Redis(存储session)、SpringSession(由Spring实现)

功能实现是简单的,要点如下:

  • 登录页面和登录后的页面
  • 登录用的controller接口
  • 鉴权用的Filter
  • 集成redis和SpringSession

实现后文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
├── pom.xml
└── src
├── main
│   ├── java
│   │   └── my
│   │   └── seckill
│   │   ├── AuthFilter.java # 实现登录功能的校验 和未登录时重定向
│   │   ├── UserService.java # 用户登录服务,用于校验提交上来的账号和密码
│   │   ├── LoginController.java # 登录使用的RESTful API和页面mapping方法
│   │   ├── RedisConfig.java # Redis配置类
│   │   └── SeckillProjectApplication.java # 启动类
│   └── resources
│   ├── application.properties # 配置文件
│   ├── static # 静态文件、页面文件夹
│   └── templates # thymeleaf动态页面模板文件夹
│   ├── login.html # 登录页面
│   └── main.html #登录后主页面

此处需要注意resource文件夹下两个文件夹statictemplates的区别:

  • 功能不同:static文件夹存放静态资源,例如html、css、js文件等;templates文件夹存放thymeleaf的动态模板文件,一般也是html(如果使用,必须在POM文件中引入thymeleaf的dependency)

  • 使用方式不同:如果想用controller的mapping方法返回视图,static文件夹下需要方法返回文件全名,例如想要返回static文件夹下order.html,controller方法需要这么实现:

    1
    2
    3
    4
    5
    @GetMapping("/order")
    public String getOrderPage() {
    // 这里必须写全名
    return "order.html";
    }

    相同的文件名,如想要返回templates文件夹下order.html,首先需要在pom中引入spring-boot-starter-thymeleaf

    1
    2
    3
    4
    5
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
    <version>${thymeleaf.version}</version>
    </dependency>

    然后实现controller的mapping方法:

    1
    2
    3
    4
    5
    @GetMapping("/order")
    public String getOrderPage() {
    // 这里只写文件名称,不写后缀
    return "order";
    }

    这里能如此实现是因为spring-boot-autoconfigure设定好了2个配置项spring.thymeleaf.prefixspring.thymeleaf.suffix。具体实现在ThymeleafProperties类下:

    image-20221003002457596

    接下来看下具体代码实现:

    AuthFilter.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    package my.seckill;

    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;

    import javax.servlet.Filter;
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.ServletRequest;
    import javax.servlet.ServletResponse;
    import javax.servlet.http.Cookie;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.time.LocalDateTime;
    import java.util.Arrays;

    /**
    * 登录权限校验Filter
    **/
    @Component
    @Slf4j
    public class AuthFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) servletRequest;
    Cookie[] cookies = request.getCookies();
    String requestURI = request.getRequestURI();
    String requestMethod = request.getMethod();
    log.info(LocalDateTime.now() + " 收到请求:" + requestMethod + " " + requestURI);
    // 静态资源不过滤
    if (requestURI.endsWith(".css") || requestURI.endsWith(".js") || requestURI.endsWith(".ico")) {
    filterChain.doFilter(servletRequest, servletResponse);
    return;
    }
    // 如果cookie为空,证明是第一次请求,那么肯定没登录;
    // 如果登录成功后,会写入一个名叫login_user的cookie和名叫user_session_id的session
    boolean isUserLoggedIn = cookies != null && Arrays.stream(cookies).anyMatch(x -> x.getName().equals("login_user"))
    && request.getSession().getAttribute("user_session_id") != null;
    if ("/exit".equals(requestURI)) {
    // 包含两种情况:已经登录 / 其它页面退出登录或者登录超时
    // 如果已经登录而且要退出登录的话,调用对应业务逻辑
    filterChain.doFilter(servletRequest, servletResponse);
    return;
    }
    // 如果未登录,而且页面不是登录页面就转到登录页面
    if (!isUserLoggedIn) {
    // 此处会有2种方法 GET & POST
    if ("/login".equals(requestURI)) {
    filterChain.doFilter(servletRequest, servletResponse);
    } else {
    // forward和redirect的区别:
    // servletRequest.getRequestDispatcher("/login").forward(servletRequest, servletResponse);
    ((HttpServletResponse) servletResponse).sendRedirect("/login");
    }
    } else {
    if ("/user".equals(requestURI)) {
    filterChain.doFilter(servletRequest, servletResponse);
    } else {
    ((HttpServletResponse) servletResponse).sendRedirect("/user");
    }
    }
    }

    }

    关于Cookie的几种操作:

    登录时添加Cookie,传送到前端:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    @PostMapping("/login")
    public @ResponseBody String doLogin(@RequestBody LoginInfo loginInfo, HttpServletRequest request, HttpServletResponse response) {
    if (loginInfo == null) {
    return "No login info found!";
    }
    boolean loginPassed = userService.checkUserAndPassword(loginInfo.getUser(), loginInfo.getPassword());
    if (!loginPassed) {
    return "Login failed!";
    }
    log.info(loginInfo.toString());
    HttpSession session = request.getSession();
    session.setAttribute("user_session_id", MD5Encoder.encode(loginInfo.user.getBytes()));
    Cookie cookie = new Cookie("login_user", loginInfo.user);
    cookie.setPath("/");
    cookie.setDomain("localhost");
    // 如果设置55min
    if (loginInfo.rememberMe) {
    cookie.setMaxAge(300);
    } else {
    // 浏览器退出即删除
    cookie.setMaxAge(-1);
    }
    response.addCookie(cookie);
    return "Success";
    }

    退出登录时删除Cookie

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @DeleteMapping("exit")
    public @ResponseBody String exitLogin(HttpServletRequest request, HttpServletResponse response) {
    Cookie loginUserCookie = Arrays.stream(request.getCookies())
    .filter(x -> x.getName().equals("login_user")).findFirst().orElse(null);
    if (loginUserCookie == null) {
    return "Not logged in!";
    }
    // Cookie没有方法删除,所以通过这种方式来删除cookie
    loginUserCookie.setValue(null);
    // 设置maxAge为0则直接删除cookie
    loginUserCookie.setMaxAge(0);
    response.addCookie(loginUserCookie);
    return "Exit success";
    }

    检查Cookie是否存在,如果存在进入到user页面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @GetMapping("/user")
    public ModelAndView userDetail(HttpServletRequest request) {
    Optional<Cookie> loginUser = Arrays.stream(request.getCookies())
    .filter(x -> x.getName().equals("login_user")).findFirst();
    if (loginUser.isEmpty()) {
    return new ModelAndView("login");
    }
    String value = loginUser.get().getValue();
    ModelAndView modelAndView = new ModelAndView("user");
    modelAndView.addObject("name", value);
    return modelAndView;
    }

    以上是基本的登录功能,完整代码参见此处

    通过redis储存session非常简单,分以下几步:

    1. 本地需要先启动redis服务
    2. 在项目pom.xml中引入session-redis和data-redis的dependency;
    3. 再在application.properties中加入Redis的配置,
    4. 启动应用即可把session存入到Redis中
    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
    </dependency>
    1
    2
    3
    4
    5
    6
    spring.redis.host=localhost
    spring.redis.port=6379
    spring.redis.database=0
    # 如果Redis没密码就不用设置
    # spring.redis.password=abc
    server.servlet.session.persistent=true

2. Dockerfile构建单个tomcat(SpringBoot)服务

1
2
3
4
5
6
7
FROM openjdk:11
MAINTAINER Steve HU

COPY target/seckill-project-0.0.1-SNAPSHOT.jar /usr/local/jar/seckill-project-0.0.1-SNAPSHOT.jar
# 使用docker环境的配置
#CMD ["java", "-jar", "/usr/local/jar/seckill-project-0.0.1-SNAPSHOT.jar", "--spring.profiles.active=docker"]
CMD ["java", "-jar", "/usr/local/jar/seckill-project-0.0.1-SNAPSHOT.jar", "--spring.profiles.active=docker-compose"]

构建运行前脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash

# 本地编译代码并打包
mvn compile package -DskipTests=true

# 构建最新的image
docker build -t seckill_img:latest .

# 停止可能在运行的container
docker stop seckill

# 删除可能存在的container
docker rm seckill

# The --rm option means to remove the container once it exits/stops.
# The -d flag means to start the container detached (in the background).
docker run -d -p 8080:8080 -p 6379:6379 --name seckill seckill_img

3. 编写docker-compose.yml实现分布式session功能

docker-compose.yml核心概念:

  • service 服务:一个个应用实例,例如mysql容器,nginx容器,订单微服务等
  • project 工程:由一组关联的服务组成的一个完整业务单元,在docker-compose.yml中定义。

使用docker-compose的步骤:

  • 编写Dockerfile构建单个服务的镜像
  • 使用docker-compose.yml定义一个完整业务单元,安排好整体应用中的各个容器服务
  • 执行docker-compose up命令启动整个工程,完成部署上线

服务列表:

  • springboot * 3
  • nginx
  • redis

docker-compose.yml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
version: '2'

# 构建自定义bridge网络,所有container都通过此网络进行通信
networks:
my-network:
driver: bridge

services:
# nginx服务,负责负载均衡
nginx_lbs:
container_name: nginx_lbs
image: 'nginx:latest'
ports:
- "8080:80"
# 本地编写conf文件,对nginx进行配置
volumes:
- './nginx.conf:/etc/nginx/nginx.conf:ro'
networks:
- my-network
# 依赖于3个app和redis
depends_on:
- my_app_1
- my_app_2
- my_app_3
- redis
# debug mode https://github.com/docker-library/docs/tree/master/nginx#using-environment-variables-in-nginx-configuration
# 开启debug模式
command: [ nginx-debug, '-g', 'daemon off;' ]
redis:
container_name: redis_db
image: 'redis:latest'
# 使用expose关键字的话,同network的container可以访问,但是host机器访问不了
expose:
- 6379
environment:
- ALLOW_EMPTY_PASSWORD=yes
networks:
- my-network
my_app_1:
container_name: app_1
image: 'seckill_img'
# 使用ports关键字的话,同network的container可以访问,host机器也可以访问
# 此处使用ports而不是expose是为了方便debug
# expose:
# - "8080"
ports:
- "8081:8080"
networks:
- my-network
my_app_2:
container_name: app_2
image: 'seckill_img'
# 使用ports关键字的话,同network的container可以访问,host机器也可以访问
# expose:
# - "8080"
ports:
- "8082:8080"
networks:
- my-network
depends_on:
- redis
my_app_3:
container_name: app_3
image: 'seckill_img'
# 使用ports关键字的话,同network的container可以访问,host机器也可以访问
# expose:
# - "8080"
ports:
- "8083:8080"
networks:
- my-network
depends_on:
- redis

其中nginx配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# https://nginx.org/en/docs/beginners_guide.html
worker_processes 1;

events {
worker_connections 1024;
}

http {
include mime.types;
default_type application/octet-stream;

sendfile on;

keepalive_timeout 65;

#增加upstream,配置多个tomcat,其中权重均为1
upstream tomcatcluster {
#my_app_1
server my_app_1:8080 weight=1;
#my_app_2
server my_app_2:8080 weight=1;
#my_app_3
server my_app_3:8080 weight=1;
}
server {
listen 80;
server_name localhost;

location / {
# 此处url最后有无'/'是有区别的
proxy_pass http://tomcatcluster;
# https://developer.aliyun.com/article/248429
proxy_redirect http://tomcatcluster http://localhost:8080;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}

完整代码参见此处

4. 思考:Cookie和Session还有必要吗?

待续。

5. 其它

下载所有本地jar的documentation:

1
mvn dependency:resolve -Dclassifier=sources