[项目总结] 在线OJ刷题系统项目技术应用(下)

🌸个人主页:https://blog.csdn.net/2301_80050796?spm=1000.2115.3001.5343
🏵️热门专栏:
🧊 Java基本语法(97平均质量分)https://blog.csdn.net/2301_80050796/category_12615970.html?spm=1001.2014.3001.5482
🍕 Collection与数据结构 (93平均质量分)https://blog.csdn.net/2301_80050796/category_12621348.html?spm=1001.2014.3001.5482
🧀线程与网络(96平均质量分) https://blog.csdn.net/2301_80050796/category_12643370.html?spm=1001.2014.3001.5482
🍭MySql数据库(93平均质量分)https://blog.csdn.net/2301_80050796/category_12629890.html?spm=1001.2014.3001.5482
🍬算法(97平均质量分)https://blog.csdn.net/2301_80050796/category_12676091.html?spm=1001.2014.3001.5482
🍃 Spring(97平均质量分)https://blog.csdn.net/2301_80050796/category_12724152.html?spm=1001.2014.3001.5482
🎃Redis(97平均质量分)https://blog.csdn.net/2301_80050796/category_12777129.html?spm=1001.2014.3001.5482
🐰RabbitMQ(97平均质量分) https://blog.csdn.net/2301_80050796/category_12792900.html?spm=1001.2014.3001.5482
感谢点赞与关注~~~
在这里插入图片描述

目录

  • 10. 阿里云短信服务
  • 11. 阿里云OSS对象存储
  • 12. docker代码沙箱
  • 13. xxl-job定时任务
    • 13.1 历史竞赛与完赛的竞赛
    • 13.2 竞赛结束之后发送站内信
  • 14. OpenFeign
  • 15. TransmittableThreadLocal
    • 15.1 技术原理
    • 15.2 项目应用
  • 16. RabbitMQ异步通信
  • 17. 数据库表设计
  • 18. Gateway网关

10. 阿里云短信服务

首先我们需要对阿里云的短信服务进行配置,我们需要配置调用发送短信API的必要数据,accessKeyId,accessKeySecret,这连个是每个用户有且仅有一个,用来拿到阿里云账号的用户权限.之后配置的是endpoint,即要发送短信的地域集群(这封短信从那个城市集群发出).

@Configuration
public class AliSmsConfig {@Value("${sms.aliyun.accessKeyId:}")private String accessKeyId;@Value("${sms.aliyun.accessKeySecret:}")private String accessKeySecret;@Value("${sms.aliyun.endpoint:}")private String endpoint;@Bean("aliClient")public Client client() throws Exception {Config config = new Config().setAccessKeyId(accessKeyId).setAccessKeySecret(accessKeySecret).setEndpoint(endpoint);return new Client(config);}
}

我们调用短信服务主要用来发送验证码,在发送验证码的方法中,我们在SendSmsRequest中定义了要发送的手机号phone,使用的签名,即在短信头的中括号中显示的签名singName,之后就是我们需要使用的短信模版idtemplateCode,之后就是我们需要往短信中填充的内容,短信的模版中有一些内容是可变的,即${ }中的内容,这个Map中就修改的是这其中的内容.

@Component
@Slf4j
public class AliSmsService {@Autowiredprivate Client aliClient;//业务配置@Value("${sms.aliyun.templateCode:}")private String templateCode;@Value("${sms.aliyun.sing-name:}")private String singName;public boolean sendMobileCode(String phone, String code) {Map<String, String> params = new HashMap<>();params.put("code", code);return sendTempMessage(phone, singName, templateCode, params);}public boolean sendTempMessage(String phone, String singName, String templateCode,Map<String, String> params) {SendSmsRequest sendSmsRequest = new SendSmsRequest();sendSmsRequest.setPhoneNumbers(phone);sendSmsRequest.setSignName(singName);sendSmsRequest.setTemplateCode(templateCode);sendSmsRequest.setTemplateParam(JSON.toJSONString(params));try {SendSmsResponse sendSmsResponse = aliClient.sendSms(sendSmsRequest);SendSmsResponseBody responseBody = sendSmsResponse.getBody();if (!"OK".equalsIgnoreCase(responseBody.getCode())) {log.error("短信{} 发送失败,失败原因:{}.... ", JSON.toJSONString(sendSmsRequest), responseBody.getMessage());return false;}return true;}  catch (Exception e) {log.error("短信{} 发送失败,失败原因:{}.... ",  JSON.toJSONString(sendSmsRequest), e.getMessage());return false;}}
}

在这里插入图片描述

11. 阿里云OSS对象存储

从OSS配置的属性中,我们就可以看出OSS对象存储服务需要的字段,和上面的短信服务一样,我们仍然需要endpoint城市集群结点URL,用户唯一的权限校验id和密钥accessKeyIdaccessKeySecret,其次,我们的配置还需要比短信服务多出了bucketNameOSS对象存储的存储空间,即存储对象的容器.其次就是pathPrefix,表示的是对象在bucket中的存储路径.最后是region,表示的是对象存储的城市服务器集群.我们需要首先在DefaultCredentialProvideraccessKeyIdaccessKeySecret两个属性配置好.之后配置进OSSClientBuilder即可,之后我们还需要配置endpointRegion,即城市集群URL和城市集群地域信息.由于OSS对象存储服务是以数据流的方式对数据进行上上传的,所以我们在上传完成之后需要对数据流进行关闭closeOSSClient().

