欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

《编程机制探析》第二十七章 Flyweight

程序员文章站 2022-05-17 13:44:24
...
《编程机制探析》第二十七章 Flyweight

上一章推介了一种叫做“层次匹配”的页面生成技术。这种技术有诸多优点,但实现起来有一个令人头疼的麻烦之处——模板解析器。凡是涉及到字符串处理的工作,一般都是琐碎乏味的。模板解析器就是如此。
本章讲述一种方案,既可以利用上“层次匹配”的妙处,又可以免除模板解析器的实现。这种方案基于一种叫做“Flyweight Pattern”(轻量级模式)的实际模式,我称之为“Flyweight匹配”方案。在讲述这个方案之前,我们需要对Flyweight Pattern有一个基本的理解。
Flyweight Pattern常见于可视化格式文本编辑器(Rich Text Editor)中。在格式文本编辑器中,一篇文档中可能包含各式各样的文本格式——斜体、黑体、粗体、下划线,等等。这些文本都是一小块一小块,或者一小段一小段的,整篇文档对象由很多小文本对象共同构成。
应对这类需求,有两种实现方案。一种就是“层次结构化文档”方案,比如,HTML就是一种典型的包含了结构信息和格式信息的层次结构化文档。整个文档解析之后,就变成了一个树形文档结构。这种方案的缺点是粒度太小,整个文本被切割成一小块一小块的对象。
另一种方案就是Flyweight。在这种方案中,文档本身并不被切割,仍然保持为一整块大的文本。那些一个个的小小的分块格式信息都分别对应着一个个的小小的对象。这些对象的组织结构有些类似于“层次结构化文档”,也是树形的。不同之处在于,这些小小的对象中,并不存放一小块文档,而是存放着一对位置信息。这对位置信息对应着一整块大文本中的起始位置和结束位置。即,每一个小对象对应着一整块大文本中的某一块文本。类似于这种场景的设计模式就叫做Flyweight Pattern。这种设计模式的主要好处就是保证了一整块大资源的完整性,不需要把整块资源切割成一小块一小块的资源。另一个好处就是文本和结构格式信息的彻底分离,避免了结构格式对文本的污染和侵入。
Flyweight Pattern比较简单,没什么好讲的。本章主要讲解基于Flyweight Pattern之上的“Flyweight匹配”文本生成技术。在讲述的过程中,Flyweight Pattern的一些特点会自然而然地体现。
本章讲述的Flyweight匹配技术是一种通用文本生成技术,包括但不限于HTML页面生成。本章采用的例子也不限于HTML文本,而是通用文本。下面,我们从一段通用文本的例子开始。比如,我们有这么一段文本。
Long long ago, there was living a king…….. blabla
假设那段文本的长度为1000。按照Flyweight Pattern原则,我们定义一套用标志文本位置的数字,组成一个树形结构,把这段文本划分成为如下的树形结构:
1---1000
    1---100
101 --- 500
   101---200
       201---400
401--500
    501---1000

如果开发语言是动态类型解释语言,上述的这个树形数据结构可以用代码中的数据结构直接表示。如果开发语言是静态类型编译语言,上述的这个树形结构数据最好用XML格式来表示。XML虽然不适合用来表示代码逻辑,但用来表示数据结构还是比较合适的。
有了这套数据结构,解析器的工作就很简单了。解析器只需要根据这个结构中的位置信息,从那个长度为1000的整块文本中取出对应的文本块,然后再构造成树形结构的文档就可以了。注意,为了动态文本的输出的便利和方便,得到的这个结果文档就是一个分成一层层、一块块文本的结构,不再遵从Flyweight Pattern。因此,Flyweight匹配技术只有从位置定义的角度来说,是Flyweight Pattern,从最终的内存模型的角度来说,就不是Flyweight Pattern了,而是传统的层次结构化文档。这是因为在一般的文档层次划分中,小文档对象的粒度不会太细,个数不会太多。如果小文档对象的粒度太细,个数太多的话,那么,内存模型方面,也应该使用Flyweight Pattern。从这一点,我们可以窥见Flyweight Pattern的具体应用场景。
为了清晰起见,上述结构中的位置信息用的全都是绝对位置信息。但是,在真正的文本解析过程中,文本是按照递归过程,一层层、一块块向下取的,即,先取出上层的大块文本,再根据下层的位置定义,取出下层的小块文本。所以,位置信息应该采用相对位置,即相对于上层大块文本开头的相对位置。那么,上面的位置信息就应该是这样:
1---1000
    1---100
