前言

学科竞赛训练管理系统的前端使用 React 创建组件,使用 Nginx 解决跨域问题;后端使用 Spring Boot 搭建项目,使用 Spring Security 保证安全,使用 Spring Data JPA 访问 PostgreSQL,使用 Redis 作为系统缓存。

学科竞赛训练管理系统是我的毕业设计。本文内容因为删掉了与我个人和学校有关的隐私内容,测试和系统设计可能描述的不是很完整。

因为是用 3 天就完成了 90% 前后端开发工作,因此代码不是很简洁,出现了大量重复、冗余代码,尽情谅解。

虽然开发时间很充裕,但我并不想在毕业设计上花太多的时间(打游戏和学习不香吗),目标就是毕业即可。

懒得写针对性的代码,直接写了通用代码一把梭。

系统地址

前端地址

后端地址

系统介绍

针对目前高校举办学科竞赛所遇到的问题,本系统主要设计六个模块,分别是通知模块、战斗力模块、比赛模块、报名模块、角色模块和团队模块。

系统架构图

安装与使用教程

安装

服务器环境

服务器需要安装如表所示的软件。

名称版本
Nginx1.16
OpenJDK11
数据库Redis 5PostgreSQL 12
Node.js12

客户端环境

安装 IE 11 或所有现代浏览器。

Nginx 配置

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

    sendfile        on;

    keepalive_timeout  65;

    server {
        listen 80;

        gzip on;
        gzip_min_length 1k;
        gzip_comp_level 9;
        gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
        gzip_vary on;
        gzip_disable "MSIE [1-6]\.";

        root D:/AntDesignProjects/galop-web/dist;

        location / {
            try_files $uri $uri/ /index.html;
            rewrite ^/(.*)$ https://localhost/$1 permanent;
        }        
        location /api/ {
            proxy_pass https://localhost:8080/;
            proxy_set_header   X-Forwarded-Proto $scheme;
            proxy_set_header   X-Real-IP         $remote_addr;
        }
    }

    server {
        listen 443 ssl http2 default_server;

        ssl_certificate D:/IdeaProjects/galop-server/core-module/src/main/resources/galop.crt;
        ssl_certificate_key D:/IdeaProjects/galop-server/core-module/src/main/resources/galop.key;
        ssl_session_timeout  5m;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_ciphers AESGCM:ALL:!DH:!EXPORT:!RC4:+HIGH:!MEDIUM:!LOW:!aNULL:!eNULL;
        ssl_prefer_server_ciphers   on;
        
        root D:/AntDesignProjects/galop-web/dist;

        location / {
            try_files $uri $uri/ /index.html;
        }
        location /api/ {
            proxy_pass https://localhost:8080/;
            proxy_set_header   X-Forwarded-Proto $scheme;
            proxy_set_header   Host              $http_host;
            proxy_set_header   X-Real-IP         $remote_addr;
        }
    }
}

其中前端文件位置可以在 root 处定义,域名可以在 location 处定义,证书位置可以在 ssl_certificatessl_certificate_key 处定义,可自行修改。

数据库配置

Redis 配置

spring:
  data:
    redis:
      repositories:
        enabled: true

  redis:
    # 数据库索引
    database: 0
    host: 127.0.0.1
    port: 6379
    password:
    # 连接超时时间
    timeout: 5000

可自行修改数据库服务器 IP 地址、端口号和密码。

PostgreSQL 配置

首先需要使用 galop_20200502.sql 文件创建数据库,创建后进行如下数据库配置。

spring:
  datasource:
    druid:
      url: jdbc:postgresql://localhost:5432/galop
      username: postgres
      password: 123456

可自行修改数据库服务器 IP 地址、端口号、账号和密码。

其他配置

可自行修改 application.yml 中如表所示配置。

名称默认值备注
Excel 模板存储位置D:\IdeaProjects\galop-server\file\可选
邮箱配置默认为空,必填
头像存储位置D:\IdeaProjects\galop-server\avatar\可选

建议配置

建议将前端代码放置在 D:\AntDesignProjects 文件夹下,将后端代码放置在 D:\IdeaProjects 文件夹下。这样可以直接使用自带的默认配置,仅需配置环境与邮箱。

配置完成后,前端需要执行 npm install 安装依赖,然后执行 umi build 编译。后端需要执行 mvn clean install 打包并发布到本地仓库。

运行

启动

运行 Nginx 服务器,Redis 和 PostgreSQL,然后运行编译后的 jar 包即可启动。

Windows 10 上具体操作如表所示。

