HttpServer模块 --- 封装TcpServer支持Http协议

目录

模块设计思想

模块代码实现


模块设计思想

本模块就是设计一个HttpServer模块,提供便携的搭建http协议的服务器的方法。

那么这个模块需要如何设计呢? 这还需要从Http请求说起。

首先http请求是分为静态资源请求和功能性请求的。

静态资源请求顾名思义就是用来获取服务器中的某些路径下的实体资源,比如文件的内容等,这一类请求中,url 中的资源路径必须是服务器中的一个有效的存在的文件路径。

而如果提取出来的资源路径并不是一个实体文件的路径,那么他大概率是一个功能性请求,这时候就有用户来决定如何处理这个请求了,也就是我们前面说过的 请求路径 和 处理方法的路由表。

但是还有一种特殊的情况就是资源路径是一个目录,比如 / ,这时候有可能是一个访问网站首页的请求,所以我们需要判断在这个路径后面加上 index.html (也可以是其他的文件名,取决于你的网站的首页的文件名) ,如果加上之后,路径有效且存在实体文件,那么就是一个静态资源请求,如果还是无效,那么就是一个功能性请求。

而功能性请求如何处理呢?这是由使用或者说搭建服务器的人来决定的。 用户未来想要提供某些功能,可以让他和某个虚拟的目录或者说特定的路径绑定起来。 比如提供一个登录功能,那么用户可以规定  /login 这个路径就代表登录的功能,未来如果收到了一个请求资源路径是 /login ,那么就不是请求实体资源,而是调用网站搭建者提供的登录的方法进行验证等操作。 一般来说这些虚拟路径不会和实体资源路径冲突。

同时,对于这种功能性请求对应的路径,他并不是说一个路径只能有一个功能,不同的请求方法,同一个路径,最终执行的方法也可以是不同的,这具体还是要看使用者的设定。

所以为了维护这样的功能性路径和需要执行的方法之间的映射关系,我们需要为每一种请求方法都维护一张路由表,路由表中其实就是保存了路径和所需要执行的方法之间的映射关系。

在我们这里,就只考虑常用的五种方法,get,post,delete,head,put,其他的暂时就不提供支持了。

    //五张路由表using Handler = std::function<void(const HttpRequest&,HttpResponse*)>; using HandlerTable = std::unordered_map<std::string,Handler>;HandlerTable _get_route; HandlerTable _post_route; HandlerTable _head_route; HandlerTable _put_route; HandlerTable _delete_route;

这是交给用户进行设置的,我们也会提供五个接口给用户用来添加处理方法。

但是,这样的表真的好吗? 

在实际的应用中,比如有以下的功能性请求的请求路径 , /login1213 , /login12124 , /login1213626 , /login12152 , /login1295 , /login1275 ,对于这样的一类路径,他们其实需要执行的是同一个方法,而并不需要为每一个类似的路径设置一个方法,而路径后半部分的数字其实后续可以当成参数来用。

那么综上所述,我们的路由表中作为 key 值的并不是 std::string ,而是只需要满足某一种匹配要求的路径,都可以执行某一方法,那么作为 key 值的其实是正则表达式。

    using HandlerTable = std::unordered_map<std::regex,Handler>;

但是如果我们编译一下就会发现,正则表达式是不能作为哈希的 key 值的,或者说不匹配默认的哈希函数。 

我们可以思考一下,我们用正则表达式作为 key 了,那么后面不管使用何种数据结构来存储正则表达式和操作方法的映射关系,我们都是要遍历整个路由表的,需要遍历表中的所有的正则表达式,然后拿着我们的路径来进行正则匹配,匹配上了就说明这是我们要找的方法,如果匹配不上就说明不是,不管怎么样,都是要进行遍历,那么其实我们直接用数组来存储也是一样的。

所以最终我们使用 vector 来存储用户方法。

    using HandlerTable = std::vector<std::pair<std::regex,Handler>>;

而HttpServer模块中除了五张路由表,还需要一个TcpServer对象,这是毋庸置疑的。 同时还需要保存一个网页根目录,这个根目录是要交给用户设置的,由使用者决定。

那么最终HttpServer的成员如下:


