这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » 综合技术 » 基础知识 » 框架设计中的常用模式

共1条 1/1 1 跳转至

框架设计中的常用模式

专家
2020-08-11 07:30:45     打赏
3.4.1. 模板方法模式

模板方法模式是框架中最常用的设计模式。其根本的思路是将算法由框架固定,而将算法中具体的操作交给二次开发者实现。例如一个设备初始化的逻辑,框架代码如下:

TBool CBaseDevice::Init()
{
  if ( DownloadFPGA() != KErrNone )
  {
    LOG(LOG_ERROR,_L(“Download FPGA fail”));
    return EFalse;
  }
  if ( InitKeyPad() != KerrNone )
  {
    LOG(LOG_ERROR,_L(“Initialize keypad fail”));
    return EFalse;
  }
  return ETrue;
}

DownloadFPGA和InitKeyPad都是CBaseDevice定义的虚函数,二次开发者创建一个继承于CBaseDevice的子类,具体来实现这两个接口。框架定义了调用的次序和错误的处理方式,二次开发者无须关心,也无权决定。

3.4.2. 创建型模式

由于框架通常都涉及到各种不同子类对象的创建,创建型模式是经常使用的。例如一个绘图软件的框架,有一个基类定义了图形对象的接口,基于它可以派生出椭圆,矩形,直线各种子类。当用户绘制一个图形时,框架就要实例化该子类。这时候可以用工厂方法,原型方法等等。

class CDrawObj
{
  public:
    virtual int DrawObjTypeID()=0;
    virtual Icon GetToolBarIcon()=0;
    virtual void Draw(Rect rect)=0;
    virtual CDrawObj* Clone()=0;
};

3.4.3. 消息订阅模式

消息订阅模式是最常用的分离数据和界面的方式。界面开发者只需要注册需要的数据,当数据变化时框架就会将数据“推”到界面。界面开发者可以无须关注数据的来源和内部组织形式。

消息订阅模式最常见的问题是同步模式下如何处理重入和超时。作为框架设计者,一定要考虑好这个问题。所谓重入,是二次开发者在消息的回调函数中执行订阅/取消订阅的操作,这会破坏消息订阅的机制。所谓超时是指二次开发者的消息回调函数处理时间过长,导致其他消息无法响应。最简单的办法是使用异步模式,让订阅者和数据发布者在独立进程/线程中运行。如果不具备此条件,则必须作为框架的重要约定,禁止二次开发者产生此类问题。

3.4.4. 装饰器模式

装饰器模式赋予了框架在后期增加功能的能力。框架定义装饰器的抽象基类,而由具体的实现者实现,动态地添加到框架中。

举一个游戏中的例子,图形绘制引擎是一个独立的模块,比如可以绘制人物的静止,跑动等图像。如果策划决定在游戏中增加一种叫“隐身衣”的道具,要求穿着此道具的玩家在屏幕上显示的是若有若无的半透明图像。应该如何设计图像引擎来适应后期的游戏升级呢?

当隐身衣被装备后,就向图像引擎添加一个过滤器。这是个极度简化的例子,实际的游戏引擎要比这个复杂。装饰器模式还常见用于数据的前置和后置处理上。

3.5. 框架的缺点

一个好的框架可以大大提高产品的开发效率和质量,但也有它的缺点。

1. 

框架一般都比较复杂,设计和实现一个好的框架需要相当的时间。所以,一般只有在框架可以被多次反复应用的时候适合,这时候,前提投入的成本会得到丰厚的回报。

2. 

3. 

框架规定了一系列的接口和规则,这虽然简化了二次开发工作,但同时也要求二次开发者必须记住很多规定,如果违反了这些规定,就不能正常工作。但是由于框架屏蔽了大量的领域细节,相对而言,其学习成本还是大大降低了的。

4. 

5. 

框架的升级对已有产品可能会造成严重的影响,导致需要完整的回归测试。对这个问题有两个办法。第一是对框架本身进行严格的测试,有必要建立完善的单元测试库,同时开发示例项目,用来测试框架的所有功能。第二则是使用静态链接,让已有产品不轻易跟随升级。当然,如果已有产品有较好的回归测试手段,就更好。

6. 

7. 

性能损失。由于框架对系统进行了抽象,增加了系统的复杂性。诸如多态这样的手段使用也会普遍的降低系统的性能。但是从整体上来看,框架可以保证系统的性能处于一个较高的水平。

8. 

4. 自动代码生成4.1. 机器能做的事就不要让人来做

懒惰是程序员的美德,更是架构师的美德。软件开发的过程就是人告诉机器如何做事的过程。如果一件事情机器自己就可以做,那就不要让人来做。因为机器不仅不知疲倦,而且绝不会犯错。我们的工作是让客户的工作自动化,多想一点,就能让我们自己的工作也部分自动化。极有耐心的程序员是好的,也是不好的。

经过良好设计的系统,往往会出现很多高度类似而且具有很强规律的代码。未经良好设计的系统则可能对同一类功能产生很多不同的实现。前面关于框架设计的部分已经证明了这一点。有时候,我们更进一步,分析出这些相似代码之中的规律,用格式化的数据来描述这些功能,而由机器来产生代码。

嵌入式系统的软件架构设计!4.2. 举例4.2.1. 消息的编码和解码