命令备注
启动 PowerShell
执行 cd D:\Nginx\nginx-1.16.1\切换到 Nginx 安装目录
start ./nginx.exe启动 Nginx 服务器
启动 WSL 2
执行 redis-server启动 Redis
启动 PostgreSQL 服务自行开启数据库服务
执行 cd D:\IdeaProjects\galop-server\core-module\target\切换到后端编译出包位置
执行 java -jar .\core-module-1.0.0.jar运行系统 jar 包

账号

系统初始内置一个管理员账号,账号为 000000,密码为 123456

管理员可为其他人批量注册管理员或普通账号,注册后的默认密码为 123456

运行

成功启动后可以通过浏览器访问 https://localhost/login 进行登录,登录后即可使用学科竞赛训练管理系统。

主要技术

开发环境

采用前后端分离的方式进行开发,具体环境如表所示。

名称版本
操作系统Windows 10
版本控制工具Git 2.26
前端开发环境
开发工具Visual Studio Code 1.44
Node.js12.16
React16.13
JavaScriptECMAScript 6
Web 服务器Nginx 1.16
后端开发环境
开发工具IntelliJ IDEA 2020
OpenJDK11
Spring Boot2.2
数据库Redis 5PostgreSQL 12
Web 服务器Tomcat 9

若干依赖不再详细列出。

运行环境

前端部署在 Nginx 服务器上,通过 Nginx 将 80 端口的请求转向 443 端口,然后将 443 端口的请求反向代理到后端的服务器上。

后端部署在装有 OpenJDK、Redis 和 PostgreSQL 的服务器上。

系统支持 IE 11 和所有现代浏览器访问。

系统设计

功能介绍

学科竞赛训练管理系统主要由通知模块、战斗力模块、比赛模块、报名模块、角色模块、团队模块以及工具模块组成。

通知模块

通知模块主要完成创建、查询、修改和删除公告的功能,通过自动化的方式来解决比赛组织和宣传成本高的问题。其中创建、修改和删除功能仅对老师开放,查询功能则对老师和学生开放。

比赛创建和比赛结果上传后,系统也会自动创建新的公告,如图所示。

自动创建公告

老师和学生登录主页的最近十条公告功能也由通知模块提供。

战斗力模块

战斗力模块主要负责提供创建和查询学生战斗力变化情况的功能,通过每场比赛的结果计算战斗力并统计比赛人数,以可视化的图表来展现学生训练成果和比赛参与度。其中创建战斗力记录由系统自动完成,查询功能仅对学生开放。

学生登录主页的战斗力可视化图表和老师登录主页的比赛人数可视化图表功能也由战斗力模块提供,如图所示。

比赛参与人数变化图

战斗力变化图

比赛模块

比赛模块主要完成创建比赛、查询和处理比赛结果的功能,通过自动化的方式来解决比赛结果处理繁琐以及训练效率低的问题。其中创建和上传比赛结果仅对老师开放,查询比赛功能对老师和学生开放,查询比赛结果功能仅对学生开放。

比赛创建和比赛结果上传后,系统会自动调用通知模块创建公告,如图所示。

比赛创建公告

比赛结果公告

尚未结束报名的比赛功能也由比赛模块提供,如图所示。

尚未结束的比赛

报名模块

报名模块主要完成比赛报名、审核和导出报名表的功能,学生可以从我的报名来看曾参加过的比赛与报名状态。通过自动化的方式解决老师难以选拔合适的参赛学生问题。其中比赛报名仅对团队队长开放,报名审核和导出报名表仅对老师开放,我的报名仅对学生开放。

老师可以根据学生所在团队的战斗力决定是否允许这个队伍参赛,如图所示。

报名审核

角色模块

角色模块主要完成用户登录、查询、批量注册与注销、导出战斗力排名表、修改信息和重置密码的功能,通过自动化的方式解决注册、注销繁琐的问题。其中用户查询、批量注册与注销、导出战斗力排名和重置密码的功能仅对老师开放,登录与修改信息则对老师和学生开放。

批量注销后,角色模块会调用战斗力模块、比赛模块和团队模块删除信息。

用户忘记密码后,若注册时填写了邮箱,即可通过邮箱接收重置密码的邮件自己进行重置,如图 所示。

重置密码邮件

若没有填写邮箱则可以通过联系老师进行密码重置。

团队模块

团队模块主要完成创建、查询、修改、发送加入申请和申请审核功能,用来解决学生组队困难的问题,如所示。

团队模块仅对学生开放。发送申请后,仅由团队的队长可见,如图所示。

团队申请审核

工具模块

工具模块主要完成字符串处理、异常统一抛出、获取当前登录用户、Excel 读取与生成、Redis 配置、分页处理等功能。

