什么是shell?模拟实现shell(深刻理解shell的内建命令)

news/2024/4/26 12:01:23/文章来源:https://blog.csdn.net/weixin_61432764/article/details/127608569

文章目录

    • 何为shell?
    • shell的模拟实现
      • 提示符打印
      • 用户输入消息的获取
      • 创建子进程执行程序
      • 运行结果
      • 内建命令
      • 环境变量与内建命令
      • 提示符最后的优化
      • 模拟实现的shell完整代码

何为shell?

shell作为命令语言解释器,负责解释我们在Linux命令行中输入的指令,并将解释后的指令传递给Linux内核。简而言之,shell是用户和Linux内核之间交互的桥梁,用户不直接接触Linux内核,而是通过shell程序与内核交互。

shell的本质是一个C语言程序,与我们平常使用的QQ,浏览器一样,区别就是:一旦Linux系统运行起来,shell程序也会运行,而QQ,浏览器这些程序需要我们手动去运行,shell则自动运行

只要用户输入一个指令,就会被shell解释。指令分为两种

1.内建命令,由shell自身执行,如打印当前工作目录(pwd),输出文本消息(echo)

2.其他命令,是一个存储在某个目录下的程序,由shell的子进程执行,如列出目录内容(ls),拷贝文件(cp)

对于用户来说,不用关心该命令是否为内建命令,只要输入命令,就会得到结果。但对于shell来说,需要检查该命令是否为内建命令,如果是,需要自己执行该命令。如果不是,shell会去PATH路径列表下查找是否有该程序,如果有,解释该指令并创建子进程执行该指令,没有的话直接报错。

为了更深刻的理解shell的运行过程,我们模拟实现一个自己的shell程序

shell的模拟实现

提示符打印

在这里插入图片描述
shell被启动后,就会在命令行中等待命令的输入,在输入命令的地方前还有一串提示符

[用户名@主机名 当前所在文件夹] $

实现shell的第一步是打印出这串提示符,用户名,主机名和当前路径都能通过getenv获取环境变量函数得到。需要特别处理的是当前目录,getenv得到的是绝对路径,而提示符显示的是当前文件夹,所以得到绝对路径后要遍历这串字符串,记录最后一个’/'出现的位置,返回该位置的后一个位置,得到的就是当前文件夹

const char* get_dir(const char* path)
{int i = 0;int index = 0;while (path[i] != '\0'){if (path[i] == '/'){index = i;}i++;}return path + index + 1; 
}// 1.显示提示符printf("[%s@%s %s^]$ ", getenv("USER"), getenv("HOSTNAME"), get_dir(getenv("PWD")));fflush(stdout);  // 由于缓冲区的存在,不打印'\n'缓冲区是不会立即刷新的,直到缓冲区满了才会刷新出内容// 所以这里打印需要手动刷新缓冲区

用户输入消息的获取

比如我要执行ls命令,ls命令后还可以携带选项,ls -a -l,shell需要获取指令与选项,并将它们分割(shell接收到的是一串字符串"ls -a -l",shell需要将字符串分割为更小的字符串"ls" “-a” “-l”)。

定义command_line接收一整行的指令,该数组中的元素保存的是字符,command_args保存分割后的指令,该数组中的元素要保存的是分割后的字符串。

#define NUM 1024
#define SIZE 128char command_line[NUM];
char* command_args[SIZE];

在存储数据之前先使用memset函数将两个数组置空。

memset(command_line, '\0', sizeof(command_line) * sizeof(char));
memset(command_args, '\0', sizeof(command_args) * sizeof(char));

获取整行数据可以使用fgets
在这里插入图片描述
在这里插入图片描述
fgets将从stream流中获取至多size个字符的字符串并保存到s中,如果遇到了’\0’或者读到了文件结束,fgets将中断。

所以我们可以用fgets从stdin标准输入流中读取NUM个字符到command_line数组中,这样就获取到了命令行的输入

fgets(command_line, NUM, stdin);

