乐者为王

Do one thing, and do it well.

图象的半影调和抖动技术

在介绍本讲内容之前,先提出一个问题?普通的黑白针式打印机能打出灰度图来吗?如果说能,从针式打印机的打印原理来分析,似乎是不可能的。因为针打是靠撞针击打色带在纸上形成黑点的,不可能打出灰色的点来;如果说不能,可是我们的确见过用针式打印机打印出来的灰色图象。到底是怎么回事呢?你再仔细看看那些打印出来的所谓的灰色图象,最好用放大镜看。你会发现,原来这些灰色图象都是由一些黑点组成的,黑点多一些,图象就暗一些;黑点少一些,图案就亮一些。下面这几张图就能说明这一点。

halftone-l401 图1. 用黑白两种颜色打印出灰度效果

图中最左边的是原图,是一幅真正的灰度图,另外三张图都是黑白二值图。容易看出,最左的那幅和原图最接近。由二值图象显示出灰度效果的方法,就是我们今天要讲的半影调(halftone)技术,它的一个主要用途就是在只有二值输出的打印机上打印图象。我们介绍两种方法:图案法和抖动法。

图案法(patterning)

图案法是指灰度可以用一定比例的黑白点组成的区域表示,从而达到整体图象的灰度感。黑白点的位置选择称为图案化。在具体介绍图案法之前,先介绍一下分辨率的概念。计算机显示器,打印机,扫描仪等设备的一个重要指标就是分辨率,单位是dpi(dot per inch),即每英寸点数,点数越多,分辨率就越高,图象就越清晰。让我们来计算一下,计算机显示器的分辨率有多高。设显示器为15英寸(指对角线长度),最多显示1280 * 1024个点。因为宽高比为4:3,则宽有12英寸,高有9英寸,则该显示器的水平分辨率为106dpi,垂直分辨率为113.8dpi。一般的激光打印机的分辨率有300dpi * 300dpi的,有600dpi * 600dpi的,720dpi * 720dpi。所以打出来的图象要比计算机显示出来的清晰的多。扫描仪的分辨率要高一些,数码相机的分辨率更高。

言归正传,前面讲了,图案化使用图案来表示像素的灰度,那么我们来做一道计算题。假设有一幅240 * 180 * 8bit的灰度图,当用分辨率为300dpi * 300dpi的激光打印机将其打印到12.8 * 9.6英寸的纸上时,每个像素的图案有多大?这道题很简单,这张纸最多可以打(300 * 12.8) * (300 * 9.6) = 3840 * 2880个点,所以每个像素可以用(3840 / 240) * (2880 / 180) = 16 * 16个点大小的图案来表示,即一个像素256个点。如果这16 * 16的方块中一个黑点也没有,就可以表示灰度256,有一个黑点,就表示灰度255,依次类推,当都是黑点时,表示灰度0,这样,16 * 16的方块可以表示257级灰度。比要求的8bit共256级灰度还多了一个,所以上面的那幅图的灰度级别完全能够打印出来。

这里有一个图案构成的问题,即黑点打在哪里?比如说,只有一个黑点时,我们可以打在正中央,也可以打16 * 16的左上角。图案可以是规则的,也可以是不规则的。一般情况下,有规则的图案比随即图案能够避免点的丛集,但有时会导致图象中有明显的线条。如图2中,2 * 2的图案可以表示5级灰度,当图象中有一片灰度为的1的区域时,如图3所示,有明显的水平和垂直线条。

halftone-l402 图2. 2 * 2的图案

halftone-l403 图3. 规则图案导致线条

如果想存储256级灰度的图案,就需要256 * 16 * 16的二值点阵,占用的空间还是相当可观的。有一个更好的办法是:只存储一个整数矩阵,称为标准图案,其中的每个值从0到255。图象的实际灰度和阵列中的每个值比较,当该值大于等于灰度时,对应点打一黑点。下面举一个25级灰度的例子加以说明。

halftone-l404 图4. 标准图案举例

图4中,左边为标准图案,右边为灰度为15的图案,共有10个黑点,15个白点。其实道理很简单,灰度为0时全是黑点,灰度每增加1,减少一个黑点。要注意的是,5 * 5的图案可以表示26种灰度,灰度25才是全白点,而不是24。下面介绍一种设计标准图案的算法,是由Limb在1969年提出的。以一个2 * 2的矩阵开始halftone-l4formu01,通过递归关系有halftone-l4formu02,其中2n * 2n是阵列中元素的个数,Un是一个2n * 2n 的方阵,所有元素都是1。根据这个算法,可以得到halftone-l4matrix01,为16级灰度的标准图案。M3(8 * 8阵)比较特殊,称为Bayer抖动表。M4是一个16 * 16的矩阵。根据上面的算法,如果利用M3,一个像素要用8 * 8的图案表示,则一幅N * N的图将变成8N * 8N大小。如果利用M4,就更不得了,变成16N * 16N了。能不能在保持原图大小的情况下利用图案化技术呢?一种很自然的想法是:如果用M2阵,则将原图中每8 * 8个点中取一点,即重新采样,然后再应用图案化技术,就能够保持原图大小。实际上,这种方法并不可行。首先,你不知道这8 * 8个点中找哪一点比较合适,另外,8 * 8的间隔实在太大了,生成的图象和原图肯定相差很大,就象图1最右边的那幅图一样。我们可以采用这样的做法:假设原图是256级灰度,利用Bayer抖动表,做如下处理if (g[y][x] >> 2) > bayer[y & 7][x & 7] then 打一白点 else 打一黑点。其中,x,y代表原图的像素坐标,g[y][x]代表该点灰度。首先将灰度右移两位,变成64级,然后将x,y做模8运算,找到Bayer表中的对应点,两者做比较,根据上面给出的判据做处理。

我们可以看到,模8运算使得原图分成了一个个8 * 8的小块,每个小块和8 * 8的Bayer表相对应。小块中的每个点都参与了比较,这样就避免了上面提到的选点和块划分过大的问题。模8运算实质上是引入了随机成分,这就是我们下面要讲到的抖动技术。