上面关于框架的实例中,可以看到消息编解码的部分已经被独立出来,和其他部分没有耦合。加上他本身的特点,非常适合进一步将其“规则化”,用机器产生代码。

编码,就是把数据结构流化;解码反之。以编码为例,代码无非是这样的:(二进制协议)

stream << a.i;
stream << a.j;
stream << a.object;

(为了简化,这里假设已经设计了一个流对象,可以流化各种数据类型,并且已经处理了诸如字节序转换等问题。)

最后我们得到一个stream。大家是否已经习惯了写这种代码?但是这样的代码不能体现工程师任何的创造性,因为我们早已经知道有i, 有j, 还有一个object,为什么还要自己敲入这些代码呢?如果我们分析一下a的定义,是不是就可以自动产生这样的代码呢?

struct dataA
{
  int i;
  int j;
  struct dataB object;
};

只需要一个简单的语义分析器解析这段代码,得到一棵关于数据类型的树,就可以轻易的产生流化的代码。这样的分析器用Python等字符串处理能力强的语言不过两百行左右。关于数据类型的树类似下图:

只要遍历这棵树,就可以生成所有数据结构的流化代码。

在上一个框架所举例的项目中,为一个硬件模块自动产生的消息编码****代码量高达三万行,几乎相当于一个小软件。由于是自动产生,没有任何错误,为上层提供了高可靠性。

还可以用XML或者其他的格式定义数据结构,从而产生自动代码。根据需要,C++/Java/Python,任何类型的都可以。如果希望提供强检查,可以使用XSD来定义数据结构。有一个商业化的产品,xBinder,很贵,很难用,还不如自己开发。(为什么难用?因为它太通用)。除了编码为二进制格式,还可以编码为任何你需要的格式。我们知道二进制格式虽然效率很高,但是太难调试(当然有些人看内存里的十六进制还是很快的),所以我们可以在编码成二进制的同时,还生成编码为其他可阅读的格式的代码,比如XML。这样,通讯使用二进制,而调试使用XML,两全其美。产生二进制的代码大概是这样的:

Xmlbuilder.addelement(“i”,a.i);
Xmlbuilder.addelement(“j”,a.j);
Xmlbuilder.addelement(“object”,a.object);

同样也很适合机器产生。同样的思路可以用来让软件内嵌脚本支持。这里不多说了。(内嵌脚本支持最大的问题是在C/C++和脚本之间交换数据,也是针对数据类型的大量相似代码。)

最近Google 发布了它的protocol buffer,就是这样的思路。目前支持C++/Python,估计很快会支持更多的语言,大家可以关注。以后就不要再手写编码****了。

4.2.2. GUI代码

上面的框架设计部分,我们说到框架对界面数据收集和界面更新无能为力,只能抽象出接口,由程序员具体实现。但是让我们看看这些界面程序员做的事情吧。(代码经过简化,可以看作伪代码)。

void onDataArrive(CDataBinder& data)
{
  m_biterror.setText(“%d”,data.biterror);
  m_signallevel.setText(“%d”,data.signallevel”);
  m_latency.setText(“%d”,data.latency”);
}

Void onCollectData(CDataBinder& data)
{
  data.biterror = atoi(m_biterror.getText());
  data. signallevel = atoi(m_ signallevel.getText());
  data. latency = atoi(m_ latency.getText());
}

这样的代码很有趣吗?想想我们可以怎么做?(XML描述界面,问题是对于复杂逻辑很难)

4.2.3. 小结

由此可见,在软件架构的过程中,首先要遵循一般性的原则,尽量将系统各个功能部分独立出来,实现高内聚低耦合,进而发现系统存在的高度重复,规律性很强的代码,进一步将他们规则化,形式化,最后用机器来产生这些代码。目前这方面最成功的应用就是消息的编解码。对界面代码的自动化生成有一定局限,但也可以应用。大家在自己的工作中要擅于发现这样的可能,减少工作量,提高工作效率。

4.2.4. Google Protocol Buffer

Google刚刚发布的Protocol Buffer是使用代码自动生成的一个典范。

Protocol buffers are a flexible, efficient, automated mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages. You can even update your data structure without breaking deployed programs that are compiled against the "old" format.

你要做的首先是定义消息的格式,Google指定了它的格式:

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
  enum PhoneType {
  MOBILE = 0;
  HOME = 1;
  WORK = 2;
}
message PhoneNumber {
  required string number = 1;
  optional PhoneType type = 2 [default = HOME];
}
  repeated PhoneNumber phone = 4;
}

Once you've defined your messages, you run the protocol buffer compiler for your application's language on your .proto file to generate data access classes. These provide simple accessors for each field (like query() and set_query()) as well as methods to serialize/parse the whole structure to/from raw bytes – so, for instance, if your chosen language is C++, running the compiler on the above example will generate a class called Person. You can then use this class in your application to populate, serialize, and retrieve Person protocol buffer messages. You might then write some code like this:

Person person;
person.set_name("John Doe");
person.set_id(1234);
person.set_email("jdoe@example.com");
fstream output("myfile", ios::out | ios::binary);
person.SerializeToOstream(&output);
Then, later on, you could read your message back in:
fstream input("myfile", ios::in | ios::binary);
Person person;
person.ParseFromIstream(&input);
cout << "Name: " << person.name() << endl;
cout << "E-mail: " << person.email() << endl;