@Slf4j
@Configuration
public class OSSConfig {@Autowiredprivate OSSProperties prop;public OSS ossClient;@Beanpublic OSS ossClient() throws ClientException {DefaultCredentialProvider credentialsProvider = CredentialsProviderFactory.newDefaultCredentialProvider(prop.getAccessKeyId(), prop.getAccessKeySecret());// 创建ClientBuilderConfigurationClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);// 使用内网endpoint进行上传ossClient = OSSClientBuilder.create().endpoint(prop.getEndpoint()).credentialsProvider(credentialsProvider).clientConfiguration(clientBuilderConfiguration).region(prop.getRegion()).build();return ossClient;}@PreDestroypublic void closeOSSClient() {ossClient.shutdown();}
}
@Data
@Component
@ConfigurationProperties(prefix = "file.oss")
public class OSSProperties {private String endpoint;private String region;private String accessKeyId;private String accessKeySecret;private String bucketName;private String pathPrefix; 
}

我们在配置好属性之后,我们就可以对外提供一个用于向OSS上传文件的Service方法了, 在一个c端的用户想要对头像进行修改的时候,为了保证某些恶意用户浪费系统资源,我们对每个用户单日上传头像的此处做了一定的限制,把每个用户上传头像的次数保存在Redis中,在上传头像之前先去Redis中查询当前用户上传头像的次数,如果超过了一定的次数限制,那么直接限制,如果没有超过,直接对Redis中的当前缓存的Value++.同时在每天的凌晨1点的时候对缓存进行刷新.进行检查之后,就可以对文件进行上传了,首先需要指定好文件路径与文件名,也就是在OSS的bucket中,我们需要把文件上传到哪个路径之下,之后我们可以使用InputStream输入流对文件进行上传,最后记得在finally方法中关闭输入流.

public OSSResult uploadFile(MultipartFile file) throws Exception {if (!test) {checkUploadCount();}InputStream inputStream = null;try {String fileName;if (file.getOriginalFilename() != null) {fileName = file.getOriginalFilename().toLowerCase();} else {fileName = "a.png";}String extName = fileName.substring(fileName.lastIndexOf(".") + 1);inputStream = file.getInputStream();return upload(extName, inputStream);} catch (Exception e) {log.error("OSS upload file error", e);throw new ServiceException(ResultCode.FAILED_FILE_UPLOAD);} finally {if (inputStream != null) {inputStream.close();}}
}
private void checkUploadCount() {Long userId = ThreadLocalUtil.get(Constants.USER_ID, Long.class);Long times = redisService.getCacheMapValue(CacheConstants.USER_UPLOAD_TIMES_KEY, String.valueOf(userId), Long.class);if (times != null && times >= maxTime) {throw new ServiceException(ResultCode.FAILED_FILE_UPLOAD_TIME_LIMIT);}redisService.incrementHashValue(CacheConstants.USER_UPLOAD_TIMES_KEY, String.valueOf(userId), 1);if (times == null || times == 0) {long seconds = ChronoUnit.SECONDS.between(LocalDateTime.now(),LocalDateTime.now().plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0));redisService.expire(CacheConstants.USER_UPLOAD_TIMES_KEY, seconds, TimeUnit.SECONDS);}
}

12. docker代码沙箱

在用户的id和用户的代码被提交到java判题功能的时候,首先需要根据用户提交的代码进行用户代码文件的构建,即createUserCodeFile,就是把用户提交的代码和主方法拼接起来,之后使用FileUtil工具类在指定的目录之下创建一个用户代码文件.之后就是初始化代码沙箱,在初始化代码沙箱的时候,我们使用的是容器池的技术,即池化技术,避免每一次提交代码都在创建容器上产生不必要的开销,直接从容器池中获取到一个docker容器并启动docker容器.创建并启动完成之后,把我们之前创建好的用户代码提交到docker容器中进行编译,如果编译不通过直接返回编译错误,并删除之前创建的docker容器和用户代码文件避免资源的浪费,如果编译通过,则把测试用例的输入带入到用户提交的代码中进行执行并得到返回结果.

@Override
public SandBoxExecuteResult exeJavaCode(Long userId, String userCode, List<String> inputList) {containerId = sandBoxPool.getContainer();//创建用户代码文件createUserCodeFile(userCode);//编译代码CompileResult compileResult = compileCodeByDocker();//编译是否通过,如果不通过,直接把容器归还给容器池,并删除用户代码路径if (!compileResult.isCompiled()) {sandBoxPool.returnContainer(containerId);deleteUserCodeFile();//返回一个失败的结果return SandBoxExecuteResult.fail(CodeRunStatus.COMPILE_FAILED, compileResult.getExeMessage());}//如果编译通过,则执行代码return executeJavaCodeByDocker(inputList);
}
//创建并返回用户代码的文件
private void createUserCodeFile(Long userId, String userCode) {//创建存放用户代码的目录String examCodeDir = System.getProperty("user.dir") + File.separator + JudgeConstants.EXAM_CODE_DIR;if (!FileUtil.exist(examCodeDir)) {FileUtil.mkdir(examCodeDir);}String time = LocalDateTimeUtil.format(LocalDateTime.now(), DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));//拼接用户代码文件格式userCodeDir = examCodeDir + File.separator + userId + Constant.UNDERLINE_SEPARATOR + time;userCodeFileName = userCodeDir + File.separator + JudgeConstants.USER_CODE_JAVA_CLASS_NAME;FileUtil.writeString(userCode, userCodeFileName, Constant.UTF8);
}