前端

以创建比赛为例。

通过封装后的单选框 Radio 组件接收比赛类型的输入;通过封装后的 Input 组件接收比赛标题的输入;通过封装后的范围选择器 Range Picker 组件接收比赛时间的输入;通过封装 Input 组件后的 Input Number 组件接收要求团队人数、冠军所得奖励、递减梯度三种数字类型的输入;通过封装 Button 组件进行数据提交。

同时需要为以上组件统一编写数据验证规则,如非空判定和数据长度判定,以及对应的提示,防止提交空数据到后端。因此需要再封装一个 Form 组件统一管理提交、数据验证与数据说明。完成以上内容后效果如图所示。

创建比赛页面

本系统前端使用的架构是基于 Flux 的 Redux 架构。Redux 架构遵循三大原则,单一数据源、只允许触发 action 改变 statereducer 必须为纯函数。action 作为 reducerseffects 的触发器,一般由 type 属性和 payload 属性组成。通过 type 属性寻找对应的 reducerseffects,将 payload 传递给它们。

因此当老师点击提交时,以上组件的 state 作为 payload,通过 payload 属性和 type 属性来触发 action 调用指定的 effects。当 action 触发 effects 之后,由 effects 将新增数据通过 HTTPS 请求发送给后端服务器,并接收后端服务器所返回的数据。根据返回数据判断是否成功,使用封装好的全局组件 Message 给出提示。完成以上内容后效果如图所示。

触发 effects

之所以调用 effects 而不是 reducer,是因为 Redux 架构要求 reducer 必须为纯函数。纯函数有两个特点,一是在执行过程中不会对外界产生影响;二是函数的参数与返回值是一一对应的,同一参数所得到的返回结果应该一致。在向服务端请求数据时,并不能保证参数与返回值一一对应;强制延时也会对外界产生影响,以上操作统称为副作用。因此,存在副作用的逻辑并不能写在 reducer 中。为了保证 reducer 是一个纯函数,同时又需要处理存在副作用的逻辑,那就需要打破“直接”这一特性。而 effect 则用来作为中间层。

effects 用于处理异步操作,例如将数据发送给后端服务器。但是根据 Redux 架构的约束,它不可以修改 state,必须再次触发 action 来调用 reducers 达成修改 state 的操作。从宏观角度看,effect 是一层中间件。从局部角度看,effect 就是一个 generator function。

effects 向服务器发起异步请求,但整体的执行结果却像同步一样。这是因为使用了 generator function 来处理异步逻辑。异步本质是事件的触发会导致程序跳转。使用 call 只是描述跳转的方式,并没有改变异步的本质,让程序看起来是同步的。

effect 的参数有两个,第一个对象可以获取 actionpayload 字段,第二个对象则是 effect 的原语集,例如 callput 就是原语集之一。其中 yieldcall 配合可以处理异步逻辑,调用之后程序会进入阻塞状态,以此来等待服务器返回结果。得到结果后解除阻塞状态,继续执行。yieldput 配合可以再次触发一个 action,用来在 effect 中使用。

某一事件导致程序进行跳转,就是异步的本质。而 callback 可以看作一种描述异步的方法。generator function 通过修改这种描述的方法来让程序变成同步的样子。

在执行时 generator function 有两方。一方是本身,一方是句柄持有者,也就是框架。当框架调用这个句柄的 next 方法时,generator function 会在下一个 yield 前暂停,并把程序执行权和 yield 后面表达式的值交给框架。框架得到执行权和值后进行处理,结束后再次调用 next 方法,把值交还给 generator function,同时恢复运行。generator function 定义了程序执行流程,并在每一个 yield 告诉框架想要完成的任务。而异步任务真正的执行逻辑则交给了框架执行。

在向服务器发起请求时,对 fetch 进行了封装,便于做好统一异常处理。值得注意的是,在发送 HTTPS 请求时,请求的路径与后端服务器所在的路径并不相同。当请求协议不同、端口不同或路径不同时,我们就认为资源请求“跨域”了。而 HTTPS 的默认规则并不允许进行“跨域”请求。

本项目使用 Nginx 反向代理解决跨域的问题。Nginx 服务器监听来自前端的请求,然后将监听到的请求转发到后端服务器上。这样 Nginx 服务器与后端服务器就是服务器之间在进行通信,不存在跨域问题。

接下来继续看创建比赛的例子,effect 处理完毕后,再次触发 action,将服务器结果和 state 传递给 reducersreducers 根据结果改变 state,然后通过新的 state 渲染页面。这样创建比赛的请求就完成了。