下面的图5就是利用这个算法,使用M3(Bayer抖动表)阵得到的,图6是使用M4阵得到的,可见两者的差别并不是很大,所以一般用Bayer表就可以了。

halftone-l405 图5. 利用M3抖动生成的图

halftone-l406 图6. 利用M4抖动生成的图

图案法源程序

抖动法(dithering)

让我们考虑更坏的情况:即使使用了图案化技术,仍然得不到要求的灰度级别。举例说明:假设有一幅600 * 450 * 8bit的灰度图,当用分辨率为300dpi * 300dpi的激光打印机将其打印到8 * 6英寸的纸上时,每个像素可以用(2400 / 600) * (1800 / 450) = 4 * 4个点大小的图案来表示,最多能表示17级灰度,无法满足256级灰度的要求。可有两种解决方案:1.减小图象尺寸,由600 * 450变为150 * 113;2.降低图象灰度级,由256级变成16级。这两种方案都不理想。这时,我们就可以采用抖动法的技术来解决这个问题。其实刚才给出的算法就是一种抖动算法,称为规则抖动(regular dithering)。规则抖动的优点是算法简单;缺点是图案化有时很明显,这是因为取模运算虽然引入了随机成分,但还是有规律的,另外,点之间进行比较时,只要比标准图案上点的值大就打白点,这种做法并不理想,因为,如果当标准图案点的灰度值本身就很小,而图象中点的灰度只比它大一点儿时,图象中的点更接近黑色,而不是白色。一种更好的方法是将这个误差传播到邻近的像素。下面介绍的Floyd-Steinberg算法就采用了这种方案。

假设灰度级别的范围从b(black)到w(white),中间值t为(b + w) /2,对应256级灰度,b = 0, w = 255, t = 127.5;设原图中像素的灰度为g,误差值为e,则新图中对应像素的值用如下的方法得到:

1
2
3
4
5
6
7
8
9
if g > t then
    打白点
    e = g - w
else
    打黑点
    e = g - b
    3 / 8 * e 加到右边的像素
    3 / 8 * e 加到下边的像素
    1 / 4 * e 加到右下方的像素

算法的意思很明白,以256级灰度为例,假设一个点的灰度为130,在灰度图中应该是一个灰点。由于一般图象中灰度是连续变化的,相邻像素的灰度值很可能与本像素非常接近,所以该点及周围应该是一片灰色区域。在新图中,130大于128,所以打了白点,但130离真正的白点255还差的比较远,误差e = 130 - 255 = -125 比较大。将3 / 8 * (-125)加到相邻像素后,使得相邻像素的值接近0而打黑点。下一次,e又变成正的,使得相邻像素的相邻像素打白点,这样一白一黑一白,表现出来刚好就是灰色。如果不传递误差,就是一片白色了。再举个例子,如果一个点的灰度为250,在灰度图中应该是一个白点,该点及周围应该是一片白色区域。在新图中,虽然e = -5也是负的,但其值很小,对相邻像素的影响不大,所以还是能够打出一片白色区域来。这样就验证了算法的正确性。其它的情况可以自己推敲。图7是利用Floyd-Steinberg算法抖动生成的图

halftone-l407 图7. 利用Floyd-Steinberg算法抖动生成的图

Floyd-Steinberg算法的源代码

bmp2txt(bmp to txt)

在讲图案化技术时,我突然想到了一个非常有趣的应用,那就是bmp2txt。如果你喜欢上BBS(电子公告牌系统),你可能想做一个花哨的签名档。瞧,这是我好朋友Casper的签名档,胖乎乎的,是不是特别可爱?

halftone-l408 图8. Casper的签名档

你仔细观察一下,就会发现,这是一幅全部由字符组成的图,因为在BBS中只能出现文字的东西。那么,这幅图是怎么做出来的呢?难道是自己一个字符一个字符拼出来的。当然不是了,有一种叫bmp2txt的应用程序(2的发音和“to”一样,所以如此命名),能把位图文件转换成和图案很相似的字符文本。是不是觉得很神奇?其实原理很简单,用到了和图案化技术类似的思想:首先将位图分成同样大小的小块,求出每一块灰度的平均值,然后和每个字符的灰度做比较,找出最接近的那个字符,来代表这一小块图象。那么,怎么确定字符的灰度呢?做下面的实验就明白了。

打开Notepad,输入字符“1”,选定该字符,使其反色。按Alt+PrintScreen键拷贝窗口屏幕。打开Paintbrush,粘贴,然后把图放到最大(*8),打开“查看” -> “缩放” -> “显示网格”菜单,如下图所示:

halftone-l409 图9. 字符“1”的灰度