Protocol Buffer的编码格式是二进制的,同时也提供可读的文本格式。效率高,体积小,上下兼容。目前支持Java,Python和C++,很快会支持更多的语言。

嵌入式系统的软件架构设计!5. 面向语言编程(LOP)5.1. 从自动化代码生成更进一步

面向语言编程的通俗定义是:将特定领域的知识融合到一种专用的计算机语言当中,从而提高人与计算机交流的效率。

自动化代码生成其实就是面向语言编程。语言不等于是编程语言,可以是图,也可以是表,任何可以建立人和机器之间交流渠道的都是计算机语言。软件开发历史上的一次生产率的飞跃是高级语言的发明。它让我们以更简洁的方式实现更复杂的功能。但是高级语言也有它的缺点,那就是从问题领域到程序指令的过程很复杂。因为高级语言是为通用目的而设计的,所以离问题领域很远。举例来说,要做一个图形界面,我可以跟另一个工程师说:这里放一个按钮,那边放一个输入框,当按下按钮的时候,就在输入框里显示Hello World。我甚至可以随手给他画出来。

对于我和他直接的交流而言,这已经足够了,5分钟。但是要让转变为计算机能够理解的语言,需要多久?

如果是汇编语言?(告诉计算机如何操作寄存器和内存)

如果是C++? (告诉计算机如何在屏幕上绘图,如果响应鼠标键盘消息)

如果有一个不错的图形界面库?(告诉计算机创建Button,Label对象,管理这些对象,放置这些对象,处理消息)

如果有一个不错的开发框架+IDE? (用WYSIWYG工具绘制,设计类,类的成员变量,编写消息响应函数)

如果有一门专门做图形界面开发的语言?

可以是这样的:

Label l {Text=””}
Button b{Text=”ok”,action=l.Text=”hello world”}

通用的计算机语言是基于变量,类,分支,循环,链表,消息这些概念的。这些概念离问题本身有着遥远的距离,而且表达能力非常有限。自然语言表达能力很强,但是歧义和冗余太多,无法格式化标准化。传统的思想告诉我们:计算机语言就是一条条的指令,编程就是写下这些指令。而面向语言编程的思想是,用尽量贴近问题,贴近人的思维的办法来描述问题,从而降低从人的思想到计算机软件转换的难度。

举一个游戏开发的例子。现在的网络游戏普遍的采用了C++或者C开发游戏引擎。而具体的游戏内容,则是由一系列二次开发工具和语言完成的。地图编辑器就是一种面向游戏的语言。Lua或者类似的脚本则被嵌入到游戏内部,用来编写武器,技能,任务等等。Lua本身不具备独立开发应用程序的能力,然而游戏引擎的设计者通过给Lua提供一系列的,各种层次上的接口,将领域知识密集的赋予了脚本,从而大大提高了游戏二次开发的效率。网络游戏的鼻祖MUD则是设计了LPC来作为游戏的开发语言。MUD的引擎MudOS和LPC之间的关系如图:

LPC创建一个NPC的代码类似如下:

inherit NPC;
void create()
{
  set_name("菜花蛇", ({ "caihua she", "she" }) );
  set("race", "野兽");
  set("age", 1);
  set("long", "一只青幽幽的菜花蛇,头部呈椭圆形。n");
  set("attitude", "peaceful");
  set("str", 15);
  set("cor", 16);
  set("limbs", ({ "头部", "身体", "七寸", "尾巴" }) );
  set("verbs", ({ "bite" }) );
  set("combat_exp", 100+random(50));
  set_temp("apply/attack", 7);
  set_temp("apply/damage", 4);
  set_temp("apply/defence",6);
  set_temp("apply/armor",5);
  setup();
}
void die()
{
  object ob;
  message_vision("$N抽搐两下,$N死了。n", this_object());
  ob = new(__DIR__"obj/sherou");
  ob->move(environment(this_object()));
  destruct(this_object());
}

LPC培养了一大批业余游戏开发者,甚至成为很多人进入IT行业的起点。原因就是它简单,易理解,100%为游戏开发设计。这就是LOP的魅力。

5.2. 优势和劣势

LOP最重要的优点是将领域知识固化到语言中,从而:

1. 

提高开发效率。

2. 

3. 

优化团队结构,降低交流成本,领域专家和程序员可以更好的合作。

4. 

5. 

降低耦合,易于维护。

6. 

其次,由于LOP不是通用语言,所涉及的范围就狭窄很多,所以:

1. 

更容易得到稳定的系统

2. 

3. 

更容易移植

4. 

相应的,LOP也有它的劣势:

1. 

LOP对领域知识抽象的要求比框架更高。

2. 

3. 

开发一门新的语言本身的成本。幸好现在设计一门新的语言不算太难,还有Lua这样的“专用二次开发”语言的支持。

4. 

5. 

性能损失。不过相比开发成本的节约,在非性能核心部分使用LOP还是很值得的。

6. 

5.3. 在嵌入式系统中的应用

举例,嵌入式设备的Web服务器。很多设备都提供Web服务用于配置,比如路由器,ADSL猫等等。这种设备所提供的web服务的典型用例是用户填写一些参数,提交给Web服务器,Web 服务器将这些参数写入硬件,并将操作结果或者其他信息生成页面返回给浏览器。由于典型的Apache,Mysql,PHP组合体积太大且不容易移植,通常嵌入式系统的Web服务都是用C/C++直接写就的。从socket管理,http协议到具体操作硬件,生成页面,都一体负责。然而对于功能复杂,Web界面要求较高的情况,用C来写页面效率就太低了。

