B/S技术开发桌面应用
随着HTML5规范的越来越完善,其功能也越来越强大,用Web技术来构建界面的代价远远小于传统桌面程序开发。笔者从事web开发多年,对B/S开发技术积累比较好,对传统桌面开发只知道一些皮毛。最近有这样一个需求:将一个用java开发的web网站做成单机版,前端页面使用到了ativex技术(IE Only)。于是笔者踏上了漫漫长征路,期间用到了Java、Node.js、C/C++、VB6.0、C#这5种开发语言,碰到了各种各样奇葩的问题,所幸的是百度虽不靠谱但谷歌还是靠谱的,国内外优秀的文章帮我解决了不少问题,下面进入正题。
1、客户端
经过我的调研,目前适合做成客户端的浏览器主要有两款,Electron 和 NW.JS。您可能要说了,为什么不用Delphi或者VB6.0内嵌的WebBrower控件做客户端,基于IE内核的,刚好需求里面网页也用到的了activeX,一举两得。笔者尝试过类似的解决方案,用了J2SE的两款swing浏览器,DJNativeSwing和JDIC,都是基于IE内核的,这种方法的主要问题是需要设置用户的IE浏览器,要允许AtiveX控件运行、加入可信站点、允许跨域访问什么的,而且客户机器千奇百怪很有可能缺少xxx.dll或者IE本身就是有问题的,因此客户端选择独立浏览器,不依赖于IE是我最终的选择。
言归正传,Electron 和 NW.JS技术架构是一致的,界面展示基于chromium浏览器(Chrome的内核也是chromium,chromium对HTML5支持的相当好),然后对本地操作的能力则是利用的node.js,在此不详细展开说chromium和node.js怎么结合起来的,相关知识大家自行了解,我们来说一下这两者区别:
(1)Electron http://www.wllm.com.cn/
①node.js的context和javascript的context是混合的,即在node.js里面可以访问页面dom元素
②node.js的第三方扩展用node-gyp编译后可以直接使用,无需再次编译
③不支持XP
(2)NW.JS(原名Node-Webkit)http://nwjs.io/
①v0.13.0以前的版本node.js和javascript的context是分开的,不可在node.js里里面访问页面dom元素
②v0.13.0以前的版本node.js的第三方扩展用node-gyp编译后,还得经过nw-gyp编译才能使用(大坑,很多第三方扩展node-gyp编译好了,nw-gyp怎么也编译不通过)
③支持XP
综上,我选择了NW.JS的最新版本(v0.13.0-rc1),要在去年还真不好决定选用哪个(2015年我尝试了nw.js的v0.12.0,死在nw-gyp打包第三方扩展上了)。
好了,独立浏览器已就绪,能很好的支持HTML5了,但是问题来了,IE的Ativex控件如何替代呢?
我们来分析下,这个Activex控件就是VB和Delphi里面的RichTextBox,作用是用来显示RTF富文本格式文档的。那么解决问题的办法有两个:
(1)将rtf转换成pdf
我们知道Chrome是原生支持打开pdf格式文档的,直接拖进去就可以打开,这个功能其实在chromium就有,但是有弊端,就是pdf文档的周围会有一堆工具按钮,打印、旋转什么的,我尝试了一下,无法隐藏,没去深究,放弃。
(2)将rtf转换成html5
在网上搜索了rtf转html方案,得到两个dll,一个dll是vc写的,支持将rtf转换成html4+本地图片文件,一个dll是c#写的,支持将rtf转换成html5,图片则以base64编码形式内嵌在html5页面里面,我选择了c#的dll程序集。但是这两个dll都不支持rtf文档里面同时含有内码和unicode码,转换会出现乱码,解决方法是调用RichTextBox控件先将同时含有内码和unicode码的rtf文本转成只含有内码的rtf文本再转换为html5。
下面就是node.js和c#程序集、VB的ocx控件交互的问题了。
碰到多语言交互我的第一想法就是建立一个统一的接口,各个语言开发接口的实现类和调用类,然后客户端按照接口调用服务端就完事。找到了大名鼎鼎的Thrift,能生成所有主流编程语言待实现接口。
Thrift的C#端开发参见
http://www.cnblogs.com/liping13599168/archive/2011/09/15/2176836.html
Thrift的Java端开发参见
http://blog.csdn.net/m13321169565/article/details/7836006
Thrift的Nodejs端开发参见
http://snoopyxdy.blog.163.com/blog/static/6011744020153243315712/
定义好通用接口文件后,利用Thrift生成node.js的客户端接口和C#的服务端接口,成功的实现了node.js和C#的交互,但是这种交互模式是C/S模式的,就是用户打开客户端以后,你还得开一个进程运行C#开发的server.exe,感觉简单的问题复杂化了,不划算,所以在此处使用Thrift是杀鸡用牛刀了,弃之,另想他法。
第二个多语言交互的想法就是COM接口了。VB和C#都提供COM组件让nodejs调用。
C#开发COM组件参见
http://www.cnblogs.com/panlijiao/archive/2012/10/14/2773882.html
这里面有个坑说出来大家注意下:
C#开发的COM组件不是标准的COM组件,不能像VB那样用regsvr32.exe注册,是用的.net提供的RegAsm.exe工具注册的,分为全局注册(GAC)和私有注册。全局注册以后就跟VB控件一样,无论你的dll放在哪里,主程序都能找到,私有注册你的程序集dll必须放在主程序同一目录下,否则找不到。全局注册要求你的程序集dll及依赖的程序集dll都必须是强签名的,我在这里浪费了一天时间,因为我上面找到的C#的dll不是强签名的,然后按照网上的方法用ILDASM.exe反汇编dll为il,强签名之后再重新汇编为dll。但是发现该dll不让反汇编,更改了ILDASM.exe源码,强行反汇编之后强签名,再用ILASM.exe重新汇编为dll,发现无法正常使用了。放弃,选择私有注册。
.Net程序集强签名参见
http://www.cnblogs.com/philzhou/archive/2012/12/06/2804680.html
去掉ILDasm的SuppressIldasmAttribute限制参见
http://www.cnblogs.com/TianFang/archive/2012/12/09/2810344.html
COM组件开发完毕之后node.js如何调用,有两个选择,一个是选择现有的第三方插件,二是自己实现插件。
(1)现有的第三方插件
一开始我想一步到位,node.js直接调用com,在npm(Node.js 的包管理器 ,是全球最大的开源库生态系统)上找到了一个满意的插件win32ole,但是该插件比较陈旧,要求node.js版本在v0.12.0以下,而nw.js的v0.13.0-rc1内置node.js版本都到v5.x.x了,无奈放弃。
后来又找到node.js的明星插件node-ffi,可以调用c++的动态链接库dll,这样就要求我在vc++里面开发调用c#和vb的com组件的dll,我盘算了一下,既然都要用到vc++开发了,何不直接开发node.js第三方插件,省去使用node-ffi。
(2)自己实现node.js插件
node.js插件开发环境搭建,就是去node.js官网下载node.js的头文件和静态库node.lib,在vs工程新建dll工程,设置工程属性,附加头文件目录添加你下载的头文件所在目录,附加库文件目录添加node.lib所在目录即可,当然你也可以选择下载node.js源代码编译生成头文件以及静态库node.lib,然后再引用。
node.js插件开发参见
http://nodejs.cn/doc/node/addons.html
vc++调用com组件方法参见
http://www.cppblog.com/woaidongmao/archive/2011/01/10/138250.html
在这里多说一句,我本来想偷下懒在VC++里面直接调用C#的dll程序集的,大家知道VC++是非托管语言,不依赖于.net,要想不通过COM组件调用C#程序集,要么把工程属性调成公共语言库支持,要么是用CLRRuntime模拟CLR执行环境,然后invoke调用C#程序集里面的方法。
①把工程属性调成公共语言库支持
VC++通过gcnew以及^来创建和访问C#的类,详细参见
http://www.2cto.com/kf/201505/401650.html
这个方法程序VS能正常编译,node-gyp编译不通过,错误一大堆,不提也罢
②是用CLRRuntime
参见
http://blogs.msdn.com/b/msdnforum/archive/2010/07/09/use-clr4-hosting-api-to-invoke-net-assembly-from-native-c.aspx
弊端是要求.net frmaework 4.0(可能有降低要求的方法),而win7才自带.net frmaework 3.5,感觉不太划算。
两条路都不顺,我就默默回到了COM组件的道路上。
对了,在这里提醒大家注意一下,多语言交互乱码问题最是常见,开发过程中我总结了一下:
C/C++的char*是ansi码,COM组件接口里面的BSTR是unicode码,node.js的v8引擎中字符串是utf-8码,
三种编码的转换函数贴出来,按照上面的关系选择转换函数,就不会乱码。
wchar_t * ANSIToUnicode(const char* str) { int textlen; wchar_t * result; textlen = MultiByteToWideChar(CP_ACP, 0, str, -1, NULL, 0); result = (wchar_t *)malloc((textlen + 1)*sizeof(wchar_t)); memset(result, 0, (textlen + 1)*sizeof(wchar_t)); MultiByteToWideChar(CP_ACP, 0, str, -1, (LPWSTR)result, textlen); return result; } char * UnicodeToANSI(const wchar_t* str) { char* result; int textlen; textlen = WideCharToMultiByte(CP_ACP, 0, str, -1, NULL, 0, NULL, NULL); result = (char *)malloc((textlen + 1)*sizeof(char)); memset(result, 0, sizeof(char)* (textlen + 1)); WideCharToMultiByte(CP_ACP, 0, str, -1, result, textlen, NULL, NULL); return result; } wchar_t * UTF8ToUnicode(const char* str) { int textlen; wchar_t * result; textlen = MultiByteToWideChar(CP_UTF8, 0, str, -1, NULL, 0); result = (wchar_t *)malloc((textlen + 1)*sizeof(wchar_t)); memset(result, 0, (textlen + 1)*sizeof(wchar_t)); MultiByteToWideChar(CP_UTF8, 0, str, -1, (LPWSTR)result, textlen); return result; } char * UnicodeToUTF8(const wchar_t* str) { char* result; int textlen; textlen = WideCharToMultiByte(CP_UTF8, 0, str, -1, NULL, 0, NULL, NULL); result = (char *)malloc((textlen + 1)*sizeof(char)); memset(result, 0, sizeof(char)* (textlen + 1)); WideCharToMultiByte(CP_UTF8, 0, str, -1, result, textlen, NULL, NULL); return result; } char* ANSIToUTF8(const char* str) { return UnicodeToUTF8(ANSIToUnicode(str)); } char* UTF8ToANSI(const char* str) { return UnicodeToANSI(UTF8ToUnicode(str)); }
调用示例
void MyObject::GetQuestionContent(const FunctionCallbackInfo& args) { Isolate* isolate = args.GetIsolate(); MyObject* obj = ObjectWrap::Unwrap(args.Holder()); _bstr_t PaperFile = UTF8ToUnicode(*v8::String::Utf8Value(args[0]->ToString())); _bstr_t ResDir = UTF8ToUnicode(*v8::String::Utf8Value(args[1]->ToString())); _bstr_t InUserKey = UTF8ToUnicode(*v8::String::Utf8Value(args[2]->ToString())); long QuestionTypeID = args[3]->Int32Value(); long QuestionID = args[4]->Int32Value(); BSTR msg = 0; _bstr_t rtf_old = obj->pAccountPaperParser->GetQuestionContent(PaperFile, ResDir, InUserKey, QuestionTypeID, QuestionID, &msg); obj->outMsg = msg; char* pParams[] = { _com_util::ConvertBSTRToString(rtf_old) }; VARIANT varOutput; CallStringMethodByName(obj->pVB, "convertRtf2Rtf", 1, pParams, &varOutput); _bstr_t html = obj->pCSharp->convertString(varOutput.bstrVal); args.GetReturnValue().Set(v8::String::NewFromUtf8(isolate, UnicodeToUTF8(html))); ::SysFreeString(msg); ::SysFreeString(rtf_old); VariantClear(&varOutput); ::SysFreeString(html); }
此处系统函数_com_util::ConvertBSTRToString等价于UnicodeToANSI。
此外,由于在VC++使用了 #import "组件所在目录myCom.dll" no_namespace 这样的调用COM组件的语法,VS编译可以通过,node-gyp认为是线程不安全的,会编译不通过,解决办法是node-gyp configure生成待编译的vc++工程之后,修改生成的xxxx(你的插件名称).vcxproj,去掉包含/MT字符串的这一行,然后再继续node-gyp build即可。
最后,VS和node-gyp生成的插件DLL,node.js可以直接使用,nw.js要想直接使用,要做点小手脚,即替换node-gyp的win_delay_load_hook.c文件后再用node-gyp编译,生成的addon在node.js和nw.js就都可以用了,比以前需要用nw-gyp再编译一遍爽多了。
参见
http://docs.nwjs.io/en/v0.13.0-rc1/For%20Users/Advanced/Use%20Native%20Node%20Modules/
有这么一段:
Starting from 0.13.0, native modules built by node-gyp or npm in upstream can be supported.
In Linux and OSX you can just load the native module directly. In windows you’ll need to replace the file
%APPDATA%\npm\node_modules\node-gyp\src\win_delay_load_hook.c with the one at https://github.com/nwjs/nw.js/blob/nw13/tools/win_delay_load_hook.c
Before 0.13.0, the V8 version and Node ABI in NW.js is different from official Node.js. To use native Node.js modules with NW.js, you have to rebuild the modules with one of following tools.
到此客户端大局已定,我还面临着最后一个问题,即VB的控件RichtextBox(即richtx32.ocx)不是dll,而是ocx,大家知道dll是没有界面的,但是ocx是有界面的,将有界面的ocx当成普通的Activex dll来看待,使用CLSIDFromProgID(OLESTR("myCom.GetRes"),&clsid)强行创建的后果就是控制台程序在win7运行正常,在xp去运行失败,直接闪退,原因应该是找不到宿主窗口。
这个问题也困扰了我一天,最终国外一篇文章有说道:
http://www.codeproject.com/script/Articles/ArticleVersion.aspx?aid=51&av=36826
通过走IDispatch接口创建com组件,可以在xp成功运行,具体原理未深究,感觉是绕过了宿主窗口检测。
客户端到此结束,一个增强型的chromium浏览器诞生。
2、服务端
服务器端是一个标准J2EE war包,那么如何在本地发布,我有以下两个方向的尝试
(1)使用php改写项目
大家知道,自PHP5.4之后 PHP内置了一个Web 服务器,php -S localhost:8080即可启动,个人测试及小网站不必依赖于Apache或者Nginx。
参见 http://yuankeqiang.lofter.com/post/8de51_b36213
此种方法的有点显而易见,php环境运行小,只有十几兆,jre环境则太笨重。不过看着war里密密麻麻的.java和.jsp文件我放弃了这种想法,改写工作量太大。
(2)选择tomcat embeded还是jetty
还是抱java的大腿,毕竟那个war包我可以不用动它了,内置一个servlet容器去运行它,tomcat embeded、jetty都不错,jetty稍微小那么一点点,我就选了jetty。另外如果你不想基于现有war包添加servlet或者filter那么可以在启动jetty的时候直接添加,参见:
http://blog.chenlb.com/2012/05/embed-jetty-http-servlet-jsp.html
服务器改动不大,比较困难的问题是服务器程序打包。java程序跨平台的代价就是,它必须依赖jre。传统的做法是用exe4j将jar打成exe,并且把同目录下的jre文件夹当成依赖目录,一并发送给用户。我一直以来都是这么做的,主要问题是jre文件太多,太大,本次项目寻求新的解决方法。
(1)直接将java代码编译成本地代码
①开源项目GCJ
https://gcc.gnu.org/java/
编译环境要求多,蛮复杂,没仔细研究
②Excelsior JET
http://www.excelsiorjet.com/
看视频应该很好用,但是收费,预算足的可以考虑
(2)一种巧妙地桥接方式
C#在推出的时候,借鉴了java语言的设计思路,因此很多C#的类和java的类是相似的。ikvm.net就是这样诞生的,他将java的类映射到.net上面的类,从而实现让java代码运行在.net framework上面,将你的jar包编译为dll程序集。
参见
http://www.ikvm.net/
我就是用的ikvm将所有jar包转成了dll,跑在了.net framework上,由于win7以上系统都自带.net framework,因此也算是脱离了jre,减小了程序体积。
3、数据库
原本B/S架构的数据库服务器是SQLServer,移植到单机版,优选Access,Java连接Access有两种方式:
(1)jdbc-odbc桥
这种方式依赖于操作系统的JET引擎,我测试了一下,在XP上打不开Access2007以上创建的.accdb数据库文件,只能连接.mdb数据库文件,而.mdb的加密算法已经落伍了,随便一个破解软件几秒钟就能试出密码。
(2)纯jdbc
①HXTT MS Access JDBC Drivers
http://www.hxtt.com/access.html
收费软件,未注册版运行一次不能查询超过50次,一次返回的记录数不能超过1000条,使用过程中发现连接Access2010创建的有密码.accdb数据库文件好像有问题
②UCanAccess
https://sourceforge.net/projects/ucanaccess/
开源Access数据库JDBC驱动,完美支持.accdb数据库,不过要想支持有密码的数据库文件,得依赖于jackcess解密库,并且自己实现调用解密库打开加密文件的解密类,下面贴上示例代码:
public static Connection getConnection(String dbpath) throws Exception { Connection conn = null; Class.forName("net.ucanaccess.jdbc.UcanaccessDriver"); String dbur1 = "jdbc:ucanaccess:///" + dbpath + ";jackcessOpener=com.test.utils.CryptCodecOpener"; conn = DriverManager.getConnection(dbur1, "admin", "密码"); return conn; }
com.test.utils.CryptCodecOpener 类如下
public class CryptCodecOpener implements JackcessOpenerInterface { public Database open(File fl, String pwd) throws IOException { DatabaseBuilder dbd = new DatabaseBuilder(fl); dbd.setAutoSync(false); dbd.setCodecProvider(new CryptCodecProvider(pwd)); dbd.setReadOnly(false); return dbd.open(); } }
到此为止,一切问题似乎都已经解决,就等着打包了。然而,最终boss却在此时出现了。
这个得从UCanAccess的实现机制来讲,它是启动一个hsql内存数据库,(从他依赖于hsqldb.jar也可以看出来),然后把Access数据库表加载进入hsql数据库里面去,(所以UCanAccess很占内存,刚打开数据库文件时能飙到200M),本来在标准jre下,这一切都没有问题,但是我把服务端程序打成jar,用ikvm.net转换成dll放在.net环境上运行时,.net表示无法创建hsql内存数据库。于是,一朝回到解放前。
痛定思痛,我决定更换数据库,换成sqlite,本身小、不占内存、查询据说还比Access快。但是开源版的sqlite是没有加密数据库功能的,作者只是预留了sqlite3_key和sqlite3_rekey接口,并未实现,有加密数据库功能的sqlite3作者是收费的,而且就算我买了也没用啊,提供的是dll,我java还得用jna去调用dll,挺麻烦。但是我又实在不想裸奔,还是得寻找jdbc方面的sqlite解决方案。
①纯java实现的sqlite
sqlitejdbc-v033-nested.jar
纯java实现的读取sqlite创建的db的jdbc jar包,不用想了,这jar包都不知道你的db怎么加密的,肯定是解不开加密的db文件的,只能打开没有加密的db,因为不同公司有自己的sqlite3_key实现。
②开源sqlite jdbc项目,依赖于sqlite3.dll
主要有两个,SQLite Wrapper by Christian 和 SQLite Xerial Driver
介绍参加
http://blog.sina.com.cn/s/blog_654337ca01016x4n.html
各自的优缺点在文章里面都有说。
我看了一下两者的源码,发现在SQLite Wrapper的源码中,以DriverManager.getConnection(dbUrl, "用户名", "密码")方式获取数据库连接时,他会先判断你有没有实现sqlite3_key函数,有的话就会去调用,从而解密数据库,我从此看到了一线希望。不过两者驱动自带的sqlite_jni.dll都是基于官方的sqlite3.dll通过jni封装而来,不带加密功能,需要自己编译生成带加密功能sqlite_jni.dll。两者对比,SQLite Xerial Driver编译环境要求较高,选择了编译SQLite Wrapper。
首先得先找到sqlite3_key函数的实现,在这里向大家推荐开源项目wxSqlite3。
https://sourceforge.net/projects/wxcode/files/Components/wxSQLite3/
下载后解压,wxSqlite3/sqlite3/secure/src目录就是带加密功能的sqlite3源码
然后按照SQLite Wrapper编译文档编译
http://www.ch-werner.de/javasqlite/
官方示例javasqlite3.mak入口文件是从sqlite源码生成sqlite3.lib再依赖于sqlite3.lib生成sqlite_jni.dll,但是你要真这么做你就死定了,一堆乱七八糟的错,能把你整崩溃,主要原因我猜是SQLite Wrapper更新慢,sqlite3和wxSqlite3更新快,下载最新版的wxSqlite3里面的sqlite3加密源码跟他的脚本不匹配。
我是从javasqlite3-dll.mak入口文件进行编译的,直接把wxSqlite3/sqlite3/secure/ase128下面生成好的带加密功能的sqlite3.lib拿过来,跳过从sqlite源码生成sqlite3.lib的步骤,从而生成sqlite_jni.dll和sqlite.jar,最终实现jdbc操作加密的db库。
至此,所有问题全部解决,java服务端主程序在jetty服务启动完成后调用独立浏览器当作客户端访问http://127.0.0.1:8080/,服务端主程序等待独立浏览器被用户关闭后退出。
打包之后,程序在xp,win7,win8,win10都可运行,当然,xp需装一下.net framework 3.5(VS2012及以后的VS版本无法安装在xp之上了,但是开发出来的dll和exe依然是可以选择兼容xp的,具体方法参见附件)。
最后提醒下,无论是jar包还是ikvm.net生成的dll,都可以被反编译,因此建议核心代码用ikvmc编译成exe并且对exe加密,不要全部用ikvmc编译成dll。
衷心感谢NW.js、Node.js、wxSqlite3、SQLite Wrapper以及SQLite Xerial Driver这些优秀的开源项目,世界有你们更精彩!