以多次触发 effect 为例,如图所示。

触发 reducers

action 被触发后,首先需要解决一些副作用,如请求数据。这些操作需要通过 effect 来完成。然后 effect 会再次触发一个新的 action。这个新的 action 根据开发者的需要来指定 type,既可以被另一个 effect 捕获,然后继续处理副作用,也可以选择由 reducer 捕获,然后结束。无论如何,最终都会来到 reducer

对于视图层,并没有办法察觉到 effectreducer 的差别。视图层仅描述我想要通过 action 执行的操作,并不关心 action 之后是由 effectreducer 处理,还是直接由 reducer 处理。这样我们就能够将数据逻辑与试图逻辑分离了。

reducer 的返回值会作为新的 state,重新渲染组件。而重新渲染组件是通过 connect 方法将新的 state 注入组件,进行渲染的。注入的本质是控制反转。有了 connect,组件不需要再管理数据,仅通过 connect 向框架描述所需的数据即可。React 也是如此,开发者并不需要直接操作 DOM,在组件中写一个返回视图的部分,这个部分就是向 React 描述我想要渲染的内容。React 负责将所描述的内容转换为 DOM,并决定渲染的时机。

后端

以查询比赛为例。

Nginx 转发的 HTTPS 请求访问后端服务器,Spring Security 拦截请求,进行 Token 验证。如图 所示。

身份验证

Spring Security 框架是用来防御常见攻击的框架。它可以提供身份验证和授权机制,并为 Authentication 和 Authorization 提供了解决方案。另外 Spring Security 框架不需要额外配置如身份验证、JAAS 策略等任何文件,使用十分便捷。

可以通过 SecurityContextHoldergetContext 方法获取 SecurityContext。我们通常通过 SecurityContext 获取 Authentication 对象,再从 Authentication 对象中取得 UserDetails,以得到详细的用户信息。

在登录时,Spring Security 通过 UserDetailsService 接口的实现类来获取登录用户的账号密码,然后由 AuthenticationManagerAuthenticationProvider 负责进行密码比对。之所以使用这样一个抽象的接口,是因为这样做 Spring Security 可以与持久层完全解耦,因此我保存 UserDetails 的 DAO 层可以是任何东西,数据库、缓存、Properties 文件或内存中都可以。

学科竞赛训练管理系统采用 JWT 方式来处理用户密码认证。JWT 作为无状态的授权校验技术,可以让后端不必保存用户状态。只需要在登录认证通过后,由后端生成一个 Token,把 Token 存入 Redis 并将 Token 返回给前端即可。前端之后的请求都要在请求头中携带 Token,后端接收 Token 后会对 Token 的格式、签名、以及权限进行验证,并于 Redis 中的 Token 进行比对。解析出权限后,根据 PreAuthorize 注解的配置进行权限验证,验证通过后则可以访问 RESTful API。当 Redis 的 Token 过期后,登录失效。具体原理如图所示。

SpringSecurity 工作流程

可见 Spring Security 是由各种各样的 Filter 组成的。WebAsyncManagerIntegrationFilter 负责把 SecurityContext 保存在异步线程中,使异步线程也可以获取到用户信息;SecurityContextPersistenceFilter 负责读取或创建 SecurityContext,并存入 SecurityContextHolder 中;HeaderWriterFilter 负责给 HTTPS 请求添加一些请求头,如 X-Content-type-Options 等;LogoutFilter 负责处理退出登录;CorsFilter 负责跨域问题;TokenFilter 是自定义 Filter,负责验证 Token;RequestCacheAwareFilter 负责缓存 request 请求;SecurityContextHolderAwareRequestFilter 负责对 request 进行包装;AnonymousAuthenticationFilter 负责处理匿名登录;SessionManagementFilter 负责限制用户发起多次会话;ExceptionTranslationFilter 负责异常拦截,只处理未登录状态下访问受保护资源和登录后访问权限不足资源的异常;FilterSecurityInterceptor 负责授权验证,FilterSecurityInterceptor 通过 SecurityContextHolder 来得到 Authentication 对象,然后从 Authentication 对象中得到用户权限,将权限与用户想要访问的资源权限进行比对。

通过 SpringSecurity 验证后,即可访问 RESTful API。如图所示。

访问 RESTfulAPI

