《WebKit 技术内幕》学习之九(1): JavaScript引擎

1 概述

1.1 JavaScript语言

        说起JavaScript语言,又要讲一个典型的从弱小到壮大的奋斗史。起初,它只是一个非常不起眼的语言,用来处理非常小众的问题。所以,从设计之初,它的目标就是解决一些脚本语言的问题,因为设计的能力有限,性能不需要重点考虑。因为使用场景少,所以用户对于性能的要求也比较低。在过去几年,由于Web时代的来临和HTML5的兴起,这一切让JavaScript前所未有地成为焦点,自然它的功能和性能在大家的努力下都有了长足的进步。

        JavaScript是一种脚本语言,主要用在Web的客户端(这样说也不准确,Node.js和其他一些用法的出现就是例外),它的出现是为了控制网页客户端的逻辑,例如同用户的交互、异步通信等需求。当然,在HTML5高速发展的今天,它的作用越来越大,被广泛地使用在各种其他技术中。

        本质上它是一种解释型语言(不知道按照现在的实现来看,这么说是不是准确),函数是它的第一等公民,也就是函数也能够当作参数或者返回值来传递。示例代码9-1是一个简单的例子,读者可以看到一个简单的函数“getOperation”,它的返回值就是一个匿名函数。

        示例代码9-1 函数作为函数的返回值

    function getOperation() {return function () {print("JavaScript");};}

        JavaScript语言的另一个重大特点就是,它是一种无类型语言,或者说是动态类型语言。相比较而言,C++或者Java等语言都是静态类型语言,它们在编译的时候就能够知道每个变量的类型。但是,JavaScript的语言特性让我们没有办法在编译的时候知道变量的类型,所以只能在运行的时候才能确定,这导致JavaScript语言的规范面临着性能方面的巨大压力。在运行时计算和决定类型,会带来很严重的性能损失,这导致了JavaScript语言的运行效率比C++或者Java都要低很多。

        先看示例代码9-2所示的一个简单的JavaScript代码,它是一个简单得不能再简单的包含两个参数的JavaScript函数,其目的就是计算参数a的属性为x的值与参数b的属性为y的值的和。