这时数数字符“1”用了几个点?是22个。我想你已经明白了,字符的灰度和它所占的黑色点数有关,点越少,灰度值越大,空格字符的灰度最大,为全白,因为它一个黑点也没有;而字符“W”的灰度值就比较低了。每个字符的面积是8 * 16(宽 * 高),所以一个字符的灰度值可以用如下的公式计算(1 - 所占的黑点数 / (8 * 16)) * 255。下面是可显示的字符,及对应的灰度,共有95个。这可是casper辛辛苦苦整理出来的呦!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static char ch[95] = {
    ' ',
    '`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', '\\',
    'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']',
    'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', ''',
    'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/',
    '~', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '|',
    'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}',
    'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"',
    'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?'
};

static int gr[95] = {
    0,
    7, 22, 28, 31, 31, 27, 32, 22, 38, 32, 40, 6, 12, 20, 38, 32, 26, 20, 24, 40,
    29, 24, 28, 38, 32, 32, 26, 22, 34, 24, 44, 33, 32, 32, 24, 16, 6, 22, 26, 22,
    26, 34, 29, 35, 10, 6, 20, 14, 22, 47, 42, 34, 40, 10, 35, 21, 22, 22, 16, 14,
    26, 40, 39, 29, 38, 22, 28, 36, 22, 36, 30, 22, 22, 36, 26, 36, 25, 34, 38, 24,
    36, 22, 12, 12, 26, 30, 30, 34, 39, 42, 41, 18, 18, 22
};

bmp2txt源程序

其实利用图案化技术,还可以实现更有趣的应用,如下面这幅图,你仔细看看,贝多芬的头像是由许多个音乐符号组成的。

halftone-l410 图10. 贝多芬的头像

世界上最好的推销员的故事

一个乡下来的小伙子去应聘城里“世界最大”的“应有尽有”百货公司的销售员。老板问他:“你以前做过销售员吗?”他回答说:“我以前是村里挨家挨户推销的小贩子。”老板喜欢他的机灵:“你明天可以来上班了。等下班的时候,我会来看一下。”

一天的光阴对这个乡下来的穷小子来说太长了,而且还有些难熬。但是年轻人还是熬到了5点,差不多该下班了。老板真的来了,问他说:“你今天做了几单买卖”

“一单。”年轻人回答说。

“只有一单?”老板很吃惊地说:“我们这儿的售货员一天基本上可以完成20到30单生意呢。你卖了多少钱?”

“300,000美元,”年轻人回答道。

“你怎么卖到那么多钱的?”目瞪口呆,半晌才回过神来的老板问道。

“是这样的,”乡下来的年轻人说,“一个男士进来买东西,我先卖给他一个小号的鱼钩,然后中号的鱼钩,最后大号的鱼钩。接着,我卖给他小号的鱼线,中号的鱼线,最后是大号的鱼线。我问他上哪儿钓鱼,他说海边。我建议他买条船,所以我带他到卖船的专柜,卖给他长20英尺有两个发动机的纵帆船。然后他说他的大众牌汽车可能拖不动这么大的船。我于是带他去汽车销售区,卖给他一辆丰田新款豪华型‘巡洋舰’。”

老板后退两步,几乎难以置信地问道:“一个顾客仅仅来买个鱼钩,你就能卖给他这么多东西?”

“不是的,”乡下来的年轻售货员回答道,“他是来给他妻子买卫生棉的。我就告诉他‘你的周末算是毁了,干吗不去钓鱼呢?’”

PowerScript标识符

在使用减号(-)、负号(-)、减1(--)运算符时,必须在这些符号的前面加上空格,原因在于PowerScript会把这些符号当成标识符的一部分。

PowerScript标识符规则:

  1. 必须以字母或下划线(_)开头;
  2. 其余字符可以是字母、数字、下划线(_)、美元符号($)、短横线(-)、号码符号(#),百分号(%);
  3. 最长40个字符(实际最大长度可达99个),中间不能有空格;
  4. 不区分大小写。

在PB.INI的[pb]节中将DashesInIdentifiers=1修改为DashesInIdentifiers=0可以禁止在标志符中使用短横线(-)。

猎狗与猎人的故事

目标

一条猎狗将兔子赶出了窝,一直追赶他,追了很久仍没有捉到。牧羊看到此种情景,讥笑猎狗说,“你们两个当中小的反而跑得快得多。”猎狗回答说:“你不知道我们两个的跑是完全不同的!我仅仅为了一顿饭而跑,他却是为了性命而跑呀!”

动力

这话被猎人听到了,猎人想:猎狗说的对啊,那我要想得到更多的猎物,得想个好法子。于是,猎人又买来几条猎狗,凡是能够在打猎中捉到兔子的,就可以得到几根骨头,捉不到的就没有饭吃。这一招果然有用,猎狗们纷纷去努力追兔子,因为谁都不愿意看着别人有骨头吃,自己没吃的。就这样过了一段时间,问题又出现了。大兔子非常难捉到,小兔子好捉。但捉到大兔子得到的奖赏和捉到小兔子得到的骨头差不多,猎狗们善于观察,发现了这个窍门,专门去捉小兔子。慢慢地,大家都发现了这个窍门。猎人对猎狗说:最近你们捉的兔子越来越小了,为什么?猎狗们说:反正结果没有什么大的区别,为什么费那么大的劲去捉那些大的呢?

保障

猎人经过思考后,决定不将分得骨头的数量与是否捉到兔子挂钩,而是每过一段时间就统计一次猎狗捉到兔子的总重量,按照重量来评价猎狗,从而决定它们一段时间内的待遇。于是,猎狗们捉到兔子的数量和重量都增加了。猎人很开心。但是,过了一段时间,猎人发现猎狗们捉兔子的数量又少了,而且越有经验的猎狗捉兔子的数量下降得越利害。于是,猎人又去问猎狗是怎么回事。猎狗说,“我们把最好的时间都奉献给了您,主人,随着时间的推移我们会慢慢变老,当我们捉不到兔子的时候,您还会给我们骨头吃吗?”

回报

猎人做出了论功行赏的决定,分析与汇总了所有猎狗捉到兔子的数量与重量,规定如果捉到的兔子超过一定的数量后,即使捉不到兔子,每顿饭也可以得到一定数量的骨头。猎狗们都很高兴,大家都努力去争取达到猎人规定的数量。一段时间过后,终于有一些猎狗达到了猎人规定的数量。这时,其中一只猎狗说:“我们这么努力,只得到几根骨头,而我们捉的猎物远远超过了这几根骨头。我们为什么不能给自己捉兔子呢?”于是,有些猎狗离开了猎人,自己捉兔子去了。

归宿

猎人意识到猎狗正在流失,并且那些流失的猎狗像野狗一般与自己的猎狗抢兔子。情况变得越来越糟,猎人不得已引诱了一条野狗,问他到底野狗比猎狗强在那里。野狗说:“猎狗吃的是骨头,吐出来的是肉啊!”接着又道:“也不是所有的野狗都顿顿有肉吃,大部分最后骨头都没的舔!不然也不至于被你诱惑。”于是,猎人又进行了改革,使得每条猎狗除基本骨头外,可获得其所猎兔肉总量的n%,而且随着服务时间加长,贡献加大,该比例还可递增,并有权分享猎人所拥有兔肉总量的m%。就这样,猎狗们与猎人一起努力,将野狗们逼得叫苦连天,纷纷要求重归猎狗队伍。

故事还在继续……

只有永远的利益,没有永远的朋友

日子一天一天地过去,冬天到了,兔子越来越少,猎人们的收成也一天不如一天。那些服务时间长的老猎狗们已老得捉不到兔子,但却仍然无忧无虑地享受着他们自以为应得的大份食物。终于有一天,猎人再也不能忍受,把他们扫地出门了,因为猎人更需要身强力壮的猎狗……

Birth of MicroBone Co.

被扫地出门的老猎狗们得到一笔不菲的赔偿金,于是,他们成立了MicroBone公司。他们采用连锁加盟的方式招募野狗,向野狗们传授猎兔的技巧,他们从猎得的兔子中抽取一部分作为管理费。当赔偿金几乎全部用于广告后,他们终于有了足够多的野狗加盟,公司开始盈利了。一年后,他们收购了猎人的家当……

Development of MicroBone Co.

MicroBone公司许诺,加盟的野狗可以得到公司n%的股份。这实在是太有诱惑力了。那些自认为怀才不遇的野狗们都以为找到了知音:终于可以做公司的主人了,不用再忍受猎人呼来唤去的不快,不用再为捉到足够多的兔子而累死累活,也不用眼巴巴地乞求猎人多给两根骨头。这一切对野狗们来说,比多得到两根骨头更加受用。于是,野狗们拖家带口地加入了MicroBone。一些在猎人门下服务的年轻猎狗也蠢蠢欲动,甚至很多自以为聪明、实际上愚蠢的猎人也想加入。好多同类型的公司雨后春笋般地成立了,BoneEase、Bone.com、ChinaBone……一时间,森林里热闹非凡。

干活的总是拿得少的,拿得多的都是不干活的

猎人凭借出售公司的钱走上了老猎狗走过的路,历经千辛万苦重新发达起来,最后又来与MicroBone公司谈判收购事宜,老猎狗出人意料地把MicroBone公司卖给了猎人。老猎狗们从此不再经营公司,转而开始靠写作赚钱,写完自传《老猎狗的一生》,又写了《如何成为出色的猎狗》、《成功猎狗500条法则》、《猎狗成功秘诀》、《如何成为一只进入管理层的猎狗》、《穷猎狗,富猎狗》等一系列成功自助读物,并且将老猎狗的故事搬上了屏幕,取名《猎狗花园》——从此,老猎狗成为家喻户晓的名人,坐收版权费,既没有风险,利润又可观,轻轻松松地过上了富人闲人的日子。

无我编程的十诫

英文原文:https://blog.codinghorror.com/the-ten-commandments-of-egoless-programming/

无我编程的十诫最早出现在杰拉尔德·温伯格(Gerald M. Weinberg)的《计算机编程心理学》这本书中:

  • 理解并接受自己会犯错误。关键是在错误进入到生产环境之前尽早地找到它们。幸运的是,除了我们几个在JPL开发的火箭制导软件外,我们行业中的错误很少是灾难性的,所以我们可以而且应该,学习、大笑、然后继续前行。
  • 你不是你的代码。记住,代码审查的整个出发点是找到问题,而且终归会找到问题。所以当问题被发现时不要太在意。
  • 不管你知道多少“karate”,总有人比你知道得更多。如果你问的话,这样的人可以教你一些新的动作。寻求并接受他人的意见,尤其是当你认为不需要的时候。
  • 不要没有商讨就重写代码。 “修复代码”和“重写代码”之间有着明显的区别。弄清两者的区别,并在代码审查的框架内追求风格变化,而不是孤独的执行者。
  • 尊重比你懂得少的人,并抱以耐心。与开发者交涉的非技术人员几乎普遍认为我们是妄自尊大的讨厌鬼。不要用愤怒和不耐烦来加深这种刻板印象。
  • 世界上唯一不变的就是变化。敞开胸怀,面带微笑地去接受变化。将需求、平台或工具的每个变化视为新的挑战,而不是一些严重地需要抗争的不便。
  • 真正的权威源自知识,而不是职位。知识产生权威,权威就会产生尊重——所以如果你想在无我的环境中得到尊重,请充实知识。
  • 为信念而战,但优雅地接受失败。明白有时你的想法将被推翻。即使你的确是正确的,也不要报复或者嚷嚷说“我早就说过”,不要把被否定的想法当作是牺牲品或者口号。
  • 不要成为“宅男”。不要成为在黑暗的办公室里编码的家伙,就算偶尔露个面,也只是为了买杯可乐。“宅男”会与其他人失去联系,淡出他们的视野,失去控制,在开放协作的环境中将没有任何位置。
  • 批评代码而不是人——对程序员好点,而不是代码。尽可能地使你的所有评论都是积极的,并且旨在改进代码。将评论与局部标准、程式规格、性能提升等相关联。

软件的人性化原则是永恒的。此书早在1971年我1岁时就写成。虽然已经过去几十年,但这些原则并没有过时,仍然值得所有程序员拜读。

Ajax:Web应用的一种新方法

英文原文:http://adaptivepath.org/ideas/ajax-new-approach-web-applications/

如果有任何关于当前的交互设计可以被称为“迷人”的话,那就是创建Web应用。毕竟,你最后一次听到有人吹嘘不在Web上的产品的交互设计是什么时候?(好吧,除了iPod。)一切都很酷,富有创意的新设计都是在线的。

尽管如此,Web交互设计师仍不能不对创建桌面软件的同事们感到一丝羡慕。桌面应用有着在Web上似乎遥不可及的丰富和响应能力。让Web迅速扩散的同样简单也造成了在我们可以提供的体验和用户能够从桌面应用得到的体验之间差距。

这一差距正在缩小。看一看Google Suggest。观察随着你输入建议项更新的方式,几乎立即。现在看看Google Maps。放大。使用鼠标抓取地图并稍稍滚动。再次滚动,一切几乎立即发生,无需等待页面重新加载。

Google Suggest和Google Maps是Web应用的一种新方法的两个例子,该方法在Adaptive Path被我们称为Ajax。这个名字是异步JavaScript + XML的简写,它代表了Web上可能的根本性转变。

定义Ajax

Ajax不是一种技术。它事实上是几种技术,每种技术都在它自身正确的地方蓬勃发展,以强大的新方式聚合在一起。Ajax包括:

经典的Web应用模型是这么工作的:在界面上的大多数用户操作触发一个HTTP请求回Web服务器。服务器做一些处理——检索数据,精密计算,与各种遗留系统对话——然后返回一个HTML页面到客户端。它是改编自Web作为超文本媒介的原定用途的一个模型,但是如同用户体验的要素的爱好者所知,该模型让Web很好地适合超文本不一定能让它很好地适合软件应用。

ajax-fig1

图1:Web应用的传统模型(左)与Ajax模型(右)的比较。

这种方法很有技术上的感觉,但它无助于很好的用户体验。当服务器在做它的事情的时候,用户在做什么?没错,等待。在任务中的每一步,用户都在等待。

显然,如果我们从头开始为应用设计Web,我们不会让用户呆呆地等待。一旦界面被加载后,为什么每次应用需要从服务器获得什么时用户交互就将陷入停顿?事实上,为什么用户应该看到所有进入服务器的应用?

Ajax有何不同

Ajax应用消除了在Web上交互的开始-停止-开始-停止的本质,通过引入中介——Ajax引擎——在用户和服务器之间。它看起来像是给应用添加一个层而让应用更少地作出响应,但事实恰好相反。

在会话开始而不是加载页面的时候,浏览器加载Ajax引擎——用JavaScript编写并且通常藏在一个隐藏的Frame中。在用户方面引擎同时负责渲染用户看到的界面和与服务器通讯。Ajax引擎允许用户和应用的交互异步发生——独立于与服务器的通讯。因此用户永远不会开始于一个空白浏览器窗口和一个沙漏图标,呆呆地等待服务器做某些事情。

ajax-fig2

图2:传统Web应用的同步交互模式(上)与Ajax应用的异步模式(下)的比较

通常会生成一个HTTP请求的每个用户操作采取JavaScript调用Ajax引擎的形式替代。任何对用户操作的响应不需要回到服务器——例如简单的数据验证,编辑内存中的数据,甚至一些导航——引擎自己处理。如果为了响应引擎需要从服务器获得什么——如果这是提交数据以进行处理,加载额外的界面代码,或者检索新的数据——引擎异步发出这些请求,通常使用XML,没有拖延用户和应用的交互。

谁在使用Ajax

Google在开发Ajax方法上做了巨大的投资。在过去一年里Google推出的所有主要产品——OrkutGmailGoogle Groups的最新beta版本、Google Suggest,以及Google Maps——都是Ajax应用。(想了解更多这些Ajax实现的技术细节,可以查看GmailGoogle Suggest,和Google Maps的这些优秀分析)别人也在模仿:Flickr中人们喜欢的许多特性依靠Ajax,而Amazon的A9.com搜索引擎已经运用了类似的技术。

这些项目表明Ajax并不只是技术层面的论调,而且对实际应用也是实用的。这不是另一种只在实验室工作的技术。Ajax应用可以是任意大小,从非常简单、功能单一的Google Suggest到非常复杂和精密的Google Maps。

在Adaptive Path,在过去的几个月中我们已经使用Ajax完成了我们自己的工作,并且我们认识到我们仅仅触及到Ajax应用可以提供的丰富交互和响应能力的一点皮毛。Ajax是Web应用的一个重要的发展,并且它的重要性只会继续增长。因为外面有那么多的开发者已经知道如何使用这些技术,我们期望看到更多的组织继Google之后率先获得Ajax提供的竞争优势。

继续前行

创建Ajax应用最大的挑战不是技术。核心Ajax技术是成熟的、稳定的、并且容易理解的。相反,挑战来自于这些应用的设计者:忘记我们所认为我们知道的关于Web的限制,并且开始想象一个更广泛、更丰富的可能性的范围。

那一定会很有趣。

Ajax Q&A

2005年3月13日: 自我们第一次发布Jesse的论文以来,我们收到了大量读者关于Ajax问题的信件。在这个Q&A中,Jesse回答了一些最常见的问题。

Q. 是Adaptive Path发明的Ajax吗?还是Google?是不是Adaptive Path帮助构建了Google的Ajax应用?

A. 既不是Adaptive Path也不是Google发明的Ajax。Google近期的产品仅仅是级别最高的Ajax应用例子。Adaptive Path没有参与Google的Ajax应用的开发,但我们一直在为我们其它的一些客户做Ajax的工作。

Q. Adaptive Path正在出售Ajax组件或注册这个名字的商标吗?我从哪里可以下载它?

A. Ajax不是你能下载的东西。它是一种方法——思考使用某种技术的Web应用架构的一种方式。Ajax这个名字和方法均不是Adaptive Path专有的。

Q. Ajax仅仅是XMLHttpRequest的另一个名字吗?

A. 不是。XMLHttpRequest只是Ajax方程式的一部分。XMLHttpRequest是使异步服务器通讯成为可能的技术组件;Ajax是我们给文章中描述的整体方法的名字,它不仅依赖于XMLHttpRequest,还依赖于CSS、DOM和其它技术。

Q. 为什么你觉得有必要给出这个名字?

A. 当和客户讨论这个方法时,我需要使用比“异步JavaScript + CSS + DOM + XMLHttpRequest”更短的东西。

Q. 异步服务器通讯技术已经存在好多年了。是什么让Ajax成为一种“新”方法的?

A. 新情况是这些技术在实际应用中的突出使用改变了Web的基本交互模型。Ajax现在大行其道是因为这些技术和行业对如何更有效地部署它们的理解已经经过了时间的发展。

Q. Ajax是一个技术平台或一种架构风格吗?

A. 都是。Ajax是以某种特殊方式同时使用的一组技术。

Q. Ajax最适合什么类型的应用?

A. 目前我们还不知道。因为它是一种相对较新的方法,我们对Ajax最适用在哪些地方的理解仍处于初级阶段。有时候传统的Web应用模型是某个问题的最合适的解决方案。

Q. 这意味着Adaptive Path是反Flash的吗?

A. 一点也不。Macromedia是Adaptive Path的一个客户,而且我们是Flash技术长期以来的支持者。随着Ajax的成熟,我们希望有时候Ajax是特定问题的最好的解决方案,而有时候Flash 是最好的解决方案。我们也有兴趣探索技术可以混合的方式(正如Flickr的案例,它使用了两者)。

Q. Ajax有显著的可访问性或浏览器兼容性限制吗?Ajax应用会破坏后退按钮吗?Ajax兼容REST吗?Ajax开发时是否有安全考虑?Ajax应用可以为那些已经关闭JavaScript的用户工作吗?

A. 所有这些问题的答案是“可能”。许多开发者已经在致力于解决这些问题。要确定Ajax所有的局限性,我们认为有更多的工作要做,并且我们期望Ajax开发社区一起来发现更多这类问题。

Q. 一些你举出的Google例子根本没有使用XML。我必须在Ajax应用中使用XML和/或XSLT吗?

A. 不。XML是最全面发展的在Ajax客户端内外获取数据的手段,但没有理由使用JavaScript Object Notation之类的技术或任何类似的结构化数据的手段为交换不能达到同样的效果。

Q. Ajax应用比传统Web应用更易于开发吗?

A. 不一定。Ajax应用必然涉及到在客户端运行复杂的JavaScript代码。让复杂的代码高效并且无缺陷不是一个掉以轻心的任务,需要更好的开发工具和框架来帮助我们面对挑战。

Q. Ajax应用经常交付比传统Web应用更好的体验吗?

A. 不一定。Ajax给交互设计师更多的灵活性。然而,我们拥有的能力越大,在运用它的时候我们必须使用的更谨慎。我们必须小心地使用Ajax来增强我们应用的用户体验,而不是削弱它。

这篇文章被来自Webhostinggeeks.com的Jovana Milutinovich翻译成了Serbo-Croatian语言。

在iBatis中如何将JavaBean的boolean值映射成数据库的CHAR(Y或N)值

Spring 1.2.8 + iBatis 2.1.5

JavaBean的boolean值映射到数据库的默认值是0和1,不是非常直观,现在希望能将boolean值映射成为Y和N。iBatis 2.0.5以后的版本提供了TypeHandlerCallback,可以用来解决这个问题。

首先定义数据表:

1
2
3
4
5
6
CREATE TABLE users (
    username varchar(40),
    password varchar(255),
    enabled char(1),
    PRIMARY KEY (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf-8;

YesNoTypeHandlerCallback实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class YesNoTypeHandlerCallback implements TypeHandlerCallback {

    public void setParameter(ParameterSetter setter, Object parameter) throws SQLException {
        if (parameter == null) {
            setter.setString("");
        } else {
            Boolean b = (Boolean)parameter;
            if (b.booleanValue()) {
                setter.setString("Y");
            } else {
                setter.setString("N");
            }
        }
    }

    public Object getResult(ResultGetter getter) throws SQLException {
        String string = getter.getString();
        if (string == null) {
            return null;
        } else if ("Y".equalsIgnoreCase(string)) {
            return new Boolean(true);
        } else {
            return new Boolean(false);
        }
    }

    public Object valueOf(String s) {
        return s;
    }
}

配置文件sql-map-config.xml中的内容如下:

1
2
3
4
5
6
<sqlMapConfig>
    <typeHandler jdbcType="CHAR" javaType="boolean"
                 callback="com.codemany.netlink.dao.support.YesNoTypeHandlerCallback" />

    <sqlMap resource="com/codemany/netlink/dao/impl/User.xml" />
</sqlMapConfig>

jdbcType="CHAR" javaType="boolean"必须有,否则YesNoTypeHandlerCallback.setParameter()不会被调用。

SqlMap映射文件User.xml部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<typeAlias alias="user" type="com.codemany.netlink.model.User" />

<resultMap id="userResult" class="user">
    <result property="username" column="username" />
    <result property="password" column="password" />
    <result property="enabled" column="enabled" jdbcType="CHAR" javaType="boolean" />
</resultMap>

<select id="getUser" parameterClass="string" resultMap="userResult">
<![CDATA[
    SELECT username, password, enabled
    FROM user WHERE username = #username#
]]>
</select>

<insert id="addUser" parameterClass="user">
<![CDATA[
    INSERT INTO user (username, password, enabled)
    VALUES (#username#, #password#, #enabled,jdbcType=CHAR,javaType=boolean#)
]]>
</insert>

jdbcType="CHAR" javaType="boolean"必须有,否则YesNoTypeHandlerCallback.getResult()不会被调用;#enabled,jdbcType=CHAR,javaType=boolean#也必须这样写,否则YesNoTypeHandlerCallback.setParameter()也不会被调用,写成#enabled:CHAR#或#enabled#都不起作用。

DbUnit结合Spring进行SqlMap的单元测试

使用DbUnit,开发者可以控制测试数据库的状态。进行一个DAO单元测试之前,DbUnit为数据库准备好初始化数据;而在测试结束时,DbUnit会把数据库状态恢复到测试前的状态。下面的例子使用DbUnit为SqlMap编写单元测试。

SqlMap映射文件User.xml的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<select id="getUser" parameterClass="String" resultClass="User">
    SELECT username, password, first_name, last_name
    FROM user WHERE username = #username#
</select>

<delete id="removeUser" parameterClass="String">
    DELETE FROM user WHERE username = #username#
</delete>

<update id="updateUser" parameterClass="User">
    UPDATE user SET
        password = #password#,
        first_name = #firstName#,
        last_name = #lastName#
    WHERE username =#username#
</update>

<insert id="insertUser" parameterClass="User">
    INSERT INTO user (username, password, first_name, last_name)
    VALUES (#username#, #password#, #firstName#, #lastName#)
</insert>

配置文件sql-map-config.xml中的内容如下:

1
2
3
4
5
6
7
8
9
10
<sqlMapConfig>
    <settings lazyLoadingEnabled="true"
              cacheModelsEnabled="true"
              enhancementEnabled="true"
              maxSessions="64"
              maxTransactions="8"
              maxRequests="128" />

    <sqlMap resource="com/codemany/netlink/dao/impl/User.xml" />
</sqlMapConfig>

首先,要为单元测试准备测试数据。我们可以用DbUnit的Flat XML格式来准备测试数据集。下面的XML文件称为目标数据库的Seed File,它为测试准备了两条数据。其中元素名user对应数据库的表名,属性username,password,first_name和last_name是表user的列名,属性值就是记录值。

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<dataset>
    <user username="ford"
        password="ford"
        first_name="Henry"
        last_name="Ford" />

    <user username="twain"
        password="twain"
        first_name="Mark"
        last_name="twain" />
</dataset>

缺省情况下,DbUnit在单元测试开始之前执行CLEAN_INSERT操作,删除Seed File中所有表的数据,并导入Seed File的测试数据。我们可以通过覆盖getSetUpOperation()和getTearDownOperation()方法来控制单元测试前和测试后的数据库状态。一种高效的实施方案就是让getSetUpOperation()方法执行REFRESH操作,通过执行这个操作,我们可以用Seed File中的数据去更新目标数据库里的数据。接下来,就是getTearDownOperation(),让他执行一个NONE操作,也就是什么也不执行。

1
2
3
4
5
6
7
protected DatabaseOperation getSetUpOperation() throws Exception {
    return DatabaseOperation.REFRESH;
}

protected DatabaseOperation getTearDownOperation() throws Exception {
    return DatabaseOperation.NONE;
}

为了方便测试,我们为SqlMap的单元测试编写一个抽象的测试基类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public abstract class SqlMapTestCase extends DatabaseTestCase {
    protected SqlMapClient sqlMapClient = null;
    protected Properties props = new Properties();

    protected IDatabaseConnection getConnection() throws Exception {
        props.load(Resources.getResourceAsStream("properties/database.properties"));

        Class.forName(props.getProperty("driver"));
        Connection conn = DriverManager.getConnection(props.getProperty("url"),
                props.getProperty("username"), props.getProperty("password"));
        return new DatabaseConnection(conn);
    }

    protected IDataSet getDataSet() throws Exception {
        String resource = "com/codemany/netlink/dao/impl/dataset.xml";
        return new FlatXmlDataSet(Resources.getResourceAsStream(resource));
    }

    protected void setUp() throws Exception {
        super.setUp();

        // Build the SqlMapClient
        Reader reader = Resources.getResourceAsReader("sql-map-config.xml");
        sqlMapClient = SqlMapClientBuilder.buildSqlMapClient(reader);

        // Tell the SqlMapClient to use the given DataSource
        DataSource dataSource = getDataSource();
        TransactionConfig transactionConfig = getTransactionConfig(dataSource);
        // Apply the given TransactionConfig to the SqlMapClient
        applyTransactionConfig(sqlMapClient, transactionConfig);
    }

    protected void tearDown() throws Exception {
        super.tearDown();

        if (sqlMapClient != null) {
            DataSource ds = sqlMapClient.getDataSource();
            Connection conn = ds.getConnection();
            conn.close();
        }
    }

    private DataSource getDataSource() throws Exception {
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setDriverClassName(props.getProperty("driver"));
        dataSource.setUrl(props.getProperty("url"));
        dataSource.setUsername(props.getProperty("username"));
        dataSource.setPassword(props.getProperty("password"));
        return dataSource;
    }

    private TransactionConfig getTransactionConfig(DataSource dataSource) throws Exception {
        Properties transactionConfigProperties = new Properties();
        transactionConfigProperties.setProperty("SetAutoCommitAllowed", "false");

        TransactionConfig transactionConfig = (TransactionConfig)ExternalTransactionConfig.class.newInstance();
        transactionConfig.setDataSource(dataSource);
        transactionConfig.initialize(transactionConfigProperties);
        return transactionConfig;
    }

    private void applyTransactionConfig(SqlMapClient sqlMapClient, TransactionConfig transactionConfig) {
        if (!(sqlMapClient instanceof ExtendedSqlMapClient)) {
            throw new IllegalArgumentException("Cannot set TransactionConfig with DataSource"
                    + "for SqlMapClient if not of type ExtendedSqlMapClient: " + sqlMapClient);
        }
        ExtendedSqlMapClient extendedClient = (ExtendedSqlMapClient)sqlMapClient;
        transactionConfig.setMaximumConcurrentTransactions(extendedClient.getDelegate().getMaxTransactions());
        extendedClient.getDelegate().setTxManager(new TransactionManager(transactionConfig));
    }
}

然后为每个SqlMap映射文件编写一个测试类,继承上面的抽象类:

1
2
3
4
5
6
7
8
public class UserSqlMapTest extends SqlMapTestCase {

    public void testGetUser() throws Exception {
        User user = (User)sqlMapClient.queryForObject("getUser", "ford");
        assertNotNull(user);
        assertEquals("ford", user.getName());
    }
}

如此就可以进行单元测试了。

如何在WebWork的资源文件中获取验证字段的值

WebWork是一个非常不错的MVC框架,有着众多的优点,也有着细微的缺陷。在实现一个用户注册验证的例子时,发现在资源文件中不能获取验证字段的值,只能每个字段一个message。

1
2
3
class User {
    private String username;
    private String password;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<field name="username">
    <field-validator type="stringlength" short-circuit="true">
        <param name="minLength">5</param>
        <param name="maxLength">15</param>
        <message key="errors.username.range" />
    </field-validator>
</field>

<field name="password">
    <field-validator type="stringlength" short-circuit="true">
        <param name="minLength">5</param>
        <param name="maxLength">15</param>
        <message key="errors.password.range" />
    </field-validator>
</field>

资源文件中的消息内容:

1
2
errors.username.range=${username} is not in the range ${minLength} through ${maxLength}.
errors.password.range=${password} is not in the range ${minLength} through ${maxLength}.

到xwork的站点上去查找解决方法,在Issue Tracker里看到有人提交了一个Feature,采用的办法是给message传递参数,看了xwork的代码后,发现这样子的做法要修改的代码量非常大,所以只能另外想办法解决。

在资源文件中可以用${fieldName}来获取要验证的字段变量名,那是否可以用${fieldValue}来得到验证字段的值呢?查看代码后发现这种办法行不通。那么是否可以用${${fieldName}}来获得字段变量的值呢?查看代码发现验证失败后会调用Validator.getMessage()方法,追踪代码来到TextParseUtil.translateVariables()方法中,找到代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while (start != -1 && x < length && count != 0) {    // 该部分代码负责查找变量,即${}包围的字符串
    c = expression.charAt(x++);
    if (c == '{') {
        count++;
    } else if (c == '}') {
        count--;
    }
}
end = x - 1;

if ((start != -1) && (end != -1) && (count == 0)) {
    String var = expression.substring(start + 2, end);

    Object o = stack.findValue(var, asType);    // 此处就是获取变量值的地方

    String left = expression.substring(0, start);
    String right = expression.substring(end + 1);

由上面的代码可以看出WebWork只处理了最外围的${},所以要让它处理像${${}}这样的格式就需要在获取变量前再次处理变量。修改后的代码如下:

1
2
3
4
5
6
7
8
9
if ((start != -1) && (end != -1) && (count == 0)) {
    String nestedExpression = expression.substring(start + 2, end);
    String var = translateVariables(nestedExpression, stack);
    //String var = expression.substring(start + 2, end);

    Object o = stack.findValue(var, asType);

    String left = expression.substring(0, start);
    String right = expression.substring(end + 1);

现在就可以处理资源文件中的多重嵌套变量了。接着把修改验证文件和资源文件修改一下就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<field name="username">
    <field-validator type="stringlength" short-circuit="true">
        <param name="minLength">5</param>
        <param name="maxLength">15</param>
        <message key="errors.range" />
    </field-validator>
</field>

<field name="password">
    <field-validator type="stringlength" short-circuit="true">
        <param name="minLength">5</param>
        <param name="maxLength">15</param>
        <message key="errors.range" />
    </field-validator>
</field>
1
errors.range=${${fieldName}} is not in the range ${minLength} through ${maxLength}.

如何去除WinRAR 3.30的注册提示框

WinRAR在使用到期后,每次打开都会弹出如下图所示的对话框窗口,十分烦人。

winrar-nag

去除Nag窗口常用的几种方法是:

  1. 使用资源修改工具将可执行文件中的Nag窗口的属性改成透明、不可见,可以变相的去除Nag窗口;
  2. 找到创建和显示Nag窗口的代码,跳过即可。显示窗口的常用函数有MessageBox、MessageBoxEx、DialogBoxParam、CreateWindowEx、CreateWindowEx、ShowWindow等;
  3. 找到创建和显示Nag窗口的函数,更改其参数,让其调用失败就可以了;
  4. 在Nag窗口弹出来后,给可以关闭它的BUTTON 送一个WM_COMMAND消息;
  5. 通过静态和动态分析找到注册码。

由于WinRAR未注册版本没有功能限制,用不着去找注册码注册。所以在这里可以用2和3两种方法去掉WinRAR的注册提示窗口。

使用Resource Hacker打开WinRAR.exe,显示Nag窗口的资源如图所示:

winrar-reminder

Nag窗口的ID号是1049,换算成16进制是0x419。用W32Dasm打开WinRAR.exe,打开Dialog References查找Dialog: DialogID_0419,结果没找到。由上图可知Nag窗口的资源名是REMINDER,打开String Data References查找REMINDER字符串试试,果然找到了,双击该项就可以来到相关代码处。

winrar-w32dasm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
* Referenced by a (U)nconditional or (C)onditional Jump at Address:
|:0043FE89(C)
|
:0043FE8F C6055037490001          mov byte ptr [00493750], 01
:0043FE96 6A00                    push 00000000
:0043FE98 68143F4400              push 00443F14
:0043FE9D FF3588EC4A00            push dword ptr [004AEC88]

* Possible StringData Ref from Data Obj ->"REMINDER"
|
:0043FEA3 68C93C4900              push 00493CC9
:0043FEA8 FF3560D04900            push dword ptr [0049D060]

* Reference To: USER32.DialogBoxParamA, Ord:0000h
|
:0043FEAE E837E30400              Call 0048E1EA

* Referenced by a (U)nconditional or (C)onditional Jump at Addresses:
|:0043FE64(C), :0043FE6D(C), :0043FE76(C), :0043FE7F(C), :0043FE8D(C)
|
:0043FEB3 833DF8104B0000          cmp dword ptr [004B10F8], 00000000
:0043FEBA 752D                    jne 0043FEE9
:0043FEBC 833DF0104B0000          cmp dword ptr [004B10F0], 00000000
:0043FEC3 7524                    jne 0043FEE9
:0043FEC5 833D08114B00FF          cmp dword ptr [004B1108], FFFFFFFF
:0043FECC 741B                    je 0043FEE9
:0043FECE 6A0A                    push 0000000A
:0043FED0 FF3508114B00            push dword ptr [004B1108]

可以看到,REMINDER字符串地址是作为参数传给了DialogBoxParamA函数,而DialogBoxParamA函数正是创建对话框的API函数。这时只需将0043FEA3处的push 00493CC9改为push 0,将一个NULL传入DialogBoxParamA函数,这个函数就会调用失败。当然之后它也就不能正常显示了,就达到了去除的目的。

现在再打开WinRAR,呵呵,大功告成,再也不会有Nag窗口出来Nag我了。