在创建一个docker容器的时候,需要对当前的docker容器进行配置,首先就是创建DefaultDockerClientConfig配置类,采用其中的createDefaultConfigBuilder()docker默认配置即可,之后为配置类指定要创建在哪个端口上,即withDockerHost.创建好配置类之后,使用DockerClientBuilder为我们需要创建的docker容器指定配置.之后拉取镜像.之后为当前容器指定其他的一些核心配置,比如限制最大内存,限制内存最大交换次数,限制cpu可以使用的核心,禁用网络等.之后为我们要创建的容器指定一个名称,之后就可以为容器传入配置正式创建容器,拿到创建好的容器id就可以正式启动容器了.

private void initDockerSanBox(){//创建一个docker客户端配置,采用默认配置,并设置端口号DefaultDockerClientConfig clientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().withDockerHost(dockerHost).build();//构建docker容器dockerClient = DockerClientBuilder.getInstance(clientConfig) //传入docker配置.withDockerCmdExecFactory(new NettyDockerCmdExecFactory()).build();//拉取镜像pullJavaEnvImage();//获取到容器的配置HostConfig hostConfig = getHostConfig();//创建容器并指定容器的名称CreateContainerCmd containerCmd = dockerClient.createContainerCmd(JudgeConstants.JAVA_ENV_IMAGE).withName(JudgeConstants.JAVA_CONTAINER_NAME);//配置容器参数CreateContainerResponse createContainerResponse = containerCmd.withHostConfig(hostConfig)//使用之前获取到的配置.withAttachStderr(true).withAttachStdout(true).withTty(true).exec();//记录容器idcontainerId = createContainerResponse.getId();//启动容器dockerClient.startContainerCmd(containerId).exec();
}
private HostConfig getHostConfig() {HostConfig hostConfig = new HostConfig();//设置挂载目录,指定用户代码路径,这是为了让容器可以访问用户代码,同时限制容器只能访问这个特定目录hostConfig.setBinds(new Bind(userCodeDir, new Volume(JudgeConstants.DOCKER_USER_CODE_DIR)));//限制docker容器使用资源//限制内存资源hostConfig.withMemory(memoryLimit);//限制最大内存hostConfig.withMemorySwap(memorySwapLimit);//限制内存最大交换次数hostConfig.withCpuCount(cpuLimit);//限制cpu可以使用的核心hostConfig.withNetworkMode("none"); //禁用网络hostConfig.withReadonlyRootfs(true); //禁止在root目录写文件return hostConfig;
}

docker容器池的创建其实和线程池的原理差不多,都是一种池化技术,首先我们在构造方法中指定该docker容器池的配置,包括容器客户端,代码沙箱镜像,挂载目录,最大内存限制,最大内存交换次数限制,使用的最大的cpu核心数,容器池中的最大容器数量,容器前缀名,归还与获取容器的阻塞队列,initDockerPool就是使用我们前面提到的创建容器的方法,为当前容器池中创建容器.getContainer方法是从docker容器池的阻塞队列中获取到docker容器,returnContainer是把docker容器归还到阻塞队列中.