shttpd是一个小巧的web服务器,小巧到只有一个.c文件,4000余行代码。虽然体积很小,却具备了最基本的功能,比如CGI。它既可以独立运行,也可以嵌入到其他的应用程序当中。shttpd在大多数平台上都可以顺利编译、运行。lua是一个小巧的脚本语言,专用于嵌入和扩展。它和C/C++代码有着良好的交互能力。

Lua引擎嵌入到shttpd中,再使用C编写一个(一些)驱动硬件的扩展,注册成为Lua的函数,形成的系统结构如下图:

这样的应用在嵌入式系统中是有一定代表性的,即,以C实现底层核心功能,而把系统的易变部分以脚本实现。大家可以思考在自己的开发过程中是否可以使用这种技术。这是LOP的一种具体应用模式。(没有创造一种全新的语言,而是使用Lua)

6. 测试6.1. 可测试性是软件质量的一个度量指标

好的软件是设计出来的,好的软件也一定是便于测试的。一个难于测试的软件的质量是难以得到保障的。在今天软件规模越来越大的趋势下,以下问题是普遍存在的:

1. 

测试只能手工进行,回归测试代价极大,实际只能执行点测,质量无法保证

2. 

3. 

各个模块只有集成到一起后才能测试

4. 

5. 

代码不经过任何单元测试就集成

6. 

这些问题的根源都在于缺乏一个良好的软件设计。一个好的软件设计应该使得单元测试,模块测试和回归测试都变得容易,从而保证测试的广度和深度,最终产生高质量的软件。除了功能,非功能性需求也必须是可测试的。所以,可测试性是软件设计中一个重要的指标,是系统架构师需要认真考虑的问题。

6.2. 测试驱动的软件架构

这里谈的是测试驱动的软件架构,而不是测试驱动的开发。TDD(Test Driven Development) 是一种开发方式,是一种编码实践。而测试驱动的架构强调的是,从提高可测试性的角度进行架构设计。软件的测试分为多个层次:

6.3. 系统测试

系统测试是指由测试人员执行的,验证软件是否完整正确的实现了需求的测试。这种测试中,测试人员作为用户的角色,通过程序界面进行测试。在大部分情况下这些工作是手工完成的。在规范的流程中,这个过程通常要占到整个软件开发时间的1/3以上。而当有新版本发布的时候,尽管只涉及了软件的一部分,测试部门依然需要完整的测试整个软件。这是由代码“副作用”特点决定的。有时候修改一个bug可以引发更多的bug,破坏原来工作正常的代码。这在测试中叫回归测试(Regression test)。对于规模较大的软件,回归测试需要很长的时间,在版本新增功能和错误修正不多的情况下,回归测试可以占到整个软件开发过程了一半以上,严重影响了软件的交付,也使软件测试部门成为软件开发流程中的瓶颈。测试过程自动化,是部分解决这个问题的办法。

作为架构师,有必要考虑如何实现软件的可自动化测试性。

6.3.1. 界面自动化测试

在没有图形化界面以前,字符方式的界面是比较容易进行自动化测试的。一个编写良好的脚本就可以实现输入和对输出的检查。但是对于图形化的界面,人的参与似乎变得不可缺少。有一些界面自动化的测试工具,如WinRunner, 这些工具可以记录下测试人员的操作成为脚本,然后通过回放这些脚本,就可以实现操作的自动化。针对嵌入式设备,有Test Quest可以使用,通过在设备中运行一个类似远程桌面的Agent,PC端的测试工具可以用图像识别的方法识别出不同的组件,并发送相应用户的输入。此类工具的基本工作原理如图:

但是这个过程在实际中存在三个问题:

1. 

可靠性差,经常中断运行。要写一个可靠的脚本甚至比开发软件还要困难。比如,按下一个按钮,有时候一个对话框立刻就出现,有时候可能要好几秒,有时候甚至不出现,操作录制工具不能自动实现这些判断,而需要手动修改。

2. 

3. 

对操作结果的判断很困难,尤其是非标准的控件。

4. 

5. 

当界面修改后,原有代码很容易失效

6. 

要应用基于图形界面的自动化测试工具,架构师在架构的时候应该考虑:

1. 

界面风格如何保持一致。应当由架构,而非程序员决定架构的风格。包括布局,控件大小,相对位置,文字,对操作的响应方式,超时时长,等等。

2. 

3. 

如何在最合适测试工具的界面和用户喜欢的界面之中折中。比如,Test Quest是基于图像识别的,那么黑白两色的界面是最有利的,而用户喜欢的渐进色就非常不利。也许让界面具备自动的切换能力最好。

4. 

对于已经完成的产品,如果架构没有为自动化测试做过考虑,所能应用的范围就非常有限,不过还是有一些思路可以供参考:

1. 

实现小规模的自动化脚本。针对一个具体的操作流程进行测试,而不是试图用一个脚本测试整个软件。一系列的小测试脚本组成了一个集合,覆盖系统的一部分功能。这些测试脚本可以都以软件启动时的状态作为基准,所以在状态处理上会比较简单

2. 

3. 