//支持Http协议的服务器
class HttpServer
{
private:TcpServer _server;std::string _base_path; //网页根目录//五张路由表using Handler = std::function<void(const HttpRequest&,HttpResponse*)>; using HandlerTable = std::vector<std::pair<std::regex,Handler>>;HandlerTable _get_route; HandlerTable _post_route; HandlerTable _head_route; HandlerTable _put_route; HandlerTable _delete_route; public:
};

后续我们都不需要写构造函数。

那么需要哪些接口呢?

然后就是提供给用户的五个设置功能方法的接口,以及设置网页根目录和服务器线程数的接口。

还需要提供给用户是否开启超时释放,以及启动服务器的接口。

提供给用户的接口就这么多,其实都很简单,难的是私有的一些接口:

首先,未来拿到一个完整请求之后,我们需要能够判断这个请求是静态资源请求还是功能性请求。如果是资源性请求我们需要怎么做? 如果是功能性请求我们有需要怎么做?

最后还需要将相应组织成一个tcp报文进行回复。

同时还需要提供未来设置给TcpServer的连接建立和新数据到来的回调方法,这两个方法是必需的,其他的三个倒是无所谓。因为在连接建立时我们必须要设置上下文,在新数据到来时必须要有逻辑来决定怎么处理。

至于具体的实现,我们一步一步慢慢来。

模块代码实现

首先实现几个简单的提供给用户的接口:当然这里的Start或者说构造还没有完全实现,因为我们还没有设置连接建立回调和新数据回调这两个回调方法。

public:void SetBasePath(const std::string& basedir){_base_path = basedir;}void Get(const std::regex& e , const Handler& cb)  //设置GET{_get_route.push_back(std::make_pair(e,cb));}void Post(const std::regex& e , const Handler& cb)  //设置POST{_post_route.push_back(std::make_pair(e,cb));}void Put(const std::regex& e , const Handler& cb)  //设置PUT{_put_route.push_back(std::make_pair(e,cb));} void Head(const std::regex& e , const Handler& cb)  //设置HEAD{_head_route.push_back(std::make_pair(e,cb));}void Delete(const std::regex& e , const Handler& cb)  //设置DELETE{_delete_route.push_back(std::make_pair(e,cb));}        void EnableInactiveRelease(int delay = 30) //启动非活跃销毁   {_server.EnableInactiveRelease(delay);}void SetThreadCount(int cnt)    //设置线程数量{_server.SetThreadCount(cnt);}void Start()                    //启动服务器{_server.Start();}

那么剩下的就是连接建立回调以及新数据回调的逻辑了,