101 --- 500
   1---100
       101---300
301--400
    501---1000

我们还可以给这些位置定义上加上名字。
top: 1---1000
    a1: 1---100
a2: 101 --- 500
   b1: 1---100
       b2: 101---300
b3: 301--400
    a3: 501---1000

有了这些名字,动态数据模型中的一层层属性名就可以匹配上去,生成最终的文本。
这种方案,我称之为“层次位置”方案。这种方案可以工作,但还是不够好。“层次位置”方案有两个主要问题。
第一个问题是位置信息不够清晰。为了程序处理方便,我们采用了相对位置信息,这就使得本来就不容易写对的位置信息更加容易出错。
第二个问题是解析难度和效率。在“层次位置”方案中,解析难度已经大大降低,可以根据位置信息直接构造文本块,但是,仍然需要递归解析,而且,解析的效率也没有达到最高,需要从上到下,一层层的分块。
如何解决这两个问题呢?“层次位置”方案可以继续进化为“绝对位置”方案,这两个问题就迎刃而解了。
在“绝对位置”方案中,我们把位置信息分成两个部分。第一个部分,只定义文本切割绝对位置,不考虑层次关系。第二个部分,只定义层次关系,不管文本切割绝对位置。
上述的位置信息用“绝对位置”方案来描述的话,就是这样:
第一部分——文本切割绝对位置
t1: 1
t2: 101
t3: 201
t4: 401
t5: 501

第二部分——层次关系
top:
    a1: t1
a2:
   b1: t2
       b2: t3
b3: t4
    a3: t5

有了第一部分——文本切割绝对位置,解析器的工作就十分轻松了,只需要按照绝对位置将文本一块块切开就可以了,不需要考虑层次关系。得到了一块块的文本之后,再根据第二部分——层次关系,将一块块文本对象组织起来,构成一个层次结构化文档。到了这个地步,解析器就是一个简单的文本切割组合器,没有任何的实现难度。也就是说,我们不再需要文本解析工作。
如果应用“绝对位置”方案的话,我们不再需要在HTML页面中添加自定义标记,只需要为每一个HTML页面定义一套“绝对位置”信息即可。从而完全消除了自定义结构划分标签对HTML的污染和侵入。
“绝对位置”方案虽然看起来很美,但是,却存在一个致命缺陷。那就是,我们没有一个简单的方法来定位文本切割的绝对位置。现有的可视化文本编辑器中,只会显示当前光标的行数和列数,并不会显示光标前面的所有字符数。前面讲述的“层次位置”方案也有同样的问题。如果这个问题不解决,本章讲述的基于切割位置的Flyweight方案就只是没有意义的空中楼阁。
要解决这个问题,我们必须编写一个特殊的文本处理程序——特征字符串查找器。这个程序的功能就是寻找文本中的特征字符串,并返回该文本存在的绝对位置。
比如,还是前面的例子,我们需要按照绝对位置来切割那段长度为1000的文本。
Long long ago, there was living a king…….. blabla
这段文本需要切割的位置,分别存在着“s2”、“s3”……等特征字符串。我们把这些特征字符串放在一个List中,作为参数,传给特征字符串查找器,就能够得到这些字符串在文档中的绝对位置。比如:
s1: 1
s2: 101
s3: 201
s4: 401
s5: 501
为了方便用户,上述的特征字符串也可以引入空格缩进,写成类似于层次结构的样式。如:
s1
  s2
  s3
    s4