”猴子测试”有一定的价值。所谓猴子测试,就是随机操作鼠标和键盘。这种测试完全不理解软件的功能,可以发现一些正常测试无法发现的错误。据微软内部的资料,微软的一些产品15%的错误是由“猴子测试”发现的。

4. 

总的来讲,基于界面的自动化测试是不成熟的。对架构师而言一定要避免功能只能通过界面才能访问。要让界面仅仅是界面,而软件大部分的功能是独立于界面并可以通过其他方式访问的。上面框架的例子中的设计就体现了这一点。

思考:如何让界面具有自我测试功能?

6.3.2. 基于消息的自动化测试

如果软件对外提供基于消息的接口,自动化测试就会变得简单的多。上面已经提到了固件的TL1接口。对于界面部分,则应该在设计的时候,将纯粹的“界面”独立出来,让它尽可能的薄,而其他部分依然可以基于消息提供服务。

在消息的基础上,用脚本语言包装成函数的形式,可以很容易的调用,并覆盖消息的各种参数组合,从而提高测试的覆盖率。关于如何将消息包装为脚本,可以参考SOAP的实现。如果使用的不是XML,也可以自己实现类似的自动代码生成。

这些测试脚本应该由开发人员撰写,每当实现了一个新的接口(也就是一条新的消息),就应该撰写相应的测试脚本,并作为项目的一部分保存在代码库中。当需要执行回归测试的时候,只要运行一遍测试脚本即可,大大提高了回归测试的效率。

所以,为了实现软件的自动化测试,提供基于消息的接口是一个很好的办法,这让我们可以在软件之外独立的编写测试脚本。在设计的时候可以考虑这个因素,适当的增加软件消息的支持。当然,TL1只是一个例子,根据项目的需要,可以选择任何适合的协议,如SOAP。

6.3.3. 自动化测试框架

在编写自动化测试脚本的时候,有很多的工作是重复的,比如建立socket连接,日志,错误处理,报表生成等。同时,对于测试人员来说,这些工作可能是比较困难的。因此,设计一个框架,实现并隐藏这些重复和复杂的技术,让测试脚本的编写者将注意力集中在具体的测试逻辑上。

这样一个框架应该实现以下功能:

1. 

完成连接的初始化等基础工作。

2. 

3. 

捕获所有的错误,保证Test Case中的错误不会打断后续的Test Case执行。

4. 

5. 

自动检测和执行Test Case。新增的Test Case是独立的脚本文件,无须修改框架的代码或者配置。

6. 

7. 

消息编解码,并以函数的方式提供给Test Case编写者调用。

8. 

9. 

方便的工具,如报表,日志等。

10. 

11. 

自动统计Test Case的运行结果并生成报告。

12. 

自动化测试框架的思路和一般的软件框架是一致的,就是避免重复劳动,降低开发难度。

下图是一个自动化测试框架的结构图:

每个Test Case都必须定义一个规定的Run函数,框架将依次调用,并提供相应的库函数供Test Case用来发送命令和获得结果。这样,测试用例的编写者就只需要将注意力集中在测试本身。举例:

def run():
  open_laser()
  assert(get_laser_state() == ON)
  insert_error(BIT_ERROR)
  assert(get_error_bit() == BIT_ERROR)

测试用例的编写者拥有的知识是“必须先打开激光器然后才能向线路上插入错误”。而架构师能提供的是消息收发,编解码,错误处理,报表生成等,并将这些为测试用例编写者隔离。

问题: open_laser, get_laser_state这些函数是谁写的?

问题:如何进一步实现知识的解耦?能否有更方便的语言来编写TestCase?

6.3.4. 回归测试

有了自动化的测试脚本和框架,回归测试就变得很简单了。每当有新版本发布时,只需运行一遍现有的Test Case,分析测试报告,如果有测试失败的Case则回归测试失败,需要重新修改,直到所有的Case完全通过。完整的回归测试是软件质量的重要保证。

6.4. 集成测试

集成测试要验证的是系统各个组成模块的接口是否工作正常。这是比系统测试更低层的测试,通常由开发人员和测试人员共同完成。

例如在一个典型的嵌入式系统中,FPGA,固件和界面是常见的三个模块。模块本身还可以划分为更小的模块,从而降低复杂度。嵌入式软件模块测试的常见问题是硬件没有固件则无法工作,固件没有界面就无法驱动;反过来,界面没有固件不能完整运行,固件没有硬件甚至无法运行。于是没有经过测试的模块直到集成的时候才能完整运行,发现问题后需要考虑所有模块的问题,定位和解决的代价都很大。假设有模块A和B,各有十个bug。如果都没有经过模块测试直接集成,可以认为排错的工作量相当于10*10等于100。

所以,在设计一个模块的时候,首先要考虑,这个模块如何单独测试?比如,如果界面和固件之间是通过SOCKET通信的,那么就可以开发一个模拟固件,在同样的端口上提供服务。这个模拟固件不执行实际的操作,但是会响应界面的请求并返回模拟的结果。并且返回的结果可以覆盖到各种典型的情况,包括错误的情况。使用这样的技术,界面部分几乎可以得到100%的验证,在集成阶段遇到错误的大大减少。

