湘潭大学新生匿名问答网站——解湘 项目总结
湘潭大学新生匿名问答网站——解湘 项目总结
一.开发进度
大一暑假过半,7月29日建立本地工程文件
其中项目在github上经历七次push(第八次为修改配置文件,防止数据库泄露),但在本地修改次数远远大于七次。
仓库地址:Appletree24/Sky31Welcome (github.com)
后端开发均为我一人完成,前端开发由他人负责。除此之外,特感谢三翼设计部门设计出此次项目的UI界面
实际应用接口数量为31个,采用APIFOX
进行团队接口管理。部分后端实现接口实际因前端功能不需没有加入。
二.项目大纲
此项目立项起,我就很明确这是个传统的论坛类项目,砍去一些如上传图片
、用户信息修改
等不必要功能。这是一个很好的练习基于Java进行Web开发的机会,不计所谓回报,我投身到了这次的开发中。
其中因技术不牢固原因,Spring Security
框架最终并未能加入项目进行权限管理。考虑使用Shiro,但因假期繁忙,最终放弃。
其中邮件验证功能因避免网站使用的复杂性,最终删去。
C/S之间使用Nginx进行反向代理,因图片功能删去,未配置CDN加速。
三.项目中遇到的问题&收获
1.Base64编码后长度必须为4的倍数
起因为项目采用Base64对用户密码进行加盐处理,但因方式不当。导致服务端频繁报错,无法正常使用。
2.Mybatis-plus导致Mysql数据库主键出现负数
测试期间发现ORM框架生成的数据在Mysql中出现了id为负数的情况。如下图所示
MP-plus有五种主键ID生成策略:
AUTO,配合数据库设置自增主键,可以实现主键的自动增长,类型为nmber;
INPUT,由用户输入;
NONE,不设置,等同于INPUT;
ASSIGN_ID,只有当用户未输入时,采用雪花算法生成一个适用于分布式环境的全局唯一主键,类型可以是String和number;
ASSIGN_UUID,只有当用户未输入时,生成一个String类型的主键,但不保证全局唯一;
其中默认为ASSIGN_ID
,即使用雪花算法进行生成。这也就导致对应生成的主键应该使用Long类型进行存储,且设置主键类型为bigint
。也可在Java代码中设置@TableId注解
3.跨域问题
老生长谈的一个问题了。本次跨域代码如下:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { //添加映射路径 registry.addMapping("/**") //是否发送Cookie .allowCredentials(true) //设置放行哪些原始域 SpringBoot2.4.4下低版本使用.allowedOrigins("*") .allowedOriginPatterns("*") //放行哪些请求方式 .allowedMethods("GET", "POST", "PUT", "DELETE") //.allowedMethods("*") //或者放行全部 //放行哪些原始请求头部信息 .allowedHeaders("*") //暴露哪些原始请求头部信息 .exposedHeaders("*"); } }
如此常见的一个问题,并不可能在开发前后端分离的项目时未进行考虑。但本次遇到的问题是,发现前端也要在vite中配置有关跨域的内容才能使cookie正常出现。
4.响应头中set-cookie
字段
- set-cookie是一个函数,由服务器向浏览器发出响应
- cookie是服务器发送给浏览器的变量
- 浏览器向服务器发送请求(put,get,post,delect方法),服务器会使用
set-cookie()方法
向本地的浏览器发送cookie,存在客户端主机中的一个文件下
set-cookie
响应头可以设置如下属性:
属性 | 意义 |
---|---|
NAME=VALUE | 赋予 Cookie 的名称和其值(必需项) |
expires=DATE | Cookie 的有效期(若不明确指定则默认为浏览器关闭前为止) |
path=PATH | 将服务器上的文件目录作为Cookie的适用对象(若不指定则默认为文档所在的文件目录) |
domain=域名 | 作为 Cookie 适用对象的域名 (若不指定则默认为创建 Cookie的服务器的域名) |
Secure | 仅在 HTTPS 安全通信时才会发送 Cookie |
HttpOnly | 加以限制, 使 Cookie 不能被 JavaScript 脚本访问 |
max-age
设置cookie的相对有效期。expire和max-age通常仅设置一个即可。比如设置max-age为1000,浏览器在添加cookie时,会自动设置它的expire为当前时间加上1000秒,作为过期时间。
如果不设置expire,又没有设置max-age,则表示会话结束后过期。
对于大部分浏览器而言,关闭所有浏览器窗口意味着会话结束。
5.用户无法发送emoji表情
Mysql中的utf8支持一个字符最多3个字节,但emoji表情为4个字节,所以需要切换为utf8mb4即可
6.敏感词过滤器
使用前缀树算法实现敏感词过滤器。
@Component public class SensitiveFilter { private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class); private static final String REPLACEMENT = "***"; private TrieNode rootNode = new TrieNode(); @PostConstruct public void init() { try ( InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt"); BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); ) { String keyword; while ((keyword = reader.readLine()) != null) { this.addKeyWord(keyword); } } catch (IOException e) { logger.error("敏感词加载失败: " + e.getMessage()); } } public String filter(String text) { if (StringUtils.isBlank(text)) { return null; } TrieNode tempNode = rootNode; int begin = 0; int position = 0; StringBuilder sb = new StringBuilder(); while (begin < text.length()) { char c = text.charAt(position); if (isSymbol(c)) { if (tempNode == rootNode) { sb.append(c); begin++; } position++; continue; } tempNode = tempNode.getSubNode(c); if (tempNode == null) { sb.append(text.charAt(begin)); position = ++begin; tempNode = rootNode; } else if (tempNode.isEnd()) { sb.append(REPLACEMENT); begin = ++position; tempNode = rootNode; } else { if (position < text.length() - 1) { position++; } else { sb.append(text.charAt(begin)); position = ++begin; tempNode = rootNode; } } } sb.append(text.substring(begin)); return sb.toString(); } private boolean isSymbol(Character c) { return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF); } private void addKeyWord(String keyword) { TrieNode tempNode = rootNode; for (int i = 0; i < keyword.length(); i++) { char c = keyword.charAt(i); TrieNode subNode = tempNode.getSubNode(c); if (subNode == null) { subNode = new TrieNode(); tempNode.addSubNode(c, subNode); } tempNode = subNode; if (i == keyword.length() - 1) { tempNode.setEnd(true); } } } private class TrieNode { private boolean isEnd = false; private Map<Character, TrieNode> setNodes = new HashMap<>(); public boolean isEnd() { return isEnd; } public void setEnd(boolean end) { isEnd = end; } public void addSubNode(Character c, TrieNode node) { setNodes.put(c, node); } public TrieNode getSubNode(Character c) { return setNodes.get(c); } } }
7.切实使用如Redis、Kafka等技术
此前只是学习过Redis、Kafka等技术,但离专业课太远,而又没有合适的项目,于是乎一直悬浮在空中,对技术一知半解。
此项目中的点赞功能、查看个人信息功能、登陆凭证存储功能、消息提醒功能均使用了Redis,也即当对象需要被频繁的访问时,我们可以使用Redis解决问题。
此项目中的消息提醒使用Kafka消息队列进行开发,在学习Kafka的过程中,再次巩固了设计模式中的发布-订阅
模式。在此抛出一个疑问,能否使用定期查询数据库的形式来实现消息提醒呢?
8.SSL证书配置
如果要使用HTTPS,那么需要配置SSL证书,在腾讯云中可白嫖免费的证书。此次采用nginx形式进行配置,只需在nginx对应配置文件中加入固定内容,并将证书文件放在置顶目录下即可,十分简单。
server { listen 443 ssl; server_name question.tsky31.cn; ssl_certificate question.tsky31.cn_bundle.crt; ssl_certificate_key question.tsky31.cn.key; ssl_session_timeout 5m; #请按照以下协议配置 ssl_protocols SSLv2 SSLv3 TLSv1 TLSv1.1 TLSv1.2; #请按照以下套件配置,配置加密套件,写法遵循 openssl 标准。 ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE; ssl_prefer_server_ciphers on; client_max_body_size 1000m; } server { listen 80; server_name question.tsky31.cn; return 301 https://$host$request_uri; }
9.域名配置流程
首先要申请一个域名,例如本次的tsky31.cn
,如今域名一般都在云服务提供商的后台可以进行管理,以腾讯云为例,在申请到域名后,进行对应域名的DNS解析管理,添加进项目所需要的域名,例如question.tsky31.cn
,完成解析
之后在服务器中进行nginx的配置即可。
10.统一接口形式
在接口的返回部分,最好是可以统一形式,采用创建一个类的方式来达到目的。其中进行重载,以适应不同的返回情况
package com.sky31.domain; import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_NULL) public class ResponseResult<T> { /** * 状态码 */ private Integer code; /** * 提示信息,如果有错误时,前端可以获取该字段进行提示 */ private String msg; /** * 查询到的结果数据, */ private T data; public ResponseResult(Integer code, String msg) { this.code = code; this.msg = msg; } public ResponseResult(Integer code, T data) { this.code = code; this.data = data; } public Integer getCode() { return code; } public void setCode(Integer code) { this.code = code; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } public T getData() { return data; } public void setData(T data) { this.data = data; } public ResponseResult(Integer code, String msg, T data) { this.code = code; this.msg = msg; this.data = data; } }
11.tomcat日志查看
关于tomcat的日志,启动部分的成功与否,以及报错信息,存放在tomcat目录下/logs中的catalina.xxxx-xx-xx.log
文件中,可以使用cat命令查看;而成功启动后的信息,如请求接口时终端的输出,则存放在目录中的catalina.out
文件中,可以使用tail -f的命令进行查看。
12.内网穿透
因此次服务器环境较为特殊,所以跟着教程学习了内网穿透,使用了工具frp。
13.拦截器配置
关于SpringBoot的拦截器,可建一名为interceptor
的软件包,在包下进行拦截器的编写。使拦截器类实现HandlerInterceptor
这个接口,选择性重写其中的perHandle
、postHandle
、afterCompletion
方法。
之后新建配置类,实现WebMvcConfigurer
接口,在其中的addInterceptors
方法中注册编写的拦截器类即可。其中拦截器注册类拥有增加排除路径的方法。
@Configuration public class InterceptorConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Autowired private MessageInterceptor messageInterceptor; @Autowired private DataInterceptor dataInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .excludePathPatterns(); registry.addInterceptor(messageInterceptor) .excludePathPatterns(); registry.addInterceptor(dataInterceptor) .excludePathPatterns(); } }
14.UV与DAU的统计
利用拦截器的特性。我们可以将uv与dau的统计功能使用redis+拦截器进行实现。因preHandle
方法会在请求到达dispatcherServlet前进行拦截,所以我们可以在其中获得用户的ip地址,根据ip进行UV的统计,按照年份天数等标准构建redis中的key,进而存放到redis中。
而DAU则是获取当前用户的信息,例如可以从token中提取等,判空后进行记录。
15.用户的保存
因许多接口都需要获取当前用户的身份,但每次都要用HttpServletRequest中获取,十分麻烦。如果想要在service、dao层中使用,就需要从controller层层传递。所以我们可以创建一个实用类来解决。
使用方式就是利用ThreadLocal类进行保存,将用户信息保存在线程中。浏览器每一次请求就是启动了一个线程,当请求结束,我们将用户的信息销毁即可
实现方式:
- 我们需要创建一个ThreadLocal类,创建一个ThreadLocal对象,设置ThreadLocal的set,remove,get方法
- 定义一个登录的拦截器类,实现HandlerInterceptor ,重写 preHandle() 和afterCompletion()方法 ,preHandle ()方法把登录信息写入ThreadLocal,afterCompletion()方法清除登录信息
- 我们需要设置一些配置信息,创建一个类实现 WebMvcConfigurer ,重写addInterceptors()方法,创建一个登录拦截器类的对象,给他添加到配置中,我们就实现了ThreadLocal保存用户信息
//HostHolder.java @Component public class HostHolder { private ThreadLocal<User> users=new ThreadLocal<>(); public void setUser(User user){ users.set(user); } public User getUser(){ return users.get(); } public void clear(){ users.remove(); } }
//在拦截器中的preHandle进行用户信息的获取 @Component public class DataInterceptor implements HandlerInterceptor { public DataInterceptor() { } @Autowired private DataService dataService; @Autowired private HostHolder hostHolder; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //统计UV String ip = request.getRemoteHost(); dataService.recordUV(ip); //统计DAU User user = hostHolder.getUser(); if (user!=null){ dataService.recordDAU(user.getId()); } return true; } }
@Component public class LoginCheckInterceptor implements HandlerInterceptor { @Override public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) { //此方法是获取登录信息,登录方式不一样获取方法不一样,用户信息保存用的UserInfoVO,里边具体信息自己定义即可 UserInfoVO userInfo = getUserInfo(request); ThreadLocalUser.set(userInfo); return true; } @Override public void afterCompletion(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler, Exception ex) { ThreadLocalUser.clear(); } }
16.环境配置
本项目使用到了Redis、ElasticSearch、Kafka等,其中不可避免地在服务器环境搭建时会遇到各种各样的报错,这里进行一个总结。
Redis:
- 一定要为服务器的redis设置密码,有不怀好意之人扫到6379端口后会放置挖矿病毒或是清空redis
- redis的配置中要关闭保护模式,且redis默认无法远程连接,要更改bind设置。可选择注释掉
- 在启动redis时,要使用上级目录中配置好的config文件,且可以使用-d参数后台运行
Kafka:
- 在运行Kafka前,要先运行zookeeper,可以将这两个设置为系统的服务。并且有先后级关系
- Kafka运行前,一定要在配置文件中进行集群的配置
ElasticSearch:
- ElasticSearch具有网页管理工具,名为es-head,可以进行配置
- ElasticSearch版本之间差别较大。本次使用为7.6版本。
- 通常会因为系统文件设置问题导致ES无法正常启动,要去修改/etc/profile文件
- ElasticSearch因安全原因不允许以root用户身份启动,要新建用户并chown -R
directoryname - [总结—elasticsearch启动失败的几种情况及解决_BIGmustang的博客-CSDN博客_elasticsearch无法启动](https://blog.csdn.net/BIGmustang/article/details/108585871#:~:text=总结—elasticsearch启动失败的几种情况及解决1、使用root用户启动失败在有一次搭建elasticsearch的时候,使用systemctl启动elasticsearch失败,然后在bin目录下面去使用启动脚本启动,发现报错不能用root用户启动,报“Caused by%3A java.lang.RuntimeException%3A can not,run elasticsearch as root”:[root%40localhost bin]%23.%2Felasticsearch[2017-12-)
17.maven
本项目构建工具选择了maven,关于pom.xml不再进行赘述。
在服务端进行maven打包时,可以使用mvn clean package -Dmaven.test.skip=true
来跳过测试类进行打包
18.Linux命令
记录一些项目部署中经常使用的命令:
- ps -ef |grep tomcat 在运行的进程中查找tomcat 之后可利用pid进行kill
- kill -9 pid
- tail -f $filename
19.统一处理报错
当项目规模逐渐增大时,报错的处理就成为了一个问题。我们可以使用一个类来统一处理报错
@ControllerAdvice(annotations = Controller.class) public class MyControllerAdvice { private static final Logger logger= LoggerFactory.getLogger(MyControllerAdvice.class); @ExceptionHandler(Exception.class) @ResponseBody public void handlerException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException { logger.error("服务器发生异常"+e.getMessage()); for (StackTraceElement element:e.getStackTrace()){ logger.error(element.toString()); } String requestHeader = request.getHeader("x-requested-with"); if (requestHeader.equals("XMLHttpRequest")){ response.setContentType("application/plain;charset=utf-8"); PrintWriter writer=response.getWriter(); writer.write(Md5AndJsonUtil.getJSONString(1,"服务器异常")); }else{ response.sendRedirect(request.getContextPath()+"/error"); } } }
20.git
起初打算采用团队形式进行开发。但因特殊原因,后端实际情况仅我一人。但仓库是已经配好的。这次学习到了使用.gitignore文件进行非上传文件的设置,只上传必要的文件,使其他用户方便使用。
HELP.md target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ ### STS ### .apt_generated .classpath .factorypath .project .settings .springBeans .sts4-cache ### IntelliJ IDEA ### .idea *.iws *.iml *.ipr ### NetBeans ### /nbproject/private/ /nbbuild/ /dist/ /nbdist/ /.nb-gradle/ build/ !**/src/main/**/build/ !**/src/test/**/build/ ### VS Code ### .vscode/
21.application.yaml
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/****?characterEncoding=utf-8&userSSL=false username: root password: redis: host: localhost port: 6379 password: database: 0 kafka: bootstrap-servers: localhost:9092 consumer: group-id: test-consumer-group enable-auto-commit: true auto-commit-interval: 3000 task: execution: pool: core-size: 5 max-size: 15 queue-capacity: 100 scheduling: pool: size: 5 elasticsearch: uris: localhost:9200 mybatis-plus: global-config: db-config: id-type: none configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
22.chmod
近期因新生返校,并且由于不是所有用户都会具备校园网内网环境的问题,我需要将项目部署运行在生产机上。虽说早有心理准备生产机的环境配置会遇到与之前不一样的问题,但还是没想到有这么多。从chmod
开始一一罗列如下:
首先因生产机安全考虑,我们并不能拥有root用户,而是以开放了一定权限的普通用户作为操作者。在使用sudo rz -E
命令上传文件后,默认权限为644
状态,这就会导致几乎所有的环境部署都无法实现了,我们使用chmod 755 -R $directoryname
递归修改文件夹下的文件权限,这样才可以使得如nginx代理访问静态资源报403
、部分框架无法正常拉起目录内文件导致无法启动
等问题不再发生
23.elasticsearch-head报错406
首先分析406,开头是4,问题发生在客户端部分,着重考虑是否是客户端出了问题。406为客户端无法解析服务端发送回的数据。那么思路应该为服务端发送了什么特殊类型的数据,导致客户端无法成功解析。于是乎向着ES框架的配置文件进行搜索。
{"error":"Content-Type header [application/x-www-form-urlencoded] is not supported","status":406}
报错内容如上
查找资料后发现需要修改ES中的_site/vendor.js
文件,修改内容如下:
首先
docker exec -it $container's hashcode or name /bin/bash
进入容器内部
apt-get update
更新apt,防止安装失败
apt-get install vim
安装vim
vim /_site/vendor.js
修改文件修改 vendor.js 共有两处,重启head插件
vi _site/vendor.js6886行
contentType: "application/x-www-form-urlencoded
改成
contentType: “application/json;charset=UTF-8”
7573行
var inspectData = s.contentType === “application/x-www-form-urlencoded” &&
修改为
var inspectData = s.contentType === “application/json;charset=UTF-8” &&
24.elasticsearch删除索引内数据,但不删除索引结构
因为换到了新机器上,但是为了不出现大问题,就将测试机配置好的ES移动到了生产机,那么至少要解决的问题就是清空掉在测试机上存储的问题内容,以免影响实际的搜索功能
因为安装了es-head,所以可以在网页端进行操作
起初并未在docker拉取插件的镜像,所以想着能否使用apifox这类工具,但可惜自己的服务器很容易,但不知为何学校特殊网络环境下的服务器就连接不上了。无奈只得启用插件在web端进行解决
curl -X POST
总感觉也可,但还没学习到如何发送json
总之最后的解决方案如下
- 在网页端输入自己要删除的索引,最终应该是这种形式——
$indexname/_delete_by_query
{ "query":{ "match_all":{ } } }
25.MVC设计模式
这个没啥好写的,就是重新温习了一下MVC设计模式罢了。但是挺有意义,写在这里吧
26.Nginx+docker
因为这次的环境部署都是我负责,所以我又要去学习了nginx和docker这两样东西。
一个意外是以往很多届成员写的nginx配置竟然存在一些问题,导致部署的时候耽误了一些时间。
这次也是终于明白了Nginx为什么叫反向代理,它应该如何配置文件。docker里的image和container到底是什么关系,人们都说docker香,那么到底好在哪里?
27.注意外部包完整性
有这么一个问题。因为另外有一个非常小的项目是他人开发,我负责部署,但是我用的是2.7.2
的springboot,而他用的是2.7.3
,导致他的项目在服务端tomcat运行时找不到2.7.3
的外部包,于是便无法运行,只能访问静态资源,接口无法调用。
28.maven安装
其实在maven的使用过程中,服务器也遇到很多配置问题,但是好像解决的太顺利了,现在不记得了……
索性贴一个安装教程吧 CentOS7——安装配置Maven(Apache Maven)_Hern(宋兆恒)的博客-CSDN博客_centos maven安装与配置
29.Tomcat 404
遇到一个很恼人的问题,Tomcat里的日志文件启动是没有报错的,因为404导致也无法分析.out文件,所以就一直在猜到底是哪里的问题
网上很容易可以搜到一些常见的解决方式,我这次碰见的是自己一个疏忽忘记掉的。
记得检查一下项目文件中有没有配置serverletinitializer这个类
/** * @AUTHOR Zzh * @DATE 2022/8/8 * @TIME 23:27 */ public class CommunityServletInitializer extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(Sky31WelcomeApplication.class); } }
三.写在最后
虽然写了很多代码,加起来删删改改的也得有几千行了。如果加一些小功能,希望能冲着1W行的目标写一下。给学校部门写这个网站是没有所谓的什么钱或者分的,有时聊天也是笑着说搬砖罢了。有时对于参与本次项目的所有人(真正付出了的人,混子另当别论吧)感到可惜,虽然技术确实锻炼到了,但没有物质上的回报还是令人觉得五味杂陈。还是对组内的小伙伴们再次致谢。
关于用户问题,因为这个项目是学校老师要求写的,虽然大家都知道竞品很多,但是也没办法。(甚至前几天还和同学赌用户会不会超过100
写这个项目感觉很累,复盘时思考一个原因也是因为学生组织的人数稀缺,而稀缺的人中搞技术的又参差不齐,有些时候甚至除了你没人能干活,导致开发之外的部署、日常运维都要你来干。这就很不合理了,鲜有人意识到吧……
对于工作的话,我是希望要么你就别开始,要么开始就立马结束。项目编写的时候经常一天从早到晚都在电脑前坐着,如果父母不带我出去转转,可能一天就没有什么其他活动了。还是要注意身体啊
本次项目的一大遗憾是因工时原因,最终没有使用SpringSecurity,会在之后研究加入
项目起初想过使用Go来编写,但最终因框架不熟悉而放弃。希望下一个项目能用Go
本次项目一定程度上参考了牛客社区
在19年来看,这样一个项目可以被拿来当作简历上的项目来宣传,但在如今,这样的项目也早已烂大街了。
这样也算是写过一个完整的Web开发项目,也在实验室写过了数据处理类的项目。十分圆满,总算能让浮躁的心静下来一点,之后还是着手408的学习吧。
在项目的编写过程中,遇到很多毛躁的时刻,有时因服务器环境配置需要推翻重来,有时因数据结构的复杂不知如何下手。导致有时自言自语说了很多负能量的话,感谢和我合作的同学以及父母的鼓励
目前打算使用Go手写一个RPC框架,或是尝试在Windows平台下使用DLL进行多语言合作编写一个程序,不再懊恼于大一学了很多语言的语法,却没有深入过这一点。
2022.9.2 深夜
本文作者:Appletree24
本文链接:https://www.cnblogs.com/appletree24/p/16651618.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步