s5
s6
这种层次结构只是一种表面上的显示,方便查看,实际上是不存在的。另外,在实际的应用中,第一个特征字符串是不需要的。放在这里只是为了方便查看。
有了这些特征字符串,我们可以得到需要的绝对位置信息。这里有两个问题需要注意。
第一个问题是特征字符串的特殊性问题。特征字符串必须足够特殊,只应该出现在需要切割的位置,在文本的其他位置不应该出现。这样,才能够保证找到的绝对位置是正确的。为了保证特征字符串足够特殊,我们可以把需要切割处的特征字符串取得长一些,直到能够保证独一无二为止。
第二个问题是文件编码问题。这是一个很重要的主题,我稍微费点口舌。
我们首先需要弄明白的问题是,什么情况下,我们才需要考虑编码?我们一定要牢牢记住一点,编码是字符串文本资源特有的属性,只有在处理字符串文本资源的时候,我们才需要考虑编码,处理二进制资源的时候,我们不需要考虑编码。
那么,什么是编码呢?我们知道,计算机的最基本存储单元是八位(8 Bits)的字节(Byte)。字符串文本资源的最基本处理单元叫做字符(char)。在简单情况下(比如一些简单的西欧字符),一个字符(char)可以用一个字节(byte)来表示。但是,在一些复杂情况下(比如一些至少需要双字节表达的亚洲字符,典型的比如简体汉字、繁体汉字等),一个字符(char)可能对应一个、两个、三个、甚至四个字节(byte)。因此,在很多语言中,char类型都是4个字节(byte)的。
字符串处理程序需要处理的是字符(char),而计算机文件中的基本存储单元是字节(byte)。那么,当进程把字符串文本资源从文件中读取出来的时候,就需要把文件中的字节(byte)转换成字符(char)。这个把字节(byte)转换成字符(char)的操作,就叫做编码(encoding)。
当进程把字符串文本资源存入到文件中的时候,又需要把字符(char)转换成字节(byte),这个操作叫做解码(decoding)。
于是我们就得到了编码(encoding)和解码(decoding)的定义。
编码(encoding):byte -> char ; byte[] -> char[]
解码(decoding):char -> byte ; char[] -> byte[]
无论是编码(encoding),还是解码(decoding),都需要一个源字符集(source char set)和一个目标字符集(target char set)。
无论是编码(encoding),还是解码(decoding),都是把资源(byte[]或者char[])从源字符集(比如,GBK)编码方式转换成目标字符集(比如,Unicode)编码方式。
因此,无论是编码(encoding),还是解码(decoding)都可以叫做编码转换。不过,还有一种特殊情况,源字符集和目标字符集相等。这种情况下,编码转换就非常简单,只是一个简单的映射工作,把char展开成byte,或者byte聚合成char,这种情况下,实质上是不需要“转换工作”的。
编码无处不在,所有的文本资源都有自己的编码,所有的进程也都有自己的编码。比如,页面资源文件本身有编码,文本编辑器有编码,文本处理程序也有编码,数据库有编码,浏览器也有编码,开发语言编译器、解释器、执行器也都有编码。
不同的进程可能有不同的编码,即,可能有不同编码的字符串(char[])。那么,不同编码的进程之间进行数据交换的时候,必须进行编码转换,这个转换的桥梁就是最基本的数据单元格式——字节流(byte[])。
两个进程之间的文本数据通信过程是这样:char[] -> byte[] -> char[]。即,经过一个解码(decoding)后再编码(encoding)的过程。这个过程颇为复杂,涉及到两次编码转换和两个字符集(char set)。
假设第一个进程叫做process1,其进程内的char[]的字符集为process1_charset;第二个进程叫做process2,其进程内的char[]的字符集为process2_charset。这时候的编码转换有两种方案。
第一种方案。process1认为另一个进程process2的字符集和自己是一样的,process1直接把process1_charset的char[]数据映射为process1_charset的byte[]。注意,由于char[]和byte[]的字符集编码是一样的,这个过程中不涉及到编码转换,只涉及到映射,即把一个char展开为一个、两个、三个甚至四个byte。做完这个简单的解码处理之后,process1把byte[]和process1_charset信息一起发给process2。
接收到byte[]和process1_charset之后,process2把process1_charset信息和自己的process2_charset进行比较,如果process1_charset和process2_charset相同,那么,只需要把byte[]映射为char[]即可,不需要编码转换。如果process1_charset和process2_charset不相同,就需要一个编码转换的过程。这就需要一个编码转换器(process_charset1 -> process2_charset)。
第二种方案。process1认为自己知道另一个进程process2的目标字符集,假设为target_charset,那么,process1在把char[]转换为byte[]的时候,就会使用一个编码转换器(process_charset1 -> process2_charset)来进行解码。
当process2收到byte[]和target_charset之后,process2把target_charset信息和自己的process2_charset进行比较。最好的情况是相同,process2就不需要做额外的编码转换工作了,只需要进行简单的映射。如果不同的话,就说明process1的编码转化工作白做了。Process2还需要一个编码转换(target_charset -> process2_charset)的过程。
从上面的例子可以看出,编码转换器的个数可能会非常多。编码A转换成编码B(即编码为A的char[]转换成编码为B的char[]),需要一个编码转换器A2B。编码B转换成编码A,又需要一个编码转换器B2A。编码A转换成编码C,又需要一个编码转换器A2C。这是一个排列组合问题。假设有N种编码,那么,就需要N * (N – 1)种编码转换器。每多一种编码,整个编码转换器的个数就会呈几何级数增长。
为了避免这个问题,人们会选择一种比较通用的编码作为中间编码,所有其他的编码都可以和这个中心编码相互转换。这样,转换器的格式就大大减少。如果有N种编码和一种中间编码的话,编码转换器的总是就是 2 * N 个。
比如,Java 虚拟机就采用unicode 作为中间编码。Java虚拟机中的所有字符串(char[])都是unicode编码的。Java开发包中提供的转化器也都是以unicode为中间编码的,都是A -> unicode(byte[]->char[],编码),uncode -> A(char[]->byte[],解码),B -> unicode(byte[]->char[],编码),unicode -> B(char[]->byte[],解码)这样的转换器。
编码转换并不是看起来这么简单,而是相当复杂易错,很容易引起编码问题。编码转换必须明确地知道源编码和目标编码,一步弄错,就全盘皆输,整个文本就成了乱码。
我们举一个例子。以Java服务器和浏览器之间的HTTP请求应答为例。
我们把浏览器编码叫做 Browser_Charset,把JVM编码叫做JVM_Charset(通常等于服务器系统编码)。
当浏览器的数据进入到JVM的时候,是一个带有Browser_Charset的byte[]。
如果用户处理程序需要一个Char类型(String)的数据,那么JVM会好心好意地把这个byte[]转换成Char。使用的转换器是 JVM_Charset -> Unicode。
注意,如果这个时候,Browser_Charset 和 JVM_Charset并不相等。那么,这个自动转换是错误的。
为了弥补这个错误。我们需要做两步工作。
(1) Unicode -> JVM_Charset,把这个Char 转换回到原来的 byte[]。
(2) Browser_Charset -> Unicode,把这个还原的byte[]转换成 String。
这个效果,和直接从HTTP Request取得byte[],然后执行 (2) Browser_Charset -> Unicode 的效果是一样的。
如果在Request里面设置了CharacterEncoding,那么POST Data参数就不需要自己手工转换了,web server的自动转换就是正确的。URL的参数编码还涉及到URL编码,需要考虑的问题多一些,没有这么简单。
JVM把数据发到浏览器的时候。也需要考虑编码问题。可以在Response里面设置。另外,HTML Meta Header里面也可以设置编码,提醒Browser选择正确编码。
文本编辑器、代码编译器、代码执行器、虚拟机、数据库、浏览器(HTML元素)中都有设置编码的地方,文本文件读写API中也提供了设置编码的参数,HTTP Request对象和HTTP Response对象中也有设置编码的接口。为了避免乱码问题,我们最好把所有涉及到的编码都设成一样。
最难处理的编码问题要属代码中的字符串资源。这部分字符串资源涉及到代码编辑器、代码编译器的编码问题,很容易出错。我的建议是,最好把代码中的字符串资源从代码中分离出去,放在一个单独的文本资源文件中。这个文件有自己的编码,可以由文本编辑器来设定。代码需要调用文本资源的时候,只要提供正确的编码,就可以获取正确的文本。
那么,这个文本资源文件应该放在哪里呢?常见的方法如下。
第一个方法是把文件放在本应用的相对路径内,然后,利用相应的API获取正确的文件路径。比如,在Java Web开发包中(servlet API)中,ServletContext对象中有getSystemResource ( path )和getRealPath ( path ) 这样的方法,获取的路径就是${webapp} / path。
这种做法有两个缺点。第一个缺点是造成了对Servlet API的依赖。第二个缺点是不容易打包发布。我们如果把文本资源打包到jar中,就无法使用servlet API来获取该文件的正确路径了。
更好的做法是使用ClassLoader的getResource ( path ) 或者 getSystemResource ( path ) 方法。这两个方法能够获取classpath中的文件,包括jar包里面的文件。比如:
javax.servlet.ServletContext.class.getClassLoader().getResource( "javax/servlet/ServletContext.class")
得到的结果是
jar:file:/${webserver}/lib/servlet-api.jar!/javax/servlet/ServletContext.class