对固件而言,因为处于系统的中间,所以问题复杂一些。一方面,要让固件可以通过GUI以外的途径被调用;另一方面则要模拟硬件的功能。对于第一点,在设计的时候,要让接口和实现分离。接口可以随意的更换,比如和GUI的接口也许是JSON,同时还可以提供telnet的TL1接口,但是实现是完全一样的。这样,在和GUI集成之前,就可以通过TL1进行完全的测试固件。对于第二点,则应该在设计的时候提取出硬件抽象层,让固件的主要实现和寄存器,内存地址等因素隔离开来。在没有硬件或者硬件设计未定的时候实现一个硬件模拟层,来保证固件可以完整运行并测试。

6.5. 单元测试

单元测试是软件测试的最基本单位,是由开发人员执行以保证其所开发代码正确的过程。开发人员应该提交经过测试的代码。未经单元测试的代码在进入软件后,不仅发现问题后很难定位,而且通过系统测试是很难做到对代码分支的完全覆盖的。TDD就是基于这个层次的开发模式。

单元测试的粒度一般是函数或者类,例如下面这个常用函数:

int atoi(const char *nptr);

这是一个功能非常单一的函数,所以单元测试对它非常有效。可以通过单元测试验证下列情况:

1. 

一般正常调用,如”9”,”1000”,”-1”等

2. 

3. 

空的nptr指针

4. 

5. 

非数字字符串,”abc”,”@#!123”,”123abc”

6. 

7. 

带小数点的字符串, “1.1”,”0.111”,”.123”

8. 

9. 

超长字符串

10. 

11. 

超大数字,”999999999999999999999999999”

12. 

13. 

超过一个的-号和位置错误的-号,”—1”,”-1-“,”-1-2”

14. 

如果atoi通过了以上测试,我们就可以放心的将它集成到软件中去了。由它再引发问题的概率就很小了(不是完全没有,因为我们不能遍历所有可能,只是挑选有代表性的异常情况进行测试)。

以上的例子可以说是单元测试的典范,但实际中却常常不是这么回事。我们常常发现写好的函数很难做单元测试,不仅工作量很大,效果也不见得好。其根本的原因是,函数没有遵循好一些原则:

1. 

单一功能

2. 

3. 

低耦合

4. 

反观atoi的例子,功能单一明确,和其他函数几乎没有任何耦合(我上面并没有写atoi的代码实现,大家可以自己实现,希望是0耦合)。

下面我举一个实际中的例子。

这是一个简单的TL1命令发送和解析软件,功能需求描述如下:

ü 通过telnet与TL1服务器通讯

ü 发送TL1命令给TL1服务器

ü 解析TL1服务器的响应

TL1是通讯行业广泛使用的一种协议,为了给不熟悉TL1的朋友简化问题,我定义了一个简化的格式:

CMD:CTAG:PAYLOAD;

CMD - 命令的名字,可以是任意字母开头,由字母和下划线组成的字符串

CTAG - 一个数字,用于标志命令的序号

PAYLOAD - 可以是任意格式的内容

; - 结束符

相应的,TL1服务器的回应也有格式:

DATE

CTAG COMPLD

PAYLOAD

;

DATE – 日期和时间

CTAG – 一个数字,和TL1 命令所携带的CTAG一样

COMPLD – 表明命令执行成功

PAYLOAD - 返回的结果,可以是任何格式的内容

; - 结束符

举例:

命令:GET-IP-CONFIG:1:;

结果:

2008-7-19 11:00:00
1 COMPLD
ip address: 192.168.1.200
gate way: 192.168.1.1
dns: 192.168.1.3
;

命令:SET-IP-CONFIG:2:172.31.2.100,172.31.2.1,172.31.2.3;

结果:

2008-7-19 11:00:05
2 COMPLD
;

软件的最上层可能是这样的:

Dict* ipconf = GET_IP_CONFIG();
ipconf->set(“ipaddr”,”172.31.2.100)
ipconf->set(“gateway”,”172.3.2.1”)
ipconf->set(“dns”,”172.31.2.1”)
SET_IP_CONFIG(ipconf);

GET_IP_CONFIG为例,这个函数应该完成的功能包括:

ü 建立telnet连接,如果连接尚未建立

ü 构造TL1命令字符串

ü 发送

ü 接收反馈

ü 解析反馈,并给IP_CONF结构复制

ü 返回

我们当然不希望每个这样的函数都重复实现这些功能,所以我们定义了几个模块:

1. 

Telnet 连接管理

2. 

3. 

TL1命令构造

4. 

5. 

TL1 结果解析

6. 

这里我们来分析TL1结果解析,假设设计为一个函数,函数的原型如下:

Dict* TL1Parse(const char* tl1response)

这个函数的功能是接受一个字符串,如果它是一个合法且已知的TL1回应,则将其中的结果提取出来,放入一个字典对象中。

这本来会是一个很便于进行单元测试的例子:输入各种字符串,检查返回结果是否正确即可。但是在这个软件中,有一个很特殊的问题:

TL1Parse在解析一个字符串时,它必须要知道当前要处理的是哪条命令的回应。但是请注意,在TL1的回应中,是不包括命令的名字的。唯一的办法是使用CTAG,这个命令和回应一一对应的数字。Tl1Parse首先提取出CTAG来,然后查找使用这个CTAG的是什么命令。这里产生了一个对外调用,也就是耦合。

有一个对象维护了一个CTAG和命令名字对应关系的表,通过CTAG,可以查询到对应的命令名,从而知道如何解析这个TL1 response.

如此一来,TL1Parse就无法进行单元测试了,至少不能轻易的进行。通常的桩函数的办法都不好用了。

