N层的应用软件系统,由于其众多的优点,已经成为典型的软件系统架构,也已经为广大开发人员所熟知。在一个典型的三层应用软件系统中,应用系统通常被划分成以下三个层次:数据库层、应用服务层和用户界面层。如下图所示:
其中,应用服务层集中了系统的业务逻辑的处理,因此,可以说是应用软件系统中的核心部分。软件系统的健壮性、灵活性、可重用性、可升级性和可维护性,在很大程度上取决于应用服务层的设计。因此,如何构建一个良好架构的应用服务层,是应用软件开发者需要着重解决的问题。
为了使应用服务层的设计达到最好的效果,我们通常还需要对应用服务层作进一步的职能分析和层次细分。很多开发者在构建应用服务层的时候,把数据库操纵、业务逻辑处理甚至界面显示夹杂在一起,或者,把业务逻辑处理等同于数据库操纵,等等,这些,都是有缺陷的做法。本文,就在这个方面进行设计时可采用的方案进行一些探讨。
为了使讨论更具有针对性,本文会讨论一些比较流行的系统架构,例如J2EE架构,以及JDO。在微软的.Net平台上,将以Websharp中间件为例。Websharp中间件是笔者开发的一个构建在微软.Net平台之上的一个中间件系统,也是实现文章所述的系统架构的支撑系统。选用这些架构做例子,也是因为.Net出现的时间比较短,目前在这个平台上没有成熟统一的架构,而J2EE是目前最成熟的构建企业应用的平台。
自本人的《 利用.Net框架开发应用系统》和《 实战揭秘:开发.Net平台应用系统框架》两篇文章发表以来,收到很多反馈和来信,提出了很多问题。因为时间的关系,不能一一回复,因此,也借本文给大家一些解答。需要说明的是,原来的Jobsinfo现在已经做了升级,名称变更为Websharp。
设计的原则和评判标准
同软件工程的原则一样,应用服务层的设计,必须遵循的最重要的原则就是高内聚和低耦合。软件分层的本来目的,就是提高软件的可维护性和可重用性,而高内聚和低耦合正是达成这一目标必须遵循的原则。尽量降低系统各个部分之间的耦合度,是应用服务层设计中需要重点考虑的问题。
内聚和耦合,包含了横向和纵向的关系。功能内聚和数据耦合,是我们需要达成的目标。横向的内聚和耦合,通常体现在系统的各个模块、类之间的关系,而纵向的耦合,体现在系统的各个层次之间的关系。
系统的框架,通常包含了一系列规范、约定和支撑类库、服务。
对于如何判断一个软件的系统框架的优劣,笔者认为,可以从以下几个方面来评判:
◆ 系统的内聚和耦合度
这是保证一个系统的架构是否符合软件工程原则的首要标准。
◆ 层次的清晰和简洁性
系统每个部分完成功能和目标必须是明确的,同样的功能,应该只在一个地方实现。如果某个功能可以在系统不同的地方实现,那么,将会给后来的开发和维护带来问题。
系统应该简单明了,过于复杂的系统架构,会带来不必要的成本和维护难度。在尽可能的情况下,一个部分应该完成一个单独并且完整的功能。
◆ 易于实现性
如果系统架构的实现非常困难,甚至超出团队现有的技术能力,那么,团队不得不花很多的精力用于架构的开发,这对于整个项目来说,可能会得不偿失。简单就是美。
◆ 可升级和可扩充性
一个系统框架,受设计时技术条件的限制,或者设计者本人对系统认识的局限,可能不会考虑到今后所有的变化。但是,系统必须为将来可能的变化做好准备,能够在今后,在目前已有的基础上进行演进,但不会影响原有的应用。接口技术,是在这个方面普遍应用的技巧。
◆ 是否有利于团队合作开发
一个好的系统架构,不仅仅只是从技术的角度来看,而且,它还应该适用于团队开发模型,可以方便一个开发团队中各个不同角色的互相协作。例如,将Web页面和业务逻辑组件分开,可是使页面设计人员和程序员的工作分开来同步进行而不会互相影响。
◆ 性能
性能对于软件系统来说是很重要的,但是,有的时候,为了能让系统得到更大的灵活性,可能不得不在性能和其他方面取得平衡。另外一个方面,由于硬件技术的飞速发展和价格的下降,性能的问题往往可以通过使用使用更好的硬件来获得提升。
应用服务层的内容
应用服务层,通常也被称为业务逻辑层,因为这一层,是应用软件系统业务逻辑处理集中的部分。然而,我将这一层称为应用服务层,而不称业务逻辑层,因为,这一层需要处理的不仅仅是业务逻辑,还包含了其他方面的内容。
从完整的角度来说,应用服务层需要处理以下内容:
◆ 数据的表示方式
数据,是软件处理的对象。从某种程度上来说,"软件,就是数据结构加算法"的说法,是有一定意义的。在面向对象的系统中,数据是用类来表示的,代表了现实世界实体对象在软件系统中的抽象。考虑所谓的MVC模式,这个部分的类属于M--实体类的范畴。由于应用软件通常会使用数据库,数据库中的数据,可以看成是对象的持久化保存。由于数据库一般是关系型的,因此,这个部分,还需要考虑类(对象)同关系型数据的映射,即通常所说的O-R MAP问题。
◆ 数据的存取方式
如同上述所说,软件系统处理的实体对象数据需要持久化保存数据库中,因此,我们必须处理系统同数据库的交互,以及数据的存取和转换方式的问题。
◆ 业务逻辑的组织方式
在面向对象的系统中,业务逻辑表现为对象之间的交互。有了上述的实体对象,以及对象的保存策略,就可以将这些对象组合起来,编写我们的业务逻辑处理程序。在业务逻辑的处理中,必须保证处理的正确性和完整性,这将会涉及到事务处理。通常,我们也会把业务逻辑封装成组件的形式,以得到最大的可重用性。
◆ 业务服务的提供方式
在我们完成系统的功能后,如何向客户提供服务,是我们需要考虑的问题。这里的客户,不仅仅是指软件的使用者,也包括调用的界面、其他程序等。例如,在一个基于Web的ASP.Net或JSP系统中,业务逻辑功能的客户便是这些ASP.Net页面或JSP页面。业务逻辑组件应该通过什么方式,直接的,或间接的,向这些客户提供服务,是这一层需要完成的任务。
◆ 层的部署和层间交互
对于一个多层的应用软件系统来说,尤其是大型的应用软件系统,通常需要把不同的部分部署在不同的逻辑或物理设备上。特别是一些基于Web的应用软件系统,其部署工作将涉及到Web服务器、组件服务器、数据库服务器等不同的服务设备。在进行应用软件架构的设计的时候,必须考虑各种不同的部署方案。
综上所述,一个完整的基于Web的应用软件系统,其架构可以用下图来表示(Websharp推荐的应用软件系统架构):
对于以上各个方面来说,每个问题都可以有很多种策略和方案,但是,在一个系统中,应该尽可能的统一这些策略和方案。也就是说,在一个系统,或者一个项目中,应该统一每个解决每个问题所采用的方法。软件的开发方法是灵活的,可以用不同的方法解决相同的问题,这会诱使开发人员采用他们认为能够表现自己的方法,但是,从整个系统来看,这将会是灾难性的。我们应该尽可能统一,就是,采用统一的数据表示方式、统一的数据存取方式、统一的业务逻辑处理方式等。
下面,将就这些部分的设计策略和可用方案进行一些比较详细的论述。
数据实体的表示
应用软件系统,从本质上来说,是计算机对现实世界的模拟。现实世界中的实体对象,在软件系统中,表现为需要处理的数据。在面向对象的系统中,这是通过"类"和"对象"来表示的。
参考著名的"MVC"模式,类可以分成实体类(M)、控制类(C)、和边界类(V),分别代表了实体对象、控制和界面显示。系统中需要处理的数据,在面向对象的系统中,属于实体类部分。
在考虑数据实体层的设计策略的时候,需要把握以下要点:
◆ 一致的数据表示方式。在一个系统中,数据的表示方式必须尽可能统一,同时,在处理单个数据和多个数据的时候,处理方式尽可能一致。
◆ 因为数据通常是需要存储到数据库中,因此,良好的映射方法是必需的。
◆ 处理好对象的粒度,即所谓的粗粒度对象、细粒度对象。
一般例子
考虑一个现实的例子,一个仓库中的产品(Product),在系统中可以使用如下定义:
public class Product{public string Name; //名称 public decimal Price;//价格 public int Count;//数量 } 可以按照如下方法使用Product类: Product p=new Product(); //……处理Product |
这是一个包含了三个属性的Product类的定义。为了便于说明,在这里,我们尽量将问题简化了。
又例如,一张入库单可以使用如下定义:
public class Form{public string ID; //入库单编号 public DateTime AddTime; //入库时间 public FormDetail[] FormDetails; //入库单明细 } public class FormDetail { public Product InProduct; //入库产品 public int Count; //入库数量 } |
对于处理单个对象,通常采用上述的方法,但是,当我们需要处理相同类的一组对象,也就是处理一个对象集合的时候,就会有一些小小的麻烦。
如前所述,我们希望在处理单个对象和对象集合的时候,处理的方式尽量统一,这对于软件开发的意义是很大的。常用的处理对象集合的方法有:
◆数组表示的方法
例如,上面的例子中当一张入库单包含多条入库单明细的时候采用的方法。为了灵活性,也可以使用容器来,如Java中的Vector或C#的ArrayList(C#)。只是,在处理对象的时候,需要一个类型转换的操作。这个问题,在支持泛型的语言中不会存在,如使用C++的标准库的容器类。
◆ObjectCollection方法。这个方法同上面的方法类似,不同之处在于,为每个实体类设计一个Collection类。例如,可以为FormDetail设计一个FormDetailsCollection类(C#):
public class FormDetailsCollection: ArrayList { public void Add(FormDetail detail) { base.Add(detail); } public new FormDetail this[int nIndex] { get{ return (FormDetail)base[nIndex]; } } } |
这么做的好处在于,在操作集合中的对象时,不必进行类型转换的操作。
◆数据集的表示方法。
采用这种方法,通常是直接把从数据库查询中获取的数据集(Recordset)作为数据处理对象。这种方法在ASP应用程序中是非常常见的做法。这种做法简单,初学者很容易掌握,但是弊病也很多。
EJB的方法
在J2EE体系中,对实体对象的处理的典型方法是Entity Bean。J2EE中使用Entity Bean来表示数据,以及封装数据的持久化储存(同数据库的交互)。由于Entity Bean比较消耗资源,而且采用的是远程调用的方式来访问,因此,在需要传递大量数据,或者在不同的层次之间传递数据的时候,往往还会采用一些诸如"值对象"(Value Object)的设计模式来提升性能。关于J2EE中的设计模式的更多内容,读者可以参考《J2EE核心模式》一书。
JDO的方法
相对于J2EE这个昂贵的方法来说,JDO提供了一个相对"轻量级"的方案。在JDO中,你可以采用一般的做法,编写实体类,然后,通过一些强化器对这些类进行强化,以使其符合JDO的规范,最后,你可以通过PersistenceManager来实现对象的持久化储存。
无论是EJB还是JDO,在同数据库进行映射的时候,都选用了XML配置文件的方式。这是一种灵活的方式。由于XML强大的表达能力,我们可以很好的用它来描述代码中的实体类和数据库之间的映射关系,并且,不用在代码中进行硬编码,这样,在情况发生变化的时候,有可能只需要修改配置文件,而不用去修改程序的源代码。关于EJB和JDO的配置文件的更多的信息,各位可以参考相关的文档,这里不再赘述了。
然而,使用XML配置文件的方式并不是唯一的方法,在微软提供的一些案例中,如Duwamish示例,就没有采用这种方式。至于开发人员在开发过程中具体采用哪种方式,是需要根据具体情况进行权衡和取舍的。
Websharp的方法
Websharp在数据的表现上,充分利用了.Net Framework类库中DataSet的功能,设计了一个EntityData类。这个类继承了DataSet,并增加了一些属性和方法。同样的,同数据库的映射关系,也是采用XML配置文件的方式。
在实际的应用中,要获取一个实体对象,可以通过如下方式取得:
EntityData Customer=EntityDataManager. GetEmptyEntity("Customer");
然后,可以通过如下方式来访问这个对象的属性:
string CustomerID=Customer["CustomerID"]
可以看到,这种方式同传统的方式有点不同。在这种方式下,数据的表现形式只有一个,那就是EntityData。其好处是明显的,不用为每个实体都单独编写一个类,能够大大减少代码的编写量。其缺点也很明显,那就是不能利用编译器类型检测的功能,如果在调用对象的属性的时候,写错了属性的名称,就可能出错,但是,这个问题可以通过工具来解决。
关于这个方面更加详细的信息,可以参见拙文:
《利用.Net框架开发应用系统 》
《 实战揭秘:开发.Net平台应用系统框架》
数据的存取方式
数据存取的目的,是持久化保存对象,以备后来的使用,如查询、修改、统计分析等。存取的对象,可以是数据库、普通文件、XML甚至其他任何方式,只要保证数据能够长久保存,并且,不会受断电、系统重起等因素的影响。在这个部分,最理想的状况,自然是能够支持除了数据库以外的各种类型的存取方式,或者,至少留有接口,能够比较方便的扩充。
因为数据库是最常用,也是最有效的数据存储方法,因此,支持数据库存储是最首先必须支持的。在不同的平台下,有不同的数据库访问的手段。例如,在Java平台下,有JDBC,在Windows平台下,可以使用ADO、ADO.Net等。但是,这些手段还比较接近底层,在实际操纵数据库的时候,需要编写大量的代码,并且,我们还需要通过手工的方式来完成将程序中的面向对象的数据存储到关系型数据库的工作。这么做,自然编程的效率不高,并且非常容易出错。但是,不可否认,这也是一种可以选用的方式。
从另外一个方面来看,由于我们前面已经解决了数据的映射问题,因此,在数据的存取方面是非常有规律的,我们完全可以让这个工作通过框架来执行。这样,我们一方面可以简化很多同数据库交互方面的代码编写工作量,能够减少出现Bug的几率,另一方面,由于框架封装了不同数据库之间的差异,使得我们在编写程序的时候,不用考虑不同数据库之间的差异,而将这个工作交给框架去做,实现软件的后台数据库无关性。
在这个部分,以下两个部分的类会显得特别重要:
◆对象--关系映射的分析类,能够通过既定的方案完成对象--关系的映射,确定数据存取方案
◆数据库操纵类:根据映射关系,将数据准确的存储到数据库中,并且封装不同数据库之间的差异。
这个部分的操作过程,可以用图大概的表示如下:
在J2EE中,这个部分比较典型的就是EntityBean中的CMP。由于在BMP中,同数据库的交互部分需要通过手工编写代码的方式来实现,因此,很难享受到容器带来的便利,只是由于EJB2.0以前的标准,CMP的功能,包括映射能力、实体关系模式等方面的功能比较弱,所以,在很多时候,我们不得不使用BMP。现在,EJB2.0,在这个方面的功能已经非常强大了,我们完全可以享受容器带来的便利,而将大部分精力放在实现更加复杂的业务逻辑方面了。
在JDO中,您同样可以通过PersistenceManager来实现同样的目标,例如,您想把一个Customer对象保存到数据库中,可以采用类似于下面的代码:
Customer customer=new Customer(……); PersistenceManager PM=PMFactory.initialize(……); Pm.persist(customer); |
代码同样非常简明和直观,没有一大堆数据库操纵的代码,也不容易发生差错。
Websharp的方案
Webshap为数据存取的类定义了IEntityDAO接口,该接口的定义如下:
public interface IEntityDAO { void InsertEntity(EntityData entity); void UpdateEntity(EntityData entity); void DeleteEntity(EntityData entity); EntityData FindByPrimaryKey(object KeyValue); } |
对于每一个实体类,可以通过扩展这个接口来实现数据访问的类。但是,由于这个接口没有提供任何实现方法,因此,到具体每个实现类的时候,如果是直接扩展自这个接口,实现的代码还必须手工填写。为了提高开发效率,减少代码编写量和出现Bug的可能性,框架提供了AbstractSingleTableDAO和AbstractMultiTableDAO.cs类,这两个类扩展自IEntityDAO,分别实现了针对单个数据库表和多个数据库表的数据库访问方法,并且,实现了IDisposable接口。这样,我们在实际编写代码的时候,只需要继承自这两个类就可以了。
例如,Customer类的数据存取类可以定义如下:
public class CustomerEntityDAO:AbstractSingleTableDAO |
然后,就可以在代码中这么使用:
Customer customer=...... using(CustomerEntityDAO CDO=new CustomerEntityDAO()) { CDO.UpdateEntity(customer); } |
更加一般的,Wensharp也提供了PersistenceManager类,可以用于将EntityData中的数据存入数据库。这个类包含了两个方法:PersistEntity和DeleteEntity。如果不想为某个实体类编写专门的DAO类,那么,也可以使用这个类来操纵实体对象。不过,目前,只支持映射成单个表的对象的自动存贮。下面是一个例子:
PersistenceManager pm=PersistenceManager.Initial(); pm. PersistEntity(entity); |
为了封装不同数据库的操作,统一的数据库访问接口是必须的。关于编写通用数据库访问类的内容,可以参见拙作:《 使用设计模式构建通用数据库访问类》。
在这个部分,另外需要注意的是,为了保证数据存储的完整性,应当考虑事务处理的功能。J2EE、JDO和Websharp都支持在数据存储的时候使用事务处理。
业务逻辑的处理
有了上面的工作,我们就可以把这些对象组合起来,编写我们的业务逻辑。在面向对象的系统中,业务逻辑表现为对象之间的交互。在一些简单的系统中,没有复杂的业务逻辑,只是一些数据的维护工作,那么,有了上面两个部分的工作,我们实际上可能已经忘成了大部分的工作。
在这个部分,由于不同系统之间业务逻辑千差万别,基本上没有办法提供统一的模式。但是,应当注意的是,在同一个系统中,采用基本一致的策略是非常必要的,这有助于消除项目内部的不一致性,使项目更加可控。甚至于,这些策略可以扩展成公司部分、甚至所有项目的策略。
值得指出的是,很多人在这个部分操纵数据库,把业务逻辑处理等同于数据库操作,这是不可取的。在业务逻辑处理中,处理的应该是对象,而不是直接同数据库打交道,这样,才能获得更好的系统结构。
在业务逻辑处理部分,由框架提供一些支撑的服务是非常必要的。这其中,最重要的一点就是事务的处理。业务逻辑的处理过程,会涉及到多个对象之间的交互,以及多次同数据库的交互。为了保证处理过程的完整性,必须使用事务处理的方法。框架必须支持事务处理。
事务处理的功能,基本上有两种选择:使用基于数据库连接的事务、使用外部事物处理服务。
使用基于数据库连接的事务,事务处理的性能相对比较高,但是,当系统涉及到多个数据库之间的交互时,基于数据库连接的事务便无能为力了。而使用专用的事务处理服务,能够适应更多的情况,并且,有测试表明,随着数据处理量的上升,两者之间的性能差异会逐渐减小。
在J2EE中,容器提供了事务处理的能力。在.Net平台上,事务处理是通过Windows COM+服务来提供的。在Websharp中,对COM+服务做了一个简单的封装。同时,也能够使用基于数据库连接的事务。
下面是一个简单的例子,表示了一张入库单入库的过程,在这个过程中,需要修改入库单上每种产品的现有库存量:
public void StoreIntoWarehouse(EntityData insertForm) { insertForm.SetCurrentTable("FormDetail"); TransactionManager transManager=new TransactionManager(); ProductEntityDAO productDAO=new ProductEntityDAO(true); FormEntityDAO formDAO=new FormEntityDAO(true); try { if(insertForm.CurrentTable.Rows.Count>0) do { string productID=insertForm["ProductID"].ToString(); decimal inCount=insertForm.GetDecimal("InCount"); EntityData product=productDAO.FindByPrimaryKey(productID); product["CurrentCount"]=product.GetDecimal("CurrentCount")+inCount; transManager.AddMethod( new TransactionManagedFunction(productDAO.UpdateEntity),product); }while(insertForm.Next()); transManager.AddMethod( new TransactionManagedFunction(formDAO.InsertEntity),insertForm); transManager.ExecuteMethods(); } catch(Exception ee) { throw ee; } finally { productDAO.Dispose(); insertForm.Dispose(); } } |
业务服务的提供
业务外观层(Business Facade)的目的,是隔离系统功能的提供者和使用者,更明确地说,是隔离业务逻辑的软件的用户界面(可以参见Facade设计模式)。这一层没有任何需要处理的逻辑,只是作为后台逻辑处理和前端用户界面的缓冲区,以达到如下目的
◆将用户界面和系统业务逻辑处理分开,这样,当业务逻辑发生变化时,不用修改客户端程序,是一种支持变化的设计方法。
◆使同一个业务逻辑能够处理不同的客户端请求。例如,可以将Facade设计成Web Service,这样,可以同时为传统的WinForm客户端程序、Web程序以及其他外部系统提供服务,而使用相同的应用服务层,同时,也可以实现系统的分布式部署。关于如何做到这一点,可以参见本文所附的Demo程序。
◆作为系统不同模块之间的调用接口。一个系统通常会包含很多模块,这些模块相对独立,又可能互相调用。为了减少各个不同部分之间的耦合度,必须采用一定的设计方法,Facade设计模式就是非常有效的一种,也是业务外观层的基础。
◆有利于项目团队的分工协作。业务外观层作为一个访问接口,将界面设计人员和逻辑设计人员分开,使得系统的开发可以实现纵向的分工,不同的开发人员可以关注自己的领域而不会受到干扰。
业务外观层的代码框架,在系统分析和设计完成后就可以完成,他需要提供的方法,就相当于在界面设计人员和逻辑设计人员之间签订了一个协议,他虽然没有实现任何逻辑,但是,他的引入,能使系统的开发更加有条理,更加简明。套用《设计模式》上的一句话,就是,"任何问题,都可以通过引入一个中间层来得到简化"。
剪裁和取舍
以上四个层次,对于大型的应用软件系统来说,是非常必要的。但是,对于一些小型的应用软件系统,如果完全按照以上的层次来做,可能反而会影响工作效率。因此,针对不同的系统,可以对架构进行一定的剪裁。
数据实体层和实体控制层,是每个应用软件系统所必需的,显然无法裁减。对于业务逻辑层和业务外观层,根据实体情况,可以进行如下裁减:
◆如果系统没有复杂的业务逻辑,而只是一些数据的操作,或者业务逻辑特别少,那么,可以省略业务逻辑层,而将相关的功能移至实体控制层。
◆如果不考虑多种客户端的情况,也不考虑分布式部署的问题,系统的模块又很少,不会产生模块间紧耦合的情况,那么,可以不使用业务外观层,而让用户界面程序直接访问业务功能。
在上面的论述中,对于每个层次,都说明了可以选择的多种方案,每一种方案都有他的优点和缺点,在具体开发的过程中,需要根据具体情况加以取舍。
系统外的话
应用软件系统架构,是软件工程的重要组成部分。设计一个好的框架,其目的很明确,那就是,在目前还没有"银弹"之前,尽最大的可能,提高软件开发的效率和软件质量,把不必要的工作和容易出错的工作,交给框架去处理。
应用服务层,在软件系统中,是一个非常复杂的部分,乍看之下,没有任何规律可行,给人无从下手的感觉。我们的目标,就是尽量化无规律为有规律,把有规律的东西提取出来,形成规范,从而减少今后的开发工作量。其方法,就是对系统进行合理的分层,这样,系统的层次清晰了,每个层次完成的功能就比较单一,就意味着每个层次的都相对更有规律可循,这样,我们就可以把这些有规律的东西交给框架去执行,或者,开发一个辅助工具,来完成这部分的代码编写工作。Websharp就提供了这样一个代码自动生成的工具。这个工具被设计成Visual Studio.Net集成开发环境的插件,在实际开发过程中,能够提供很多便利。这是系统层次清晰带来的另外一个好处。
对于一个软件公司来说,统一的系统框架的意义不仅仅在于软件开发的本身。一个统一的系统框架,也是公司知识管理的重要组成部分。公司如果有一个或有限个数的明确的软件框架,那么,这些框架就可以成为凝结公司开发人员经验、智慧的载体,并且可以在不断的实践中加以充实和完善。由于公司的软件系统的框架比较统一,那么当某个项目更换或增加开发人员的时候,后来的人也能够比较容易接手,这对于公司的开发管理是具有非常重要的意义的。
关于系统框架同知识管理的关系的内容,因为不是本文的重点,限于篇幅的关系,所以不再赘述,会另文加以说明。
结语
应用软件系统的应用服务层是非常复杂的,为了使得系统的结构更加清晰,找出其中规律性的东西,就需要我们对系统做进一步的层次划分。对于每个层次,都可以有多种设计的策略,每一种策略都不可能做到尽善尽美,这就需要我们在实际中加以取舍。
限于本人的认识和水平,文章中所说观点和方法或有不当之处,还请大家能够指教,一起探讨。
文章来源于领测软件测试网 https://www.ltesting.net/