但获取到的字符串的’\0’之前还有一个’\n’(因为我们在命令的最后敲了回车,来表示命令输入的结束,这个’\n’会影响命令的识别,需要将其消除::用strlen得到字符串长度(不包括’\0’),字符串最后一个字符’\n’,的下标为长度-1,将该位置置为’\0’,表示字符串的结束,同时也消除了’\n’

command_line[strlen(command_line) - 1] = '\0'; // 消除'\n'

获取到整行字符串后,使用strtok将整行字符串分割成命令与选项
在这里插入图片描述

在这里插入图片描述
strtok使用:第一次调用strtok需要传入要被分割的字符串,之后如果要继续分割,就不用继续传该字符串的地址,只要传NULL,第二个参数是分割字符串的标志,对于分割整行命令,其分割的标志为" "。

当没有字符串要分割时,strtok就返回NULL,否则返回分割的第一个字符串,根据这个特点,可以先对command_line进行第一次分割并保存到command_args中,接着使用while循环分割完所有的子串。

// 获取命令并解析
#define SEP " "
#define NUM 1024
#define SIZE 128char command_line[NUM];
char* command_args[SIZE];
memset(command_line, '\0', sizeof(command_line) * sizeof(char));
memset(command_args, '\0', sizeof(command_args) * sizeof(char));
fgets(command_line, NUM, stdin);
command_line[strlen(command_line) - 1] = '\0'; // 消除'\n'
command_args[0] = strtok(command_line, SEP); 
int index = 1;
// 当还有字符串要分割时,strtok返回值不为空
// 分割结束,strtok返回空,while循环的条件为假,循环结束
while (command_args[index++] = strtok(NULL, SEP)); 

创建子进程执行程序

分析完指令后,shell会调用fork()函数创建一个子进程,让子进程调用exec()程序替换函数,替换子进程,让子进程执行成分析后的指令,同时父进程阻塞等待子进程并打印退出消息。

int id = fork();
if (id == 0)
{// childexecvp(command_args[0], command_args);exit(-1); // 如果程序替换失败,子进程会执行该语句,返回-1
}
else
{int status = 0;int ret = waitpid(id, &status, 0);if (ret == id) // 等待成功{// 父进程接收子进程的退出码,并打印printf("等待成功, code:%d, sig:%d\n", (status >> 8) & 0xFF, status & 0x7F);}
}

运行结果

在这里插入图片描述
myshell是编译myshell.c生成的可执行程序,运行shell,为了与Linux自己的shell区分,我写的shell打印出的提示符有个^,上面是打印提示符的效果。
在这里插入图片描述
执行ls -a命令的结果
在这里插入图片描述
但是目前实现的shell有一个问题:执行pwd命令,打印当前的工作目录,然后cd …回到上一级目录,再执行pwd,通过打印的目录我们发现:没有回到上一级目录,要知道其中的原因就需要了解内建命令

内建命令

开头说到,shell的命令分为内部命令和其他命令,何为内建命令? 内建命令是一个需要shell自己执行的命令,即shell不创建子进程,自己亲自执行的命令。

为什么要有内建命令? shell创建子进程执行指令,当子进程执行完命令,子进程就退出了。 有这样一个场景:当子进程执行的指令是cd更改工作路径时,子进程执行cd命令,其工作路径确实发生了改变,但是父进程的工作路径没有变化(进程具有独立性,两个进程不会相互影响),接着子进程退出,此时向shell输入pwd命令,打印当前工作目录,shell又创建子进程执行pwd命令,这个子进程与之前退出的子进程没有关系,是重新创建的,由于父子进程代码共享,它们的工作路径相同,所以打印出的工作路径没有变化。体现给用户的感觉就是cd没有被执行,与上面截图中的问题相同。

所以,shell对于一些命令是必须要亲自执行的:比如cd更改工作路径,将shell的工作路径修改后,由于子进程的工作路径与父进程相同,更改分进程的工作路径后,父进程创建出的子进程的工作路径也是被修改过的,体现给用户的感觉就是当前的工作路径改变了

对于内建命令,shell是怎么实现的? shell中有许多内建命令,当shell接收到指令并解析后,需要判断用户输入的命令是否为内建命令,如果是就执行拦截操作,使子进程不再被创建,自己执行该指令。如果不是内建命令,则创建子进程执行该命令。

接着谈模拟实现:

Linux提供了一个系统接口,可以改变当前工作路径,chdir,将当前进程的工作路径修改为path。
在这里插入图片描述

// 对该系统接口封装
void change_dir(const char* new_path)
{chdir(new_path);
}// 获取命令并解析
#define SEP " "
#define NUM 1024
#define SIZE 128char command_line[NUM];
char* command_args[SIZE];
memset(command_line, '\0', sizeof(command_line) * sizeof(char));
memset(command_args, '\0', sizeof(command_args) * sizeof(char));
fgets(command_line, NUM, stdin);
command_line[strlen(command_line) - 1] = '\0'; // 消除'\n'
command_args[0] = strtok(command_line, SEP); 
int index = 1;
// 当还有字符串要分割时,strtok返回值不为空
// 分割结束,strtok返回空,while循环的条件为假,循环结束
while (command_args[index++] = strtok(NULL, SEP)); // 解析完命令后,判断是否有内建命令需要拦截
if (strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL) // 要确保第二个参数不为空
{change_dir(command_args[1]); // 第二个参数是要进入的工作路径continue; 					 // shell被死循环执行,continue跳过后面创建子进程的代码 
}

shell对于内建命令的拦截,是在解析指令后,判断命令是否为内建,如果是,跳转执行对应的函数,并跳过之后创建子进程的过程。

添加内建命令的拦截后再执行cd指令。
在这里插入图片描述

环境变量与内建命令

我们知道,环境变量具有全局属性,具体表现是:一般情况下,子进程的环境变量继承父进程的环境变量,父进程的环境变量改变后,创建的子进程的环境变量也会跟着改变,但不会影响父进程的父进程,也就是说:全局属性体现为向下影响

环境变量的创建或修改需要使用export指令,格式为:export xxx=value,比如创建一个环境变量myval,它的值为123:export myval=123。基于对内建命令的理解,如果让shell创建子进程执行export创建myval这个环境变量,那么shell的子进程就具有了myval,当子进程执行完export指令,子进程退出,由于子进程的环境变量不会向上影响,所以shell的环境变量没有改变,环境变量改变的是已经退出的子进程,体现给用户的感觉是:环境变量没有被创建。我们创建环境变量的目的是希望在shell中创建环境变量,使得shell以下的子进程都具有该变量,所以export也是一个内建命令,需要特别的处理。

接着来说模拟实现:
在这里插入图片描述
在这里插入图片描述
Linux提供了修改环境变量的接口,将要修改或创建的环境变量以xxx=value的字符串方式传入该接口,xxx环境变量就会被创建或者修改。

所以在shell解析完指令后,判断指令是否为export,如果是则进行拦截,调用该接口创建环境变量。

(系统中有一个环境变量列表envrion,类型为char**,存储了当前进程的环境变量字符串char*,当你使用putenv添加环境变量时,实质上是向envrion的列表中添加了一个字符串的地址
在这里插入图片描述

需要注意的是添加环境变量时,环境变量列表不会environ 不会拷贝字符串,所以原字符串的修改会影响添加到环境变量列表中的环境变量)

if (strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
{// command_args[1]是xxx=value形式的字符串add_env(command_args[1]); // 是否有问题?continue;
}

由于模拟实现的shell使用command_line获取整行的输入,接着将每个命令与选项的首地址放到数组command_args中,myshell每次读取命令时,都会对command_line进行清空,也就意味着command_args[1]所指向的字符串也会被清空,在myshell添加完环境变量后,command_args[1]被添加到environ中,再次接收其他指令,command_args[1]被清空,环境变量也就不存在,所以向environ传递的字符串必须要是一个不会被随意更改的字符串。

#define NUM 1024
char env_buffer[NUM]; 
// 更规范的做法是动态申请堆上的空间,这里的全局变量只是用来测试,只能添加一个环境变量
void add_env(char* new_env)
{putenv(new_env);
}
if (strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
{// 先拷贝字符串到env_buffer中,再将env_buffer添加到环境变量列表中strcpy(env_buffer, command_args[1]);add_env(env_buffer);continue;
}

在这里插入图片描述
添加完export内建命令的运行结果。我在当前目录下写了个C++程序mycmd,运行mycmd将打印环境变量"lll"所对应的值,然后运行myshell程序,运行mycmd,没有打印信息,说明进程中没有lll环境变量,然后使用export命令创建环境变量lll并赋值123,最后运行mycmd程序,打印出lll变量的值——123,说明环境变量创建成功,export内建命令完成工作。

提示符最后的优化

模拟实现内建命令cd后,可以使用cd进入一个工作目录,但是命令行的提示符却没有发生改变
在这里插入图片描述
有了对环境变量的理解,模拟的shell可以完成最后的一次优化:使提示符打印出的当前工作目录与cd命令同步

提示符打印的当前工作目录是根据环境变量PWD获取的,而我们模拟实现的cd命令只是将当前工作路径修改(chdir函数不会改变PWD环境变量),所以模拟实现的cd命令还应该改变PWD环境变量,使打印的提示也随着cd改变。

还需要根据chdir的返回值判断要进入的工作目录是否存在,当工作目录不存在时,不能修改PWD环境变量,应该要提示用户输入的路径错误。只有输入的工作目录存在,shell才会跳转到该目录下,并且修改PWD环境变量。

char path_buf[NUM];void change_dir(const char* new_path)
{ strcpy(path_buf, "PWD=" );if (chdir(new_path) == -1){printf("cd: %s: No such file or directory\n", new_path);}else // 当更改目录成功,修改环境变量中的PWD变量{// 进入到新的工作路径下,获取当前工作路径,并连接到path_buf后,作为PWD的value值strcat(path_buf, get_current_dir_name()); putenv(path_buf);}
}

优化后的shell运行结果
在这里插入图片描述

模拟实现的shell完整代码

当然,到目前为止,myshell与真正的shell之间还是有差距的,还有优化的空间,比如ls对应当前目录下的文件,myshell的打印没有颜色,shell有颜色,这需要在ls后加上–color=auto在这里插入图片描述
实际上我们经常使用的ls,是ls --color=auto的别名(alias是一个取别名操作),在模拟实现的shell中,解析完命令后判断命令是否有ls,如果有,再在解析的参数后加上–color=auto。

但是这些不是模拟实现shell的最终目的,模拟实现shell的最终目的是

理解shell的工作原理,shell的本质:一个C语言程序,shell对于两种命令的处理方式以及shell是怎么创建子进程执行指令的(fork+exec)。

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>#define NUM 1024
#define SIZE 128
#define SEP " "char command_line[NUM];
char* command_args[SIZE];
char env_buffer[NUM];
char path_buf[NUM];void change_dir(const char* new_path)
{ strcpy(path_buf, "PWD=" );if (chdir(new_path) == -1){printf("cd: %s: No such file or directory\n", new_path);}else // 当更改目录成功,修改环境变量中的PWD变量{strcat(path_buf, get_current_dir_name());putenv(path_buf);}
}void add_env(char* new_env)
{putenv(new_env);
}const char* get_dir(const char* path)
{int i = 0;int index = 0;while (path[i] != '\0'){if (path[i] == '/'){index = i;}i++;}return path + index + 1; 
}int main()
{while (1){// 1.显示提示符printf("[%s@%s %s^]$ ", getenv("USER"), getenv("HOSTNAME"), get_dir(getenv("PWD")));fflush(stdout);// 2.获取用户输入memset(command_line, '\0', sizeof(command_line) * sizeof(char));memset(command_args, '\0', sizeof(command_args) * sizeof(char));fgets(command_line, sizeof(command_line) * sizeof(char), stdin);if (strlen(command_line) > 1){command_line[strlen(command_line) - 1] = '\0'; // 消除'\n'command_args[0] = strtok(command_line, SEP); int index = 1;if (strcmp(command_args[0], "ls") == 0)command_args[index++] = "--color=auto";while (command_args[index++] = strtok(NULL, SEP));if (strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL){change_dir(command_args[1]);continue; }if (strcmp(command_args[0], "export") == 0 && command_args[1] != NULL){strcpy(env_buffer, command_args[1]);add_env(env_buffer);continue;}//  创建子进程执行其他命令int id = fork();if (id == 0){// childexecvp(command_args[0], command_args);exit(-1);}else{int status = 0;int ret = waitpid(id, &status, 0);if (ret == id) // 等待成功{printf("等待成功, code:%d, sig:%d\n", (status >> 8) & 0xFF, status & 0x7F);}}	} // end of -- if (strlen(command_line) > 1)}  // end of -- while(1)return 0;
}

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

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

相关文章

【STL】容器 - set和map的使用

目录 前言 一.键值对 1.在SGI - STL中对键值对的定义: 2.make_pair 二.set 1.set的概念与注意事项 2.set的使用(常用接口) <1>.构造函数 <2>.迭代器与范围for <3>.插入和查找 <4>.删除erase <5>.计数count 三.map 1.map的概念与注…

SpringCloudAlibaba-Nacos配置中心

开发环境 开发工具&#xff1a;IDEA 2021.3.2 JDK版本&#xff1a;JDK1.8 Maven版本&#xff1a;Maven3.8 SpringCloud版本&#xff1a;Hoxton.SR12 SpringCloudAlibaba版本&#xff1a;2.2.7.RELEASE SpringBoot版本&#xff1a;2.3.12.RELEASE Nacos版本&#xff1a;2.0.3 准…

Allegro基本规则设置指导书之Same Net Spacing规则设置

Allegro基本规则设置指导书之Same Net Spacing规则设置 下面介绍基本规则设置指导书之Same Net Spacing规则设置 设置Line到其它的间距规则 从左往右 线到线,通孔pin,表贴pin,测试pin,通孔Via,盲埋孔,测试孔,微孔,铜皮,Bond finger,hole之间的间距 设置pin到其它的…

【1. MySQL锁机制】

文章目录共享锁&#xff08;读锁也叫S锁&#xff09;/排他锁&#xff08;写锁也叫X锁&#xff09;行锁表锁意向锁间隙锁乐观锁悲观锁共享锁&#xff08;读锁也叫S锁&#xff09;/排他锁&#xff08;写锁也叫X锁&#xff09; 共享锁&#xff08;S锁&#xff09;&#xff1a;当一…

【数据结构】搜索二叉树(C++实现)

目录 一、二叉搜索树的概念 二、二叉搜索树的实现 2.1 节点的定义及构造 2.2 树的结构及功能展示 2.3 树的 Insert 2.4 树的中序遍历 2.4 树的 Find 2.5 树的 Erase 2.6 拷贝构造、赋值运算符重载、析构函数 三、递归实现树的增删查 3.1 递归实现 FindR 3.2 递归实…

Linux开发工具

目录 一、yum工具 1.yum 背景知识 &#xff08;1&#xff09;商业生态 &#xff08;2&#xff09;开源生态 &#xff08;3&#xff09;软件生态本土化 2.yum 的基本使用 &#xff08;1&#xff09;查看软件包 &#xff08;2&#xff09;软件包名称构成 &#xff08;3&a…

高级架构师_Redis_第2章_数据类型与底层数据结构

高级架构师_Redis_第2章_数据类型与底层数据结构 文章目录高级架构师_Redis_第2章_数据类型与底层数据结构第二章&#xff1a;数据类型与底层数据结构本章学习目标&#xff1a;第一节&#xff1a;Redis 数据类型选择和应用场景1.1 Redis 的 Key 的设计1.2 String 字符串类型1.3…

SpringSecurity Oauth2实战 - 04 自定义AuthProvider实现登录认证

文章目录1. 搭建资源服务器1. Token存储配置类 TokenStoreAutoConfiguration2. 资源服务器配置类 ResourceServerAutoConfiguration3. 在META-INF/spring.factories文件下添加配置类2. 搭建授权服务器1. 密码加密配置类 PasswordEncodeConfig2. RestTemplateConfig3. 授权服务器…

SQL学习笔记(未完待续)

鉴于自己最近在做后端开发的工作时&#xff0c;发现自己的SQL能力实在太差&#xff0c;开始学习SQL语句基础&#xff0c;学习过程中在本博客进行笔记记录&#xff0c;课程参考&#xff1a;https://www.bilibili.com/video/BV1UE41147KC?p2 基本概念 DBMS: 数据库管理系统&am…

基于Python实现的文章整合搜索引擎网站(Scrapy+Django+MySQL)

目 录 摘 要… 1 1 概述… 6 2 技术选型… 6 2.1 Scrapy-Redis 分布式爬虫 … 6 2.1.1 Redis… 6 2.1.2 Scrapy… 7 2.2 MySQL 数据存储 … 8 2.3 Django 搭建搜索网站 … 8 2.4 ElasticSearch 搜索引擎 … 9 2.4.1 Elasticsearch-RTF… 9 2.4.2 Elasticsearch-head… 10 2.4.3…

Kotlin编程实战——集合(07)

一 概述 集合概述构造集合迭代器(Iterable<T>)区间与数列序列(Sequence<T>)集合操作概述集合转换集合过滤加减操作符分组(groupBy())取集合的一部分取单个元素集合排序集合聚合操作集合写操作List 相关操作Set 相关操作Map 相关操作 二 集合概述 set、list 、map…

【python】Numpy统计函数总结

文章目录函数列表相关系数直方图函数列表 最值amin, amax, nanmin, nanmax, 极差ptp分位数percentile∗^*∗ quantile∗^*∗,统计量中位数median∗^*∗&#xff1b;平均数mean∗^*∗&#xff1b;变化幅度var&#xff1b;加权平均average标准差std&#xff1b;协方差cov&#x…

运算放大器正反馈负反馈判别法

---------------------------------------------------------------------------------------------------------------- 反馈可分为负反馈和正反馈。前者使输出起到与输入相反的作用&#xff0c;使系统输出与系统目标的误差减小&#xff0c;系统趋于稳定&#xff1b;后者使输出…

浅谈java中的String

Java中的String类型不属于八大基本数据类型&#xff0c;而是一个引用数据类型&#xff0c;所以在定义一个String对象的时候如果不直接赋值给这个对象&#xff0c;它的默认值就是null。我们要怎么理解String类型的不可变&#xff0c;在JDK源码中String这个类的value方法被final关…

【C++】如何修改set的值

问题&#xff1a;尝试通过begin方法得到的迭代器去修改值&#xff0c;发现会报错。 set<string> st{"hello", "world", "good"}; set<string>::iterator it st.begin(); *it "test"; 原因&#xff1a;我们可以在源码里…

怎么搭建搜题接口api

怎么搭建搜题接口api 本平台优点&#xff1a; 多题库查题、独立后台、响应速度快、全网平台可查、功能最全&#xff01; 1.想要给自己的公众号获得查题接口&#xff0c;只需要两步&#xff01; 2.题库&#xff1a; 查题校园题库&#xff1a;查题校园题库后台&#xff08;点击…

RTSP协议学习Ubuntu环境准备

文章目录RTSP协议学习Ubuntu环境准备RTSP协议概述Ubuntu环境准备一、Ubuntu安装FFmpeg二、安装ZLMediaKit1、获取代码2、强烈推荐3、编译器3.1、编译器版本要求3.2、安装编译器4、cmake5、依赖库5.1、依赖库列表5.2、安装依赖库6、构建和编译项目7、运行8、测试三、测试推流测试…

【Tomcat】解决Tomcat服务器乱码问题

俩地方开展出现乱码的原因1、以startup.bat文件打开的服务器出现乱码2、在IDEA中运行Tomcat服务器出现乱码问题3、有关社区版IDEA如何开发JavaWeb项目出现乱码的原因 使用了错误的字符编码去解码字节流&#xff0c;所以出现乱码咱思维要清晰&#xff0c;就去找字符编码是否与其…

【TS04——接口的多态——泛型接口】

接口的多态&#xff0c;同一个方法&#xff0c;传入不同的参数&#xff0c;他所对应的操作不同成为多态【参数不同】或者可以理解为同一个方法&#xff0c;返回不同的结果&#xff0c;称之多态。 interface IfnPerson {run():voidrun(id:number):voidrun(id:number,name:strin…

【生日快乐】Node.js 实战 第1章 欢迎进入Node.js 的世界 1.3 安装Node

Node.js 实战 文章目录Node.js 实战第1章 欢迎进入Node.js 的世界1.3 安装Node第1章 欢迎进入Node.js 的世界 1.3 安装Node 安装Node的最简单的方法是使用其官网上的安装程序。可以用对应Mac或 Windows的安装程序安装最新的当前版。 官网安装包下载地址&#xff1a;https://…