示例代码9-2 一个简单的JavaScript函数

    function add(a, b) {return a.x*a.y + b.x*b.y;  // 这里对象a和b的类型未知}

        问题来了,当JavaScript引擎分析到该段代码的时候,根本没有办法知道a和b是什么类型,唯一的办法就是运行的时候根据实际传递过来的对象再来计算。读者可能会好奇,这好像并没什么特别的嘛,事实上这会导致严重的性能问题。

        让我们来简单解释一下为什么静态类型能够大量地节省运行时间。示例代码9-3是一个简单的C++函数,它同9-2类似,不同之处在于参数必须指定类型。

        示例代码9-3 一个简单的C++函数

 
int add(Class1 a, Class1 b) {class Class1 {
return a.x*a.y + b.x*b.y;int x;
}int y;
}

        当编译示例代码9-3中左边部分的时候,根据右边部分类型Class1的定义,获取对象a的属性x的时候,其实就是对象a的地址,大小是一个整形。同时获取对象b的属性y的时候,其实就是对象b的地址加上4个字节(不同的平台上可能不同,但是一旦平台确定,其值是固定的),这些都是在生成本地代码的时候确定的,无须在运行本地代码的时候决定它们的地址和类型是什么,这显然能够节省时间。

        图9-1 示例代码9-3中的类和对象的结构表示

        图9-1中最右侧表示的是类Class1的属性对应的地址信息,在编译阶段,编译器根据int类型来决定属性占用4个字节,地址就是对象的地址,因为偏移量为0。所以对于y来说,访问它只需要将对象地址加上4个字节即可,也就是偏移量为4。所以在编译的时候,能够确定访问对象a中属性的偏移量,根据这些信息,可以生成相应的汇编代码。其中的符号信息,例如字符“x”和“y”运行时都不再需要,因为不再需要额外的查找这些属性地址的工作。在C++和Java等语言中,已事先知道所存取的成员变量(类)类型,所以语言解释系统(Interpreting System)只要利用数组和位移来存取这些变量和方法的地址等。位移信息使它只要几个机器语言指令,就可以存取变量、找出变量或执行其他任务。

        现在继续回到JavaScript代码中来,对于传统的JavaScript解释器,所有这一切都是解释执行,所以效率不会高到哪去。不管是解释器还是现在更为高效的JIT(Just-In-Time)技术,面临的难题都是类型问题。现在我们也将JavaScript代码的处理分成两个阶段,就是编译阶段(虽然跟传统的编译有些不同)和执行阶段。对于JavaScript引擎来说,因为没有C++或者Java这样的强类型语言的类型信息,所以JavaScript引擎通常的做法就是如图9-2所表示的方法来存储每一个对象。

                        图9-2 示例代码9-2的对象a和b的结构表示

        基本的工作方式是这样,当创建对象a的时候(这个当然是执行阶段),如果它包含两个属性(根据JavaScript的语言特性,没有类型,而且这些属性都是动态创建的,属性就是前面说的C++的类成员变量),那么引擎会为它们创建如图9-2左边所示的结构,也就是属性名-属性值对,需要强调的是这些属性名(典型的做法就是采用字符串)都是会被保存的,因为之后访问该对象的属性值时需要通过属性名匹配来获取相应的值。读者看到对象b是同样的结构,也同样保存相同的属性,因为JavaScript没有类型,所以每个对象需要自己保存这些信息。在降低性能的同时,读者也会发现它们存在内容冗余的部分,比如对象a和对象b都保存相同的属性名,随着对象的增多,这显然会带来空间上的巨大浪费。

        追根究底,这里的目的获取对象属性值的具体位置,也就是相对于对象基地址的偏移位置。从这个角度来看,JavaScript和C++语言(下面的解释需要对C++语言有一些基本的认识)上的区别包括以下几个部分。

  • 编译确定位置 :C++有明确的两个阶段,而编译这些位置的偏移信息都是编译器在编译的时候就决定了的,当C++代码编译成本地代码之后,对象的属性和偏移信息都计算完成。因为JavaScript没有类型,所以只有在对象创建的时候才有这些信息,因而只能在执行阶段确定,而且JavaScript语言能够在执行时修改对象的属性(不是属性值,而是添加或者删除属性本身)。
  • 偏移信息共享 :C++因为有类型定义,所以所有对象都是按照该类型来确定的,而且不能在执行的时候动态改变类型,因为这些对象都是共享偏移信息的。访问它们只需要按照编译时确定的偏移量即可。而对于C++模板的支持,其实是多份代码,因为本质上其道理是相同的。JavaScript则不同,每个对象都是自描述,属性和位置偏移信息都包含在自身的结构中。
  • 偏移信息查找 :C++中查找偏移地址很简单,都是在编译代码时,对使用到某类型的成员变量直接设置偏移量。而对于JavaScript,使用到一个对象则需要通过属性名匹配才能查找到对应的值,这实在太费时间了。

        对于这个问题读者可能觉得其对性能的影响不大,其实不是这样。因为对象属性的访问非常普遍而且次数非常频繁,而通过偏移量来访问值并且知道该值的类型,使用少数两个汇编指令就能完成,但是,对于图9-2中的通过属性名来匹配对于性能造成的影响可能会多很多倍,因为属性名匹配需要特别长的时间,而且额外浪费很多内存空间。

        有方法解决这一问题吗?答案是肯定的。当然要达到跟C++和Java一样的效率很难,但是已经有很多方法能够逐步接近了,笔者在介绍JavaScriptCore引擎和V8引擎的时候再论述它们,因为这些新技术的确带来了性能上的巨大进步。

        推动JavaScript运行速度提高的另一大利器是JIT(Just-In-Time)技术,它不是一项全新的技术,其作用是解决解释性语言的性能问题,主要思想是当解释器将源代码解释成内部表示的时候(Java字节码就是一个典型例子),JavaScript的执行环境不仅是解释这些内部表示,而且将其中一些字节码(主要是使用率高的部分)转成本地代码(汇编代码),这样可以被CPU直接执行,而不是解释执行,从而极大地提高性能。JIT技术被广泛地使用在各种语言的执行环境中,例如Java虚拟机,经过长时间的演进之后,目前使用在JavaScript的众多引擎中,例如JavaScriptCore、V8、SpiderMonkey等中。

        下面要说的是JavaScript的作用域链和闭包等概念,它们非常重要,这两个概念带来了编程上的便易性和模块化,本节主要讲述它们的原理,后面会介绍它们是如何被实现的。

        首先介绍一个学术解释,“闭包是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分”。通俗来说,就是当执行到一条语句的时候,哪些对象(或者其他环境因素)能够被使用。JavaScript使用作用域链来实现闭包,作用域链由执行环境维护,JavaScript中所有的标识符都是通过作用域链来查找值的。用示例来解释它们比较清楚,示例代码9-4是两段功能类似但是影响却不同的常见JavaScript代码,下面结合闭包和作用域链来分析它们。

        假设这一段代码被保存在一个单独的JS文件中,当某个包含该JS文件的网页运行在浏览器中的时候,JavaScript已经预先创建好一个全局的域,该域会包含一个全局的上下文,该上下文可能包含window、navigator(网页中)等内置的对象,同时也包含当前执行位置的一些信息。例如代码9-4中的第一行,当执行到该行时,就定义了“me”并赋值1,上下文就包含了一个“me”变量,接下来的语句就能够使用该变量。图9-3中的全局上下文就包含这些信息。

 图9-3 示例代码9-4中左侧所涉及的作用域链

示例代码9-4 使用闭包技术的JavaScript函数

 
var me = 1;var me = 1;
function add(x) {(function (x) {
var me = 2;var me = 2;
function internal() {function internal() {
return me + x;return me + x;
}}
return internal() + 3;    return internal() + 3;
}})(1);
add(1);

        当执行到左边第二行的时候,函数“add”也被加入全局上下文中(事实上,这有两个阶段,在之前的阶段,“add”已经被加入上下文中,所以在“add”函数声明之前使用它也是可以的),所有的代码都能够使用它。如果不巧在它之前也有同样名为“add”的函数,那么之前的函数会被覆盖。所以,假如我们并不希望“add”被其他地方使用,而且不要覆盖之前的函数,因为这样会污染全局空间,造成不必要的麻烦。一个正确的做法是示例代码9-4中右侧的使用方法,稍后做介绍。

        在“add”函数中,执行环境同样会建立一个该函数的上下文,包含该函数中的心理,例如第三行,又是一个变量“me”。该上下文同时会指向全局上下文。继续看代码,到函数“internal”内部,同样如此,执行环境也会为它建立一个上下文,如图9-3中右下角的上下文,指向它的父上下文,这样其实就形成了一个作用域链。在“internal”函数内部,它使用了变量“me”。首先,执行环境检查当前的上下文,查找有无变量“me”,当然在本例中无法找到,于是它会接着在它的父上下文中查找,显然“add”函数的上下文中包含“me”,所以不需要继续向上查找。如果“add”函数上下文中没有包含该变量,那么执行环境会不停向上查找,直到找遍全局上下文为止。

        下面解释示例代码9-4右侧函数的好处。当包含一个.js文件的时候,它的全局函数在其他.js文件中也可见,这直接导致名字冲突和模块化问题。因为没有C++的名空间机制和Java的包机制,每个.js文件中的函数命名可能相同,这直接导致名冲突。当开发者只是希望该“add”函数在内部使用的时候,那么他可以像右侧一样使用一个匿名函数,然后直接调用它,这样这个函数就不会污染全局空间。同时,匿名函数内部也使用了一个内部函数“internal”。根据前面介绍的作用域链技术,“internal”函数只在该匿名函数内部有效,完全不会影响其他代码,这里使用的就是闭包技术。

1.2 JavaScript引擎

        什么是JavaScript引擎?简单来讲,就是能够将JavaScript代码处理并执行的运行环境。要解释这一概念,需要了解一些编译原理的基础概念和现代语言需要的一些新编译技术。

        首先来看C/C++语言。由前面描述可知,处理该语言通常的做法实际上就是使用编译器直接将它们编译成本地代码,这一切都是由开发人员在代码编写完成之后实施的,如图9-4所示。用户只是使用这些编译好的本地代码,被系统的加载器加载执行,这些本地代码由操作系统调度CPU直接执行,无须额外处理。

图9-4 C++编译器生成本地代码的过程

        其次,来看看Python等脚本语言。处理脚本语言通常的做法是开发者将写好的代码直接交给用户,用户使用脚本的解释器将脚本文件加载然后解释执行,如图9-5所示。当然,现在Python也可以支持将脚本编译生成中间表示。但是,通常情况下,脚本语言不需要开发人员去编译脚本代码,这主要是因为脚本语言对使用场景和性能的要求与其他类型的语言不同。

图9-5 解释器解释执行过程

        然后来看看Java语言。其做法是明显的两个阶段,首先是像C++语言一样的编译阶段,但是,同C++编译器生成的本地代码结果不同,Java代码经过编译器编译之后生成的是字节码,字节码是跨平台的一种中间表示,不同于本地代码。该字节码与平台无关,能够在不同操作系统上运行。在运行字节码阶段,Java的运行环境是Java虚拟机加载字节码,使用解释器执行这些字节码。如果仅是这样,那Java的性能就比C++差太多了。现代Java虚拟机一般都引入了JIT技术,也就是前面说的将字节码转变成本地代码来提高执行效率。图9-6描述这两个阶段,第一阶段对时间要求不严格,第二阶段则对每个步骤所花费的时间非常敏感,时间越短越好。

图9-6 Java代码的编译和执行过程

        最后回到JavaScript语言上来。前面已经说了它是一种解释性脚本语言。但是,众多工程师不断投入资源来提高它的速度使得它能够使用Java虚拟机和C++编译器中的众多技术来改进工作方式。早期也是由解释器来解释即可,就是将源代码转变成抽象语法树,然后在抽象语法树上解释执行。随着将Java虚拟机的JIT技术引入,现在的做法是将抽象语法树转成中间表示(也就是字节码),然后通过JIT技术转成本地代码,这能够大大地提高执行效率。当然也有些直接从抽象语法树生成本地代码的JIT技术,例如V8。这是因为JavaScript跟Java还是有以下一些区别的。

        其一是类型。JavaScript是无类型的语言,其对于对象的表示和属性的访问比Java存在更大的性能损失。不过现在已经出现了一些新的技术,参考C++或者Java的类型系统的优点,构建隐式的类型信息,这些后面将逐一介绍。

        其二,Java语言通常是将源代码编译成字节码,这同执行阶段是分开的,也就是从源代码到抽象语法树再到字节码这段时间的长短是无所谓的(或者说不是特别重要),所以尽可能地生成高效的字节码即可。而对于JavaScript而言,这些都是在网页和JavaScript文件下载后同执行阶段一起在网页的加载和渲染过程中来实施的,所以对它们的处理时间也有着很高的要求。

        图9-7描述了JavaScript代码执行的过程,这一过程中因为不同技术的引入,导致其步骤非常复杂,而且因为都是在代码运行过程中来处理这些步骤,所以每个阶段的时间越少越好,而且每引入一个阶段都是额外的时间开销,可能最后的本地代码执行效率很高,但是如果之前的步骤耗费太多时间,最后的执行结果可能并不会好。所以不同的JavaScript引擎选择了不同的路径,这里先不仔细介绍,后面再描述它们。

                图9-7 JavaScript代码的编译和执行过程

所以一个JavaScript引擎不外乎包括以下几个部分。

  • 编译器 。主要工作是将源代码编译成抽象语法树,在某些引擎中还包含将抽象语法树转换成字节码。
  • 解释器 。在某些引擎中,解释器主要是接收字节码,解释执行这个字节码,同时也依赖垃圾回收机制等。
  • JIT工具 。一个能够能够JIT的工具,将字节码或者抽象语法树转换成本地代码,当然它也需要依赖牢记
  • 垃圾回收器和分析工具(ProfiIer) 。它们负责垃圾回收和收集引擎中的信息,帮助改善引擎的性能和功效。

1.3 JavaScript引擎和渲染引擎

        前面介绍了网页的工作过程需要使用两个引擎,也就是渲染引擎和JavaScript引擎。从模块上看,它们是两个独立的模块,分别负责不同的事情:JavaScript引擎负责执行JavaScript代码,而渲染引擎负责渲染网页。JavaScript引擎提供调用接口给渲染引擎,以便让渲染引擎使用JavaScript引擎来处理JavaScript代码并获取结果。这当然不是全部,事情也不是这么简单,JavaScript引擎需要能够访问渲染引擎构建的DOM树,所以JavaScript引擎通常需要提供桥接的接口,而渲染引擎则根据桥接接口来提供让JavaScript访问DOM的能力。在现在众多的HTML5能力中,很多都是通过JavaScript接口提供给开发者的,所以这部分同样需要根据桥接接口来实现具体类,以便让JavaScript引擎能够回调渲染引擎的具体实现。图9-8描述了两种引擎之间的相互调用关系。

                        图9-8 渲染引擎和JavaScript引擎的关系

        在WebKit中,两种引擎通过桥接接口来访问DOM结构,这对性能来说是一个重大的损失因为每次JavaScript代码访问DOM都需要通过复杂和低效的桥接接口来完成。鉴于访问DOM树的普遍性,这是一个常见的问题。

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

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

相关文章

[Android] Android架构体系(2)

文章目录 Bionic精简对系统调用的支持:不支持 System V IPC:有限的 Pthread 功能:有限支持C:不再支持本地化和/或宽字符:Bionic新增的特性系统属性硬编码写死的UID/GID内置了DNS解析硬编码写死的服务和协议 硬件抽象层Linux内核匿名共享内存(ASHMem)BinderLoggerION 内存管理内…

ZXing开源库生成二维码

引言 二维码(QR Code)作为一种快速、高容量、高密度的矩阵条码,已经在各行各业得到广泛应用。ZXing(Zebra Crossing)是一款由Google开源的Java二维码生成和解析库,提供了丰富的功能和易于使用的API。本篇博…

西瓜书学习笔记——Boosting(公式推导+举例应用)

文章目录 引言AdaBoost算法AdaBoost算法正确性说明AdaBoost算法如何解决权重更新问题?AdaBoost算法如何解决调整下一轮基学习器样本分布问题?AdaBoost算法总结实验分析 引言 Boosting是一种集成学习方法,旨在通过整合多个弱学习器来构建一个…

架构篇08:架构设计三原则

文章目录 合适原则简单原则演化原则小结 成为架构师是每个程序员的梦想,但并不意味着把编程做好就能够自然而然地成为一个架构师,优秀程序员和架构师之间还有一个明显的鸿沟需要跨越,这个鸿沟就是“不确定性”。 对于编程来说,本…

【Docker】在centos中安装nginx

🎉🎉欢迎来到我的CSDN主页!🎉🎉 🏅我是平顶山大师,一个在CSDN分享笔记的博主。📚📚 🌟推荐给大家我的博客专栏《【Docker】安装nginx》。🎯&#…

7+细胞焦亡+ceRNA+实验验证,如何脱离套路求创新?

导语 今天给同学们分享一篇生信文章“Dissection of pyroptosis-related prognostic signature and CASP6-mediated regulation in pancreatic adenocarcinoma: new sights to clinical decision-making”,这篇文章发表在Apoptosis期刊上,影响因子为7.2。…

【JAVA】我和我的第一个“对象”相遇

🌈个人主页: Aileen_0v0 🔥热门专栏: 华为鸿蒙系统学习|计算机网络|数据结构与算法 ​💫个人格言:“没有罗马,那就自己创造罗马~” #mermaid-svg-zAjv1fTLGQmnqncy {font-family:"trebuchet ms",verdana,arial,sans-serif;font-siz…

安泰ATA-2082高压放大器如何驱动超声探头进行无损检测

无损检测技术是一种在不破坏或影响被检测物体性能的前提下,通过物理或化学方法对其内部或表面的缺陷进行检测的技术。在无损检测领域,超声检测是一种广泛应用的方法,而ATA-2082高压放大器则是实现高效、精确超声检测的关键设备之一。本期内容…

HTTPS基本概念

HTTP 与 HTTPS 有哪些区别? HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。HTTP 连接建立相…

CVE-2024-0738 Mldong ExpressionEngine RCE漏洞分析

漏洞描述 A vulnerability, which was classified as critical, has been found in ???? mldong 1.0. This issue affects the function ExpressionEngine of the file com/mldong/modules/wf/engine/model/DecisionModel.java. The manipulation leads to code injection…

Vue+Element(el-switch的使用)+springboot

目录 1、编写模板 2、发送请求 3、后端返数据 1.Controller类 2.interface接口(Service层接口) 3.Service(接口实现) 4.interface接口(Mapper层接口) 5.xml 6.效果 4、el-switch属性 1、编写模板 …

数字图像处理(实践篇)二十六 使用cvlib进行人脸检测、性别检测和目标检测

目录 1 安装cvlib 2 涉及的函数 3 实践 4 其他 cvlib一个简单,高级,易于使用的开源Python计算机视觉库。 1 安装cvlib # 安装依赖pip install opencv-python tensorflow# 安装cvlibpip install cvlib</

iLO 安装中文固件包

前言 安装中文版本的安装包&#xff0c;需要把对应的ilo安装到固定的版本上&#xff0c;ilo的版本是2.70。必须是这个版本&#xff1b; 如果不是这个版本就需要刷到对应的ilo版本 下载对应的固件包。 到这个界面选择文件&#xff0c;然后点击上载。 以上就是刷系统包的步骤。 …

MATLAB 和 Octave 之间的区别

MATLAB 和 Octave 之间的区别 MATLAB 和 Octave 有一些区别&#xff0c;但大多数都是相同的。你可以说 Octave 是 MATLAB 的免费版本&#xff0c;语法几乎与 MATLAB 相同。但是 MATLAB 比 Octave 具有更多功能&#xff0c;就像具有 Simulink 一样&#xff0c;后者用于开发和分…

前端基于XLSX实现数据导出到Excel表格,以及提示“文件已经被损坏,无法打开”的解决方法

文章目录 一、vue实现导出excel1、前端实现1、安装xlsx依赖2、引入3、方法4、使用4.1、将一个二维数组转成sheet4.2、将一个对象数组转成sheet4.3、合并单元格4.4、一次导出多个sheet 5、支持的文件格式 2、后端实现 二、导出文件损坏1、前端请求导出接口&#xff0c;增加返回类…

宝塔FTP文件传输服务结合cpolar内网穿透实现远程连接本地服务

⛳️ 推荐 前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到网站。 文章目录 ⛳️ 推荐1. Linux安装Cpolar2. 创建FTP公网地址3. 宝塔FTP服务设置4. FTP服务远程连接小结 5. 固定FTP公网地址6. 固…

C++ 20 Module

头文件包含一直是C/C的传统&#xff0c;它使代码声明与实现分离&#xff0c;但它有一个非常大的问题就是会被重复编译&#xff0c;拖累编译速度。 通常一个标准头文件iostream展开后可能达几十万甚至上百万行。笔者使用下面的示例进行测试&#xff0c;新建一个main.cc&#xf…

C++:迭代器失效问题

目录 1.vector迭代器失效问题 1.底层空间改变 ​编辑 2.指定位置元素的删除操作 2.Linux下的迭代器失效检测 1.扩容 2.删除 3.解决方法 1.vector迭代器失效问题 迭代器的主要作用就是让算法能够不用关心底层数据结构&#xff0c;其底层实际就是一个指针&#xff0c;或者是…

前端使用css去除input框的默认样式

关键点&#xff1a; /* 关键点&#xff0c;让输入框无边框 */outline:none; border:none; 1.效果图 2.html <div class"container"><input type"text" placeholder"请输入用户名"><input type"text" placeholder&q…

springboot+mysql校园社团信息管理系统-计算机毕业设计源码62756

目 录 摘要 第1章 绪论 1.1 研究背景 1.2 研究意义 1.3论文结构与章节安排 第2章 相关技术 ....... 2.1开发技术 2.2 Java简介 2.3 MVVM模式 2.4 B/S结构 2.5 MySQL数据库 2.6 SpringBoot框架介绍 第3章 系统分析 6 3.1 可行性分析 6 3.2 系统流程分析 6 3.3 …