RESTful API 是一种遵守对方法、风格以及对体系结构的一些列约束的 API。一些后端服务可能是有着一个类似 /getxxx/1,使用 GET 或 POST 请求的服务,这样的可以称之为 RPC。因为无法知道应该如何与此服务进行交互,如果想要使用这样的项目那必须编写一个帮助文档来说明每一个服务的作用。很多人也将此称为 RESTful,但是它并不是。Roy Fielding 解释了 RESTful:只有由超文本驱动的 API,才能被称作是 RESTful API。由此可见,首先,要做到每个 URI 标识一种网络资源。资源是网络上的一个信息,如文本、图片或服务。其次,客户端与服务器之间以某种表现层传递这种资源。表现层是资源的表现形式,如图片可以使用 svg 格式,文本可以使用 json 格式表现出来。网址最后的 .html 实际是一种表现层,如果加上 .html 并不能说明这是资源的位置,只能说明这是资源的一种表现形式。最后,客户端通过 GET、POST 等原语操作对 URI 所代表的资源进行交互,以此将表现层的状态进行改变。HTTPS 协议是无状态协议,状态都会在服务端保存。因此想要改变表现层的状态,则只能使用 HTTPS 协议。只有做到以上三点,才可以被称为 RESTful API。

RESTful API 会对前端发来的数据进行验证,对于不需要返回数据的 API 来说,通过验证则将成功的信息返回,随后异步调用 Service 进行业务逻辑处理。若前端需要返回数据,如比赛查询需要返回查询结果,则同步调用 Service 处理,等待数据返回后将数据按前端需要的格式封装,将其返回。如图所示。

结束服务调用

当数据通过验证后,则会传递给 Service 进行业务逻辑处理。在浏览比赛接口中,数据是查询条件和分页数据,Service 负责处理动态查询。

通过 Spring Data JPA 中的 JpaSpecificationExecutor 接口来实现动态查询。JpaSpecificationExecutor 接口提供了五种动态查询方法,分别是查询单条数据、查询多条数据、查询多条数据并排序、查询多条数据并分页与排序、查询数量。在浏览比赛接口中用到了分页并排序。

此方法定义如下:

Page<T> findAll(@Nullable Specification<T> var1, Pageable var2);

排序和分页只需要创建 Pageable 实例即可,如下所示:

PageRequest.of(当前页数, 每页展示数量, Sort.by(排序方式, 排序字段));

动态查询则创建 Specification<T> 实例即可,如下所示:

Specification<T> specification = (Specification<T>) (root, criteriaQuery, criteriaBuilder) -> {
    ArrayList<Predicate> andQuery = new ArrayList<>();
    if (某查询条件!= null) {
        Path<字段类型> id = root.get(字段名);
        Predicate idEqual = criteriaBuilder.equal(id, 查询条件);
        andQuery.add(idEqual);
    }
……
    Predicate[] andPredicates = andQuery.toArray(new Predicate[0]);
    return criteriaBuilder.and(andPredicates);
};

最后只需要将 Specification<T> 实例和 Pageable 实例作为参数即可实现动态查询。

以上是在缓存未命中的情况下才会执行,并且会将类地址、方法名、包名和参数作为键,将返回的结果作为值,存入缓存。若缓存存在,则无需执行以上代码,直接从缓存中取出数据即可。

当 Service 执行完毕后,会将返回的数据封装成前端所需的格式,存入请求体中返回。

测试

性能瓶颈

产生原因

因为本次测试以最坏情况为准,假设每人都会在查询团队之前创建新的团队,导致团队缓存失效,Redis 被穿透。因此涉及到团队查询的接口都会从数据库中进行查询,导致耗时较高。如表所示。

接口名获取用户信息获取战斗力变化数据获取尚未结束的比赛获取前十条公告获取战斗力排行榜获取团队信息创建团队获取比赛信息比赛报名
平均值65ms34ms33ms31ms29ms1169ms52ms40ms1094ms
90%154ms104ms103ms95ms97ms3146ms122ms126ms1716ms

因获取团队信息和比赛报名接口都涉及到了查询团队信息,因此在缓存被穿透的情况下耗时异常的高。

优化方案

因本系统性能目前已达到预计要求,所以仅提出以下理论优化方案,不做具体实现。

因查询团队性能瓶颈是由 Redis 缓存失效导致,所以可以建立二级缓存。当缓存失效时,为数据表加锁,保证只有一个进程进行数据库的查询。其他进程发现加锁后返回二级缓存中的数据,查询完毕后再更新对应的缓存即可。尽管只有拿到锁的用户可以看到最新数据,一部分人没有及时看到最新的数据,但是这部分人只是少数,只需要刷新一下就可以看到最新数据了。

用以上方案缓解 Redis 失效问题后再次压测,系统指标一切正常,实际 TPS 大于预计的 275TPS,90% 的请求最大响应时间为 186ms,平均值为 75ms,可以满足学生报名需求。