/*** 实现容器池,避免因为创建容器而产生的开销*/
@Slf4j
public class DockerSandBoxPool {private DockerClient dockerClient;//容器客户端private String sandboxImage;//代码沙箱镜像private String volumeDir;//挂载目录,与宿主机中的目录进行关联private Long memoryLimit;//最大内存限制private Long memorySwapLimit;//最大内存交换次数限制private Long cpuLimit;//使用的最大的cpu核心数private int poolSize;//容器池中的最大容器数量private String containerNamePrefix;//容器前缀名private BlockingQueue<String> containerQueue;//归还与获取容器的阻塞队列private Map<String, String> containerNameMap;public DockerSandBoxPool(DockerClient dockerClient,String sandboxImage,String volumeDir, Long memoryLimit,Long memorySwapLimit, Long cpuLimit,int poolSize, String containerNamePrefix) {this.dockerClient = dockerClient;this.sandboxImage = sandboxImage;this.volumeDir = volumeDir;this.memoryLimit = memoryLimit;this.memorySwapLimit = memorySwapLimit;this.cpuLimit = cpuLimit;this.poolSize = poolSize;this.containerQueue = new ArrayBlockingQueue<>(poolSize);this.containerNamePrefix = containerNamePrefix;this.containerNameMap = new HashMap<>();}public void initDockerPool() {log.info("------  创建容器开始  -----");for(int i = 0; i < poolSize; i++) {createContainer(containerNamePrefix + "-" + i);}log.info("------  创建容器结束  -----");}public String getContainer() {try {return containerQueue.take();} catch (InterruptedException e) {throw new RuntimeException(e);}}public void returnContainer(String containerId) {containerQueue.add(containerId);}

13. xxl-job定时任务

13.1 历史竞赛与完赛的竞赛

在每天的凌晨一点,都需要对竞赛的列表进行刷新,我们需要先从数据库中查询到结束时间早于当前时间的竞赛和晚于当前时间的竞赛,由于c端用户获取到竞赛列表的时候是首先从Redis中拿到的,所以我们需要对缓存中的竞赛列表进行刷新,即refreshCache(unFinishList, CacheConstants.EXAM_UNFINISHED_LIST);refreshCache(historyList, CacheConstants.EXAM_HISTORY_LIST);.

@XxlJob("examListOrganizeHandler")
public void examListOrganizeHandler() {log.info("*** examListOrganizeHandler ***");List<Exam> unFinishList = examMapper.selectList(new LambdaQueryWrapper<Exam>().select(Exam::getExamId, Exam::getTitle, Exam::getStartTime, Exam::getEndTime).gt(Exam::getEndTime, LocalDateTime.now()).eq(Exam::getStatus, Constants.TRUE).orderByDesc(Exam::getCreateTime));refreshCache(unFinishList, CacheConstants.EXAM_UNFINISHED_LIST);List<Exam> historyList = examMapper.selectList(new LambdaQueryWrapper<Exam>().select(Exam::getExamId, Exam::getTitle, Exam::getStartTime, Exam::getEndTime).le(Exam::getEndTime, LocalDateTime.now()).eq(Exam::getStatus, Constants.TRUE).orderByDesc(Exam::getCreateTime));refreshCache(historyList, CacheConstants.EXAM_HISTORY_LIST);log.info("*** examListOrganizeHandler 统计结束 ***");
}

13.2 竞赛结束之后发送站内信

还是在每天的固定时间,从数据库中查询当天结束的竞赛,针对参加这些竞赛的用户创建站内信,通知用户竞赛结束并公布排名.

@XxlJob("examResultHandler")
public void examResultHandler() {LocalDateTime now = LocalDateTime.now();LocalDateTime minusDateTime = now.minusDays(1);//从当前时间中减去一天List<Exam> examList = examMapper.selectList(new LambdaQueryWrapper<Exam>().select(Exam::getExamId, Exam::getTitle).eq(Exam::getStatus, Constants.TRUE).ge(Exam::getEndTime, minusDateTime)// 结束时间 >= minusDateTime(前一天).le(Exam::getEndTime, now));// 结束时间 <= now(当前时间)if (CollectionUtil.isEmpty(examList)) {return;}Set<Long> examIdSet = examList.stream().map(Exam::getExamId).collect(Collectors.toSet());List<UserScore> userScoreList = userSubmitMapper.selectUserScoreList(examIdSet);Map<Long, List<UserScore>> userScoreMap = userScoreList.stream().collect(Collectors.groupingBy(UserScore::getExamId));createMessage(examList, userScoreMap);
}

14. OpenFeign

用户在c端调用Submit接口提交代码的时候,由于判题服务和代码提交服务是在两个不同的服务当中,需要通过OpenFeign的方式来把friend服务构造好的JudgeSubmitDTO参数(其中包含用户提交的代码)提交到判题服务中,由代码沙箱进行代码运行之后,得到判题的结果.

@FeignClient(contextId = "RemoteJudgeService",value = Constant.JUDGE_SERVICE)
public interface RemoteJudgeService {@PostMapping("/judge/doJudgeJavaCode")R<UserQuestionResultVO> doJudgeJavaCode(@RequestBody JudgeSubmitDTO judgeSubmitDTO);
}

15. TransmittableThreadLocal

15.1 技术原理

见线程与网络专栏"线程池,定时器,ThreadLocal".需要注意的一点是,之所以在当前项目中不直接使用ThreadLocal,是因为,ThreadLocal在一些场景下会出现问题,比如在线程池进行线程复用的时候会出现上下文污染,上一个线程中的信息回被下一个线程读取到,在消息异步处理的时候可能会导致子线程无法拿到在父线程中设置的信息.

15.2 项目应用

由于我们在某些时候需要在程序中用到当前用户的信息,所以我们需要在ThreadLocal中设置当前用户的userId以及userKey,但是需要注意的是,我们不可以在网关的服务中对ThreadLocal的信息进行设置,因为网关属于一个单独的服务,与其他的服务属于不同的进程,在网关设置的信息无法在其他的服务拿到,所以我们需要在拦截器中对其进行设置.拦截器与网关不同,只要那个服务调用了拦截器,当前拦截器就属于这个服务.

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String token = getToken(request);  //请求头中获取tokenif (StrUtil.isEmpty(token)) {return true;}Claims claims = tokenService.getClaims(token, secret);Long userId = tokenService.getUserId(claims);String userKey = tokenService.getUserKey(claims);ThreadLocalUtil.set(Constants.USER_ID, userId);ThreadLocalUtil.set(Constants.USER_KEY, userKey);tokenService.extendToken(claims);return true;
}

