Skip to content

Node 面试题

为什么要用 Node 做中间层?

为了更加彻底的前后端分离!

很多时候我们会认为前后端分离概念中,SPA 做到了集中的展现。从某种意义上来说,SPA 确实做到了前后端分离,但是这种方式有存在问题:

  1. Web 服务中,SPA 类的占比不高,很多场景下 SPA 不能作为一种通用的解决方案(服务端渲染、同步+异步+异步混合)
  2. 现阶段的 SPA 开发模式中,接口通常是按照逻辑来展现数据的,有时候为了提高效率,后端会根据前端需要的数据结构做数据封装,这就意味着后端还是做了 View 层的工作,违背了前后端分离的初衷

前后端分离的模式不止是物理层的区分,客户端上运行的就是前端,服务端运行的就是后端。这种想法现在已经无法满足前后端分离的模式。前端负责 View 层和 Controller 层,后端只负责 Model 层处理业务/数据,才是想要的真正的前后端分离的模式。

现有开发阶段出现的问题:

  1. 前后端职责不清。在传统的不分离模式中,我们看到一大堆复杂逻辑的前后端混杂在一起的代码会异常头疼,有的时候完全没法维护。虽然前后端分离能缓解这种问题,因为从物理层方面决定了不可能把前后端代码携带一起,但是治标不治本。
  2. 开发效率问题。在第一个问题影响开发效率的基础上,再说几个影响前后端开发效率的事。淘宝的 Web 基本上都是基于 MVC 框架 webx,决定了前端的职责还是写好静态 demo,由后端翻译。传统前后端分离的模式中,后端也需要根据前端需要的数据模型进行返回(比如排序、筛选等),后端也没法摆脱展现的问题,专注于逻辑层的开发。
  3. SEO优化问题。

如果想解决以上所说的问题,就必然需要一种 Web 服务帮我们实现以前后端所做的事情,Node 应运而生。

可能存在的问题:

  1. SPA 模式中,后端已经提供了所需的数据接口,View 层前端已经可以控制,为什么要多加 Node 这一层?

确实,在 spa 模式下,Node 这一层看似没有太大的必要。但是,如果我们跳出前端的视野,从整个项目的视角,会有不一样的理解。

一个最简单的数据流:前端 -> 后端 -> 数据库

在这个原始的流程之间,我们有很多事情可以做。来看和前端最近这条线:前端 -> 后端

代理、缓存、限流、日志、监控、鉴权、路由、灰度、...

上面列出的是一些我们一个服务除了前端的展示,Service 端的网络服务,API 的提供,数据落库等之外也要做的部分事情。

返回来再看我们的问题,在前后端分离的天然选择下,前端可不能单单只关注页面的交互展现,Node 中间层可以承担更多的责任:

  • 代理:在开发环境下,我们可以利用代理来,解决最常见的跨域问题;在线上环境下,我们可以利用代理,转发请求到多个服务端
  • 缓存:缓存其实是更靠近前端的需求,用户的动作触发数据的更新,node 中间层可以直接处理一部分缓存需求
  • 限流:node 中间层,可以针对接口或者路由做响应的限流
  • 日志:相比其他服务端语言,node中间层的日志记录,能更方便快捷的定位问题(是在浏览器端还是服务端)
  • 监控:擅长高并发的请求处理,做监控也是合适的选项(前端性能&错误监控)
  • 鉴权:有一个中间层去鉴权,也是一种单一职责的实现(「泊时易」parkingQueryFee 接口因业务需求没法做鉴权,导致被攻击)
  • 路由:前端更需要掌握页面路由的权限和逻辑
  • 服务端渲染:node 中间层的解决方案更灵活,比如 SSR、模板直出、利用一些 JS 库做预渲染等等
  • 更多的可能性...

多了 Node 中间层,不可避免的会带来一定的性能损耗。但同时,合理的分层能让组织架构更加清晰,会大大提高开发效率,性能带来的损失是在一定程度上可以弥补回来的。

参考资料:

为何要对生产环境的 Node 使用反向代理?

反向代理是什么?

一个反向代理(reverse proxy)基本上就是一种用于接受请求并将它们转发到某处另一个 HTTP server 上的特殊 web server,也会接受响应并转发回给原始的请求者。

为何应该使用一个反向代理?

  • SSL 终端
  • Gzip 压缩
  • 集群化
  • 企业路由
  • 性能收益
  • 简化应用代码

参考资料:

Node 有哪些特征,与其他服务器端对比

特点:单线程、事件驱动、非阻塞I/O

什么是 Streams?解释一下有哪些类型?