怎么办?

重新设计,消除耦合。

TL1Parse拆分为两个函数:

Tl1_header TL1_get_header(const char* tl1response)
Dict* TL1_parse_payload(const char* tl1name ,const char* tl1payload)

这两个函数都可以单独进行完整的单元测试。而这两个函数的代码基本就是TL1Parse切分了一下,但是其可测试性得到了很大的提高,得到一个可靠的解析器的可能性自然也大大提升了。

这个例子演示了如何通过设计来提高代码的可测试性—这里是单元测试。一个随意设计,随意实现的软件要进行单元测试将会是一场噩梦,只有在设计的时候就考虑到单元测试的需要,才能真正的进行单元测试。

6.5.1. 圈复杂度测量

模块的复杂度直接影响了单元测试的覆盖率。最著名的度量代码复杂度的方法是圈复杂度测量。

计算公式:V(F)=e-n+2。其中e是流程图中的边的数量,n是节点数量。简单的算法是统计如 if、while、do和switch 中的 case 语句数加1。适合于单元测试的代码的复杂度一般认为不应该超过10。

6.5.2. 扇入扇出测量

扇入是指一个模块被其他模块所引用。扇出是指一个模块引用其他模块。我们都知道好的设计应该是高内聚低耦合的,也就是高扇入低扇出。一个扇出超过7的模块一般认为是设计欠佳的。扇出过大的模块进行单元测试不论从桩设置还是覆盖率上都是困难的。将系统的传出耦合和传入耦合的数量结合起来,形成另一个度量:不稳定性。

不稳定性 = 扇出 / (扇入 + 扇出)

这个值的范围从0到1。值越接近1,它就越不稳定。在设计和实现架构时,应当尽量依赖稳定的包,因为这些包不太可能更改。相反的,依赖一个不稳定的包,发生更改时间接受到伤害的可能性就更大。

6.5.3. 框架对单元测试的意义

框架的应用在很大程度上可以帮助进行单元测试。因为二次开发者被限定实现特定的接口,而这些接口势必都是功能明确,简单,低耦合的。之前的框架示例代码也演示了这一点。这再次说明了,由高水平的工程师设计出的框架,可以强制初级工程师产生高质量的代码。

7. 维护架构的一致性

在实际的开发中,代码偏离精心设计的架构是很常见的事情,比如下图示例了一个嵌入式设备中设计的MVC模式:

View依赖于Controller和Model, Controller依赖于Model,Model作为底层服务提供者,不依赖View或者Controller. 这是一个适用的架构,可以在相当程度上分离业务,数据和界面。但是,某个程序员在实现时,使用了一个从Model到View的调用,破坏了架构。

这种现象通常发生在产品的维护阶段,有时也发生在架构的实现阶段。为了增加一个功能或者修正一个错误,程序员由于不理解原有架构的思路,或者只是单纯的偷懒,走了“捷径”。如果这样的实现不能及时发现并纠正,设计良好的架构就会被渐渐破坏,也就是我们常说的“架构”腐烂了。通常一个有一定年龄的软件产品的架构都有这个问题。如何监视并防止这种问题,有技术上的和管理上的手段。

技术上,借助工具,可以对系统组件的依赖进行分析,架构的外在表现最重要的就是各个部分的耦合关系。有一些工具可以统计软件组件的扇入和扇出。可以用这种工具编写测试代码,对组件的扇出进行检测,一旦发现测试失败,就说明架构遭到了破坏。这种检查可以集成在一些IDE中, 在编译时同步进行,或者在check in的时候进行。更高级的工具可以对代码进行反向工程生成UML,可以提供更进一步的信息。但通常对扇入扇出做检查就可以了。

通过设置代码检视的开发流程,对程序员check in的代码进行评审,也可以防止此类问题。代码检视是开发中非常重要的一环,它属于开发后期阶段用来防止坏的代码进入系统的重要手段。代码检视通常要关注以下问题:

1. 

是否正确完整的完成了需求

2. 

3. 

是否遵循了系统的架构

4. 

5. 

代码的可测试性

6. 

7. 

错误处理是否完备

8. 

9. 

代码规范

10. 

代码检视通常以会议的形式进行,时间点设置在项目阶段性完成,需要check in代码时。对于迭代式开发,则可以在一个迭代周期结束前组织。参与人员包括架构师,项目经理,项目成员,其他项目的资深工程师等。一般时间不要太长,以不超过2个小时为宜。会议前2天左右发出会议通知和相关文档代码,与会者必须先了解会议内容,进行准备。会议中,由代码的作者首先讲解代码需要实现的功能,自己的实现思路。然后展示代码。与会者根据自己的经验提出各种问题和改进意见。这种会议最忌讳的是让作者感到被指责或者轻视,所以,会议组织者要首先定义会议的基调:会议成功与否的标准不是作者的代码质量如何,而是与会者是否提供了有益的建议。会后由作者给与会者打分,而不是反之。

嵌入式系统的软件架构设计!8. 一个实际嵌入式系统架构的演化

上世纪九十年代,互联网的极速发展让通讯测试设备也得到了极大的发展。那个年代,能够实现某种测量的硬件是竞争的核心,软件的目的仅仅是驱动硬件运行起来,再提供一个简单的界面。所以,最初的产品的软件结构非常简单,类似前面的城铁门禁系统。

