一:源起
1.何为CB/S的应用程序
C/S结构的应用程序,是客户端/服务端形式的应用程序,这种应用程序要在客户电脑上安装一个程序,客户使用这个程序与服务端通信,完成一定的操作。
B/S结构的应用程序,是浏览器/服务端形式的应用程序,这种应用程序不用在客户端部署任何东西,客户只需要通过浏览器与服务端通信,来完成一定的操作。
两种类型的程序优缺点对比:
对比内容 |
C/S结构的应用程序 |
B/S结构的应用程序 |
部署 |
较困难 |
方便 |
升级 |
较困难 |
方便 |
对客户端的控制权限 |
高 |
低 |
数据实时性 |
高 |
较高 |
通信效率 |
高 |
较高 |
跨平台性 |
弱 |
强 |
由上可知,两种形式的应用程序各有利弊。架构师在做技术选型的时候,往往会根据项目需要,对比这两种技术形式的优缺点,做出正确的选择。
然而,国内大多数企业应用程序,需要频繁、及时的更新升级、需要更高的客户端控制权限、需要更高的数据实时性和更高的通信效率,但却不在意部署上的问题。
这时,架构师就考虑把C/S结构的应用程序和B/S结构的应用程序结合起来,让客户端嵌套一个浏览器以与服务器通信,完成一定的操作。这样的程序就是CB/S结构的应用程序。
这样做的好处是一般的业务逻辑只要在服务端更新升级,即可体现在客户端。对于客户端系统权限、基于Socket的通信等浏览器核心无法完成的操作,可以由客户端来完成。客户端可以直接与服务端通信,也可以通过浏览器核心与服务端通信。
下图为CB/S结构应用程序的基本示意图:
目前还有一种介于C/S和B/S结构的应用程序之间的应用程序:RIA富互联网应用程序,这种结构的应用程序一般都是基于浏览器插件来运行的,它有较高的客户端控制权限(比B/S程序高,但比C/S程序低),通信方式也有较多的选择(不只是基于HTTP协议),目前较常见的RIA技术有:Adobe的flex技术、微软的Silverlight技术、Oracle的WebStart技术。架构师在做技术选型的时候,也可以综合权衡采用这些技术。 |
2.为何选择QT的WebKit与Extjs开发企业应用
ExtJs是一个用于创建Web用户界面的JS框架,提供了丰富的界面部件及布局方式,对于web开发者来说,实现企业应用所需的各种画面只要掌握JS语言即可。不必再引入flash或silverlight技术,而且能很容易的创建风格统一的企业应用程序。
虽然ExtJs支持各种流行的浏览器,甚至包括IE6,但是它在IE系浏览器下运行、渲染的效率不高。在谷歌浏览器下表现最好,FireFox浏览器次之(这得益于谷歌浏览器的JS脚本引擎)。
然而谷歌浏览器和FireFox浏览器的核心都是WebKit(苹果公司开源的浏览器核心,负责解析HTML文本,并呈现到界面上),所以,要想让我们的CB/S+ExtJs结构的应用程序能有更好的表现,我们必须采用WebKit核心的浏览器。
虽然我们能很方便的获得WebKit的源码,然而编译它却十分耗时费力,不但要选对编译工具,还要安装一系列的SDK,编译时间更是长的惊人(这几乎是大型C++项目的通病)。编译出来的DLL使用起来也不是很方便(要翻阅大量的WebKit的API)。
幸运的是QT界面库为我们做了这些工作,QT库中包含webkit的浏览器控件,并且这个C++库是跨平台的,也就是说基于这几项技术开发的CB/S企业应用可以部署在Linux系统内。
除了使用QT界面库,还可以选择gtk+和wxWidgets两个界面库,而且这两个界面库都对WebKit做过包装,但是从开发方式,生产效率,运行速度等多方面考虑,还是QT最为合适。
QT界面库也分为两个版本,一个是收费的digia提供的QT,另一个是免费的qt-project提供的QT(GPL V3 LGPL V2),这里我们选择免费版的QT,本文第三节会介绍如何搭建开发环境。
架构师除了选择QT的WebKit做浏览器核心之外,还可以选择CEF(Chromium Embedded Framework,项目地址:https://code.google.com/p/chromiumembedded/)这个项目是对谷歌浏览器的重新编译、封装,分为两个版本线,CEF1和CEF3,我曾对此项目做过一些研究,研究的相关资料参见:http://www.cnblogs.com/liulun/archive/2013/03/18/2874276.html;另外,还有一个node webkit的项目(地址:https://github.com/rogerwang/node-webkit)也是对谷歌浏览器的重新编译和封装,但它引入了NodeJs,使用简单的HTML JS CSS就可以编写出绚丽的客户端界面。node webkit目前处于V0.7.X版本。 |
二:思路
1.目标
- 搭建一个CB/S结构的企业应用程序
- 尽量保证系统的执行效率
- 尽量保证系统升级更新的便利性
- 尽量保证系统的可扩展性
2.方案
ExtJs框架是一个比较庞大的框架,一般B/S结构的程序使用ExtJS框架,都是把ExtJs的框架放在服务端,这样用户每次请求页面的时候,都会去访问ExtJS框架的JS文件,从而产生大量的磁盘IO和网络消耗,这也是ExtJS框架看起来渲染很慢的一个因素。B/S结构的应用程序无法解决这个问题,主要是因为无法控制客户端的浏览器,CB/S结构的程序就能轻松解决这个问题。可以把ExtJs框架打包进客户端程序中,随客户端程序分发给使用者,使用者请求页面时,使用的是本地的ExtJS框架的JS文件,业务逻辑程序则仍旧使用服务端的。这样做减少了磁盘IO和网络消耗,保证了系统的执行效率;服务端对业务逻辑程序依旧保持着很好的控制权,保证了系统升级更新的便利性
关于系统的可扩展性,ExtJs就能很好的处理,在下一节中会有详细描述。
3.难点
CB/S结构的应用程序其实就是一个高度定制的浏览器。为了让这个浏览器完成指定的功能(比如:包含ExtJs框架的js文件,做成cookie,发起请求等)难免会有很多客户端和浏览器核心的交互。这些交互涉及到C++,Js,HTML,CSS等的互操作,是系统在技术上的难点。
三:客户端浏览器实现
1.搭建开发环境
我们下载基于MinGW 4.8, OpenGL创建的QT 5.1,地址为:http://qt-project.org/downloads。不选择基于VS编译器的QT是因为用VS编译器编译出的DLL依赖VS运行时,分发程序时较困难。下载并安装后,你会看到这并不是一个简单的界面库,它还包含了一个IDE,Qt Creator。
安装完成后,就可以使用Qt Creator来创建你自己的基于Qt的桌面程序,你可以在Qt Creator的欢迎界面看到入门程序、示例程序和帮助文档。Qt的开发方式并不是本文所讲述的重点,建议读者到官网学习。
虽然我们可以成功在Qt Creator内编译并成功执行程序,但到windows目录下通过双击执行编译出的exe程序,就不能正常运行,这是因为可执行程序所需的动态链接库并没有与可执行程序在同一个目录内,至于可执行程序依赖哪些动态链接库,我们将在本文第四节详细描述。
2.边框和标题栏
目前大部分windows桌面程序都使用自定义的边框和标题栏,比如QQ,360安全卫士等,使用MFC或Windows API自定义窗口的标题栏和边框并不是一件容易的事情,使用Qt来开发Windows桌面程序也有一样的困难。
由于我们开发的是企业应用系统,这类系统一般情况下都出于最大化状态,所以我们在考虑自定义标题栏和边框的时候就可以不用考虑还原按钮、拖拽改变窗口大小和位置的功能。但是,我们需要为标题栏增加一个下拉菜单按钮,以使用户完成系统设置、打开调试器等相关功能。
另外,为了使标题栏和业务界面中ExtJs的风格一致,我们索性去掉了主窗口的标题栏和边框,直接使用ExtJs来生成。
在Qt中去掉标题栏和边框是很容易的事,创建窗口的时候设置一个WindowFlags即可,见如下代码:
w.setWindowFlags(Qt::FramelessWindowHint); |
但设置此WindowFlags之后随之带来的问题是,窗口将撑满整个屏幕,把系统的任务栏也遮住了,这显然不是我们想要的,解决此问题需要重写Qt窗口类的changeEvent槽,见如下代码:
if(event->WindowStateChange) { switch(this->windowState()) { case Qt::WindowMinimized: this->hide(); event->ignore(); break; case Qt::WindowMaximized: QDesktopWidget* desktopWidget =QApplication::desktop(); QRect deskRect =desktopWidget->availableGeometry(); this->resize(deskRect.width(), deskRect.height()); break; } } |
这样创建的Qt窗口将不具有标题栏和边框,至于如何用ExtJs来渲染标题栏,以及如何实现标题栏的最小化及关闭等功能,将在后续小节讲述。
3.打开新窗口
使用Qt的WebKit非常简单,直接把QWebView控件拖放到界面中去即可,但是默认的QWebView在实现上有些缺憾,比如无法打开新窗口,无法下载文件,无法打印等。然而这些功能是一个浏览器所必备的功能,我们的CB/S企业应用系统也需要这些功能。要想让浏览器支撑这些功能,只能通过重写QWebView来完成。
要想让自制的浏览器打开新窗口,需要重写QWebView的createWindow方法,见如下代码:(UtmpWebView即为QWebView的子类)
UtmpWebView* webView = new UtmpWebView; QWebPage* newWeb = new QWebPage; if(type == QWebPage::WebModalDialog) { webView->setWindowModality(Qt::ApplicationModal); } webView->setAttribute(Qt::WA_DeleteOnClose,true); webView->setPage(newWeb); webView->show(); return webView; |
然而,这只能应对a标签的target属性为_blank的新窗口链接,无法应对使用javascript通过window.open的方式打开新窗口的场景。要想满足这一点,必须在QWebView的构造函数里,更改一下浏览器的配置参数,代码如下:
QWebSettings* default_settings = QWebSettings::globalSettings(); default_settings->setAttribute(QWebSettings::JavascriptEnabled,true); default_settings->setAttribute(QWebSettings::JavascriptCanOpenWindows,true); |
4.打印
我们经常在网页中通过javascript使用window.print的方式来调用打印机打印HTML页面,常见的浏览器都会支持这个功能,然而QWebView默认并不支持此功能,要想让我们定制的浏览器支持此功能必须为其做一个事件链接,代码如下:
connect(this->page(), SIGNAL(printRequested(QWebFrame*)),this,SLOT(customPrintRequested(QWebFrame*))); this->page()->setForwardUnsupportedContent(true); |
customPrintRequested槽的实现如下:
QPrinter* p = new QPrinter(QPrinter::HighResolution); QPrintDialog printDialog(p, this); printDialog.setWindowTitle("UTMP打印"); if(printDialog.exec() != QDialog::Accepted) { return; } frame->print(p); |
5.下载
同样QWebView默认也不支持下载文件。所有的浏览器把请求的响应分为两类,一类是浏览器可以解析的(Html文本),另一类是浏览器无法解析的(文件),常见的浏览器遇到无法解析的文件,往往会下载到本地给用户使用,要想让QWebView支持下载,就必须截获浏览器的unsupportedContent信号,该信号所对应的槽的代码实现如下
ShellExecuteA(NULL, "open", reply->url().toString().toStdString().c_str(), "", "", SW_SHOW); |
注意,要想让上面的代码正确执行,必须在头文件中引入windows.h(这也体现出QT框架与NativeAPI能没有任何限制的轻松交互)。上面的代码是调用了系统默认的浏览器来完成下载。当然读者也可以考虑自己实现下载线程并提示下载进度、保存地址等。
6.与页面脚本交互
我们既然选择自己开发浏览器,那么浏览器一定能自如的让页面执行一些特殊脚本,页面也可以通过脚本让浏览器完成一些脚本无法完成的操作。此功能一般的浏览器都无法支撑,只有我们自定义的QWebView可以轻松实现。
我们知道javascript在页面中执行都会用到window对象,比如,我们调用alert()方法时,其实是调用window.alert()方法,使用document对象时,其实是使用window.document对象,要想让浏览器能与页面脚本交互,我们必须让浏览器给页面的window对象注册一个子对象(window对象的属性)。
遇到的第一个问题并不是如何注册此对象,而是在何时注册。由于在页面加载之初,window对象就已经初始化完成了,此时为其注册子对象已为时已晚,必须在其初始化之前为其注册,为此QWebView专门提供了javaScriptWindowObjectCleared信号,在刷新网页、打开新网页和加载嵌套的iframe页面时(window对象初始化时),此信号都会被触发。与此信号关联的槽,代码如下:
this->page()->mainFrame()->addToJavaScriptWindowObject("QtWinFrame", this); |
如你所见,我们为window对象注册了一个名为QtWinFrame的对象。这就像浏览器为window对象注册document子对象一样,要想让页面脚本能调用浏览器核心的方法,必须为让浏览器核心提供相应的方法才行,由于我们在第二小节已经把窗口默认的标题栏和边框去掉了,所以必须通过页面javascript来关闭浏览器和最小化浏览器,假设我们在浏览器核心中实现的方法代码如下:
void UtmpWebView::SetFrameWindow(int flag) { switch(flag) { case 0: this->close(); break; case 1: this->showMinimized(); break; } } |
在浏览器页面内,只要通过如下javascript代码,即可让浏览器核心执行相应的操作:
QtWinFrame.SetFrameWindow(1);QtWinFrame.SetFrameWindow(0); |
相对于“脚本让浏览器执行工作”来说,“浏览器让脚本执行工作”就简单很多,只需要在浏览器中调用evaluateJavaScript方法即可,见如下代码:
this->page()->mainFrame()->evaluateJavaScript("testFun();"); |
注意:这有些类似于javascirpt中的eval()方法,如果前端框架中引入了ExtJs,最好不要直接使用此方法来调用ExtJs提供的函数,执行效率非常慢。可以先在页面上用普通的js函数包装一下ExtJs提供的函数,再来调用。
7.打开脚本调试器
调试javascript代码一直以来都是开发人员面临的老大难的问题,自从有了FireBug和谷歌浏览器自带的javascript调试器之后,这个问题得到了很大程度的解决,所以有个好的javascript调试器十分关键。QWebView也提供了相应的调试工具(我认为就是谷歌浏览器的javascript调试器,但未经验证。)。使浏览器核心打开调试器的代码如下:
QDialog* d = new QDialog(this,(Qt::WindowMinimizeButtonHint|Qt::WindowMaximizeButtonHint|Qt::WindowCloseButtonHint)); d->setAttribute(Qt::WA_DeleteOnClose, true); QWebInspector* wi = new QWebInspector(d); wi->setPage(this->page()); d->setLayout(new QVBoxLayout()); d->layout()->setMargin(0); d->layout()->addWidget(wi); d->show(); d->resize(600,350); |
由于我们在系统启动的时候,使用Qt::FramelessWindowHint属性禁用掉了窗口的标题栏和边框,所以在打开调试器子窗口的时候,要恢复该子窗口的标题栏和边框,为此我们多做了一些工作,读者也可以自己实现QDialog类型的父类,以应对更多子窗口业务。
8.截获浏览器请求
既然我们对浏览器有最大的控制权,那么我们就希望当浏览器完成指定工作时通知我们,好让我们做一些前期或后期的处理。最常见的工作莫过于浏览器发起请求了。我们知道浏览器解析一个网页的过程中,可能会发起多次请求,比如图片标签的src路径,iframe标签的src路径,js/css资源的路径等等。要想知道这些请求何时发起,何时终结需要重写QNetworkAccessManager,然后通过如下方式,让浏览器加载自定义的QNetworkAccessManager
QNetworkAccessManager *oldManager = webview->page->networkAccessManager(); MyNetworkAccessManager *newManager = new MyNetworkAccessManager(oldManager, this); webview->page->setNetworkAccessManager(newManager); |
然后,我们可以在自定义的MyNetworkAccessManager类中重写createRequest(QNetworkAccessManager::Operation operation,const QNetworkRequest &request, QIODevice *device)方法,其中request参数,包含了原始请求的URL信息,此方法需要返回一个QNetworkReply对象,假设我们想改变原始请求的路径,可以按如下操作方式来完成
return QNetworkAccessManager::createRequest(operation, myrequest, device); |
如你所见,我们用QNetworkAccessManager新建了一个请求(createRequest的返回值为QNetworkReply类型),该请求中myrequest实参的类型为QNetworkRequest,其他两个实参从原始方法中获得。
9.本地化ExtJs库
一般我们使用ExtJs(官方地址:http://www.sencha.com/products/extjs/),都是把它部署在服务端,浏览器请求页面时,也会相应的加载ExtJs的资源以渲染界面,但由于ExtJs包含众多js文件和其他资源,通过网络来加载的话,一方面增加了服务器IO消耗,另一方面增加了网络延时,很多用户反应基于ExtJs的网络应用呈现速度慢,都是这两个原因导致的。
现在我们开发自己的浏览器,就可以把Extjs库(不包含业务JS代码,因为业务JS代码易于变化,不适合当作资源放在客户端)当作资源放在客户端,对于一个客户端来说,体积越小越好,然而以ext4.2.1 gpl版为例,官方提供的压缩包里,有很多内容不适合打包到客户端中。比如:教程、文档、源码、示例等,读者可以自行将这些内容删掉,然后把精简后的ExtJs类库放到浏览器应用程序编译文件夹内([appDirectory]\build-UTMP-Desktop_Qt_5_1_1_MinGW_32bit-Debug\debug),这样Extjs类库就与我们的浏览器可执行程序在同一个目录下了,如果让浏览器使用Extjs类库的资源,还应该在此目录下创建一个静态文件,以引入同目录下的静态资源,代码如下:
<link href="ext-4.2.1.883/resources/Css/ext-all.css" rel="stylesheet" type="text/css" /> <script src="ext-4.2.1.883/ext-all-debug.js"></script> |
当然,单单引入资源,还无法呈现ExtJs的绚丽界面,此时还需要引入一个服务器端的JS文件,此文件通过Extjs的类库加载机制,加载更多的业务JS,以达到实现特定业务逻辑的目的。我们在下一节中会详细介绍这些内容。
<script src="http://localhost:8080/UTMP/app.js"></script> |
在QT中只需要通过本地路径加载这个静态页面即可,代码如下:
UtmpWebView w; QDir dir(QDir::currentPath()); QUrl url = url.fromLocalFile(dir.path()+"/debug/index.html"); w.load(url); |
由此可见,保存在客户端的资源基本都是业务无关的、比较稳定的、不易变更的资源。保存在服务端的内容,都是与业务有关的,比较容易变更的内容,这种机制主要意图是保证了业务的可升级性。
四:服务端业务脚本
1.OPOA模式
使用Extjs的企业应用系统大多都是OPOA模式(One Page One Application),OPOA模式的WEB系统只有一个页面,在这个页面中会引入extjs的资源并通过js来渲染一个框架页面,然后根据用户的操作载入更多的js代码,来完成不同的业务。对于我们的系统来说这个页面就是放在客户端本地debug目录下的静态页面。这个页面引入了一个服务器端的js文件(http://localhost:8080/UTMP/app.js),通过此文件以及由此文件加载的其他js文件,我们渲染出了一个框架页面,见如下代码:
Ext.application({ name:'UTMP', appFolder:'http://10.0.7.109:8080/UTMP/app', controllers:["sys.index"], views:["sys.menuTree","sys.titleBar","sys.contentTabPanel"], launch:function(){ Ext.create('Ext.Viewport',{ layout:'border', items:[ {xtype: 'menuTree'}, {xtype: 'titleBar'}, {xtype: 'contentTabPanel'} ] }); } }); |
如你所见,这是一个Extjs系统的开始(Ext.application),而且我们使用了Extjs的MVC模式(关于ExtJs的MVC模式的相关资料请参阅:http://docs.sencha.com/extjs/4.2.1/#!/guide/application_architecture),系统界面中包含三个视图:menuTree、titleBar和contentTabPanel。由于我们设计的浏览器没有标题栏,所以视图titleBar就是系统的标题栏,它包含了关闭、最小化按钮。
2.定制模块加载基址
Extjs有一套独特的模块加载机制,它可以通过js类的名称空间来加载相应的js代码文件,比如视图文件的名称空间是UTMP.sys.menuTree,ExtJs框架会从appFolder指定的路径下找sys目录下的menuTree.js文件。在普通的ExtJs项目中,appFolder属性并不用设定为绝对路径,只需要使用相对路径即可,但由于我们的项目的主页(静态页面)是放在客户端本地的,如果使用相对路径的话,ExtJs框架就会在客户端本地寻找相应的资源,然而我们的业务JS文件都是放在服务端的,所以势必会无法加载这些资源。
3.定制AJAX请求基址
模块加载机制可以通过设置appFolder基路径来解决,但是对于业务JS代码随处可见的AJAX请求该如何处理呢?确实,AJAX请求也会面临这种问题,而且更为突出。因为在ExtJs中对AJAX请求做了很多封装:proxy、store、request、load等,随处可见ajax的身影。幸而ExtJs是一个对象化程度较高的js类库,使得这个问题能很容易的解决。
在ExtJs中所有Ajax请求都离不开Ext.data.Connection类的支撑,我们可以使用ExtJs提供的观察者模式来注册Ext.data.Connection类的beforerequest事件(这是一种侵入性较强的做法),这样所有的AJAX请求,不管是proxy发起的,还是request发起的,都逃不出我们的手心,具体实现代码如下:
Ext.util.Observable.observe(Ext.data.Connection,{ beforerequest: function(conn, options, eOpts){ options.url = "http://10.0.7.109:8080/UTMP/"+options.url; } }); |
五:分发
1.依赖的动态链接库
在使用QTCreator开发基于QT的应用程序时,不管是debug编译还是release编译,都无法到编译目录下,通过双击exe程序来执行应用(会提示“无法启动此程序,因为计算机中丢失xxxx.dll....”的错误信息),之所以在IDE内能顺利执行,是因为IDE已经为程序执行创建好了环境,但倘若不解决此问题,就无法把应用程序分发给直接用户。
要解决此问题只要把Qt类库提供的dll文件放在可执行程序的目录下或其所在目录的子目录下即可,在C:\Qt\Qt5.1.1\5.1.1\mingw48_32\bin目录下有Qt类库提供的大多数dll,这些dll名称以字母d结尾的是debug编译的应用程序所依赖的类库,不以字母d结尾的则是release编译的应用程序所需要的类库,除了此目录内的dll外,在C:\Qt\Qt5.1.1\5.1.1\mingw48_32\plugins目录下还有一些应用程序需要的dll类库。
如此数量众多的dll,都需要打包到我们最终的安装程序中去吗?当然不用这么做。通过IDE执行我们的应用程序时,我们只需要通过processExplorer工具来查看应用程序进程所依赖的dll,即可判定哪些dll是需要打包到安装包中去的(大多数情况下可以这么做,如果是开发人员通过代码动态加载的类库,那么我相信你也会自行甄别的)。
2.打包
可能有的读者会问:“我可不可以把类库静态编译到exe中去呢?”当然可以,但是非常麻烦,你需要自己静态编译整个QT工程,还需要对IDE做出相应的调整(要编译QT的Webkit还需要做更多的工作),这是一项耗时、耗力还不一定能成功的工作,我不建议这么做。
当我们找到应用程序依赖的所有dll后,我们就可以使用打包工具来制作应用程序的安装包,当然也可以自己开发安装包工具(可以参见我的博客:http://www.cnblogs.com/liulun/archive/2011/12/12/2284360.html)。
国内外有很多不错的打包工具,我推荐使用inno setup(http://www.jrsoftware.org/),它支持编写脚本来控制安装过程,使用LZMA压缩算法来打包程序(压缩效率非常高,是7-zip使用的压缩算法),但它并不支持中文安装界面,目前社区有开发者提供了针对inno setup的中文语言包,使用起来也非常方便。
源码地址:https://github.com/utry/BrowserCore/
请不要让我调试代码,老夫很忙!懒的管!