首先连接建立的时候,我们需要设置一个上下文给Connection对象。

    void OnConnect(const PtrConnection& conn){//设置一个上下文HttpContext ctx;conn->SetContext(ctx);}

剩下的就是最复杂的新数据回调了。

    void OnMessage(const PtrConnection& conn,Buffer* buf)   //获取新数据回调{}

首先第一步需要将上下文获取出来。

        // 1 获取上下文Any* context = conn->GetContext();HttpContext* pctx = context->GetData<HttpContext>();

然后就需要通过上下文对缓冲区数据进行解析,也就是调用HttpContext的接口进行处理,但是我们要看处理结果是什么来判断下一步怎么做。

        // 2 解析缓冲区数据pctx->RecvHttpRequest(buf);HttpRequest& req = pctx->GetRequest();HttpResponse resp;//判断解析是否出错if(pctx->RespStatu() >= 400)  //请求解析出错,此时的_recv_statu 也一定是RECV_ERR{HandlerError(req,resp);         //调用错误处理方法WriteResponse(conn,req,resp);   //返回响应conn->ShutDown();               //发生错误就关闭连接return;}if(pctx->RecvStatu() != RECV_OVER)      //还没收到一个完整请求return;//走到这里说明req是一个完整的请求
    void HandlerError(HttpRequest& req , HttpResponse& resp);void WriteResponse(const PtrConnection& conn , HttpRequest& req , HttpResponse& resp);

这里用到的两个接口我们一会再来实现。

接受到一个请求之后,其实我们就需要进行方法的路由了,那么我们直接再封装成一个接口。

        // 3 数据处理,路由Route(req,resp);    //进行方法路由,判断是不是静态资源请求。
    void Route(HttpRequest& req , HttpResponse& resp);

那么路由的过程中会填充好我们的响应的关键信息。 

处理完之后,我们就需要将响应发回给客户端。

        // 4 返回给客户端WriteResponse(conn,req,resp);

最后我们需要判断需不需要关闭连接,因为Http协议是请求应答式的服务,一般来说,只处理一个请求之后就会关闭连接。但是我们不要忘了长连接这个技术,也就是说,如果对方支持长连接,那么我们就不需要关闭连接,而是重置上下文之后进行下一个请求的处理。

        // 5 处理完之后重置上下文pctx->Reset();// 6 判断长短连接if(resp.Close())    //如果是短连接就直接关闭{conn->ShutDown();return;}//如果是长连接就需要搞成循环,读取下一个报文

如果是长连接的话,那么我们上面的处理的流程就应该是循环式的。

    void OnMessage(const PtrConnection& conn,Buffer* buf)   //获取新数据回调{while(buf->ReadSize() > 0)      //从逻辑上来说 while(1) 也是一样的{// 1 获取上下文Any* context = conn->GetContext();HttpContext* pctx = context->GetData<HttpContext>();// 2 解析缓冲区数据pctx->RecvHttpRequest(buf);HttpRequest& req = pctx->GetRequest();HttpResponse resp;//判断解析是否出错if(pctx->RespStatu() >= 400)  //请求解析出错,此时的_recv_statu 也一定是RECV_ERR{HandlerError(req,resp);         //调用错误处理方法WriteResponse(conn,req,resp);   //返回响应conn->ShutDown();               //发生错误就关闭连接return;}if(pctx->RecvStatu() != RECV_OVER)      //还没收到一个完整请求return;//走到这里说明req是一个完整的请求// 3 数据处理,路由Route(req,resp);    //进行方法路由,判断是不是静态资源请求。// 4 返回给客户端WriteResponse(conn,req,resp);// 5 处理完之后重置上下文pctx->Reset();// 6 判断长短连接if(resp.Close())    //如果是短连接就直接关闭{conn->ShutDown();return;}//如果是长连接就需要搞成循环,读取下一个报文}}

那么接下来就是里面用到的接口的实现了。

我们先来完成Route接口,在路由的接口中,首先我们需要判断资源路径是不是静态资源,如果是,那么就需要读取文件,如果不是,那么就需要进行任务的路由或者说派发。

    void Route(HttpRequest& req , HttpResponse& resp){if(IsFileResquest(req,resp))       //判断是否是静态资源请求return FileHandler(req,resp);//否则就需要到几个方法表中进行路由if(req._method == "GET")return Dispatcher(req,resp,_get_route);if(req._method == "POST")return Dispatcher(req,resp,_post_route);if(req._method == "PUT")return Dispatcher(req,resp,_put_route); if(req._method == "HEAD")return Dispatcher(req,resp,_head_route);if(req._method == "DELETE")return Dispatcher(req,resp,_delete_route);         //如果走到了这里,说明前面的处理方法都不行,那么一定是请求出问题了resp._statu = 405;      //Method Not AllowedHandlerError(req,resp,resp->_statu);}

那么静态资源如何判断处理呢?下面是判断的方法:

    bool IsFileResquest(HttpRequest& req , HttpResponse& resp) //判断以及处理静态资源{// 1 首先需要判断有没有设置资源根目录if(_base_path.empty()) return false;    //肯定不是静态资源请求// 2 静态资源请求的方法必须是 GET 或者 HEAD ,因为其他的方法不是用来获取资源的if(!(req._method == "GET" || req._method == "HEAD")) return false;//然后静态资源请求的路径必须是一个合法的路径if(Util::IsValid(req._path) == false) return false;//最后就需要判断请求的路径的资源是否存在//但是我们需要考虑路径是目录的时候,给它加上一个 index.htmlstd::string path = req._path;if(path.back() == '/') path += "index.html";//判断文件是否存在DEBUG_LOG("path:%s",path.c_str());std::string real_path = _base_path+path; if(Util::IsRegular(real_path) == false) return false;return true;    //走到这里才算是一个静态资源请求}

静态资源方法如何处理? 其实很简单,将文件读取出来放到响应的正文就行了,不过读取完之后还需要设置一些响应的Content相关的头部字段。

    void HandlerFile(HttpRequest& req , HttpResponse& resp) //处理静态资源请求{std::string path = _base_path+req._path;if(path.back() == '/') path +="index.html";Util::ReadFile(path,&resp._body);//然后设置响应头部字段//在这里我们可以只设置 Content-Type 字段,Content-Length可以交给WriteResponse接口来设置std::string mime = Util::GetMime(path);resp.AddHeader("Content-Type",mime);}

然后就是功能性请求的路由,其实就是遍历方法表进行匹配就行了。

    void Dispatcher(HttpRequest& req , HttpResponse& resp , const HandlerTable& table){for(std::pair<const std::regex& , Handler> p: table){const std::regex& e = p.first;const Handler& cb = p.second;std::smatch matches;bool ret = std::regex_match(req._path,matches,e);if(ret) return cb(req,&resp);}//走到这里说明路由表中没有对应的方法resp._statu = 404;  //Not FoundHandlerError(req,resp,resp->_statu);}

那么到此为止,路由的方法就解决了。

剩下的就是错误处理以及响应的格式化了。

错误的处理我们可以返回一个错误的展示界面

    void HandlerError(HttpRequest& req , HttpResponse& resp ,int statu){std::string body;body += "<!DOCTYPE html>";body += "<html><head><title>";body += std::to_string(statu);body += Util::StatuDesc(statu);body += "</title></head><body><h1>抱歉,该页面无法找到。</h1>";body += "<p>请检查您输入的网址是否正确,或者 <a href=\"/\">返回首页</a>。</p>";body += "</body></html>";resp._body = body;resp.AddHeader("Content-Type","text/html");}

最后就是处理一下我们的WriteResponse接口,

第一步需要完善响应的报头字段:

        if(req.Close()) resp.AddHeader("Connection","close");else  resp.AddHeader("Connection","keep-alive");if(resp._body.size()&&!resp.HasHeader("Content-Length")) resp.AddHeader("Content-Length",std::to_string(resp._body.size()));if(resp._body.size() && !resp.HasHeader("Content-Type")) resp.AddHeader("Content-Type","application/octet-stream");//重定向信息if(resp._redirect_flag) resp.AddHeader("Location",resp._redirect_url);

然后需要按指定格式组织响应,我们可以使用 osstream 这个字符流对象

        // 2 组织响应std::ostringstream out;//响应行  HTTP/1.0 404 NotFound\r\nout<<req._version<<" "<<std::to_string(resp._statu)<<" "<<Util::StatuDesc(resp._statu)<<"\r\n";//头部字段for(auto& p : resp._headers){out<<p.first<<": "<<p.second<<"\r\n";} //空行out<<"\r\n";//正文out<<resp._body;

最后就是发送出去

        // 3 发送conn->Send(out.str().c_str(),out.str().size());

那么WriteResponse的总体的代码:

    void WriteResponse(const PtrConnection& conn , HttpRequest& req , HttpResponse& resp){// 1 先把响应的头部字段完善了if(req.Close()) resp.AddHeader("Connection","close");else  resp.AddHeader("Connection","keep-alive");if(resp._body.size()&&!resp.HasHeader("Content-Length")) resp.AddHeader("Content-Length",std::to_string(resp._body.size()));if(resp._body.size() && !resp.HasHeader("Content-Type")) resp.AddHeader("Content-Type","application/octet-stream");//重定向信息if(resp._redirect_flag) resp.AddHeader("Location",resp._redirect_url);// 2 组织响应std::ostringstream out;//响应行  HTTP/1.0 404 NotFound\r\nout<<req._version<<" "<<std::to_string(resp._statu)<<" "<<Util::StatuDesc(resp._statu)<<"\r\n";//头部字段for(auto& p : resp._headers){out<<p.first<<": "<<p.second<<"\r\n";} //空行out<<"\r\n";//正文out<<resp._body;// 3 发送conn->Send(out.str().c_str(),out.str().size());}

那么最后我们再完善一下构造函数,需要传入一个端口号来对我们内部的TcpServer对象进行初始化,以及绑定两个回调函数,

    HttpServer(int port ,int delay = 30):_server(port) {_server.EnableInactiveRelease(delay);   //我们的http服务器默认是开启超时释放的_server.SetConnectCallBack(std::bind(&HttpServer::OnConnect,this,std::placeholders::_1));_server.SetMessageCallBack(std::bind(&HttpServer::OnMessage,this,std::placeholders::_1,std::placeholders::_2));}

那么我们也会注意到一个问题,就是新数据到来进行处理的时候,解析失败会调用 HandlerError和WriteResponse,而WriteResponse中会用到 req 的version ,但是我们实际上可能并没有读取到,所以我们可以给version一个初始值,可以给HttpRequest增加一个构造函数。

    HttpRequest():_version("HTTP/1.0"){}

其他的倒是没什么大问题了。

那么我们的http服务器的设计也就设计完了。

为了防止头文件重复包含,我们也需要加上条件编译。

#ifndef __HTTP__MUDUO__SERVER
#define __HTTP__MUDUO__SERVER
//  头文件内容
#endif

我们的服务器的代码编译是没有问题的,后续我们会对其进行测试,来修正项目中的一些没有注意到的bug。

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

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

相关文章

外接数据库给streamlit等web APP带来的变化

之前我采用sreamlit制作了一个调查问卷的APP&#xff0c; 又使用MongoDB作为外部数据存储&#xff0c;隐约觉得外部数据库对于web APP具有多方面的意义&#xff0c;代表了web APP发展的趋势之一&#xff0c;似乎是作为对这种趋势的响应&#xff0c;streamlit官方近期开发了st.c…

sql题库中常见问答

一.解答题 (15*2) 1 Drop, delete,truncates 三者的区别? ①、drop和truncate属于数据库的定义语言(DDL) ②、delete属于数据库的操作语言(DML) ③、drop可以删除全表结果,且删除的同时会删除表数据 ④、delete 和truncate只能删除表数据,truncate会删除表数据一起…

黄山谷捷IPO拟募资5亿元,增强核心竞争力

根据深交所发行上市审核进度&#xff0c;10月28日&#xff0c;黄山谷捷股份有限公司首发申请审核状态变更为“提交注册”。据悉&#xff0c;黄山谷捷本次拟公开发行不超过2,000万股&#xff0c;占本次发行后总股本的比例不低于25.00%。 招股书&#xff08;注册稿&#xff09;披…

无人机拦截捕获/直接摧毁算法详解!

一、无人机拦截捕获算法 网捕技术 原理&#xff1a;抛撒特殊设计的网具&#xff0c;捕获并固定无人机。 特点&#xff1a; 适用于小型无人机。 对无人机的损害较小&#xff0c;基本不影响其后续使用。 捕获成功率较高&#xff0c;且成本相对较低。 应用实例&#xff1a;…

.NET Core WebApi第4讲:控制器、路由

一、控制器是什么&#xff1f; 1、创建一个空的API控制器&#xff1a;TestController.cs 2、里面有一个类叫TestController&#xff0c;把它叫做控制器 因为它继承了ControllerBase类&#xff0c;ControllerBase类里提供了一系列的方法&#xff0c;使得TestController这个类具…

WAF+AI结合,雷池社区版的强大防守能力

网上攻击无处不不在&#xff0c;为了保护我自己的网站&#xff0c;搜索安装了一个开源免费的WAF 刚安装完成就收到了海外的攻击&#xff0c;看到是海外的自动化攻击工具做的 雷池刚好也有AI分析&#xff0c;于是就尝试使用这个功能&#xff0c;看看这个ai能力到底怎么样 以下…

Python批量查找包含多个关键词的PDF文件

在信息爆炸的时代&#xff0c;数据管理变得愈发重要。U盘作为一种便携式存储设备&#xff0c;常常承载着我们大量的个人和工作数据。然而&#xff0c;随着文件数量的增加&#xff0c;在U盘中快速找到特定文件常常成为一个令人头疼的难题。我们通常可以采用everything来快速查找…

Chromium HTML5 新的 Input 类型tel对应c++

一、Input 类型: tel <!DOCTYPE html> <html> <head> <meta charset"utf-8"> <title>test</title> </head> <body><form action"demo-form.php">电话号码: <input type"tel" name…

cisco网络安全技术第4章测试及考试

测试 以下 ACE 将放置在何处&#xff1f; permit icmp any any nd-na 试题 1选择一项&#xff1a; 在连接到另一个路由器并已启用 IPv6 的路由器接口上 使用下一代防火墙而不是状态防火墙的一个好处是什么&#xff1f; 试题 2选择一项&#xff1a; 主动而不是被动防护互…

【SQL】SQL函数

&#x1f4e2; 前言 函数 是指一段可以直接被另一段程序调用的程序或代码。主要包括了以下4中类型的函数。 字符串函数数值函数日期函数流程函数 &#x1f384; 字符串函数 ⭐ 常用函数 函数 功能 CONCAT(S1,S2,...Sn) 字符串拼接&#xff0c;将S1&#xff0c;S2&#xff0…

Springboot 整合 Java DL4J 构建自然语言处理之机器翻译系统

&#x1f9d1; 博主简介&#xff1a;CSDN博客专家&#xff0c;历代文学网&#xff08;PC端可以访问&#xff1a;https://literature.sinhy.com/#/literature?__c1000&#xff0c;移动端可微信小程序搜索“历代文学”&#xff09;总架构师&#xff0c;15年工作经验&#xff0c;…

第三份代码:VoxelNet的pytorch实现

VoxelNet是点云体素化处理的最开始的网络结构设计&#xff0c;通过完全弄明白整个VoxelNet的pytorch实现是非常有必要的。 参考的代码是这一份&#xff1a;GitHub - RPFey/voxelnet_pytorch: modification of voxelnet 参考文章&#xff1a;VoxelNet论文解读和代码解析_voxel…

vue3-element-admin 去掉登录

1.src/router/index 去掉登录路由 // {// path: "/login",// component: () > import("/views/login/index.vue"),// meta: { hidden: true },// },2./src/plugins/permission修改 未登录 import type {NavigationGuardNext,RouteLocationNormal…

CentOS 9 Stream 上安装 Redis

CentOS 9 Stream 上安装 Redis CentOS 9 Stream 上安装 RedisCentOS 9 Stream 上卸载 Redis CentOS 9 Stream 上安装 Redis 在 CentOS 9 Stream 上安装 Redis&#xff0c;可以按照以下步骤进行&#xff1a; 更新系统包&#xff1a; sudo dnf update -y安装 Redis&#xff1a; …

NLP算法工程师精进之路:顶会论文研读精华

1.学术能力培养 全部论文资料下载&#xff1a; 将论文和 GitHub 资源库匹配 papers with code https://paperswithcode.com/OpenGitHub 新项目快报Github pwc&#xff1a;https://github.com/zziz/pwc GitXiv&#xff1a;http://www.gitxiv.com/ 文章撰写 Overleaf [Autho…

01_AI编程案例展示:借助AI轻松爬取海量网盘链接

爬虫案例展示 今天,我们将展示如何利用AI快速开发一个网络爬虫&#xff0c; 使用的工具是Python和Claude 3.5 Sonnet(国内可用豆包替代) 我们的目标是爬取panhub.fun网站上的夸克网盘链接, 即使你是编程新手,也可以轻松完成这样的任务。 案例1-批量爬取panhub网盘整合包 下…

【C++动态规划 01背包】2787. 将一个数字表示成幂的和的方案数

本文涉及知识点 C动态规划 C背包问题 LeetCode2787. 将一个数字表示成幂的和的方案数 给你两个 正 整数 n 和 x 。 请你返回将 n 表示成一些 互不相同 正整数的 x 次幂之和的方案数。换句话说&#xff0c;你需要返回互不相同整数 [n1, n2, …, nk] 的集合数目&#xff0c;满…

websocket的使用

websocket的封装&#xff0c;面对后端为服务架构 // websocket.js import Vue from vue;class WebSocketService {constructor() {this.socket null;this.state Vue.observable({isConnected: false,currentUrl: ,retries: 0,maxRetries: 5,reconnectInterval: 3000});this.…

一分钟学会Python基础

Python 是一种广泛使用的高级编程语言&#xff0c;因其简洁和易读的语法而受到欢迎。下面是一些 Python 基础知识的概述&#xff1a; 1. 安装 Python 1.下载: 你可以从 Python 官网 下载适合你操作系统的版本。 2.安装: 安装过程中&#xff0c;确保勾选 "Add Python to …

vue下载安装

目录 vue工具前置要求&#xff1a;安装node.js并配置好国内镜像源下载安装 vue 工具 系统&#xff1a;Windows 11 前置要求&#xff1a;安装node.js并配置好国内镜像源 参考&#xff1a;本人写的《node.js下载、安装、设置国内镜像源&#xff08;永久&#xff09;&#xff…