优点:程序简单明了的实现了用户的需求,一个程序员就可以全部搞定。

缺点:完全没有划分模块,底层上层耦合严重。

8.1. 数据处理

用户要求能将测量结果保存下来,并可以重新打开。数据存储模块和界面被独立出来。

依然保持上面的主逻辑,但是界面部分不仅可以显示实时的数据,也可以从ResultManager中读取数据来显示。

优点:数据和界面分离的雏形初步显现

缺点:ResultManager只是作为一个工具存在,负责保存和装载历史数据。界面和数据的来源依然耦合的很紧。不同的界面需要的不同数据都是通过硬编码判断的。

8.2. 窗口管理

随着功能不断复杂,界面窗口越来越多,原来靠一个类来绘制各种界面的方式已经不能承受。于是窗口的概念被引入。每个界面都被视为一个窗口,窗口中的元素为控件。窗口的打开,关闭,隐藏则由窗口管理器负责。

优点:界面功能以窗口的单位分离,不再是一个超大的集合。

缺点:虽然有了窗口管理器,但是界面依然是直接和底层耦合的,依然是大循环结构。

8.3. MVC模式

随着规模进一步扩大,最初的大循环结构终于无法满足日益复杂的需求了。标准的MVC模式被引入,经历了一次大的重构。

数据中心作为Model被独立出来,保存着当前最新的数据。View被放在了独立的任务中执行,定期从DataCenter轮询数据。用户的操作通过View发送给Controller,进一步调用硬件驱动执行。硬件执行的结果从驱动到Controller更新到DataCenter中。界面,数据,命令三者基本解耦。ResultManager成为DataCenter的一个组件,View不再直接与其通讯。

MVC模式的引入,第一次让这个产品了有真正意义上职责明晰,功能独立的架构。

8.4. 大量类似模块,低效的复用

到上一步,作为一个单独的嵌入式设备,其架构基本可以满足需求。但是随着市场的扩展,越来越多的设备被设计出来。这些设备虽然执行的具体测量任务不同,但是他们都有着同样的操作方式,类似的界面,更主要的是,它们面临的问题领域是相同的。长期以来,复制和粘贴是唯一的复用方式,甚至类名变量名都来不及改。一个错误在一个设备上被修正,同样一段代码的错误在其他设备上却来不及修改。而随着团队规模的扩大,甚至MVC的基本架构在一些新设备上都没能遵守。

最终框架被引入了这个系列的产品。框架确定了如下内容:

1. 

MVC模式的基本架构

2. 

3. 

窗口管理器和组件布局算法

4. 

5. 

多国语言方案(字符串管理器)

6. 

7. 

日志系统

8. 

9. 

内存分配器和内存泄露检测

10. 

8.5. 远程控制

客户希望将设备固定安放在网络的某个位置,作为“探针”使用,在办公室通过远程控制来访问这个设备。这对于原本是作为纯手持设备设计的系统又是一个挑战。幸运的是,MVC架构具有相当的弹性,早期的投入获得了回报。

TL1 Server 对外提供基于Telnet的远程控制接口。在系统内部,它的位置相当于View,只和原有的Controller和DataCenter通讯。

8.6. 自动化的TL1解释器

由于TL1命令相当多,而TL1又往往不是客户的第一需求,很多设备的TL1命令开始不完整。究其原因,还是手写TL1命令的解释器太累。后来通过引入Bison和Flex,这个问题有所改善,但还是不足。自动化代码生成在这个阶段被引入。通过以如下的格式定义TL1,工具可以自动生成TL1的编码和****代码。

CMD_NAME
{
  cmd = “SET-TIME-CONFIG::<ctag>::<year>,<month>,<day>,<hour>,<minute>,[<second>]”
  year = 1970..2100
  month = 1..12
  day = 1..31
  hour = 0..23
  minute = 0..59
  second = 0..59
}

8.7. 测试的难题

经过数十年的积累,产品已经成为一个系列,几十种设备。大部分设备进入了维护期,经常有客户提一些小的改进,或者要求修正一下缺陷。繁重的手工回归测试成为了噩梦。

基于TL1的自动化测试极大的解放了测试人员。通过在PC上运行的测试脚本,回归测试变得简单而可靠。唯一不足的是界面部分无法验证。

基于Test Quest的自动化工具需要在设备运行的pSOS系统上开发一个类似远程桌面的软件,而这在pSOS上并非易事。不过好消息是,由于框架固定了界面的风格和布局算法,基于Test Quest的自动化工具会有很高的识别效率。

8.8. 小结

从这个实际的嵌入式产品重构的历程可以看出,第三步引入MVC模式和第四步的框架化是非常关键的。成熟的MVC模式保证了后续一系列的可扩充性,而框架则保证了这个架构的在所有产品中的准确重用。

9. 总结

本文是针对嵌入式软件开发的特点,讨论架构设计的思路和方法。试图给大家提供一种思想,启发大家的思维。框架,自动化代码生成和测试驱动的架构是核心内容,其中框架又是贯穿始终的要素。有人问我,什么是架构师,怎么样才能成为架构师?我回答说:编码,编码,再编码;改错,改错,再改错。当你觉得厌烦的时候,停下来想想,怎么才能更快更好的完成这些工作?架构师就是在实践中产生的,架构师来自于那些勤于思考,懒于重复的人。





关键词: 框架     设计     中的     常用     模式    

共1条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]