流的概念是不间断的,它可以不间断的从某个地方读取数据,或者向某个地方写入数据。

有4种类型的流数据:可读,可写,可读写,可转换

Nodejs 是如何支持多处理器平台的?(部署 Node 服务时如何利用服务器的多核?)

Nodejs 是一个单进程单线程的服务器引擎,不管多么强大的硬件,只能利用到单个 CPU 进行计算。

但是现在的服务器基本都是多核的,这导致了服务器资源的浪费,而且一旦程序出现未知异常, 就会使整个进程奔溃,导致服务不可用。在 v0.6.0 版本,Nodejs 内置了 cluster 的特性。让 Node 可以利用多核 CPU 实现并行。

Nodejs 在底层使用了 libuv 库来实现多线程 IO 操作,其对用户不可见。但是 Nodejs 的主程序还是运行在单进程单线程上。

cluster 介绍:

cluster 是一个 Nodejs 内置的模块,用于 Nodejs 多核处理。cluster 模块,可以帮助我们简化多进程并行化程序的开发难度,轻松构建一个用于负载均衡的集群。

cluster 意为集成,集成了两个方面,第一个方面就是集成了 child_process.fork 方法创建 node 子进程的方式,第二个方面就是集成了根据多核 CPU 创建子进程后,自动控制负载均衡的方式。

js
const express = require("express");
const cluster = require("cluster");
const fs = require("fs");
const os = require("os");

if (cluster.isMaster) {
    // 主进程不负责具体的业务处理,而是负责调度和管理工作进程
    console.log("master" + process.pid + "正在运行");
    const cpus = os.cpus().length;

    // 为计算机上的每个 CPU 核产生一个新的工作进程
    for (let i = 0; i < cpus; i++) {
        cluster.fork();
    }

    // 监听工作进程退出,以便我们知道何时出现问题或意外
    cluster.on("exit", (worker, code, signal) => {
        console.log("工作进程" + worker.process.pid + "已退出");
    });
} else {
    // 工作进程可以共享任何 TCP 连接,在本例子中,共享的是一个 HTTP 服务器
    const app = express();
    const pid = process.pid;

    app.listen(3000, () => {
        console.log(`工作进程${cluster.worker.process.pid} is runing`);
    });

    app.get("/test", function (req, res, next) {
        console.log(`${cluster.worker.process.pid}`);
        fs.readFile('./package-lock.json', (err, data) => {
            res.send(data);
        });
    });
}

child_process 实现多进程

child_process 提供了4个方法,用于新建子进程,这4个方法分别为 spawn, execFile, exec, fork。所有的方法都是异步的:

  • spawn: 子进程中执行的是非 Node 程序,提供一组参数后,执行的结果以流的形式返回
  • execFile: 进程中执行的是非 Node 程序,提供一组参数后,执行的结果以回调的形式返回
  • exec: 子进程执行的是非 Node 程序,传入一串 shell 命令,执行后结果以回调的形式返回,与 execFile 不同的是 exec 可以直接执行一串 shell 命令
  • fork: 子进程执行的是 Node 程序,提供一组参数后,执行的结果以流的形式返回,与 spawn 不同,fork 生成的子进程只能执行 Node 应用

exec, execFile, spawn, fork 执行的子进程都是默认异步的,子进程的运行不会阻塞主进程。除此之外,child_process 模块同样也提供了 execFileSync, spawnSync, execSync 来实现同步的方式执行子进程。

参考资料

setTimeout, setImmediate, nextTick 的区别是什么?

  1. process.nextTick 中回调函数的优先级高于 setImmediate
  2. 在实现上,process.nextTick 的回调函数保存在一个数组中,setImmediate 则保存在一个链表中
  3. setImmediate 可以使用 clearImmediate 清除,process.nextTick 不能被清除
  4. setTimeout 的优先级高于 setImmediate

setTimeout 采用的是类似 IO 观察者,setImmediate 采用的是 check 观察者,而process.nextTick() 采用的是 idle 观察者,三种观察者的优先级顺序是:idle 观察者 >> io 观察者 > check 观察者

SSR 的原理是什么,解决了哪些问题?

能在服务端渲染为 html 字符串得益于 vue 组件结构是基于 Virtual Dom 的。Virtual Dom 是 dom 的抽象表达,它不是真实的 dom,它是由 js 对象组成的树,每个节点代表了一个 dom。因为 Virtual Dom 所以在服务端 vue 可以把 js 对象解析为 html 字符串。