16. RabbitMQ异步通信

在提交用户代码的时候,我们除了使用OpenFeign,最优的方案还是使用RabbitMQ进行异步通信.
还是在提交功能和判题功能的交互中,可能同时会有很多用户提交代码,而且判题功能逻辑较为复杂,这时候我们就需要用到消息队列来对消息进行削峰处理和异步处理,保证消息准确从提交功能到达判题功能.
在提交功能中,我们把构造好的JudgeSubmitDTO提交到消息队列中.

@Override
public boolean rabbitSubmit(UserSubmitDTO submitDTO) {Integer programType = submitDTO.getProgramType();if (ProgramType.JAVA.getValue().equals(programType)) {//按照java逻辑处理JudgeSubmitDTO judgeSubmitDTO = assembleJudgeSubmitDTO(submitDTO);judgeProducer.produceMsg(judgeSubmitDTO);return true;}throw new ServiceException(ResultCode.FAILED_NOT_SUPPORT_PROGRAM);
}

在判题功能中,我们设置一个Listener对消息进行监听,确保判题服务可以拿到JudgeSubmitDTO.

@Slf4j
@Component
public class JudgeConsumer {@Autowiredprivate IJudgeService judgeService;@RabbitListener(queues = RabbitMQConstants.OJ_WORK_QUEUE)public void consume(JudgeSubmitDTO judgeSubmitDTO) {log.info("收到消息为: {}", judgeSubmitDTO);judgeService.doJudgeJavaCode(judgeSubmitDTO);}
}

但是这里有一个问题,由于我们是通过消息队列来把代码提交给另一个服务的,所以我们无法从判题服务中把判题的结果再次返回到friend服务中,所以我们只能在数据库中再维护一张表tb_user_submit用来保存判题的结果,在判题服务中,我们把判题的结果保存到表中,在friend服务中,从数据库中获取到判题的结果即可.

private void saveUserSubmit(JudgeSubmitDTO judgeSubmitDTO, UserQuestionResultVO userQuestionResultVO) {UserSubmit userSubmit = new UserSubmit();BeanUtil.copyProperties(userQuestionResultVO, userSubmit);userSubmit.setUserId(judgeSubmitDTO.getUserId());userSubmit.setQuestionId(judgeSubmitDTO.getQuestionId());userSubmit.setExamId(judgeSubmitDTO.getExamId());userSubmit.setProgramType(judgeSubmitDTO.getProgramType());userSubmit.setUserCode(judgeSubmitDTO.getUserCode());userSubmit.setCaseJudgeRes(JSON.toJSONString(userQuestionResultVO.getUserExeResultList()));userSubmit.setCreateBy(judgeSubmitDTO.getUserId());userSubmitMapper.delete(new LambdaQueryWrapper<UserSubmit>().eq(UserSubmit::getUserId, judgeSubmitDTO.getUserId()).eq(UserSubmit::getQuestionId, judgeSubmitDTO.getQuestionId()).isNull(judgeSubmitDTO.getExamId() == null, UserSubmit::getExamId).eq(judgeSubmitDTO.getExamId() != null, UserSubmit::getExamId, judgeSubmitDTO.getExamId()));userSubmitMapper.insert(userSubmit);
}
@Override
public UserQuestionResultVO exeResult(Long examId, Long questionId, String currentTime) {Long userId = ThreadLocalUtil.get(Constants.USER_ID, Long.class);UserSubmit userSubmit = userSubmitMapper.selectCurrentUserSubmit(userId, examId, questionId, currentTime);UserQuestionResultVO resultVO = new UserQuestionResultVO();if (userSubmit == null) {resultVO.setPass(QuestionResType.IN_JUDGE.getValue());} else {resultVO.setPass(userSubmit.getPass());resultVO.setExeMessage(userSubmit.getExeMessage());if (StrUtil.isNotEmpty(userSubmit.getCaseJudgeRes())) {resultVO.setUserExeResultList(JSON.parseArray(userSubmit.getCaseJudgeRes(), UserExeResult.class));}}return resultVO;
}

17. 数据库表设计

在这里插入图片描述
我们在涉及站内信的数据库表设计的时候,我们设计的是分开两张表的方式,之所以我们需要这样设计,是因为我们向不同的用户发送的消息可能是相同的,如果全部存在一张表中,会浪费很大的空间,所以我们选择把消息内容和发送人接收人内容分开存储.

18. Gateway网关

网关主要是对用户的权限做一些校验,我们采用了自定义过滤器的方式,其中自定义过滤器类中实现了GlobalFilter接口,证明是全局过滤器,会应用到所有路由请求上,实现Order用于指定过滤器的优先级.想要从网络请求中获取到相关的请求信息,首先我们需要从ServerWebExchange获取到网络请求.

ServerHttpRequest request = exchange.getRequest();

当然我们有一些接口是不需要经过网关权限验证的,比如用户登录功能,用户注册功能,游客可浏览页面,我们可以配置一个白名单,对白名单中的值不进行权限校验.继续执行下一个过滤器.由于网关中只有这一个自定义过滤器,所以相当于直接进入了后端服务中.
其中,白名单类IgnoreWhiteProperties中,我们使用@RefreshScope热更新,当Nacos中的配置更新之后,不需要重新读取配置,会立即进行配置更新,使用@ConfigurationProperties注解从配置文件中直接读取配置相关信息,对类中的属性进行注入.无需使用@Value注入.其中private List<String> whites = new ArrayList<>();中存放的就是从Nacos中读取出来的白名单路由信息.

@Autowired
private IgnoreWhiteProperties ignoreWhite;
// 跳过不需要验证的路径(白名单中的路径,比如登录功能)
if (matches(url, ignoreWhite.getWhites())) {return chain.filter(exchange);
}@Configuration
@RefreshScope //配置热更新,无需刷新,配置更新之后自动更新
@ConfigurationProperties(prefix = "security.ignore")//在配置文件中找到security.ignore,为响应属性注入值
public class IgnoreWhiteProperties {/*** 放行白名单配置,网关不校验此处的白名单,比如登录接口*/private List<String> whites = new ArrayList<>();public List<String> getWhites() {return whites;}public void setWhites(List<String> whites) {this.whites = whites;}
}

我们在if条件中使用match函数对请求的路径进行校验,ignoreWhite.getWhites()是我们从Nacos中获取到的白名单配置,如果url符合白名单中的通配符表达式,那么就返回一个true,证明这个URL无需进行校验.其中AntPathMatcher.match方法就是专门用来做通配符校验的.

private boolean matches(String url, List<String> patternList) {if (StrUtil.isEmpty(url) || patternList.isEmpty()) {return false;}for (String pattern : patternList) {if (isMatch(pattern, url)) {return true;}}return false;
}
private boolean isMatch(String pattern, String url) {AntPathMatcher matcher = new AntPathMatcher();return matcher.match(pattern, url);
}

由于我们的用户token是保存在header中的,所以我们需要从header中获取到用户的token信息.由于Header中是一些key-value形式的信息,其中HttpConstants.AUTHENTICATION是我们在header中保存用户token的key,拿着这个key,就可以获取到value,即用户令牌.*由于OAuth 2.0和JWT规范,Authorization头的Token通常以Bearer开头,所以如果字符串中有Bearer前缀,我们需要把这个前缀去掉,获取到纯净的token信息.

//从http请求头中获取token
String token = getToken(request);
if (StrUtil.isEmpty(token)) {return unauthorizedResponse(exchange, "令牌不能为空");
}
/*** 从请求头中获取请求token*/
private String getToken(ServerHttpRequest request) {String token =request.getHeaders().getFirst(HttpConstants.AUTHENTICATION);// 如果前端设置了令牌前缀,则裁剪掉前缀if (StrUtil.isNotEmpty(token) &&token.startsWith(HttpConstants.PREFIX)) {token = token.replaceFirst(HttpConstants.PREFIX, StrUtil.EMPTY);}return token;
}

之后我们就可以从获取到的token信息中解析用户详细信息.其中我们需要传入我们在Nacos中配置好的签名密钥.
在解析JWT的方法中.我们传入用户令牌和签名密钥,我们就可以拿到Body信息.即用户信息.如果没有解析出用户的详细信息,即if (claims == null),我们就判断用户传入的令牌不正确或者已经过期.
判断令牌不正确之后,我们就需要给前端返回一个错误信息,其中webFluxResponseWriter方法封装一个JSON类型的响应数据,其中包括设置http请求的状态码,设置header,设置返回结果,并通过response.writeWith封装进入Mono(异步的、零或一个结果的流式数据)异步返回.

Claims claims;
try {claims = JWTUtils.parseToken(token, secret); //获取令牌中信息 解析payload中信息if (claims == null) {return unauthorizedResponse(exchange, "令牌已过期或验证不正确!");}
} catch (Exception e) {return unauthorizedResponse(exchange, "令牌已过期或验证不正确!");
}
public static Claims parseToken(String token, String secret) {return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, Stringmsg) {log.error("[鉴权异常处理]请求路径:{}", exchange.getRequest().getPath());return webFluxResponseWriter(exchange.getResponse(), msg,ResultCode.FAILED_UNAUTHORIZED.getCode());
}
//拼装webflux模型响应
private Mono<Void> webFluxResponseWriter(ServerHttpResponse response,String msg, int code) {response.setStatusCode(HttpStatus.OK);//设置http响应的状态码response.getHeaders().add(HttpHeaders.CONTENT_TYPE,MediaType.APPLICATION_JSON_VALUE);//设置headerR<?> result = R.fail(code, msg);DataBuffer dataBuffer =response.bufferFactory().wrap(JSON.toJSONString(result).getBytes());return response.writeWith(Mono.just(dataBuffer));//异步返回result
}

之后我们就可以从解析出的用户信息Claims中获取到UserKey,之后从redis查询当前的userKey是否存在,如果不存在,则证明登录状态已经过期.和上面一样,校验不同过之后,我们就构造一个http响应数据(JSON响应数据),使用Mono进行异步返回.

String userKey = JWTUtils.getUserKey(claims); //获取jwt中的key
boolean isLogin = redisService.hasKey(getTokenKey(userKey));//判断Redis中是否还存在当前用户的UserKey
if (!isLogin) {return unauthorizedResponse(exchange, "登录状态已过期");
}

之后需要校验Claim数据的完整性,确保userId也存在,方便我们进行之后的操作.

String userId = JWTUtils.getUserId(claims); //判断jwt中的信息是否完整
if (StrUtil.isEmpty(userId)) {return unauthorizedResponse(exchange, "令牌验证失败");
}

如果进行了上面的校验之后,均没有返回错误信息,则证明redis中的token信息是正确的,我们就根据UserKey把用户信息从redis中拿出来(即拿出UserKey的value信息),之后我们需要对当前用户的身份进行校验,看看当前用户是管理员还是普通用户,如果身份对不上,还是和上面一样,通过Mono异步方式返回错误信息.
具体的校验方式,是首先拿到我们之前的从ServerWebExchange获取到的URL信息,看看URL中包含/system还是/friend,如果是/system,则只有管理员才可以访问,如果是/friend,只有普通用户才可以访问,我们可以从redis中获取到的用户信息中获取到用户的身份信息,以此来作比较.

LoginUser user = redisService.getCacheObject(getTokenKey(userKey),LoginUser.class);//把该用户的身份信息从Redis中拿出来
if (url.contains(HttpConstants.SYSTEM_URL_PREFIX) &&!UserIdentity.ADMIN.getValue().equals(user.getIdentity())) {//如果获取URL中的前缀发现是system前缀,但是当前用户的身份不是管理员return unauthorizedResponse(exchange, "令牌验证失败");
}
if (url.contains(HttpConstants.FRIEND_URL_PREFIX) &&!UserIdentity.ORDINARY.getValue().equals(user.getIdentity())) {//从URL中获取前缀发现是friend,但是当前用户不是普通用户return unauthorizedResponse(exchange, "令牌验证失败");
}

最后进行返回,把当前请求交给下一个过滤器.

return chain.filter(exchange);

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/76986.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Qt音频输出:QAudioOutput详解与示例

1. 简介 QAudioOutput是Qt多媒体框架中的一个关键类&#xff0c;它提供了将PCM&#xff08;脉冲编码调制&#xff09;原始音频数据发送到音频输出设备的接口。作为Qt多媒体组件的一部分&#xff0c;QAudioOutput允许开发者在应用程序中实现音频播放功能&#xff0c;支持多种音…

【计算机网络】Linux配置SNAT/DNAT策略

什么是NAT&#xff1f; NAT 全称是 Network Address Translation&#xff08;网络地址转换&#xff09;&#xff0c;是一个用来在多个设备共享一个公网 IP上网的技术。 NAT 的核心作用&#xff1a;将一个网络中的私有 IP 地址&#xff0c;转换为公网 IP 地址&#xff0c;从而…

Redis淘汰策略详解!

目录 一、为什么需要淘汰策略&#xff1f; &#x1f914;二、Redis 的淘汰策略详解 &#x1f447;三、如何选择合适的淘汰策略&#xff1f; &#x1f914;➡️✅四、如何切换 Redis 的淘汰策略&#xff1f; ⚙️&#x1f527;五、总结 &#x1f389; &#x1f31f;我的其他文章…

存储基石:深度解读Linux磁盘管理机制与文件系统实战

Linux系列 文章目录 Linux系列前言一、磁盘1.1 初识磁盘1.2 磁盘的物理结构1.3 磁盘的存储结构1.4 磁盘的逻辑结构 二、文件系统2.1 系统对磁盘的管理2.2 文件在磁盘中的操作 前言 Linux 文件系统是操作系统中用于管理和组织存储设备&#xff08;如硬盘、SSD、USB 等&#xff…

本节课课堂总结

匿名子类&#xff1a; 说明 和 Java 一样&#xff0c;可以通过包含带有定义或重写的代码块的方式创建一个匿名的子类。 单例对象&#xff08;伴生对象&#xff09; Scala语言是完全面向对象的语言&#xff0c;所以并没有静态的操作&#xff08;即在Scala中没有静态的概念&a…

I²C、SPI、UART、CAN 通信协议详解

一、协议基本特性对比 特性ICSPIUARTCAN通信类型同步、半双工同步、全双工异步、全双工异步、多主多从信号线SDA&#xff08;数据&#xff09;、SCL&#xff08;时钟&#xff09;MOSI、MISO、SCK、SS&#xff08;片选&#xff09;TX&#xff08;发送&#xff09;、RX&#xff…

【diffusers 进阶(十五)】dataset 工具,Parquet和Arrow 数据文件格式,load dataset 方法

系列文章目录 【diffusers 极速入门&#xff08;一&#xff09;】pipeline 实际调用的是什么&#xff1f; call 方法!【diffusers 极速入门&#xff08;二&#xff09;】如何得到扩散去噪的中间结果&#xff1f;Pipeline callbacks 管道回调函数【diffusers极速入门&#xff0…

第十三章:持久化存储_《凤凰架构:构建可靠的大型分布式系统》

第十三章 持久化存储 一、Kubernetes存储设计核心概念 &#xff08;1&#xff09;存储抽象模型 PersistentVolume (PV)&#xff1a;集群级别的存储资源抽象&#xff08;如NFS卷/云存储盘&#xff09;PersistentVolumeClaim (PVC)&#xff1a;用户对存储资源的声明请求&#…

以太网安全

前言&#xff1a; 端口隔离可实现同一VLAN内端口之间的隔离。用户只需要将端口加入到隔离组中&#xff0c;就可以实现隔离组内端口之间的二层数据的隔离端口安全是一种在交换机接入层实施的安全机制&#xff0c;旨在通过控制端口的MAC地址学习行为&#xff0c;确保仅授权设备能…

跨域问题前端解决

由于浏览器的同源策略&#xff0c;前后端分离的项目&#xff0c;调试的时候总是会遇到跨域的问题&#xff0c;这里通过修改前端代码解决跨域问题。 首先先查看前端代码的根目录下&#xff0c;有没有vue.config.js文件, 若有&#xff0c;使用方法1&#xff0c;若没有此文件&…

Elasticsearch 报错index_closed_exception

index_closed_exception 是 Elasticsearch 中的一个异常类型&#xff0c;它通常发生在尝试对一个已经被关闭&#xff08;closed&#xff09;的索引执行搜索、写入或其他操作时。在 Elasticsearch 中&#xff0c;索引是用来存储和检索数据的逻辑命名空间&#xff0c;可以将其类比…

LearnOpenGL-笔记-其九

今天让我们完结高级OpenGL的部分&#xff1a; Instancing 很多时候&#xff0c;在场景中包含有大量实例的时候&#xff0c;光是调用GPU的绘制函数这个过程都会带来非常大的开销&#xff0c;因此我们需要想办法在每一次调用GPU的绘制函数时尽可能多地绘制&#xff0c;这个过程就…

PDF预览-搜索并高亮文本

在PDF.js中实现搜索高亮功能可以通过自定义一些代码来实现。PDF.js 是一个通用的、基于Web的PDF阅读器&#xff0c;它允许你在网页上嵌入PDF文件&#xff0c;并提供基本的阅读功能。要实现搜索并高亮显示文本&#xff0c;你可以通过以下几个步骤来完成&#xff1a; 1. 引入PDF…

二叉树——队列bfs专题

1.N叉树的层序遍历 我们之前遇到过二叉树的层序遍历&#xff0c;只需要用队列先进先出的特性就可以达到层序遍历的目的。 而这里不是二叉树&#xff0c;也就是说让节点的孩子入队列时不仅仅是左右孩子了&#xff0c;而是它的所有孩子。而我们看这棵多叉树的构造&#xff0c;它…

Python高级爬虫之JS逆向+安卓逆向1.1节-搭建Python开发环境

目录 引言&#xff1a; 1.1.1 为什么要安装Python? 1.1.2 下载Python解释器 1.1.3 安装Python解释器 1.1.4 测试是否安装成功 1.1.5 跟大神学高级爬虫安卓逆向 引言&#xff1a; 大神薯条老师的高级爬虫安卓逆向教程&#xff1a; 这套爬虫教程会系统讲解爬虫的初级&…

Windows 安装和使用 ElasticSearch

SpringBoot3 整合 Elasticsearch 1. ElasticSearch 1.1 ES &#xff08;1&#xff09;ES 是一个开源的分布式搜索和分析引擎&#xff0c;专为处理大模型数据而设计&#xff0c;它能够实现近乎实时的数据检索、分析和可视化&#xff0c;广泛用于全文搜索、日志分析和监控&…

matplotlib初探

库引入 import matplotlib.pyplot as pltpyplot.figure 创建新图形或激活现有图形

NVM 多版本Node.js 管理全指南(Windows系统)

&#x1f9d1; 博主简介&#xff1a;CSDN博客专家、全栈领域优质创作者、高级开发工程师、高级信息系统项目管理师、系统架构师&#xff0c;数学与应用数学专业&#xff0c;10年以上多种混合语言开发经验&#xff0c;从事DICOM医学影像开发领域多年&#xff0c;熟悉DICOM协议及…

实验室预约|实验室预约小程序|基于Java+vue微信小程序的实验室预约管理系统设计与实现(源码+数据库+文档)

实验室预约小程序 目录 基于微信小程序的实验室预约管理系统设计与实现 一、前言 二、系统功能设计 三、系统实现 1、微信小程序前台 2、管理员后台 &#xff08;1&#xff09;管理员登录 &#xff08;2&#xff09;实验室管理 &#xff08;3&#xff09;公告信息管理…

SpringBoot底层-数据源自动配置类

SpringBoot默认使用Hikari连接池&#xff0c;当我们想要切换成Druid连接池&#xff0c;底层原理是怎样呢 SpringBoot默认连接池——Hikari 在spring-boot-autoconfiguration包内有一个DataSourceConfiguraion配置类 abstract class DataSourceConfiguration {Configuration(p…