首页
留言
导航
统计
Search
1
追番推荐!免费看动漫的网站 - 支持在线观看和磁力下载
2,516 阅读
2
推荐31个docker应用,每一个都很实用
1,314 阅读
3
PVE自动启动 虚拟机 | 容器 顺序设置及参数说明
934 阅读
4
一条命令,永久激活!Office 2024!
618 阅读
5
优选 Cloudflare 官方 / 中转 IP
490 阅读
默认分类
服务器
宝塔
VPS
Docker
OpenWRT
Nginx
群晖
前端编程
Vue
React
Angular
NodeJS
uni-app
后端编程
Java
Python
SpringBoot
SpringCloud
流程引擎
检索引擎
Linux
CentOS
Ubuntu
Debian
数据库
Redis
MySQL
Oracle
虚拟机
VMware
VirtualBox
PVE
Hyper-V
计算机
网络技术
网站源码
主题模板
登录
Search
标签搜索
Java
小程序
Redis
SpringBoot
docker
Typecho
Cloudflare
docker部署
虚拟机
WordPress
群晖
uni-app
CentOS
Vue
Java类库
Linux命令
防火墙配置
Mysql
脚本
Nginx
微醺
累计撰写
264
篇文章
累计收到
11
条评论
首页
栏目
默认分类
服务器
宝塔
VPS
Docker
OpenWRT
Nginx
群晖
前端编程
Vue
React
Angular
NodeJS
uni-app
后端编程
Java
Python
SpringBoot
SpringCloud
流程引擎
检索引擎
Linux
CentOS
Ubuntu
Debian
数据库
Redis
MySQL
Oracle
虚拟机
VMware
VirtualBox
PVE
Hyper-V
计算机
网络技术
网站源码
主题模板
页面
留言
导航
统计
搜索到
264
篇与
的结果
2024-05-28
使用OpenAI API搭建AI助理的JavaScript实现
前言今天我们来聊聊如何使用OpenAI和JavaScript的json-server技术来搭建一个独属于你的AI助理用来解决你提出各种请求,这项功能在传统的后端项目中是不可能实现的但是现在我们有了大模型这一利器我们可以使用大模型来快速的分析并处理我们给出的请求。在AI时代我们可以使用JavaScript来快速构建一个独属于我们的AI助理,使用json-server技术将json文件快速变成后端数据,后端再调用OpenAI的接口来对你所发出的请求进行处理。话不多说,直接开干。准备工作第一次看本文章的朋友们,建议先仔细阅读前文:使用OpenAI API进行情感分析的JavaScript实现一文教你使用Node.js脚本调用OpenAi API接口实现对话功能一: 创建项目文件打开vscode,新建三个文件夹。这三个文件夹的作用分别是: Ai服务,后端目录,前端目录 ,文件夹结构如图所示:二:初始化工程初始化后端项目工程 npm init -y创建好了项目所需的文件夹后,右键进入backend文件夹的终端,进入到终端。端输入指令 npm init -y 将项目初始为后端工程。初始化成功后会出现 packager.json 文件,如图所示:引入json-server继续在当前终端中输入指令 npm i json-server 导入成功后,我们会在 package.json 文件夹中看到已经成功引入了 json-server 的依赖。如图所示:导入后端数据打开 package.json 文件,将 "scripts" 中的内容修改为: "dev": "json-server users.json"新建一个名为 users.json 的文件,该文件中的内容则为本项目所需要用到的数据最终结构如下图所示:进入到 users.json 文件中输入需要用到的数据这里给出本项目中用到的数据:{ "users": [ { "id": 1, "name": "科比·布莱恩特", "hometown": "费城" }, { "id": 2, "name": "坤坤", "hometown": "温州" }, { "id": 3, "name": "阿伦·艾弗森", "hometown": "费城" }, { "id": 4, "name": "丁真珍珠", "hometown": "理塘" } ] }到此,该项目的后端数据就构建好了。此时,继续在命令行输入指令 npm run dev ,将后端项目运行起来。此时可以在控制台看到后端地址。打开浏览器,访问该地址可以看到 users.json 文件中的数据。访问该地址的不同 id 时,可以看到该id对应的人物信息OpenAI的接入来到 ai_server 文件夹,右键进入到该文件的终端。一. 初始化后端工程 npm init -y该操作和上一步一致二.导入项目所需要的库npm i openai该指令用于在项目中安装 openai 这个包,有了这个包就可以使用OpenAI的API接口了。 执行这个命令会做以下几件事:下载和安装包:从npm仓库下载 openai 包及其依赖到项目的 node_modules 目录。更新 package.json:执行完这个命令会将 openai 添加到 dependencies 字段,记录为项目依赖。如图所示:npm i dotenv该指令用于安装 dotenv 这个包,有了这个包,可以使用 .env 配置文件,加载 .env 文件中的环境变量到 process.env 对象中。将项目的私密内容封装到 .env 文件中不向外处暴露,可以很大的提高项目的安全性能。 如下所示:至此该项目所需的环境已经搭建完毕,下面我将为大家介绍如何编写代码实现。代码编写http服务的搭建(项目的核心部分)来到 ai_server 文件夹,新建一个名为 main.js 的 js 文件,输入以下js代码:// ai openai, :8888/users?question= // node 的内置模块 // - 搭建http服务 const http = require('http'); const url = require('url'); const OpenAI = require('openai'); require('dotenv').config(); const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, // proxy baseURL: 'https://api.chatanywhere.tech/v1' }) const server = http.createServer(async function (req, res) { res.setHeader('Access-Control-Allow-Origin', '*'); // 允许所有来源访问,也可以指定具体的域名,如'http://example.com' res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); // 允许的请求方法 res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); // http 基于请求响应的简单协议 req 请求 res 响应 if (req.url.indexOf('/users') >= 0) { // users ai 服务 const parsedUrl = url.parse(req.url, true); // console.log(parsedUrl); const { question, users } = parsedUrl.query; console.log(question, users) const prompt = ` ${users} 请根据以上用户的json数据,回答${question}这个问题. 如果回答不了,就返回不清楚,谢谢。 ` const response = await client.chat.completions.create({ model: 'gpt-3.5-turbo', messages: [{ role: "user", content: prompt }], temperature: 0, // 控制输出的随机性,0表示更确定的输出 }); const result = response.choices[0].message.content || ''; console.log(result); let info = { message: result } res.statusCode = 200; res.setHeader('Content-Type', 'text/json'); res.end(JSON.stringify(info)) } }) server.listen(8888, function () { console.log('服务器启动了') })代码详解以上代码主要作用是搭建一个基于 Node.js 的简单 HTTP 服务器,使用了 Node.js 的内置 http 模块和第三方模块 url、OpenAI 以及 dotenv 来创建一个能够与 OpenAI 的 GPT 模型交互的服务,以处理特定的 HTTP GET 请求并返回 AI 生成的响应。下面是代码的详细解析:导入模块和配置http: Node.js的内置模块,用于创建HTTP服务器。url: Node.js的内置模块,用于URL解析。OpenAI: 块用于与OpenAI API交互,主要是用来调用gpt-3.5-turbo模型。dotenv: 用于加载.env文件中的环境变量,用于读取封装的OpenAI的API密钥。通过 require 语句导入这些模块,并使用 dotenv.config() 加载环境变量,然后实例化 OpenAI客户端,其中 baseURL 指定了代理的 API端点。创建HTTP服务器使用 http.createServer() 方法创建一个 HTTP服务器,传入一个异步函数作为回调处理请求和响应。设置响应头允许跨域访问,允许 任何源('*'),允许 GET、POST 和 OPTIONS 请求方法,以及指定允许的请求头。 res.setHeader('Access-Control-Allow-Origin', '*'); // 允许所有来源访问,也可以指定具体的域名 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); // 允许的请求方法 res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');请求逻辑处理判断请求 URL 是否包含 /users 路径,如果是,则继续处理。if (req.url.indexOf('/users') >= 0)使用 url.parse() 解析请求 URL 的查询参数。const parsedUrl = url.parse(req.url, true);解构出 question 和 users 两个查询参数。 const { question, users } = parsedUrl.query;构造一个 prompt,包含提供的 JSON 数据和问题,要求 AI 模型根据这些信息回答提出的问题。使用 await client.chat.completions.create() 异步调用 OpenAI API,传递模型类型(gpt-3.5-turbo)、消息内容(包含构造的prompt)以及控制输出随机性的temperature参数。从API响应中提取第一选择的回复内容。将回复内容封装成 JSON 对象 info,设置响应状态码为 200(成功),响应头为 Content-Type: text/json,最后使用 res.end() 发送JSON格式的响应给客户端。启动服务器调用 server.listen(8888) 使服务器在本地 8888 端口上监听 HTTP 请求。总结该服务器的核心功能是接收包含用户数据和问题的 HTTP GET 请求,利用 gpt-3.5-turbo 模型生成针对该问题的回答,然后将这个回答作为JSON响应返回给客户端。用于构建HTML页面中的AI助理功能,根据用户提供的一系列用户数据和问题,返回相应的解答。前端代码来到 frontend 文件夹中,新建一个名为 index.html 的文件,在该文件中输入以下代码:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>AI全栈</title> <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container"> <div class="row col-md-6 col-md-offset-3"> <h1>AI全栈</h1> <table class="table table-striped" id="user_table"> <thead> <tr> <th>ID</th> <th>姓名</th> <th>家乡</th> </tr> </thead> <tbody> </tbody> </table> <form name="aiForm" method="get" action="http://www.baidu.com"> <div class="form-group"> <label for="questionInput">向AI助理提问:</label> <input type="text" name="question" class="form-control" id="questionInput" placeholder="请输入您想问的users相关问题"> </div> <button type="submit" class="btn btn-default">提交</button> </form> <div class="row" id="message"></div> </div> </div> <script> const oMessage = document.querySelector('#message'); const oBody = document.querySelector('#user_table tbody'); const oForm = document.forms['aiForm']; let usersDate = [] fetch('http://localhost:3000/users') .then(data => data.json()) .then(users => { usersDate = users; oBody.innerHTML = users.map(user => ` <tr> <td>${user.id}</td> <td>${user.name}</td> <td>${user.hometown}</td> </tr> `).join('') }) oForm.addEventListener('submit', function (event) { // 阻止页面提交 阻止表单的默认行为 event.preventDefault(); // name 属性去找 性能更好 const question = this["question"].value.trim(); console.log(question); fetch(`http://localhost:8888/users?question=${question}&users=${JSON.stringify(usersDate)}`) .then(data => data.json()) .then(res => { // console.log(res); document.querySelector('#message').innerHTML = res.message; }) }) </script> </body> </html>代码详解该项目的 HTML 页面结合了 Bootstrap 框架和 JavaScript(使用fetch API)来实现一个用户信息展示和交互的示例。获取用户数据使用 fetch() 从http://localhost:3000/users(该地址为后端项目封装数据的地址,查询方式见上文) 获取数据。数据返回后,将其转换为JSON格式,并将结果赋值给 usersDate。遍历 usersDate,动态创建表格行()并添加到 oBody 中。表单提交处理给表单添加事件监听器到表单,当表单被提交时触发。使用 event.preventDefault() 阻止表单的默认提交行为(防止页面跳转)。获取用户在 questionInput 输入框中输入的问题。使用 fetch() 向http://localhost:8888/users发送问题和用户数据的GET请求。(注意:这里需要将用户数据信息转为json格式)fetch(`http://localhost:8888/users?question=${question}&users=${JSON.stringify(usersDate)}`)服务器返回的数据转为 JSON格式,并且从中获取 message 属性,最后将其内容显示在 oMessage 元素内。总结前端的 HTML 页面与后端的 Node.js 服务器代码配合工作,用户可以在界面上查看用户数据,输入问题,然后获取 AI 的回复。项目运行效果展示来到 ai_server 文件夹的终端,运行 main.js 来启动后端服务器。打开项目的前端页面,输入指令: 请问有哪些同学是老乡后端在接收到用户给出的请求时,调用 OpenAi 的接口立马给出了问题的答案。本篇文章就到此为止啦,希望通过这篇文章能对你了解使用 OpenAI API 搭建 AI助理 有所帮助,本人水平有限难免会有纰漏,欢迎大家指正。如觉得这篇文章对你有帮助的话,欢迎点赞收藏加关注,感谢支持🌹🌹。
2024年05月28日
50 阅读
0 评论
0 点赞
2024-03-18
vue2到vue3中插槽slot变化详解---从slot,slot-scope到v-slot的变化
前言vue 插槽,目前到3.0有2种方式,第一种,在2.6之前使用的是 slot 和 slot-scpe 2.6后已被官方废弃,但在2.x版本仍被支持,第二种是vue 在2.6版本后更新的新指令 v-slot 来替代slot 和 slot-scpe那么什么是插槽呢,作用又是什么插槽,简单说,插槽就是杯子,杯子里面装的是饮料还是牛奶,由外部倒入什么来决定 ,就好比下面的代码,我需要一个子组件,他有部分内容,需要根据我当前页面需要来展示,我如何将html模板传人到子组件就需要使用插槽。所以我定义了一个子组件item,我用solt标签定义了一个默认插槽,为在父组件使用时,需要传递到item组件的模板,占个位置, 这样我在组件,使用item子组件,在其中编写,html模板就会被渲染到子组件默认插槽//父组件 <template> <tab> <item > <div>装一杯牛奶</div> <item> <tab> </template> //item子组件 <template> <div> <slot ></slot>//默认插槽 在父组件使用item子组件,item标签包裹的内容将默认被渲染到子组件的 solt中 <h1> 我是杯子 </h1> </div> </template>这样的好处,显而易见,可以让组件模块化更清晰,同时复用性更高,不至于,我要一杯茶,我就要定义一个组件,我要一杯牛奶我又定义一个组件,有了插槽,我只需要定义一个杯子,要喝什么由使用的传人决定。上述代码也叫默认插槽,就是默认把模板全部渲染到solt中,如果需要指定渲染,就需要使用具名插槽,简单说就是起一个名字,告诉他小红该坐那儿,小明该坐那儿具名插槽//父级 <template> <div> <layout> <div solt="header">头部标题</div> <div >显示的内容</div> <div slot="footer">尾部</div> </layout> </div> </template> //layout子组件 <template> <div> <layout> <h1>layout子组件</h1> <slot name="header"></slot> //这种就叫具名插槽 <slot></slot> //如果不指定名字,就会将模板中未匹配到的内容渲染到默认插槽中,这里为显示的内容 <slot name="footer"></slot> </layout> </div> </template>上面已说, 具名插槽 简单说就是起一个名字,告诉他小红该坐那儿,小明该坐那儿tip: 当你的子组件中 如layout 中并不存在,slot这个元素,那么在父页面中 这个标签中的内容都会被抛弃作用域插槽父组件提供了模板给子组件,那么子组件如何反馈给父组件呢,例如:我定义了一个杯子,我需要告诉使用的人,我这个杯子,只能装300mL,这时我们就需要用slot-scope来接收子组件上通过v-bind绑定的值。作用域插槽,就是能让插槽内容访问到子组件中才有的数据//父级 <template> <div> <cup> <div solt="size" slot-scope="data"> {{data.msg}} </div> </cup> </div> </template> //cup子组件 <template> <div> <slot name="size" :msg="msg"></slot> </div> </template> <script> export default { data(){ return{ msg:'300mL大小的杯子' } } } </script>解构prop的写法下面写法等同上面//父级 <template> <div> <cup> <div slott="size" slot-scope="{msg}"> {{msg}} </div> </cup> </div> </template>上述是vue2.6之前的版本,之后vue官方废弃了上面的语法,改为v-solt来代替,然后大家就想知道,区别在哪呢首先就是 用一个指令合并了solt 和solt-scope2个attribute,写法更加简洁,其次就是语义化更明显2.6之前的写法会出现作用域混淆的问题例:<one> <two slot-scope="one"> //接受到的作用域,是外层one组件的,而不是当前组件two <three slot-scope="two"> <template slot-scope="three"> {{ one }} {{ two }} {{ three }} </template> </three> </two> </one>如上述代码一层子组件时,你能清晰的看清作用域是哪一个组件的,但多层嵌套后,每一层接收的作用域是外层组件的而不是当前组件的,这样,就会不清晰,所以vue希望能实现,当前组件,接受当前组件的作用域于是就有了v-solt 下面是改良后的代码,是否更加的清晰了<one v-slot="one"> <twotwo v-slot="two"> //接收到的作用域为当前two组件的 <three v-slot="three"> {{ one }} {{ two }} {{ three }} </three> </bar> </one>如果没看懂 那么下面来阐述,v-solt的使用变化,v-solt 默认插槽和原来不同便是,原来的solt属性可以定义在任何元素上,现在v-solt只能是template元素上,只有一种额外情况,就是独占默认插槽,我们先看常规情况。v-slot:default这种就是具名的写法//父组件 <template> <item > <template v-slot:default> // v-slot:default可以不加,只能定义在template上 <div>装一杯牛奶</div> </template> <item> </template> //item子组件 <template> <div> <slot ></slot>//默认插槽 <h1> 我是杯子 </h1> </div> </template>未具名的solt 元素,自动默认名为default 你可以不写,当然如果你要看的更清晰,独占默认插槽提供内容只有默认插槽,上述就满足此条件,所以我们可以这样写//父组件 <template> <item v-slot:default> //v-slot:default可以不加 <div>装一杯牛奶</div> <item> </template> //item子组件 <template> <div> <slot ></slot>//默认插槽 <h1> 我是杯子 </h1> </div> </template>v-solt具名插槽//父级 <template> <div> <layout> <template v-slot:header>//v-slot指令使用插槽 <div >头部标题</div> </template> <div >显示的默认内容</div> <!-- 或者 <template v-slot:default> <div >显示的默认内容</div> </template> --> <template v-slot:footer> <div >尾部</div> </template> </layout> </div> </template> //layout子组件 <template> <div> <layout> <h1>layout子组件</h1> <slot name="header"></slot> //这种就叫具名插槽 <slot></slot> //如果不指定名字,就会将模板中未匹配到的内容渲染到默认插槽中,这里为显示的内容 <slot name="footer"></slot> </layout> </div> </template>v-solt作用域插槽这是改动最大地方//父级 <template> <div> <cup> <template v-slot:default="data"> //具名写法 <div> {{data.msg}} </div> </template> <!-- 或者 <template v-slot="data"> <div > {{data.msg}} </div> </template> --> </cup> </div> </template> //cup子组件 <template> <div> <slot :msg="msg"></slot > </div> </template> <script> export default { data(){ return{ msg:'300mL大小的杯子' } } } </script>当为独占默认插槽时,v-solt可以省略default不写注意默认插槽的缩写语法不能和具名插槽混用,因为它会导致作用域不明确下面是官方的例子<!-- 无效,会导致警告 --> <current-user v-slot="slotProps"> {{ slotProps.user.firstName }} <template v-slot:other="otherSlotProps"> slotProps is NOT available here </template> </current-user>所以当出现多个插槽的时候,请使用完整的基于 template 的语法解构props的写法这里使用上面cup组件的例子<template> <div> <cup> <template v-slot:default="{msg}"> //解构 <div> {{msg}} </div> </template> </cup> </div> </template>v-slot 的解构还提供 重命名的写法<template> <div> <cup> <template v-slot:default="{msg : size}"> //别名 <div> {{size}} </div> </template> </cup> </div> </template>动态插槽名v-slot 支持2.6的动态参数写法<layout> <template v-slot:[attributeName]> ... </template> </layout>这里的 attributeName 会被作为一个 JavaScript 表达式进行动态求值,求得的值将会作为最终的参数来使用。例如,如果你的 Vue 实例有一个 data property attributeName,其值为 "header",那么这个绑定将等价于 v-slot:header。插槽的缩写2.6后插槽 可以把参数之前的所有内容 (v-slot:) 替换为字符 #。例如 v-slot:header 可以被重写为 #headerv-slot: 后面必须有值,不可写成#="{data}"<template> <div> <cup> <template #default="msg"> <div> {{size}} </div> </template> </cup> </div> </template>
2024年03月18日
62 阅读
0 评论
0 点赞
2024-03-15
Maven异常:was cached in the local repository, resolution will not be reattempted until the update
mvn打包时遇到异常,使用 -X 参数输出详细的日志(mvn -X package),异常信息如下:Failure to find cn.com.xxx... in https://maven.aliyun.com/repository/public was cached in the local repository, resolution will not be reattempted until the update interval of aliyunmaven has elapsed or updates are forced基本情况导致报错的依赖为私有的jar,是手动放置到本地仓库的。镜像仓库配置了阿里云的。可能原因 由于第一次构建项目时没事先放置本地依赖,Maven会尝试从远程下载依赖,找不到依赖就会导致构建失败,并会在依赖的目录下新建了 .lastUpdated 文件。在这之后,手动放置了依赖。第二次进行构建,Maven默认会使用本地缓存的库来编译工程,当发现 .lastUpdated 时,则会认为该依赖异常,最终导致构建失败。解决方案 尝试删除仓库中的.lastUpdated文件,先进入到仓库根目录,再执行如下命令Window环境for /r %i in (*.lastUpdated) do del %ifor /r %i in (_remote.repositories) do del %iLinux环境find . -name "*.lastUpdated" | xargs rm -frfind . -name "_remote.repositories" | xargs rm -fr
2024年03月15日
48 阅读
0 评论
0 点赞
2024-02-26
后端如何做到无感刷新Token?
前言为什么需要无感刷新Token?自动刷新token前端token续约疑问及思考为什么需要无感刷新Token?最近浏览到一个文章里面的提问,是这样的: 当我在系统页面上做业务操作的时候会出现突然闪退的情况,然后跳转到登录页面需要重新登录系统,系统使用了Redis做缓存来存储用户ID,和用户的token信息,这是什么问题呢?解答: 突然闪退,一般都是由于你的token过期的问题,导致身份失效。解决方案: 自动刷新tokentoken续约思路 如果Token即将过期,你在验证用户权限的同时,为用户生成一个新的Token并返回给客户端,客户端需要更新本地存储的Token,还可以做定时任务来刷新Token,可以不生成新的Token,在快过期的时候,直接给Token增加时间自动刷新token自动刷新token是属于后端的解决方案,由后端来检查一个Token的过期时间是否快要过期了,如果快要过期了,就往请求头中重新放一个token,然后前端那边做拦截,拿到请求头里面的新的token,如果这个新的token和老的token不一致,直接将本地的token更换接下来拿代码举例子先引入依赖<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.5.1</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>这是一个生成token的例子import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; import java.util.Date; import java.util.UUID; public class JwtUtil { // 有效期为 public static final Long JWT_TTL = 60 * 60 * 1000 * 24;// 60 * 60 * 1000 * 24 一个小时 // 设置秘钥明文 --- 自己改就行 public static final String JWT_KEY = "qx"; // 用于生成uuid,用来标识唯一 public static String getUUID(){ String uuid = UUID.randomUUID().toString().replaceAll("-", "");//token用UUID来代替 return uuid; } /** id : 标识唯一 subject : 我们想要加密存储的数据 ttl : 我们想要设置的过期时间 */ // 生成token jwt加密 subject token中要存放的数据(json格式) public static String createJWT(String subject) { JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间 return builder.compact(); } // 生成token jwt加密 public static String createJWT(String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间 return builder.compact(); } // 创建token jwt加密 public static String createJWT(String id, String subject, Long ttlMillis) { JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间 return builder.compact(); } private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; SecretKey secretKey = generalKey(); long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); if(ttlMillis==null){ ttlMillis=JwtUtil.JWT_TTL; } long expMillis = nowMillis + ttlMillis; Date expDate = new Date(expMillis); return Jwts.builder() .setId(uuid) //唯一的ID .setSubject(subject) // 主题 可以是JSON数据 .setIssuer("sg") // 签发者 .setIssuedAt(now) // 签发时间 .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥 .setExpiration(expDate); } // 生成加密后的秘钥 secretKey public static SecretKey generalKey() { byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY); SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES"); return key; } // jwt解密 public static Claims parseJWT(String jwt) throws Exception { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } }写个单元测试,测试一下@Test void test() throws Exception { String token = JwtUtil.createJWT("1735209949551763457"); System.out.println("Token: " + token); Date tokenExpirationDate = getTokenExpirationDate(token); System.out.println(tokenExpirationDate); System.out.println(tokenExpirationDate.toString()); long exp = tokenExpirationDate.getTime(); long cur = System.currentTimeMillis(); System.out.println(exp); System.out.println(cur); System.out.println(exp - cur); } // 解析令牌并获取过期时间 public static Date getTokenExpirationDate(String token) { try { SecretKey secretKey = generalKey(); Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); return claims.getExpiration(); } catch (ExpiredJwtException | SignatureException e) { throw new RuntimeException("Invalid token", e); } }可以看到我们的 exp 过期时间的毫秒数为 1703651262000可以看到我们的 cur 当前时间的毫秒数为 1703564863035我们将两者相减得到的值为 86398965ms,我们可以算一下一天的毫秒数是多少 1000 60 60 * 24 ms = 86400000ms这样我们就能够拿到token的过期时间tokenExpirationDate了我们就可以通过在校验token的时候,如果token校验通过了,此时我们拿到该token的过期时间,以(过期时间 - 当前时间)进行判断如果说 (过期时间 - 当前时间) 小于约定的值,那么我们就重新根据token里面的信息,重新创建一个token,将新的token放到请求头中返回给前端,前端去进行本地存储更新token前端token续约token的续约偏向于前端的解决方案,即由前端来进行token的过期时间的判断,首先前后端需要对接商量好一个token续约的接口,当前端发现这个token快要过期的时候,向后端发送该token,然后后端将该token的过期时间延长。「前端采用的是双Token的方式,access-token 和 refresh-token即 AT 和 RT」「而对于纯后端的方式,就是只有access-token这一个token」「那么问题来了 AT 和 RT 到底有什么区别?为什么需要RT?」「在前端实现方案来说,RT是用来在AT即将过期的时候,用RT获取最新的token」我解释一下我的观点:AT的暴露机会更多,每个请求都要携带,所以设置的过期时间短一点,「减少劫持风险」RT只会暴露在auth服务中用来刷新at,设置的过期时间长一点,「增加便利性。」AT 和 RT 是为了网络传输安全,网络传输中,容易暴露 AT,因为 AT 时间短,暴露后风险系数才低「这种是标准的安全处理,其实已经无需探讨他的合理性,就好像 https 之于 http 一样」疑问及思考要是前端有一个表单页面,长时间不进行请求的发送,此时用户填写完表单了,再点击提交的时候,后端返回401了,怎么办?也就是说,虽然你后端可以无感刷新Token,但是你后端无感刷新Token的前提是:前端得发请求,如果用户长时间不进行页面的交互,即没有进行任何业务逻辑的跳转什么的,就单纯的往表单上面填东西,什么请求也没发的情况下,后端是无法感知Token过期的这种情况怎么解决? 对于纯后端的解决方案,我是这样想的让前端在表单填写内容的时候做处理,如果提交返回的是401,那么前端就需要获取表单存在本地存储 然后跳转登录页,登录成功后,返回这个页面,然后从本地存储取出来再回显到表单上面。对于前端的解决方案,我是这样想的对于后端来说就是AT过期了,而对于前端来说就是AT和RT都过期了,怎么处理?需要监听refresh token的过期时间,在接近过期的时候向后端发起请求来刷新refresh token 或者是定期刷新一下refresh token和后端的解决方案一样,前端做一个类似草稿箱的功能对表单等元素进行保存
2024年02月26日
29 阅读
0 评论
0 点赞
2024-02-26
动态更改 Spring 定时任务 Cron 表达式
在 SpringBoot 项目中,我们可以通过 @EnableScheduling 注解开启调度任务支持,并通过 @Scheduled 注解快速地建立一系列定时任务。@Scheduled 支持下面三种配置执行时间的方式:cron(expression):根据Cron表达式来执行。fixedDelay(period):固定间隔时间执行,无论任务执行长短,两次任务执行的间隔总是相同的。fixedRate(period):固定频率执行,从任务启动之后,总是在固定的时刻执行,如果因为执行时间过长,造成错过某个时刻的执行(晚点),则任务会被立刻执行。最常用的应该是第一种方式,基于Cron表达式的执行模式,因其相对来说更加灵活。可变与不可变默认情况下,@Scheduled 注解标记的定时任务方法在初始化之后,是不会再发生变化的。 Spring 在初始化 bean 后,通过后处理器拦截所有带有 @Scheduled 注解的方法,并解析相应的的注解参数,放入相应的定时任务列表等待后续统一执行处理。到定时任务真正启动之前,我们都有机会更改任务的执行周期等参数。换言之,我们既可以通过 application.properties 配置文件配合 @Value 注解的方式指定任务的 Cron 表达式,亦可以通过 CronTrigger 从数据库或者其他任意存储中间件中加载并注册定时任务。这是 Spring 提供给我们的可变的部分。但是我们往往要得更多。能否在定时任务已经在执行过的情况下,去动态更改 Cron 表达式,甚至禁用某个定时任务呢?很遗憾,默认情况下,这是做不到的,任务一旦被注册和执行,用于注册的参数便被固定下来,这是不可变的部分。创造与毁灭既然创造之后不可变,那就毁灭之后再重建吧。于是乎,我们的思路便是,在注册期间保留任务的关键信息,并通过另一个定时任务检查配置是否发生变化,如果有变化,就把“前任”干掉,取而代之。如果没有变化,就保持原样。先对任务做个简单的抽象,方便统一的识别和管理:public interface IPollableService { /** * 执行方法 */ void poll(); /** * 获取周期表达式 * * @return CronExpression */ default String getCronExpression() { return null; } /** * 获取任务名称 * * @return 任务名称 */ default String getTaskName() { return this.getClass().getSimpleName(); } }最重要的便是 getCronExpression() 方法,每个定时服务实现可以自己控制自己的表达式,变与不变,自己说了算。至于从何处获取,怎么获取,请诸君自行发挥了。接下来,就是实现任务的动态注册:@Configuration @EnableAsync @EnableScheduling public class SchedulingConfiguration implements SchedulingConfigurer, ApplicationContextAware { private static final Logger log = LoggerFactory.getLogger(SchedulingConfiguration.class); private static ApplicationContext appCtx; private final ConcurrentMap<String, ScheduledTask> scheduledTaskHolder = new ConcurrentHashMap<>(16); private final ConcurrentMap<String, String> cronExpressionHolder = new ConcurrentHashMap<>(16); private ScheduledTaskRegistrar taskRegistrar; public static synchronized void setAppCtx(ApplicationContext appCtx) { SchedulingConfiguration.appCtx = appCtx; } @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { setAppCtx(applicationContext); } @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { this.taskRegistrar = taskRegistrar; } /** * 刷新定时任务表达式 */ public void refresh() { Map<String, IPollableService> beanMap = appCtx.getBeansOfType(IPollableService.class); if (beanMap.isEmpty() || taskRegistrar == null) { return; } beanMap.forEach((beanName, task) -> { String expression = task.getCronExpression(); String taskName = task.getTaskName(); if (null == expression) { log.warn("定时任务[{}]的任务表达式未配置或配置错误,请检查配置", taskName); return; } // 如果策略执行时间发生了变化,则取消当前策略的任务,并重新注册任务 boolean unmodified = scheduledTaskHolder.containsKey(beanName) && cronExpressionHolder.get(beanName).equals(expression); if (unmodified) { log.info("定时任务[{}]的任务表达式未发生变化,无需刷新", taskName); return; } Optional.ofNullable(scheduledTaskHolder.remove(beanName)).ifPresent(existTask -> { existTask.cancel(); cronExpressionHolder.remove(beanName); }); if (ScheduledTaskRegistrar.CRON_DISABLED.equals(expression)) { log.warn("定时任务[{}]的任务表达式配置为禁用,将被不会被调度执行", taskName); return; } CronTask cronTask = new CronTask(task::poll, expression); ScheduledTask scheduledTask = taskRegistrar.scheduleCronTask(cronTask); if (scheduledTask != null) { log.info("定时任务[{}]已加载,当前任务表达式为[{}]", taskName, expression); scheduledTaskHolder.put(beanName, scheduledTask); cronExpressionHolder.put(beanName, expression); } }); } }重点是保存 ScheduledTask 对象的引用,它是控制任务启停的关键。而表达式“-”则作为一个特殊的标记,用于禁用某个定时任务。当然,禁用后的任务通过重新赋予新的 Cron 表达式,是可以“复活”的。完成了上面这些,我们还需要一个定时任务来动态监控和刷新定时任务配置:@Component public class CronTaskLoader implements ApplicationRunner { private static final Logger log = LoggerFactory.getLogger(CronTaskLoader.class); private final SchedulingConfiguration schedulingConfiguration; private final AtomicBoolean appStarted = new AtomicBoolean(false); private final AtomicBoolean initializing = new AtomicBoolean(false); public CronTaskLoader(SchedulingConfiguration schedulingConfiguration) { this.schedulingConfiguration = schedulingConfiguration; } /** * 定时任务配置刷新 */ @Scheduled(fixedDelay = 5000) public void cronTaskConfigRefresh() { if (appStarted.get() && initializing.compareAndSet(false, true)) { log.info("定时调度任务动态加载开始>>>>>>"); try { schedulingConfiguration.refresh(); } finally { initializing.set(false); } log.info("定时调度任务动态加载结束<<<<<<"); } } @Override public void run(ApplicationArguments args) { if (appStarted.compareAndSet(false, true)) { cronTaskConfigRefresh(); } } }当然,也可以把这部分代码直接整合到 SchedulingConfiguration 中,但是为了方便扩展,这里还是将执行与触发分离了。毕竟除了通过定时任务触发刷新,还可以在界面上通过按钮手动触发刷新,或者通过消息机制回调刷新。这一部分就请大家根据实际业务情况来自由发挥了。验证我们创建一个原型工程和三个简单的定时任务来验证下,第一个任务是执行周期固定的任务,假设它的Cron表达式永远不会发生变化,像这样:@Service public class CronTaskBar implements IPollableService { @Override public void poll() { System.out.println("Say Bar"); } @Override public String getCronExpression() { return "0/1 * * * * ?"; } }第二个任务是一个经常更换执行周期的任务,我们用一个随机数发生器来模拟它的善变:@Service public class CronTaskFoo implements IPollableService { private static final Random random = new SecureRandom(); @Override public void poll() { System.out.println("Say Foo"); } @Override public String getCronExpression() { return "0/" + (random.nextInt(9) + 1) + " * * * * ?"; } }第三个任务就厉害了,它仿佛就像一个电灯的开关,在启用和禁用中反复横跳:@Service public class CronTaskUnavailable implements IPollableService { private String cronExpression = "-"; private static final Map<String, String> map = new HashMap<>(); static { map.put("-", "0/1 * * * * ?"); map.put("0/1 * * * * ?", "-"); } @Override public void poll() { System.out.println("Say Unavailable"); } @Override public String getCronExpression() { return (cronExpression = map.get(cronExpression)); } }如果上面的步骤都做对了,日志里应该能看到类似这样的输出:定时调度任务动态加载开始>>>>>> 定时任务[CronTaskBar]的任务表达式未发生变化,无需刷新 定时任务[CronTaskFoo]已加载,当前任务表达式为[0/6 * * * * ?] 定时任务[CronTaskUnavailable]的任务表达式配置为禁用,将被不会被调度执行 定时调度任务动态加载结束<<<<<< Say Bar Say Bar Say Foo Say Bar Say Bar Say Bar 定时调度任务动态加载开始>>>>>> 定时任务[CronTaskBar]的任务表达式未发生变化,无需刷新 定时任务[CronTaskFoo]已加载,当前任务表达式为[0/3 * * * * ?] 定时任务[CronTaskUnavailable]已加载,当前任务表达式为[0/1 * * * * ?] 定时调度任务动态加载结束<<<<<< Say Unavailable Say Bar Say Unavailable Say Bar Say Foo Say Unavailable Say Bar Say Unavailable Say Bar Say Unavailable Say Bar小结我们在上文通过定时刷新和重建任务的方式来实现了动态更改 Cron 表达式的需求,能够满足大部分的项目场景,而且没有引入 quartzs 等额外的中间件,可以说是十分的轻量和优雅了。当然,如果各位看官有更好的方法,还请不吝赐教。
2024年02月26日
14 阅读
0 评论
0 点赞
2024-02-20
开源流程引擎三巨头:activiti、flowable、camunda,最推荐使用哪个?
市场上比较有名的开源流程引擎有 osworkflow、jbpm、activiti、flowable、camunda 。其中: Jbpm4、Activiti、Flowable、camunda 四个框架同宗同源,祖先都是 Jbpm4 ,开发者只要用过其中一个框架,基本上就会用其它三个。低代码平台、办公自动化(OA)、BPM平台、工作流系统均需要流程引擎功能,对于市场上如此多的开源流程引擎,哪个功能和性能好,该如何选型呢?一、主流开源流程引擎介绍1、OsworkflowOsworkflow是一个轻量化的流程引擎,基于状态机机制,数据库表很少,Osworkflow提供的工作流构成元素有:步骤(step)、条件(conditions)、循环(loops)、分支(spilts)、合并(joins)等,但不支持会签、跳转、退回、加签等这些操作,需要自己扩展开发,有一定难度,如果流程比较简单,osworkflow是很好的选择,但该开源组件已过时,长时间没有版本升级了。官方网站:http://www.opensymphony.com/osworkflow/2、JBPMJBPM由JBoss公司开发,目前最高版本JPBM7,不过从JBPM5开始已经跟之前不是同一个产品了,JBPM5的代码基础不是JBPM4,而是从Drools Flow重新开始,基于Drools Flow技术在国内市场上用的很少,所以不建议选择jBPM5以后版本。jBPM4诞生的比较早,后来JBPM4创建者Tom Baeyens离开JBoss后,加入Alfresco后很快推出了新的基于jBPM4的开源工作流系统Activiti,另外JBPM以hibernate作为数据持久化ORM也已不是主流技术,现在时间节点选择流程引擎,JBPM不是最佳选择。官方网站:https://www.jbpm.org/3、Activitiactiviti由Alfresco软件开发,目前最高版本activiti 7。activiti的版本比较复杂,有activiti5、activiti6、activiti7几个主流版本,选型时让人晕头转向,有必要先了解一下activiti这几个版本的发展历史。activiti5和activiti6的核心leader是Tijs Rademakers,由于团队内部分歧,在2017年时Tijs Rademakers离开团队,创建了后来的flowable,activiti6以及activiti5代码已经交接给了 Salaboy团队。activiti6以及activiti5的代码官方已经暂停维护了,Salaboy团队目前在开发activiti7框架,activiti7内核使用的还是activiti6,并没有为引擎注入更多的新特性,只是在activiti之外的上层封装了一些应用。结论是activiti谨慎选择。官方网站:https://www.activiti.org/4、flowableflowable基于activiti6衍生出来的版本,flowable目前最新版本是v6.6.0,开发团队是从activiti中分裂出来的,修复了一众activiti6的bug,并在其基础上研发了DMN支持,BPEL支持等等,相对开源版,其商业版的功能会更强大。以flowable6.4.1版本为分水岭,大力发展其商业版产品,开源版本维护不及时,部分功能已经不再开源版发布,比如表单生成器(表单引擎)、历史数据同步至其他数据源、ES等。 Flowable 是一个使用 Java 编写的轻量级业务流程引擎,使用 Apache V2 license 协议开源。2016 年 10 月,Activiti 工作流引擎的主要开发者离开 Alfresco 公司并在 Activiti 分支基础上开启了 Flowable 开源项目。基于 Activiti v6 beta4 发布的第一个 Flowable release 版本为6.0。Flowable 项目中包括 BPMN(Business Process Model and Notation)引擎、CMMN(Case Management Model and Notation)引擎、DMN(Decision Model and Notation)引擎、表单引擎(Form Engine)等模块。官方网站:https://flowable.com/open-source/5、CamundaCamunda基于activiti5,所以其保留了PVM,最新版本Camunda7.15,保持每年发布2个小版本的节奏,开发团队也是从activiti中分裂出来的,发展轨迹与flowable相似,同时也提供了商业版,不过对于一般企业应用,开源版本也足够了, 强烈推荐camunda流程引擎,功能和性能表现稳定。 选择camunda的理由:1)通过压力测试验证Camunda BPMN引擎性能和稳定性更好。2)功能比较完善,除了BPMN,Camunda还支持企业和社区版本中的CMMN(案例管理)和DMN(决策自动化)。Camunda不仅带有引擎,还带有非常强大的工具,用于建模,任务管理,操作监控和用户管理,所有这些都是开源的。官方网站:https://docs.camunda.org/manual/7.15/introduction/二、flowable与Camunda对比分析1、功能方面对比由于Flowable与Camunda好多功能都是类似的,因此在这里重点罗列差异化的功能camunda支持流程实例的迁移,比如同一个流程有多个实例,多个流程版本,不同流程实例运行在不同的版本中,camunda支持任意版本的实例迁移到指定的流程版本中,并可以在迁移的过程中支持从哪个节点开始。camunda基于PVM技术,所以用户从Activii5迁移到camunda基本上毫无差异。flowable没有pvm了,所以迁移工作量更大(实例的迁移,流程定义的迁移、定时器的迁移都非常麻烦)。camunda对于每一个CMD命令类都提供了权限校验机制,flowable没有。camunda继续每一个API都有批处理的影子,flowable几乎没有。比如批量挂起流程、激活流程等,使用camunda可以直接使用API操作,使用Flowable则只能自己去查询集合,然后循环遍历集合并操作。camunda很多API均支持批处理,在批量处理的时候可以指定是异步方式操作或者是同步方式操作。异步的话定时器会去执行。Flowable没有异步批处理的机制。比如批量异步删除所有的历史数据。camunda启动实例的时候支持从哪个节点开始,而不是仅仅只能从开始节点运转实例。Flowable仅仅只能从开始节点运转实例。camunda支持任意节点的跳转,可以跳转到连线也可以跳转到节点,并且在跳转的过程中支持是否触发目标节点的监听器。flowable没有改原生API需用户去扩展。camunda支持双异步机制,第一个异步即节点可以异步执行,第二个异步方式是:完成异步任务后,还可以继续异步去执行任务后面的连线。所以称之为双异步机制,flowable只有第一种异步方式。camunda支持多种脚本语言,这些脚本语言可以在连线上进行条件表达式的配置,开箱即用。比如python、ruby、groovy、JUEL。flowable仅仅支持JUEL、groovy。开箱即用的意思就是如果想用python直接引入jython包就可以用了,不需要额外配置。camunda支持外部任务,比如我们有时候想在一个节点中执行调用第三方的API或者完成一些特定的逻辑操作,就可以使用外部任务,外部任务有两种表,并支持第三方系统定期来抓取并锁定外部任务,然后执行业务完毕之后,完成外部任务,流程实例继续往下执行。外部任务的好处就是解决了分布式事物的问题。在flowable中我们可以使用httpTask任务,我个人更倾向于camunda外部任务,因为这个外部任务有外部系统决定什么时候完成,httpTask是不等待任务,实例走到这个节点之后,调用一个api就直接往下跑了,外部任务不会继续往下跑,有外部系统去决定啥时候往下跑。camunda支持为用户定制一些个性化的偏好查找API,比如张三每次查询任务的时候,一般固定点击某某三个查询条件过滤数据,使用camunda就可以将这三个查询条件进行持久化,下次张三来了,就可以直接根据他的偏好进行数据的过滤,类似机器学习。camunda支持历史数据的批量删除或者批量迁移到其他介质,比如批量迁移到es,flowable没有该机制。camunda支持在高并发部署流程的时候,是否使用锁机制,flowable没有该机制。camunda支持单引擎多组合、多引擎多库。flowable仅仅支持单引擎多组合。camunda支持流程实例跨流程定义跳转,flowable没有该机制。camunda支持分布式定时器,flowable没有该机制。flowable支持nosql,camunda只有nosql的解决方案。camunda支持优化流程,以及了解流程引擎的瓶颈所在和每个环节的耗时,flowable没有该机制。camunda修改了流程模板xml解析方式,相比flowable性能更好。camunda在解析流程模板xml的时候,去除了activiti5的双解析机制,相对而言耗时时间更短。flowable没有了pvm所以规避了双解析机制。camunda可以在任意节点添加任意的属性,flowable原生API没有,需要自己扩展。camunda框架没有为流程生成图片的API(所有流程图展示以及高亮均在前端动态计算),activiti5/6/flowable5/flowable6有图片生成以及高亮的API.camunda可以在节点中定义定时作业的优先级,也可以在流程中进行全局优先级的定义。当节点没有定义优先级的时候可以使用全局的优先级字段。activiti5/6/flowable5/flowable6没有改功能。camunda可以再流程中定义流程的tag标记,activiti5/6/flowable5/flowable6没有改功能。camunda/activiti5/6/flowable5/flowable6 均不支持国产数据库,比如人大金仓 和 达梦。flowable6支持LDAP,openLDAP,camunda不支持。activiti5不支持。2、性能方面对比笔者通过flowable和camunda多组对比测试,camunda性能比flowablet提升最小10%,最大39%,而且camunda无报错,flowable有报错,camunda在高并发场景下稳定性更好。性能测试详细文章见:https://lowcode.blog.csdn.net/article/details/109030329三、选型推荐推荐大家使用 camunda(流程引擎)+ bpmn-js(流程设计器) 组合,笔者在公司项目中经过实战验证, camunda 在功能方面比 flowable、activiti 流程引擎强大,性能和稳定性更突出。
2024年02月20日
36 阅读
0 评论
0 点赞
2024-02-09
幻兽帕鲁Palworld服务端一键搭建教程
本文所使用一键脚本基于Docker,理论上适用于所有x86架构的主流Linux系统(不支持ARM架构),推荐Debian11 12 Ubuntu20.04 22.04现在内存泄漏在大多数脚本已经带有释放内存的插件了如果你是使用windows搭建,可以尝试使用Mem Reduct可在不重启服务端下,释放内存!一键项目地址:https://github.com/miaowmint/palworld拥有web可视化控制的项目:https://github.com/Hoshinonyaruko/palworld-go以下教程原创 猫猫摸大鱼简要搭建方法:境外机运行一键脚本curl -o palinstall.sh https://raw.githubusercontent.com/miaowmint/palworld/main/install.sh && chmod +x palinstall.sh && bash palinstall.sh如果是境内机访问不了 github ,可以使用我托管的一键脚本curl -o palinstall.sh https://blog.iloli.love/install.sh && chmod +x palinstall.sh && bash palinstall.shtips:第一次运行需要使用一键安装脚本,后续可以直接输入 palworld 进行管理后续都是废话可以不看了脚本使用的是我自己构建的镜像,如果不需要其他功能只是简单开服的话也可以直接运行 docker run -dit --name steamcmd --net host miaowmint/palworld 不需要运行其它任何命令就可以连接服务器了如果想要自行构建镜像可以使用 main 分支里的 Dockerfile详细使用说明运行一键脚本输入 1 安装幻兽帕鲁服务端装成功,此时已经可以连接了(正常需要拉取镜像,约2.9G,我这里已经拉取)修改服务端配置(后续全部为可选步骤,不配置不影响游玩)运行命令 palworld 接下来输入 4 修改服务端配置此时不要乱输入,先前往 https://www.xuehaiwu.com/Pal/ 进行可视化配置,配置完成后点击页面最下方的 生成配置文件 ,并复制如图框选区域的 数字 将这串数字直接粘贴到 shell 然后回车,服务端配置修改就完成了~~导入幻兽帕鲁存档及配置如果你在其他的服务器有存档,想要导入的话,就可以使用这个功能(仅验证了linux开服的存档,windows的和单人存档不保证有效)运行命令 palworld ,输入数字 8 ,开始导入幻兽帕鲁存档及配置。如果你运行过此脚本的导出功能,并且没有删除备份的话,直接回车即可如果你是在运行此脚本前已经搭建好了服务端的,并且是按照我之前写的那四条命令的教程搭建的,可以使用此脚本进行导出然后导入回档至于其他方法搭建的服务端的 Saved 文件夹在哪,(我不知道)导出幻兽帕鲁存档及配置运行命令 palworld ,输入数字 9 。不到一秒钟就导出完成了,导出的存档文件夹为位于 /data/palworld/ 下的 Saved 文件夹。至于其它的配置项就没什么需要额外注意的点了,在这里就不过多赘述了。欢迎大家来使用这个一键脚本 😎
2024年02月09日
65 阅读
0 评论
0 点赞
2024-02-06
SpringBoot 优雅实现超大文件上传,通用方案
前言文件上传是一个老生常谈的话题了,在文件相对比较小的情况下,可以直接把文件转化为字节流上传到服务器,但在文件比较大的情况下,用普通的方式进行上传,这可不是一个好的办法,毕竟很少有人会忍受,当文件上传到一半中断后,继续上传却只能重头开始上传,这种让人不爽的体验。那有没有比较好的上传体验呢,答案有的,就是下边要介绍的几种上传方式详细教程秒传1、什么是秒传通俗的说,你把要上传的东西上传,服务器会先做MD5校验,如果服务器上有一样的东西,它就直接给你个新地址,其实你下载的都是服务器上的同一个文件,想要不秒传,其实只要让MD5改变,就是对文件本身做一下修改(改名字不行),例如一个文本文件,你多加几个字,MD5就变了,就不会秒传了。2、本文实现的秒传核心逻辑a、利用redis的set方法存放文件上传状态,其中key为文件上传的md5,value为是否上传完成的标志位,b、当标志位true为上传已经完成,此时如果有相同文件上传,则进入秒传逻辑。如果标志位为false,则说明还没上传完成,此时需要在调用set的方法,保存块号文件记录的路径,其中key为上传文件md5加一个固定前缀,value为块号文件记录路径分片上传1、什么是分片上传分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(我们称之为Part)来进行分别上传,上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件。2、分片上传的场景1、大文件上传2、网络环境环境不好,存在需要重传风险的场景断点续传1、什么是断点续传断点续传是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传或者下载未完成的部分,而没有必要从头开始上传或者下载。本文的断点续传主要是针对断点上传场景。2、应用场景断点续传可以看成是分片上传的一个衍生,因此可以使用分片上传的场景,都可以使用断点续传。3、实现断点续传的核心逻辑在分片上传的过程中,如果因为系统崩溃或者网络中断等异常因素导致上传中断,这时候客户端需要记录上传的进度。在之后支持再次上传时,可以继续从上次上传中断的地方进行继续上传。为了避免客户端在上传之后的进度数据被删除而导致重新开始从头上传的问题,服务端也可以提供相应的接口便于客户端对已经上传的分片数据进行查询,从而使客户端知道已经上传的分片数据,从而从下一个分片数据开始继续上传。4、实现流程步骤a、方案一,常规步骤将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;初始化一个分片上传任务,返回本次分片上传唯一标识;按照一定的策略(串行或并行)发送各个分片数据块;发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件。b、方案二、本文实现的步骤前端(客户端)需要根据固定大小对文件进行分片,请求后端(服务端)时要带上分片序号和大小服务端创建conf文件用来记录分块位置,conf文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认的0,已上传的就是Byte.MAX_VALUE 127(这步是实现断点续传和秒传的核心步骤)服务器按照请求数据中给的分片序号和每片分块大小(分片大小是固定且一样的)算出开始位置,与读取到的文件片段数据,写入文件。5、分片上传/断点上传代码实现a、前端采用百度提供的webuploader的插件,进行分片。因本文主要介绍服务端代码实现,webuploader如何进行分片,具体实现可以查看链接: http://fex.baidu.com/webuploader/getting-started.htmlb、后端用两种方式实现文件写入,一种是用RandomAccessFile,如果对RandomAccessFile不熟悉的朋友,可以查看链接: https://blog.csdn.net/dimudan2015/article/details/81910690另一种是使用MappedByteBuffer,对MappedByteBuffer不熟悉的朋友,可以查看链接进行了解: https://www.jianshu.com/p/f90866dcbffc后端进行写入操作的核心代码a、RandomAccessFile实现方式@UploadMode(mode = UploadModeEnum.RANDOM_ACCESS) @Slf4j public class RandomAccessUploadStrategy extends SliceUploadTemplate { @Autowired private FilePathUtil filePathUtil; @Value("${upload.chunkSize}") private long defaultChunkSize; @Override public boolean upload(FileUploadRequestDTO param) { RandomAccessFile accessTmpFile = null; try { String uploadDirPath = filePathUtil.getPath(param); File tmpFile = super.createTmpFile(param); accessTmpFile = new RandomAccessFile(tmpFile, "rw"); //这个必须与前端设定的值一致 long chunkSize = Objects.isNull(param.getChunkSize()) ? defaultChunkSize * 1024 * 1024 : param.getChunkSize(); long offset = chunkSize * param.getChunk(); //定位到该分片的偏移量 accessTmpFile.seek(offset); //写入该分片数据 accessTmpFile.write(param.getFile().getBytes()); boolean isOk = super.checkAndSetUploadProgress(param, uploadDirPath); return isOk; } catch (IOException e) { log.error(e.getMessage(), e); } finally { FileUtil.close(accessTmpFile); } return false; } } b、MappedByteBuffer实现方式@UploadMode(mode = UploadModeEnum.MAPPED_BYTEBUFFER) @Slf4j public class MappedByteBufferUploadStrategy extends SliceUploadTemplate { @Autowired private FilePathUtil filePathUtil; @Value("${upload.chunkSize}") private long defaultChunkSize; @Override public boolean upload(FileUploadRequestDTO param) { RandomAccessFile tempRaf = null; FileChannel fileChannel = null; MappedByteBuffer mappedByteBuffer = null; try { String uploadDirPath = filePathUtil.getPath(param); File tmpFile = super.createTmpFile(param); tempRaf = new RandomAccessFile(tmpFile, "rw"); fileChannel = tempRaf.getChannel(); long chunkSize = Objects.isNull(param.getChunkSize()) ? defaultChunkSize * 1024 * 1024 : param.getChunkSize(); //写入该分片数据 long offset = chunkSize * param.getChunk(); byte[] fileData = param.getFile().getBytes(); mappedByteBuffer = fileChannel .map(FileChannel.MapMode.READ_WRITE, offset, fileData.length); mappedByteBuffer.put(fileData); boolean isOk = super.checkAndSetUploadProgress(param, uploadDirPath); return isOk; } catch (IOException e) { log.error(e.getMessage(), e); } finally { FileUtil.freedMappedByteBuffer(mappedByteBuffer); FileUtil.close(fileChannel); FileUtil.close(tempRaf); } return false; } } c、文件操作核心模板类代码@Slf4j public abstract class SliceUploadTemplate implements SliceUploadStrategy { public abstract boolean upload(FileUploadRequestDTO param); protected File createTmpFile(FileUploadRequestDTO param) { FilePathUtil filePathUtil = SpringContextHolder.getBean(FilePathUtil.class); param.setPath(FileUtil.withoutHeadAndTailDiagonal(param.getPath())); String fileName = param.getFile().getOriginalFilename(); String uploadDirPath = filePathUtil.getPath(param); String tempFileName = fileName + "_tmp"; File tmpDir = new File(uploadDirPath); File tmpFile = new File(uploadDirPath, tempFileName); if (!tmpDir.exists()) { tmpDir.mkdirs(); } return tmpFile; } @Override public FileUploadDTO sliceUpload(FileUploadRequestDTO param) { boolean isOk = this.upload(param); if (isOk) { File tmpFile = this.createTmpFile(param); FileUploadDTO fileUploadDTO = this.saveAndFileUploadDTO(param.getFile().getOriginalFilename(), tmpFile); return fileUploadDTO; } String md5 = FileMD5Util.getFileMD5(param.getFile()); Map<Integer, String> map = new HashMap<>(); map.put(param.getChunk(), md5); return FileUploadDTO.builder().chunkMd5Info(map).build(); } /** * 检查并修改文件上传进度 */ public boolean checkAndSetUploadProgress(FileUploadRequestDTO param, String uploadDirPath) { String fileName = param.getFile().getOriginalFilename(); File confFile = new File(uploadDirPath, fileName + ".conf"); byte isComplete = 0; RandomAccessFile accessConfFile = null; try { accessConfFile = new RandomAccessFile(confFile, "rw"); //把该分段标记为 true 表示完成 System.out.println("set part " + param.getChunk() + " complete"); //创建conf文件文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认0,已上传的就是Byte.MAX_VALUE 127 accessConfFile.setLength(param.getChunks()); accessConfFile.seek(param.getChunk()); accessConfFile.write(Byte.MAX_VALUE); //completeList 检查是否全部完成,如果数组里是否全部都是127(全部分片都成功上传) byte[] completeList = FileUtils.readFileToByteArray(confFile); isComplete = Byte.MAX_VALUE; for (int i = 0; i < completeList.length && isComplete == Byte.MAX_VALUE; i++) { //与运算, 如果有部分没有完成则 isComplete 不是 Byte.MAX_VALUE isComplete = (byte) (isComplete & completeList[i]); System.out.println("check part " + i + " complete?:" + completeList[i]); } } catch (IOException e) { log.error(e.getMessage(), e); } finally { FileUtil.close(accessConfFile); } boolean isOk = setUploadProgress2Redis(param, uploadDirPath, fileName, confFile, isComplete); return isOk; } /** * 把上传进度信息存进redis */ private boolean setUploadProgress2Redis(FileUploadRequestDTO param, String uploadDirPath, String fileName, File confFile, byte isComplete) { RedisUtil redisUtil = SpringContextHolder.getBean(RedisUtil.class); if (isComplete == Byte.MAX_VALUE) { redisUtil.hset(FileConstant.FILE_UPLOAD_STATUS, param.getMd5(), "true"); redisUtil.del(FileConstant.FILE_MD5_KEY + param.getMd5()); confFile.delete(); return true; } else { if (!redisUtil.hHasKey(FileConstant.FILE_UPLOAD_STATUS, param.getMd5())) { redisUtil.hset(FileConstant.FILE_UPLOAD_STATUS, param.getMd5(), "false"); redisUtil.set(FileConstant.FILE_MD5_KEY + param.getMd5(), uploadDirPath + FileConstant.FILE_SEPARATORCHAR + fileName + ".conf"); } return false; } } /** * 保存文件操作 */ public FileUploadDTO saveAndFileUploadDTO(String fileName, File tmpFile) { FileUploadDTO fileUploadDTO = null; try { fileUploadDTO = renameFile(tmpFile, fileName); if (fileUploadDTO.isUploadComplete()) { System.out .println("upload complete !!" + fileUploadDTO.isUploadComplete() + " name=" + fileName); //TODO 保存文件信息到数据库 } } catch (Exception e) { log.error(e.getMessage(), e); } finally { } return fileUploadDTO; } /** * 文件重命名 * * @param toBeRenamed 将要修改名字的文件 * @param toFileNewName 新的名字 */ private FileUploadDTO renameFile(File toBeRenamed, String toFileNewName) { //检查要重命名的文件是否存在,是否是文件 FileUploadDTO fileUploadDTO = new FileUploadDTO(); if (!toBeRenamed.exists() || toBeRenamed.isDirectory()) { log.info("File does not exist: {}", toBeRenamed.getName()); fileUploadDTO.setUploadComplete(false); return fileUploadDTO; } String ext = FileUtil.getExtension(toFileNewName); String p = toBeRenamed.getParent(); String filePath = p + FileConstant.FILE_SEPARATORCHAR + toFileNewName; File newFile = new File(filePath); //修改文件名 boolean uploadFlag = toBeRenamed.renameTo(newFile); fileUploadDTO.setMtime(DateUtil.getCurrentTimeStamp()); fileUploadDTO.setUploadComplete(uploadFlag); fileUploadDTO.setPath(filePath); fileUploadDTO.setSize(newFile.length()); fileUploadDTO.setFileExt(ext); fileUploadDTO.setFileId(toFileNewName); return fileUploadDTO; } } 总结在实现分片上传的过程,需要前端和后端配合,比如前后端的上传块号的文件大小,前后端必须得要一致,否则上传就会有问题。其次文件相关操作正常都是要搭建一个文件服务器的,比如使用fastdfs、hdfs等。本示例代码在电脑配置为4核内存8G情况下,上传24G大小的文件,上传时间需要30多分钟,主要时间耗费在前端的md5值计算,后端写入的速度还是比较快。如果项目组觉得自建文件服务器太花费时间,且项目的需求仅仅只是上传下载,那么推荐使用阿里的oss服务器,其介绍可以查看官网:https://help.aliyun.com/product/31815.html阿里的oss它本质是一个对象存储服务器,而非文件服务器,因此如果有涉及到大量删除或者修改文件的需求,oss可能就不是一个好的选择。
2024年02月06日
28 阅读
0 评论
0 点赞
2024-02-06
SSO 单点登录和 OAuth2.0 的区别和理解
一、概述SSO 是Single Sign On的缩写,OAuth是Open Authority的缩写,这两者都是使用令牌的方式来代替用户密码访问应用。流程上来说他们非常相似,但概念上又十分不同。SSO大家应该比较熟悉,它将登录认证和业务系统分离,使用独立的登录中心,实现了在登录中心登录后,所有相关的业务系统都能免登录访问资源。OAuth2.0 原理可能比较陌生,但平时用的却很多,比如访问某网站想留言又不想注册时使用了微信授权。以上两者,你在业务系统中都没有账号和密码,账号密码是存放在登录中心或微信服务器中的,这就是所谓的使用令牌代替账号密码访问应用。二、SSO两者有很多相似之处,下面我们来解释一下这个过程。先来讲解SSO,通过SSO对比OAuth2.0,才比较好理解OAuth2.0的原理。SSO的实现有很多框架,比如CAS框架,以下是CAS框架的官方流程图。特别注意:SSO是一种思想,而CAS只是实现这种思想的一种框架而已上面的流程大概为:用户输入网址进入业务系统 Protected App ,系统发现用户未登录,将用户重定向到单点登录系统 CAS Server ,并带上自身地址service参数用户浏览器重定向到单点登录系统,系统检查该用户是否登录,这是SSO(这里是CAS)系统的第一个接口,该接口如果用户未登录,则将用户重定向到登录界面,如果已登录,则设置全局session,并重定向到业务系统用户填写密码后提交登录,注意此时的登录界面是SSO系统提供的,只有SSO系统保存了用户的密码,SSO系统验证密码是否正确,若正确则重定向到业务系统,并带上SSO系统的签发的ticket浏览器重定向到业务系统的登录接口,这个登录接口是不需要密码的,而是带上SSO的ticket,业务系统拿着ticket请求SSO系统,获取用户信息。并设置局部session,表示登录成功返回给浏览器 sessionId (tomcat中叫 JSESSIONID )之后所有的交互用 sessionId 与业务系统交互即可最常见的例子是,我们打开淘宝APP,首页就会有天猫、聚划算等服务的链接,当你点击以后就直接跳过去了,并没有让你再登录一次三、OAuth2.0OAuth2.0 有多种模式,这里讲的是OAuth2.0授权码模式,OAuth2.0的流程跟SSO差不多,在OAuth2中,有授权服务器、资源服务器、客户端这样几个角色,当我们用它来实现SSO的时候是不需要资源服务器这个角色的,有授权服务器和客户端就够了。授权服务器当然是用来做认证的,客户端就是各个应用系统,我们只需要登录成功后拿到用户信息以及用户所拥有的权限即可用户在某网站上点击使用微信授权,这里的某网站就类似业务系统,微信授权服务器就类似单点登录系统之后微信授权服务器返回一个确认授权页面,类似登录界面,这个页面当然是微信的而不是业务系统的用户确认授权,类似填写了账号和密码,提交后微信鉴权并返回一个ticket,并重定向业务系统。业务系统带上ticket访问微信服务器,微信服务器返回正式的token,业务系统就可以使用token获取用户信息了简介一下OAuth2.0的四种模式:授权码(authorization-code)授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。隐藏式(implicit)有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌。这种方式没有授权码这个中间步骤,所以称为(授权码)“隐藏式”(implicit)密码式(password)如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为"密码式"(password)。客户端凭证(client credentials)最后一种方式是凭证式(client credentials),适用于没有前端的命令行应用,即在命令行下请求令牌。简单流程四、说一下几个名词的区别首先,SSO 是一种思想,或者说是一种解决方案,是抽象的,我们要做的就是按照它的这种思想去实现它其次,OAuth2 是用来允许用户授权第三方应用访问他在另一个服务器上的资源的一种协议,它不是用来做单点登录的,但我们可以利用它来实现单点登录。在本例实现SSO的过程中,受保护的资源就是用户的信息(包括,用户的基本信息,以及用户所具有的权限),而我们想要访问这这一资源就需要用户登录并授权,OAuth2服务端负责令牌的发放等操作,这令牌的生成我们采用JWT,也就是说JWT是用来承载用户的Access_Token的最后,Spring Security、Shiro 是用于安全访问的,用来做访问权限控制。
2024年02月06日
28 阅读
0 评论
0 点赞
2024-02-03
ubuntu 利用systemback制作镜像备份(可用)
前言在ubuntu 22.04版本下,安装 systemback 成功制作出 iso 镜像文件,并在 VM虚拟机 下进行还原安装;移植好的镜像文件包含了当前的系统环境依赖,部署服务,系统文件内的图片,视频等内容。安装软件安装systemback安装make安装cdtools安装systembacksystemback 用来打包系统成压缩包,后再利用 cdtools 将其转换为iso镜像文件。安装systemback工具直接全部复制,粘贴到终端运行:sudo sh -c 'echo "deb [arch=amd64] http://mirrors.bwbot.org/ stable main" > /etc/apt/sources.list.d/systemback.list' sudo apt-key adv --keyserver 'hkp://keyserver.ubuntu.com:80' --recv-key 50B2C005A67B264F sudo apt-get update sudo apt-get install systemback安装make编译器sudo apt-get install make安装gcc环境包sudo apt update sudo apt install build-essential安装cdtoolssudo apt install aria2 aria2c -s 10 https://nchc.dl.sourceforge.net/project/cdrtools/alpha/cdrtools-3.02a07.tar.gz下载完成后打开对应文件夹进行解压tar -xzvf cdrtools-3.02a07.tar.gz cd cdrtools-3.02 make make installsystemback系统打包进行 live 打包,执行下面命令:systemback-sustart点击 创建Live 系统可以 包含用户数据文件 点击 创建新的 如果出现报错:错误会在终端中显示出来(大概率由于snap文件内的内容报错)解决方法——卸载报错的软件或者删除报错的文件打包好的文件会生成在上一步选择好的工作目录(默认为 /home )到此已经结束!
2024年02月03日
252 阅读
0 评论
0 点赞
1
...
5
6
7
...
27