服务端渲染的优点

  • 更好的 SEO,搜索引擎爬虫可以抓取渲染好的页面
  • 更快的内容到达时间(首屏加载更快),因为服务端只需要返回渲染好的HTML,这部分代码量很小的,所以用户体验更好

服务端渲染的缺点

  • 首先就是开发成本比较高,比如某些生命周期钩子函数(如 beforeCreate, created)能同时运行在服务端和客户端,因此第三方库要做特殊处理,才能在服务器渲染应用程序中运行。
  • 由于服务端渲染要用 Nodejs 做中间层,所以部署项目时,需要处于 Node.js server 运行环境。在高流量环境下,还要做好服务器负载和缓存策略。

服务器端渲染 vs 预渲染(SSR vs PreRendering)

营销/宣传等纯静态页面可使用预渲染,无需使用 web 服务器实时动态编译 HTML,优点是更简单,并且可以作为一个完全静态站点。

如果你使用 webpack,你可以使用 prerender-spa-plugin 轻松地添加预渲染。

SSR 会有哪些坑?

  1. 一套代码两套环境执行 vue 的生命周期钩子函数中, 只有 beforeCreatecreated 会在服务器端渲染过程中被调用,如果在两套环境的代码中加入具有副作用的代码或访问特定平台的 API,将出现各种问题。比如服务端没有 window, document 对象, 如果加了对这个对象的引用和操作,将在服务端引起报错中断
  2. 服务端数据的预获取 在服务端预获取数据的钩子函数中,不要进行额外的操作,任何对于数据的额外操作都要在 vuex 的体系下进行,vuex 在 Vue SSR 方案下,应使用惰性注册的方案
  3. 接口代理问题
  4. cookie 穿透
  5. 路由模式 因为 hash 模式的路由提交不到服务器上,因此 ssr 的路由需要采用 history 的方式

参考资料:

如果你要读取一个特别大的文件应该如何做?

Nodejs 进行视频之类大文件读取时不能像读取图片之类的一次性读取,而是必须读取一部分返回一部分,这样客户端的播放才会边缓冲边播放,而不必等待全部缓冲完再播放。

js
var fs = require('fs');

function readBigFileEntry(filename, response) {
    path.exists(filename, function(exists) {
        if (!filename || !exists) {
            response.writeHead(404);
            response.end();
            return;
        }

        var readStream = fs.ReadStream(filename);

        var contentType = 'none';
        var ext = path.extname(filename);
        switch (ext) {
            case ".flv":
                contentType = "video/flv";
                break;
        }

        response.writeHead(200, {
            'Content-Type': contentType,
            'Accept-Ranges': 'bytes',
            'Server': 'Microsoft-IIS/7.5',
            'X-Powered-By': 'ASP.NET'
        });

        readStream.on('close', function() {
            response.end();
            console.log('Stream finished.');
        });
        readStream.pipe(response);
    });
}

通过 fs 模块的 ReadStream 方法,拿到视频流,然后绑定关闭事件:当流读取到结尾的时候结束 response 请求,最后通过 pipe 方法进行小块小块的读取。这里的 head 信息不能添加 Content-Length 属性,因为必须分段读取,如果加了这个属性,浏览器就会以为请求结束了从而关闭请求。

你们有没有对服务端的异常进行监控?

sentry 监控异常

钉钉 webhook

Fundebug

在线上出现问题时如何在应用层面监控 CPU 和 Memory 的信息?

(没接触过,不会~)

如何查看一个 Node 的服务端应用的内存和 CPU?

Node 启动完成后,可以通过 ps aux | grep node 查看具体进程情况

你们的 Node 的服务端应用如何部署?

Jenkins + Docker + PM2

注意:Thinks 会强制使用 cluster,然后使用 master/worker 的方式提供服务(多进程),所以就不能开启 pm2 中的 cluster 模式(如果开启,那么启动服务会直接报错退出)