假设web.ListController是web应用中的一个类。
web.ListController.class.getClassLoader().getResource("web/ListController.class")
得到的结果是
file:/${webapp}/WEB-INF/classes/web/ListController.class

web.ListController.class.getClassLoader().getResource("messages.properties")
得到的结果是
file:/ ${webapp}/WEB-INF/classes/messages.properties

以上是Java的例子,在其他语言中,也有类似于ClassLoader和classpath的对应物。请读者自行研究。
编码问题涉及到方方面面,不可轻忽。对于前面讲到的“绝对位置”方案来说,编码更是非常重要,丝毫错不得。因为,编码直接决定了特征字符串在整个文本中的绝对位置。
由此可见,“绝对位置”方案用起来是相当麻烦的,虽然节省了文本解析器,但是却引入了一个特征字符串查找器。当然,特征字符串查找器的实现比文本解析器还是简单许多的。
为了使用“绝对位置”方案,我们需要把“文本切割绝对位置”和“层次关系”这两个部分完全分成两个文件。这是为了应对HTML的内容变动。
当HTML内容变化极大,影响到层次结构关系的时候,“文本切割绝对位置”和“层次关系”这两个部分都需要改动。但是,这种情况很少见,大多数时候,HTML的内容变动不会影响到HTML结构。这时候,我们只需要修改“文本切割绝对位置”,而不需要修改“层次关系”,所以,我们把这两部分放到两个不同的文件中。
“层次关系”这部分变动可能不大,我们可以手写,放在那里就可以了。“文本切割绝对位置”的变动可能大一些,但这部分内容比较整齐和简单,我们可以采用批量处理自动生成的方法。
当一批HTML页面第一次建好的时候,我们要用特征字符串查找器批量处理所有的HTML页面,查找其中的特征字符串的位置,并据此生成一批对应的“文本切割绝对位置”。
当HTML内容的每一次改动之后,就运行这个批量处理文件,根据时间,处理其中变动过的HTML,并重新生成“文本切割绝对位置”。
这个过程比较复杂,让我们从头走一遍。首先,我们有了一批HTML文件。我们需要为这些HTML文件定义一批“切割位置特征字符串”的对应文件。然后,我们用特征字符串查找器批量处理这些文件(HTML文件以及对应的特征字符串文件),自动生成一批“文本切割绝对位置”文件。然后,我们根据“文本切割绝对位置”文件,手工写一批“层次关系”文件。有了这样一套工具和流程,“绝对位置”Flyweight方案才有实用性。
不过是文本结构分块而已,却引入了这么多麻烦,到底值不值得?这个就见仁见智了。
在HTML很长、数据模型层次很深的情况下,“HTML混入代码”技术将会陷入噩梦,if else for 语句块经常找不到头和尾。“层次匹配”技术的 begin 和 end 标记中都带有对应一致的名字,一定程度上缓解了这个问题。但是,当begin end 嵌套层次比较多,甚至可能还有同名块的情况下,整个文档结构就很难看清楚了。在这种情况下,“绝对位置”Flyweight方案的优势就极为明显了,“层次关系”中的整个文档结构一目了然。