Docker 部署有什么好处?

  1. 更高效的利用系统资源 docker 对系统资源的利用率更高,无论是应用执行速度,内存损耗或者文件存储速度,都要比传统虚拟机技术更高效。因此,相比虚拟机技术,一个相同配置的主机往往可以运行更多数量的应用。

  2. 更快速的启动时间 传统的虚拟机技术启动应用服务往往需要数分钟,而 docker 容器应用,由于直接运行于宿主内核,无需启动完整的操作系统,因此可以做到秒级,甚至毫秒级的启动时间,大大的节约了开发测试,部署的时间。

  3. 一致的运行环境 开发过程中常见的一个问题是环境一致问题,由于开发环境,测试环境,生产环境不一致,导致有些 bug 并未在开发过程中发现。而 docker 的镜像提供了除内核外完整的运行时环境,确保环境一致性,从而不会在出现「这段代码在我机器上没问题」这类问题。

  4. 持续交付和部署 对开发和运维人员来说,最希望就是一次创建和部署,可以在任意的地方运行。(定制应用镜像来实现集成、持续交付、部署。开发人员可以通过 Dockerfile 来进行镜像构建,并结合持续集成系统进行集成测试,而运维人员则可以直接在生产环境中快速部署该镜像,甚至结合持续部署系统进行自动部署)。而且使用 Dockerfile 使镜像构建透明化,不仅仅开发团队可以理解应用运行环境,也方便运维团队理解应用运行所需条件,帮助更好的生产环境中部署该镜像。

  5. 更轻松的迁移 由于 docker 确保了执行环境的一致性,使得应用的迁移更加的容易。docker 可以在很多平台上运行,无论是物理机、虚拟机、公有云、私有云、甚至是笔记本、其运行结果是一致的。因此用户可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上,而不用担心运行环境的变化导致应用无法正常运行的情况。

  6. 更轻松的维护和拓展 docker 使用的分层存储以及镜像的技术,使得应用重复部分的复用更为容易,也使得应用的维护更新更加简单,基于基础镜像进一步扩展镜像也变得十分简单。此外,docker 团队同各个开源项目团队一起维护了一大批高质量的官网镜像,既可以直接在生产环境使用,又可以作为基础进一步定制,大大的降低了应用服务的镜像制作成本。

Docker 底层原理时什么?隔离环境主要隔离什么环境?

Docker 的核心技术其实已经有很多年的历史了,Linux 命名空间(Namespace)、控制组(CGroups)和 UnionFS 三大技术支撑了目前 Docker 的实现,也是 Docker 能够出现的最重要原因。

隔离了进程、网络、文件系统、物理资源(CPU、内存、磁盘I/O、网络带宽)

参考资料:

进程与线程

计算机的核心是 CPU,它承担了所有的计算任务,而操作系统是计算机的管理者,它负责任务的调度,资源的分配和管理,统领整个计算机硬件。应用程序是具有某种功能的程序,程序是运行于操作系统之上的。

进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。

线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。

进程与线程的区别

  1. 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位
  2. 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线
  3. 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号等),某进程内的线程在其它进程内不可见
  4. 调度和切换:线程上下文切换比进程上下文切换要快得多

为何不使用多进程而是使用多线程?

  • 线程廉价,启动比较快,退出比较快,对系统资源的冲击也比较小。而且线程彼此分享了大部分核心对象(File Handle)的拥有权。
  • 多进程不可预期且测试困难

参考资料

Node 实现多进程之 PM2

PM2 是一个带有负载均衡功能的 Node 应用的进程管理器。

主要特性:

  • 内建负载均衡(使用 Node cluster 集群模块)
  • 后台运行
  • 0 秒停机重载,我理解大概意思是维护升级的时候不需要停机
  • 具有 Ubuntu 和 CentOS 的启动脚本
  • 停止不稳定的进程(避免无限循环)
  • 控制台检测
  • 提供 HTTP API
  • 远程控制和实时的接口 API(Nodejs 模块,允许和 PM2 进程管理器交互)

如何在 Node 端配置路径别名(类似于 Webpack 中的 alias 配置)

NODE_PATH 设置路径别名

json
"scripts": {
    "start": "cross-env NODE_PATH=.;./mod node index.js",
}

NODE_PATH 的路径用分号(Windows)或冒号(MacOS 或 Linux)分割多个路径,. 表示本目录,./mod 表示一个子目录。

缺点:不同系统设置多个路径的分隔符不同,用了 cross-env 也于事无补

module-alias 模块

bash
npm i --save module-alias
json
// Aliases
"_moduleAliases": {
    "@root"      : ".", // Application's root
    "@deep"      : "src/some/very/deep/directory/or/file",
    "@my_module" : "lib/some-file.js",
    "something"  : "src/foo", // Or without @. Actually, it could be any string
}

// Custom module directories, just like `node_modules` but with your private modules (optional)
"_moduleDirectories": ["node_modules_custom"],

这个模块可以在 packag.json 中注册 alias,跟 Webpack 很相似。其原理是:

  1. 修改了 NodeJS 的查找路径的方法 Module._resolveFilename,先从 alias 中找,然后替换,再用原来的方法找
  2. 修改了 NodeJS 内部的 Module._nodeModulePaths,使得 _moduleDirectories 中的目录可以实现类似 node_modules 的效果