X-Spirit的陋室铭

生活有的时候面临许多选择,这些选择让人困惑。 生活有的时候没有任何选择,这个时候让人压抑。 但无论生活有没有给你选择,你只能做一件事情: 排除困难,勇往直前!

2007年12月18日星期二

[转]采用左右值编码来存储无限分级树形结构的数据库表设计

之前我介绍过一种按位数编码保存树形结构数据的表设计方法,详情见:
  浅谈数据库设计技巧(上)

  该设计方案的优点是:只用一条查询语句即可得到某个根节点及其所有子孙节点的先序遍历。由于消除了递归,在数据记录量较大时,可以大大提高列表效率。但是,这种编码方案由于层信息位数的限制,限制了每层能所允许的最大子节点数量及最大层数。同时,在添加新节点的时候必须先计算新节点的位置是否超过最大限制。

  上面的设计方案必须预先设定类别树的最大层数以及最大子节点数,不是无限分级,在某些场合并不能采用,那么还有更完美的解决方案吗?通过 google的搜索,我又探索到一种全新的无递归查询,无限分级的编码方案——左右值。原文的程序代码是用php写的,但是通过仔细阅读其数据库表设计说明及相关的sql语句,我彻底弄懂了这种巧妙的设计思路,并在这种设计中新增了删除节点,同层平移的需求(原文只提供了列表及插入子节点的sql语句)。

  下面我力图用比较简短的文字,少量图表,及相关核心sql语句来描述这种设计方案:

  首先,我们弄一棵树作为例子:

商品
|---食品
|    |---肉类
|    |    |--猪肉
|    |---蔬菜类
|          |--白菜
|---电器
     |--电视机
     |--电冰箱

 
采用左右值编码的保存该树的数据记录如下(设表名为tree):
Type_id
Name
Lft
Rgt
1
商品
1
18
2
食品
2
11
3
肉类
3
6
4
猪肉
4
5
5
蔬菜类
7
10
6
白菜
8
9
7
电器
12
17
8
电视机
13
14
9
电冰箱
15
16
 
第一次看见上面的数据记录,相信大部分人都不清楚左值(Lft)和右值(Rgt)是根据什么规则计算出来的,而且,这种表设计似乎没有保存父节点的信息。下面把左右值和树结合起来,请看:
          1商品18
     +---------------------------------------+
                2食品11                                    12电器17
          +-----------------+                     +---------------------+
    3肉类6          7蔬菜类10          13电视机14       15电冰箱16
    4猪肉5           8白菜9
请用手指指着上图中的数字,从1数到18,学习过数据结构的朋友肯定会发现什么吧?对,你手指移动的顺序就是对这棵树的进行先序遍历的顺序。接下来,让我讲述一下如何利用节点的左右值,得到该节点的父节点,子孙节点数量,及自己在树中的层数。
 
假定我们要对节点“食品”及其子孙节点进行先序遍历的列表,只需使用如下一条sql语句:
select * from tree where Lft between 2 and 11 order by Lft asc
查询结果如下:
Type_id
Name
Lft
Rgt
2
食品
2
11
3
肉类
3
6
4
猪肉
4
5
5
蔬菜类
7
10
6
白菜
8
9
 
那么某个节点到底有多少子孙节点呢?很简单,子孙总数 =(右值-左值-1)/2
以节点“食品”举例,其子孙总数=(11-2-1)/ 2 = 4
 
同时,我们在列表显示整个类别树的时候,为了方便用户直观的看到树的层次,一般会根据节点所处的层数来进行相应的缩进,那么,如何计算节点在树中的层数呢?还是只需通过左右值的查询即可,以节点“食品”举例,sql语句如下:
select count(*) from tree where lft <= 2 and rgt >= 11
为了方便列表,我们可以为tree表建立一个视图,添加一个层数列,该类别的层数可以写一个自定义函数来计算。该函数如下:
CREATE FUNCTION dbo.CountLayer
(   
   
@type_id int
)
RETURNS int
AS
begin
   
declare @result int
   
set @result=0
   
declare @lft int
   
declare @rgt int
   
if exists (select 1 from tree where type_id=@type_id)
   
begin
       
select @lft=lft,@rgt=rgt from tree where type_id=@type_id
       
select @result = count(*) from tree where lft <= @lft and rgt >= @rgt
   
end   
   
return @result
end
GO
然后,我们建立如下视图:
CREATE VIEW dbo.TreeView
AS
SELECT type_id, name, lft, rgt, dbo.CountLayer(type_id) AS layer FROM dbo.tree ORDER BY lft
GO
 
给出对于给定某个节点,对该节点及其子孙节点进行先序遍历的存储过程:
CREATE PROCEDURE [dbo].[GetTreeListByNode]
(
   
@type_id int --给定节点标识
)
AS
declare @lft int
declare @rgt int
if exists (select 1 from tree where type_id=@type_id)
   
begin
       
select @lft=lft,@rgt=rgt from tree where type_id=@type_id
       
select * from TreeView where lft between @lft and @rgt order by lft asc
   
end
go
 
现在,我们使用上面的存储过程来列表节点“食品”及其所有子孙节点,查询结果如下:
Type_id
Name
Lft
Rgt
Layer
2
食品
2
11
2
3
肉类
3
6
3
4
猪肉
4
5
4
5
蔬菜类
7
10
3
6
白菜
8
9
4
 
 
采用左右值编码的设计方案,在进行类别树的遍历时,由于只需进行2次查询,消除了递归,再加上查询条件都为数字比较,效率极高,类别树的记录条目越多,执行效率越高。看到这里,相信不少人对这种设计方案有所心动了,下面让我们接着看看如何在这种表结构中实现插入、删除、同层平移节点(变更同层节点排序)的功能。
 
假定我们要在节点“肉类”下添加一个子节点“牛肉”,该树将变成:
                                     1商品18+2
                      +--------------------------------------------+
                2食品11+2                                  12+2电器17+2
          +-----------------+                                    +-------------------------+
    3肉类6+2   7+2蔬菜类10+2         13+2电视机14+2    15+2电冰箱16+2
    +-------------+
4猪肉5  6牛肉7  8+2白菜9+2
 
看完上图相应节点左右值的变化后,相信大家都知道该如何写相应的sql脚本吧?下面我给出相对完整的插入子节点的存储过程:
CREATE PROCEDURE [dbo].[AddSubNodeByNode]
(
   
@type_id int,
   
@name varchar(50)
)
AS
declare @rgt int
if exists (select 1 from tree where type_id=@type_id)
   
begin
      
SET XACT_ABORT ON
      
BEGIN TRANSACTION
       
select @rgt=rgt from tree where type_id=@type_id
       
update tree set rgt=rgt+2 where rgt>=@rgt
       
update tree set lft=lft+2 where lft>=@rgt
       
insert into tree (name,lft,rgt) values (@name,@rgt,@rgt+1)   
       
COMMIT TRANSACTION
      
SET XACT_ABORT OFF   
   
end
go
 
然后,我们删除节点“电视机”,再来看看该树会变成什么情况:
                                            1商品20-2
                       +-----------------------------------+
                2食品13                                14电器19-2
          +-----------------+               
       3肉类8          9蔬菜类12              17-2电冰箱18-2
    +----------+
4猪肉5  6牛肉7  10白菜11
 
  相应的存储过程如下:
CREATE PROCEDURE [dbo].[DelNode] 
   
@type_id int
AS
declare @lft int
declare @rgt int
if exists (select 1 from tree where type_id=@type_id)
   
begin
      
SET XACT_ABORT ON
      
BEGIN TRANSACTION
       
select @lft=lft,@rgt=rgt from tree where type_id=@type_id
       
delete from tree where lft>=@lft and rgt<=@rgt
       
update tree set lft=lft-(@rgt-@lft+1) where lft>@lft
       
update tree set rgt=rgt-(@rgt-@lft+1) where rgt>@rgt
       
COMMIT TRANSACTION
      
SET XACT_ABORT OFF   
End
 
  注意:因为删除某个节点会同时删除该节点的所有子孙节点,而这些被删除的节点的个数为:(被删节点的右值-被删节点的左值+1)/2,而任何一个节点同时具有唯一的左值和唯一的右值,故删除作废节点后,其他相应节点的左、右值需要调整的幅度应为:减少(被删节点的右值-被删节点的左值+1)。
 
  最后,让我们看看平移节点“电器”,将其和其所有子孙节点移动到节点“食品”之前后,该树会变成什么情况:
 
             1商品18
+-----------------------------------+
                14-12电器17-12                      2+4食品13+4
                                                               +----------------------+               
             15-12电冰箱16-12      3+4肉类8+4      9+4蔬菜类12+4       
                                                +-------------------+
                                      4+4猪肉5+4     6+4牛肉7+4 10+4白菜11+4
 
大家仔细观察一下交换后同层2个节点和其所有子孙节点左右值的变化,可以发现一个明显的规律,那就是,节点“电器”及其所有子孙节点的左右值均减少12,而节点“食品”及其所有子孙节点的左右值均增加4。而节点“电器”+其子孙节点的数量为2,节点“食品”+其子孙节点的数量为6,这其中有什么联系吗?还记得我在删除节点的存储过程后面的注释吗?任何一个节点同时具有唯一的左值和唯一的右值。让我们把节点数量*2,正好和节点左右值需要调整的幅度相等。由此规律,我们可以编写出类似下面的存储过程来实现节点同层前移的功能:
CREATE PROCEDURE [dbo].[MoveNodeUp] 
   
@type_id int
AS
declare @lft int
declare @rgt int
declare @layer int
if exists (select 1 from tree where type_id=@type_id)
   
begin
      
SET XACT_ABORT ON
      
BEGIN TRANSACTION
       
select @lft=lft,@rgt=rgt,@layer=layer from TreeView where type_id=@type_id
       
if exists (select * from TreeView where rgt=@lft-1 and layer=@layer)
          
begin
              
declare @brother_lft int
              
declare @brother_rgt int
              
select @brother_lft=lft,@brother_rgt=rgt from TreeView where rgt=@lft-1 and layer=@layer
              
update tree set lft=lft-(@brother_rgt-@brother_lft+1) where lft>=@lft and rgt<=@rgt
              
update tree set lft=lft+(@rgt-@lft+1) where lft>=@brother_lft and rgt<=@brother_rgt
              
update tree set rgt=rgt-(@brother_rgt-@brother_lft+1) where rgt>@brother_rgt and rgt<=@rgt
              
update tree set rgt=rgt+(@rgt-@lft+1) where lft>=@brother_lft+(@rgt-@lft+1) and rgt<=@brother_rgt
          
end
       
COMMIT TRANSACTION
      
SET XACT_ABORT OFF   
   
end
 
  注意:节点的同层平移可以采用临时表来做中介,降低代码的复杂度。不用临时表来处理也行,但是update语句顺序一定要考虑周详。否则,一旦出现bug,对整个类别表的破坏是惊人的,强烈推荐在做上述工作前对类别表进行完整备份。
 
  同层下移的存储过程和同层上移类似,有兴趣的朋友可以自己动手编写体味一下其中的细节,我就不在这里列出来了。
 
  最后,我对上面这种左右值编码实现无限分级类别树的方案做一个总结:
  优点:在消除递归的前提下实现了无限分级,而且查询条件是基于整形数字比较的,效率很高。可以进行先序列表,添加,修改,删除,同层平移等常规操作,基本满足需求。
  缺点:由于这种左右值编码的方式和常见的阿拉伯数字直观排序不同,再加上节点在树中的层次,顺序不是直观显示出来,而必须通过简单的公式计算后得到,需要花费一定的时间对其数学模型进行深入理解。而且,采用该方案编写相关存储过程,新增,删除,同层平移节点需要对整个树进行查询修改,由此导致的代码复杂度,耦合度较高,修改维护的风险较高。

【转】浅谈数据库设计

说到数据库,我认为不能不先谈数据结构。1996年,在我初入大学学习计算机编程时,当时的老师就告诉我们说:计算机程序=数据结构+算法。尽管现在的程序开发已由面向过程为主逐步过渡到面向对象为主,但我还是深深赞同8年前老师的告诉我们的公式:计算机程序=数据结构+算法。面向对象的程序开发,要做的第一件事就是,先分析整个程序中需处理的数据,从中提取出抽象模板,以这个抽象模板设计类,再在其中逐步添加处理其数据的函数(即算法),最后,再给类中的数据成员和函数划分访问权限,从而实现封装。
  数据库的最初雏形据说源自美国一个奶牛场的记账薄(纸质的,由此可见,数据库并不一定是存储在电脑里的数据^_^),里面记录的是该奶牛场的收支账目,程序员在将其整理、录入到电脑中时从中受到启发。当按照规定好的数据结构所采集到的数据量大到一定程度后,出于程序执行效率的考虑,程序员将其中的检索、更新维护等功能分离出来,做成单独调用的模块,这个模块后来就慢慢发展、演变成现在我们所接触到的数据库管理系统(DBMS)——程序开发中的一个重要分支。
  下面进入正题,首先按我个人所接触过的程序给数据库设计人员的功底分一下类:
  1、没有系统学习过数据结构的程序员。这类程序员的作品往往只是他们的即兴玩具,他们往往习惯只设计有限的几个表,实现某类功能的数据全部塞在一个表中,各表之间几乎毫无关联。网上不少的免费管理软件都是这样的东西,当程序功能有限,数据量不多的时候,其程序运行起来没有什么问题,但是如果用其管理比较重要的数据,风险性非常大。
  2、系统学习过数据结构,但是还没有开发过对程序效率要求比较高的管理软件的程序员。这类人多半刚从学校毕业不久,他们在设计数据库表结构时,严格按照教科书上的规定,死扣E-R图和3NF(别灰心,所有的数据库设计高手都是从这一步开始的)。他们的作品,对于一般的access型轻量级的管理软件,已经够用。但是一旦该系统需要添加新功能,原有的数据库表差不多得进行大换血。
  3、第二类程序员,在经历过数次程序效率的提升,以及功能升级的折腾后,终于升级成为数据库设计的老鸟,第一类程序员眼中的高人。这类程序员可以胜任二十个表以上的中型商业数据管理系统的开发工作。他们知道该在什么样的情况下保留一定的冗余数据来提高程序效率,而且其设计的数据库可拓展性较好,当用户需要添加新功能时,原有数据库表只需做少量修改即可。
  4、在经历过上十个类似数据库管理软件的重复设计后,第三类程序员中坚持下来没有转行,而是希望从中找出“偷懒”窍门的有心人会慢慢觉悟,从而完成量变到质变的转换。他们所设计的数据库表结构有一定的远见,能够预测到未来功能升级所需要的数据,从而预先留下伏笔。这类程序员目前大多晋级成数据挖掘方面的高级软件开发人员。
  5、第三类程序员或第四类程序员,在对现有的各家数据库管理系统的原理和开发都有一定的钻研后,要么在其基础上进行二次开发,要么自行开发一套有自主版权的通用数据库管理系统。
  我个人正处于第三类的末期,所以下面所列出的一些设计技巧只适合第二类和部分第三类数据库设计人员。同时,由于我很少碰到有兴趣在这方面深钻下去的同行,所以文中难免出现错误和遗漏,在此先行声明,欢迎大家指正,不要藏私哦8)
  一、树型关系的数据表
  不少程序员在进行数据库设计的时候都遇到过树型关系的数据,例如常见的类别表,即一个大类,下面有若干个子类,某些子类又有子类这样的情况。当类别不确定,用户希望可以在任意类别下添加新的子类,或者删除某个类别和其下的所有子类,而且预计以后其数量会逐步增长,此时我们就会考虑用一个数据表来保存这些数据。按照教科书上的教导,第二类程序员大概会设计出类似这样的数据表结构:
类别表_1(Type_table_1)
名称     类型    约束条件   说明
type_id      int        无重复    类别标识,主键
type_name   char(50)    不允许为空   类型名称,不允许重复
type_father   int         不允许为空   该类别的父类别标识,如果是顶节点的话设定为某个唯一值
  这样的设计短小精悍,完全满足3NF,而且可以满足用户的所有要求。是不是这样就行呢?答案是NO!Why?
  我们来估计一下用户希望如何罗列出这个表的数据的。对用户而言,他当然期望按他所设定的层次关系一次罗列出所有的类别,例如这样:
总类别
  类别1
    类别1.1
      类别1.1.1
    类别1.2
  类别2
    类别2.1
  类别3
    类别3.1
    类别3.2
  ……
  看看为了实现这样的列表显示(树的先序遍历),要对上面的表进行多少次检索?注意,尽管类别1.1.1可能是在类别3.2之后添加的记录,答案仍然是N次。这样的效率对于少量的数据没什么影响,但是日后类型扩充到数十条甚至上百条记录后,单单列一次类型就要检索数十次该表,整个程序的运行效率就不敢恭维了。或许第二类程序员会说,那我再建一个临时数组或临时表,专门保存类型表的先序遍历结果,这样只在第一次运行时检索数十次,再次罗列所有的类型关系时就直接读那个临时数组或临时表就行了。其实,用不着再去分配一块新的内存来保存这些数据,只要对数据表进行一定的扩充,再对添加类型的数量进行一下约束就行了,要完成上面的列表只需一次检索就行了。下面是扩充后的数据表结构:
类别表_2(Type_table_2)
名称     类型    约束条件                     说明
type_id      int        无重复                    类别标识,主键
type_name   char(50)    不允许为空                   类型名称,不允许重复
type_father   int         不允许为空                   该类别的父类别标识,如果是顶节点的话设定为某个唯一值
type_layer    char(6)     限定3层,初始值为000000       类别的先序遍历,主要为减少检索数据库的次数
  按照这样的表结构,我们来看看上面例子记录在表中的数据是怎样的:
type_id      type_name          type_father          type_layer
1             总类别               0                 000000
2             类别1                1                 010000
3             类别1.1              2                 010100
4             类别1.2              2                 010200
5             类别2                1                 020000
6             类别2.1              5                 020100
7             类别3                1                 030000
8             类别3.1              7                 030100
9             类别3.2              7                 030200
10            类别1.1.1            3                 010101
……
  现在按type_layer的大小来检索一下:SELECT * FROM Type_table_2 ORDER BY type_layer
列出记录集如下:
type_id      type_name          type_father          type_layer
1             总类别               0                 000000
2             类别1                1                 010000
3             类别1.1              2                 010100
10            类别1.1.1            3                 010101
4             类别1.2              2                 010200
5             类别2                1                 020000
6             类别2.1              5                 020100
7             类别3                1                 030000
8             类别3.1              7                 030100
9             类别3.2              7                 030200
……
  现在列出的记录顺序正好是先序遍历的结果。在控制显示类别的层次时,只要对type_layer字段中的数值进行判断,每2位一组,如大于0则向右移2个空格。当然,我这个例子中设定的限制条件是最多3层,每层最多可设99个子类别,只要按用户的需求情况修改一下type_layer的长度和位数,即可更改限制层数和子类别数。其实,上面的设计不单单只在类别表中用到,网上某些可按树型列表显示的论坛程序大多采用类似的设计。
  或许有人认为,Type_table_2中的type_father字段是冗余数据,可以除去。如果这样,在插入、删除某个类别的时候,就得对type_layer 的内容进行比较繁琐的判定,所以我并没有消去type_father字段,这也正符合数据库设计中适当保留冗余数据的来降低程序复杂度的原则,后面我会举一个故意增加数据冗余的案例。
  二、商品信息表的设计
  假设你是一家百货公司电脑部的开发人员,某天老板要求你为公司开发一套网上电子商务平台,该百货公司有数千种商品出售,不过目前仅打算先在网上销售数十种方便运输的商品,当然,以后可能会陆续在该电子商务平台上增加新的商品出售。现在开始进行该平台数据库的商品信息表的设计。每种出售的商品都会有相同的属性,如商品编号,商品名称,商品所属类别,相关信息,供货厂商,内含件数,库存,进货价,销售价,优惠价。你很快就设计出4个表:商品类型表(Wares_type),供货厂商表(Wares_provider),商品信息表(Wares_info):
商品类型表(Wares_type)
名称     类型    约束条件                     说明
type_id      int        无重复                    类别标识,主键
type_name   char(50)    不允许为空                   类型名称,不允许重复
type_father   int         不允许为空                   该类别的父类别标识,如果是顶节点的话设定为某个唯一值
type_layer    char(6)     限定3层,初始值为000000       类别的先序遍历,主要为减少检索数据库的次数
供货厂商表(Wares_provider)
名称     类型    约束条件                     说明
provider_id   int        无重复                    供货商标识,主键
provider_name char(100)   不允许为空                   供货商名称
商品信息表(Wares_info)
名称   类型    约束条件                     说明
wares_id       int       无重复                      商品标识,主键
wares_name     char(100)  不允许为空                     商品名称
wares_type   int        不允许为空           商品类型标识,和Wares_type.type_id关联
wares_info     char(200)  允许为空                       相关信息
provider       int        不允许为空                     供货厂商标识,和Wares_provider.provider_id关联
setnum         int        初始值为1                      内含件数,默认为1
stock          int        初始值为0                      库存,默认为0
buy_price      money      不允许为空                     进货价
sell_price     money      不允许为空                     销售价
discount       money      不允许为空                     优惠价
  你拿着这3个表给老板检查,老板希望能够再添加一个商品图片的字段,不过只有一部分商品有图片。OK,你在商品信息表(Wares_info)中增加了一个haspic的BOOL型字段,然后再建了一个新表——商品图片表(Wares_pic):
商品图片表(Wares_pic)
名称   类型    约束条件                     说明
pic_id        int        无重复                      商品图片标识,主键
wares_id      int         不允许为空                     所属商品标识,和Wares_info.wares_id关联
pic_address  char(200)   不允许为空           图片存放路径
  程序开发完成后,完全满足老板目前的要求,于是正式启用。一段时间后,老板打算在这套平台上推出新的商品销售,其中,某类商品全部都需添加“长度”的属性。第一轮折腾来了……当然,你按照添加商品图片表的老方法,在商品信息表(Wares_info)中增加了一个haslength的BOOL型字段,又建了一个新表——商品长度表(Wares_length):
商品长度表(Wares_length)
名称   类型    约束条件                     说明
length_id     int        无重复                      商品图片标识,主键
wares_id      int         不允许为空                     所属商品标识,和Wares_info.wares_id关联
length       char(20)    不允许为空           商品长度说明
  刚刚改完没多久,老板又打算上一批新的商品,这次某类商品全部需要添加“宽度”的属性。你咬了咬牙,又照方抓药,添加了商品宽度表(Wares_width)。又过了一段时间,老板新上的商品中有一些需要添加“高度”的属性,你是不是开始觉得你所设计的数据库按照这种方式增长下去,很快就能变成一个迷宫呢?那么,有没有什么办法遏制这种不可预见性,但却类似重复的数据库膨胀呢?我在阅读《敏捷软件开发:原则、模式与实践》中发现作者举过类似的例子:7.3 “Copy”程序。其中,我非常赞同敏捷软件开发这个观点:在最初几乎不进行预先设计,但是一旦需求发生变化,此时作为一名追求卓越的程序员,应该从头审查整个架构设计,在此次修改中设计出能够满足日后类似修改的系统架构。下面是我在需要添加“长度”的属性时所提供的修改方案:
  去掉商品信息表(Wares_info)中的haspic字段,添加商品额外属性表(Wares_ex_property)和商品额外信息表(Wares_ex_info)2个表来完成添加新属性的功能。
商品额外属性表(Wares_ex_property)
名称   类型    约束条件                     说明
ex_pid        int        无重复                      商品额外属性标识,主键
p_name        char(20)    不允许为空                     额外属性名称
商品额外信息表(Wares_ex_info)
名称     类型    约束条件                     说明
ex_iid          int        无重复                      商品额外信息标识,主键
wares_id        int         不允许为空                     所属商品标识,和Wares_info.wares_id关联
property_id    int         不允许为空           商品额外属性标识,和Wares_ex_property.ex_pid关联
property_value  char(200)   不允许为空                     商品额外属性值
  在商品额外属性表(Wares_ex_property)中添加2条记录:
ex_pid            p_name
1                商品图片
2                商品长度
  再在整个电子商务平台的后台管理功能中追加一项商品额外属性管理的功能,以后添加新的商品时出现新的属性,只需利用该功能往商品额外属性表(Wares_ex_property)中添加一条记录即可。不要害怕变化,被第一颗子弹击中并不是坏事,坏的是被相同轨道飞来的第二颗、第三颗子弹击中。第一颗子弹来得越早,所受的伤越重,之后的抵抗力也越强

三、多用户及其权限管理的设计
  开发数据库管理类的软件,不可能不考虑多用户和用户权限设置的问题。尽管目前市面上的大、中型的后台数据库系统软件都提供了多用户,以及细至某个数据库内某张表的权限设置的功能,我个人建议:一套成熟的数据库管理软件,还是应该自行设计用户管理这块功能,原因有二:
  1.那些大、中型后台数据库系统软件所提供的多用户及其权限设置都是针对数据库的共有属性,并不一定能完全满足某些特例的需求;
  2.不要过多的依赖后台数据库系统软件的某些特殊功能,多种大、中型后台数据库系统软件之间并不完全兼容。否则一旦日后需要转换数据库平台或后台数据库系统软件版本升级,之前的架构设计很可能无法重用。

  下面看看如何自行设计一套比较灵活的多用户管理模块,即该数据库管理软件的系统管理员可以自行添加新用户,修改已有用户的权限,删除已有用户。首先,分析用户需求,列出该数据库管理软件所有需要实现的功能;然后,根据一定的联系对这些功能进行分类,即把某类用户需使用的功能归为一类;最后开始建表:
功能表(Function_table)
名称  类型      约束条件   说明
f_id          int          无重复        功能标识,主键
f_name     char(20)    不允许为空       功能名称,不允许重复
f_desc      char(50)    允许为空           功能描述

用户组表(User_group)
名称       类型      约束条件   说明
group_id          int            无重复              用户组标识,主键
group_name    char(20)   不允许为空      用户组名称
group_power  char(100)  允许为空         用户组权限表,内容为功能表f_id的集合

用户表(User_table)
名称    类型    约束条件   说明
user_id             int                    无重复             用户标识,主键
user_name       char(20)           无重复             用户名
user_pwd        char(20)           不允许为空     用户密码
user_type         int                    不允许为空     所属用户组标识,和User_group.group_id关联

  采用这种用户组的架构设计,当需要添加新用户时,只需指定新用户所属的用户组;当以后系统需要添加新功能或对旧有功能权限进行修改时,只用操作功能表和用户组表的记录,原有用户的功能即可相应随之变化。当然,这种架构设计把数据库管理软件的功能判定移到了前台,使得前台开发相对复杂一些。但是,当用户数较大(10人以上),或日后软件升级的概率较大时,这个代价是值得的。

  四、简洁的批量m:n设计
  碰到m:n的关系,一般都是建立3个表,m一个,n一个,m:n一个。但是,m:n有时会遇到批量处理的情况,例如到图书馆借书,一般都是允许用户同时借阅n本书,如果要求按批查询借阅记录,即列出某个用户某次借阅的所有书籍,该如何设计呢?让我们建好必须的3个表先:

书籍表(Book_table)
名称   类型    约束条件   说明
book_id        int                   无重复              书籍标识,主键
book_no       char(20)         无重复              书籍编号
book_name   char(100)       不允许为空      书籍名称
……

借阅用户表(Renter_table)
名称    类型    约束条件   说明
renter_id           int                   无重复               用户标识,主键
renter_name      char(20)         不允许为空       用户姓名
……

借阅记录表(Rent_log)
名称   类型    约束条件   说明
rent_id          int                   无重复                借阅记录标识,主键
r_id              int                   不允许为空        用户标识,和Renter_table.renter_id关联
b_id             int                   不允许为空        书籍标识,和Book_table.book_id关联
rent_date     datetime           不允许为空        借阅时间
……

  为了实现按批查询借阅记录,我们可以再建一个表来保存批量借阅的信息,例如:

批量借阅表(Batch_rent)
名称   类型   约束条件   说明
batch_id       int                 无重复            批量借阅标识,主键
batch_no      int                 不允许为空    批量借阅编号,同一批借阅的batch_no相同
rent_id          int                不允许为空    借阅记录标识,和Rent_log.rent_id关联
batch_date   datetime        不允许为空    批量借阅时间

  这样的设计好吗?我们来看看为了列出某个用户某次借阅的所有书籍,需要如何查询?首先检索批量借阅表(Batch_rent),把符合条件的的所有记录的rent_id字段的数据保存起来,再用这些数据作为查询条件带入到借阅记录表(Rent_log)中去查询。那么,有没有什么办法改进呢?下面给出一种简洁的批量设计方案,不需添加新表,只需修改一下借阅记录表(Rent_log)即可。修改后的记录表(Rent_log)如下:

借阅记录表(Rent_log)
名称   类型   约束条件   说明
rent_id          int                无重复               借阅记录标识,主键
r_id              int                不允许为空        用户标识,和Renter_table.renter_id关联
b_id             int                不允许为空        书籍标识,和Book_table.book_id关联
batch_no      int                不允许为空        批量借阅编号,同一批借阅的batch_no相同
rent_date     datetime       不允许为空         借阅时间
……

  其中,同一次借阅的batch_no和该批第一条入库的rent_id相同。举例:假设当前最大rent_id是64,接着某用户一次借阅了3本书,则批量插入的3条借阅记录的batch_no都是65。之后另外一个用户租了一套碟,再插入出租记录的rent_id是68。采用这种设计,查询批量借阅的信息时,只需使用一条标准T_SQL的嵌套查询即可。当然,这种设计不符合3NF,但是和上面标准的3NF设计比起来,哪一种更好呢?答案就不用我说了吧。

  五、冗余数据的取舍
  上篇的“树型关系的数据表”中保留了一个冗余字段,这里的例子更进一步——添加了一个冗余表。先看看例子:我原先所在的公司为了解决员工的工作餐,和附近的一家小餐馆联系,每天吃饭记账,费用按人数平摊,月底由公司现金结算,每个人每个月的工作餐费从工资中扣除。当然,每天吃饭的人员和人数都不是固定的,而且,由于每顿工作餐的所点的菜色不同,每顿的花费也不相同。例如,星期一中餐5人花费40元,晚餐2人花费20,星期二中餐6人花费36元,晚餐3人花费18元。为了方便计算每个人每个月的工作餐费,我写了一个简陋的就餐记账管理程序,数据库里有3个表:

员工表(Clerk_table)
名称    类型    约束条件   说明
clerk_id            int                    无重复                员工标识,主键
clerk_name      char(10)           不允许为空        员工姓名

每餐总表(Eatdata1)
名称    类型    约束条件   说明
totle_id            int                    无重复               每餐总表标识,主键
persons           char(100)         不允许为空       就餐员工的员工标识集合
eat_date          datetime           不允许为空       就餐日期
eat_type          char(1)             不允许为空       就餐类型,用来区分中、晚餐
totle_price       money              不允许为空       每餐总花费
persons_num   int                    不允许为空        就餐人数

就餐计费细表(Eatdata2)
名称  类型  约束条件   说明
id             int             无重复              就餐计费细表标识,主键
t_id          int             不允许为空      每餐总表标识,和Eatdata1.totle_id关联
c_id         int             不允许为空      员工标识标识,和Clerk_table.clerk_id关联
price        money       不允许为空      每人每餐花费

  其中,就餐计费细表(Eatdata2)的记录就是把每餐总表(Eatdata1)的一条记录按就餐员工平摊拆开,是个不折不扣的冗余表。当然,也可以把每餐总表(Eatdata1)的部分字段合并到就餐计费细表(Eatdata2)中,这样每餐总表(Eatdata1)就成了冗余表,不过这样所设计出来的就餐计费细表重复数据更多,相比来说还是上面的方案好些。但是,就是就餐计费细表(Eatdata2)这个冗余表,在做每月每人餐费统计的时候,大大简化了编程的复杂度,只用类似这么一条查询语句即可统计出每人每月的寄餐次数和餐费总帐:

SELECT clerk_name AS personname,COUNT(c_id) as eattimes,SUM(price) AS ptprice FROM Eatdata2 JOIN Clerk_tabsle ON (c_id=clerk_id) JOIN eatdata1 ON (totleid=tid) WHERE eat_date>=CONVERT(datetime,'"&the_date&"') AND eat_date< P>

  想象一下,如果不用这个冗余表,每次统计每人每月的餐费总帐时会多麻烦,程序效率也够呛。那么,到底什么时候可以增加一定的冗余数据呢?我认为有2个原则:

  1、用户的整体需求。当用户更多的关注于,对数据库的规范记录按一定的算法进行处理后,再列出的数据。如果该算法可以直接利用后台数据库系统的内嵌函数来完成,此时可以适当的增加冗余字段,甚至冗余表来保存这些经过算法处理后的数据。要知道,对于大批量数据的查询,修改或删除,后台数据库系统的效率远远高于我们自己编写的代码。
  2、简化开发的复杂度。现代软件开发,实现同样的功能,方法有很多。尽管不必要求程序员精通绝大部分的开发工具和平台,但是还是需要了解哪种方法搭配哪种开发工具的程序更简洁,效率更高一些。冗余数据的本质就是用空间换时间,尤其是目前硬件的发展远远高于软件,所以适当的冗余是可以接受的。不过我还是在最后再强调一下:不要过多的依赖平台和开发工具的特性来简化开发,这个度要是没把握好的话,后期维护升级会栽大跟头的。

[转]数据库中的树形结构 - JAVA 设计(通用)

我们通常会在应用中碰到树形结构的内容,比如 文件夹/文件模型, 部门组织结构,目录树等等,通常在设计模式中叫做 compose 模式。

在数据库中常常这样表示: 我们以Catalog (分级目录) 为例子

Catalog (分级目录)

字段名称

字段

类型

备注

目录ID

catalog_id

varchar(36)

pk, not null

目录名称

catalog_name

varchar(50)

not null

父目录ID

parent_id

varchar(36)

fk

创建时间

create_datetime

datetime

not null

目录描述

description

varchar(200)

 


我们考虑在数据库中一次将所有数据读入内存,然后在内存中生成一个Tree,这样可以减少数据库的访问,增加性能,并且只有的数据方式改变的时候,全部重新从数据库中生成Tree,否则一直保持在内存中。

我们使用标准的DAO模式先生成 POJO类(Catalog)和DAO类(CatalogDAO)。

然后我们建立相对通用的 Tree 和 TreeNode 类。

Tree.java

package com.humpic.helper.tree;

import java.util.*;

import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public abstract class Tree {
   
protected static Log log = LogFactory.getLog(Tree.class);

   
private Map treeNodeMaps = new Hashtable();
   
private TreeNode root;

   
/**
     * root if it's parent is empty
    
*/
   
protected void reload(List nodes) {
        log.info(
"tree will start reload all data");
       
       
synchronized (this) {
           
// initialize
            treeNodeMaps.clear();
            root
= null;

            List treeNodes
= new Vector(nodes.size());
           
for (int i = 0; i < nodes.size(); i++) {
                TreeNode node
= this.transform(nodes.get(i)); // transform
                treeNodes.add(node);
                node.setTree(
this);
                treeNodeMaps.put(node.getNodeId(), node);
            }
           
           
for (int i = 0; i < treeNodes.size(); i++) {
                TreeNode node
= (TreeNode) treeNodes.get(i);
                String parentId
= node.getParentId();
               
if (this.isRootNode(node)) {
                    if (root == null) {
                        root
= node;
                    }
else {
                        log.error(
"find more then one root node. ignore.");
                    }

                }
else {
                   
TreeNode parent = (TreeNode) treeNodeMaps.get(parentId);
                   
if (parent != null) {
                        parent.addChild(node);
                        node.setParent(parent);
                    }
else {
                        log.warn(
"node [id=" + node.getNodeId() + "]: missing parent node.");
                    }

                }
            }
        }

       
if (root == null) {
            log.error(
"the root node is not be defined");
        }
    }

    protected boolean isRootNode(TreeNode node) {
       
return StringUtils.isBlank(node.getParentId());
    }

    public TreeNode getRootNode() {
       
return root;
    }

   
public TreeNode getTreeNode(String nodeId) {
       
return (TreeNode) treeNodeMaps.get(nodeId);
    }

   
public void addTreeNode(TreeNode node) {
       
synchronized (this) {
            treeNodeMaps.put(node.getNodeId(), node);

            String parentId
= node.getParentId();
           
if (StringUtils.isNotBlank(parentId)) {
                TreeNode parent
= getTreeNode(parentId);
               
if (parent != null) {
                    parent.addChild(node);
                    node.setParent(parent);
                }
else {
                    log.error(
"parent cannot be found: " + node.getParentId());
                }
            }
else {
               
if (root == null) {
                    root
= node;
                }
else {
                    log.error(
"find more then one root node. ignore.");
                }
            }
        }
    }

   
public void deleteTreeNode(String nodeId) {
       
synchronized (this) {
            TreeNode node
= getTreeNode(nodeId);
           
if (node == null)
               
throw new IllegalArgumentException(nodeId + " cannot be found.");

           
if (node.getParent() == null) {
                root
= null;
                treeNodeMaps.clear();
                log.warn(
"the root node has been removed.");
            }
else {
                node.getParent().getChildren().remove(node);

                treeNodeMaps.remove(nodeId);
                List children
= node.getAllChildren();
               
for (int i = 0; i < children.size(); i++) {
                    TreeNode n
= (TreeNode) children.get(i);
                    treeNodeMaps.remove(n.getNodeId());
                }
            }
        }
    }

   
/**
     * <pre>
     * Usage: Office -&gt;
     *
     * public TreeNode transform(Object info) {
     *     OfficeInfo office_info = (OfficeInfo) info;
     *     TreeNode node = new TreeNode();
     *     node.setNodeId(office_info.getOfficeId());
     *     node.setParentId(office_info.getParentId());
     *     node.setBindData(office_info);
     *     return node;
     * }
     * </pre>
    
*/
   
protected abstract TreeNode transform(Object info);
}

TreeNode.java

package com.humpic.helper.tree;

import java.util.List;
import java.util.Vector;

import org.apache.commons.collections.Predicate;
import org.apache.commons.lang.ObjectUtils;

public class TreeNode {

   
private Tree tree;
   
private TreeNode parent;
   
private List children = new Vector();
   
private List childrenGroup = new Vector();
   
private String nodeId;
   
private String parentId;
   
private Object bindData;

   
public String getNodeId() {
       
return nodeId;
    }

   
public void setNodeId(String nodeId) {
       
this.nodeId = nodeId;
    }

   
public String getParentId() {
       
return parentId;
    }

   
public void setParentId(String parentId) {
       
this.parentId = parentId;
    }

   
public Object getBindData() {
       
return bindData;
    }

   
public void setBindData(Object bindData) {
       
this.bindData = bindData;
    }

   
public Tree getTree() {
       
return tree;
    }

   
public void setTree(Tree tree) {
       
this.tree = tree;
    }

   
public void setParent(TreeNode parent) {
       
this.parent = parent;
    }

   
public TreeNode getParent() {
       
return this.parent;
    }

   
public List getChildren() {
       
return this.children;
    }

   
public void addChild(TreeNode node) {
        children.add(node);
    }

   
/**
     * get all children, and chilren's children
    
*/
   
public List getAllChildren() {
       
if (this.childrenGroup.isEmpty()) {
           
synchronized (this.tree) {
               
for (int i = 0; i < this.children.size(); i++) {
                    TreeNode node
= (TreeNode) this.children.get(i);
                   
this.childrenGroup.add(node);
                   
this.childrenGroup.addAll(node.getAllChildren());
                }
            }
        }
       
return this.childrenGroup;
    }

   
/**
     * get all children, and chilren's children
    
*/
  
 public List getAllChildren(Predicate predicate) {
        List groups
= new Vector();
        fillAllChildren(groups, predicate);
       
return groups;
    }

   
private void fillAllChildren(List groups, Predicate predicate) {
       
for (int i = 0; i < this.children.size(); i++) {
            TreeNode node
= (TreeNode) this.children.get(i);
           
if (predicate.evaluate(node)) {
                groups.add(node);
                node.fillAllChildren(groups, predicate);
            }
        }
    }

    /**
     * get all parents, and parent's parent
    */

   
public List getParents() {
        List results =
new Vector();
        TreeNode parent =
this.getParent();
       
while (parent != null) {
            results.add(parent);
            parent = parent.getParent();
        }
       
return results;
    }

   
/**
     * A.isMyParent(B) == B is A' parent ? <br>
     * root.isMyParent(null) == true; <br>
     * root.isMyParent(*) == false <br>
     * *.isMyParent(null) == false
    
*/
   
public boolean isMyParent(String nodeId) {
        TreeNode target
= tree.getTreeNode(nodeId);
        TreeNode parent
= this.getParent();
       
if (parent == null) {
           
return target == null;
        }
else {
           
return parent.equals(target);
        }
    }

   
/**
     * A.isMyAncestor(B) == B is A' ancestor ? <br>
     * *.isMyAncestor(null) == true;
    
*/
   
public boolean isMyAncestor(String nodeId) {
        TreeNode target
= tree.getTreeNode(nodeId);
       
if (target == null)
           
return true;

       
return target.getAllChildren().contains(this);
    }

   
/**
     * A.isMyBrother(B) == B is A' brother ? <br>
     * *.isMyBrother(null) == false
    
*/
   
public boolean isMyBrother(String nodeId) {
        TreeNode target
= tree.getTreeNode(nodeId);
       
if (target == null)
           
return false;

        TreeNode p1
= this.getParent();
        TreeNode p2
= target.getParent();
       
return ObjectUtils.equals(p1, p2);
    }

}

然后建立业务 Tree

CatalogTree.java

package com.humpic.helper.tree;

import java.util.List;
import org.apache.commons.collections.Predicate;

public class CatalogTree extends Tree {
   
private static CatalogTree instance = null;

   
private CatalogTree() {}
   
   
public static synchronized CatalogTree getInstance() {
       
if (instance == null) {
            instance = new CatalogTree();
            instance.reloadCatalogs();
        }
       
return instance;
    }

   
protected TreeNode transform(Object info) {
        Catalog catalog
= (Catalog) info;
        TreeNode node
= new TreeNode();
        node.setNodeId(catalog.getCatalogId());
        node.setParentId(catalog.getParentId());
        node.setBindData(catalog);
       
return node;
    }

   
public void reloadCatalogs() {
        List nodes
= CatalogDAO.getInstance().findAll();
       
super.reload(nodes);
    }

   
public Catalog getCatalogNode(String catalogId) {
        TreeNode node
= super.getTreeNode(catalogId);
       
return node == null ? null : (Catalog) node.getBindData();
    }
}

最后,我们只要使用以下的语句就可以了:

1. CatalogTree.getInstance().getTreeNode(...)
2. CatalogTree.getInstance().getCatalogNode(...)
3. CatalogTree.getInstance().getRootNode()

然后通过 TreeNode,就可以得到 parent, parents 和 children, allChildren

2007年12月14日星期五

很有趣的計算

 

如果令 A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 分别等于百分之
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,

那么Hard work (努力工作)H+A+R+D+W+O+R+K 8+1+18+4+23+15+18+11 = 98%,

Knowledge(知识)K+N+O+W+L+E+D+G+E 11+14+15+23+12+5+4+7+5 = 96%,

Love(爱情),L+O+V+E12+15+22+5 = 54%,

Luck(好运),L+U+C+K12+21+3+11 = 47%
(这些我们通常认为重要的东西往往并不是最重要的)。

什么能使得生活变得圆满?是Money(金钱)吗?
不!
M+O+N+E+Y = 13+15+14+5+25 =72%。

是Leadership(领导能力)吗?
不!
L+E+A+D+E+R+S+H+I+P =12+5+1+4+5+18+19+9+16 =89%。

那么,什么能使生活变成100%的圆满呢?每个问题都有其解决之道,只要你把目光放得远一点!
ATTITUDE(心态)A+T+T+I+T+U+D+E= 1+20+20+9+20+21+4+5 = 100%。

我们对待工作、生活的态度能够使我们的生活达到100%的圆满

2007年12月10日星期一

把回忆放在心里

把回忆放在心里

让它慢慢消融

变成心底的力量

往好处想想

心就能豁然开朗

——

我很幸福

这一刻

我觉得自己

真棒!

2007年12月7日星期五

超敏感

工作,已经将我拉向一个我自己都不认识的自我。我无法控制自己那些奇怪的想法。人和人之间需要这样互相提防、互相猜疑吗?我想要放开一点,坦然的面对,但是始终有个强烈的信号告诉我,要学会提防……

The Little Things Give You Away, 爱, 恨, 痛, 痴, 狂, 思念, 麻木………这些词汇充斥着头脑。

May be someday, I'll say goodbye to myself. The real me will pull me out.

2007年11月30日星期五

A Little Short Break(2)

Coming back from Shanghai, life continues. All I must face is the test case and the fu*king d*mn cell phone. My mentor will leave this company for a new job, only left a bad condition to me. I've to fight against all the bad situation by myself. I'm so depressed. After she left, many workmates asked me if I feel a little sad, I said, "Yes, a little" as a response. Yes, of course, there is no excuse for me to blame her, but she really did something good for someone but nothing to me.

But when a hero meet his chance, he must change all the situation by himself! Those hypocrites must pay for what they've owed. It's time for revenge, and a real hero must fight against all the pain and finally reach the goal.

A friend of mine reminds me to change my job. But I think I should study more and try to find more chances. L P must be performed at about Jan.2008 or laterly July.2008.

2007年11月23日星期五

A Little Short Break(1)

After a long time of dreariness, I decided to continue my blog life. But there was so many things that I missed to record in my blog... So I will write them down for filler.

Firstly, I want to talk about my journey to Shanghai, 'cause it was so amazing. On November 17th, 2007, I was on the train to Shanghai. It was really a long trip, and I kept staying on the train for almost 24 hours. When I got up the next morning, I felt that my teeth were a little hurt when I occluded them. So I think I cannot sleep in a downhill pose any more, the gravity will pull my teeth out of my mouth... But a girl beside me took my attention. She was reading, no, excectly was recite the lyrics of Linkin Park. And I found many some other person who was doing this too. It was so funny, 'cause there were so many person who have the same aim for this journey with me.

Steping on the ground in front of Shanghai Railway Station, I met my friend who was acquainted in Wuhan during my training time at Worksoft Corp.(but now it is called VanceInfo, I think it is just bullshit...).

This friend is working for Huawei Corp. and majored in writing C++ codes to implement the arithmetic in the router. He told me a lot about the life he is living, and I found that everyone has a hard life now, no matter what you do, no matter who you are, developer or tester, we are facing the same problem, that is, how to survive in the world which is based on the relationship among the persons.

But time goes so fast, the concert which is performed by Linkin Park, the most popular alternative rock 'n roll band in the world, was going to start. After the farewall to this friend, I entered the gymnasium by myself. The air is so cold, and I was waiting for the beginning of this concert full of expection.

When the light turned off, the music at the beginning wasn't performed by Linkin Park, but a Chinese band which is called Shouren. All the audience were shouting loudly:"Get out! Son of b*tches!". Then the voice became more unitive. Ther're shouting:"Linkin Park, Linkin Park..." At that time, I suddenly realised why do the Chinese bands get little progress. We should give them more support, though they may be not so mature.

After six songs, Shouren quited the sence. All the lights in the gymnasium turned on, and the audience continued waiting for their favorite band. We played the game called people wave for warming our cold bodies.

Linkin Park began to perform! Wow, it was so amazing and excited! The first song is One Step Closer. And everybody shouted loudly with Chester. The next 90 minutes, audience had a wonderful night, with their favorite band, and favorite music. They co-operated many songs, such as In The End, No More Sorrow, What i've Done, Faint and more...

In my opinion, Linkin Park is such a successful rock 'n roll band which can attract my interesting. Their songs gives me a sense on my mind, that is abreacting all the gloomy mood. Though I have to face the truth in the end, but a little time with their songs is the most powerful moment in my mental life. I will think more about my current life and the further life.

2007年11月7日星期三

我想要的

一点点钢琴
加上
一点点的爵士鼓点
再加上
一点点萨克斯的哔卟

一点点小号
加上
一点点双响筒声
再加上
一点点吉他弦的颤动

一杯热茶
一个悠闲的午后
一些来自朋友的消息

是的

我要的就是这些

2007年11月6日星期二

回不去了

上周偶尔看到小象的签名,说道回母校触景生情的一刻。忽然联想到自己9月底回学校的时候。理工校区还是没变,依然那么纯朴,宁静,周围的小商店甚至都没有换个牌匾。但是西区就显得荒凉了许多,很多树都不在了,好在还有人。宿舍楼上插着的红旗还在那里垂着头飘着,只是里面住的不再是自己熟悉的那群人,而那群人现在也已经散落在以北京为中心,半径不出中国国界的范围之内。真的很害怕回想几个月前还在体验的宿舍生活:和宿舍人说说笑笑,在别的宿舍打拳皇,到水房冲凉……这一切的一切都仿佛那么真实,但是这些场景又确确实实仅仅是记忆的影子。

工作快半年了,每天除了按手机还要应付各种微妙的人际关系,真的觉得很累。以前那种成就感也荡然无存。我慢慢变得多疑和敏感起来,话也少了。兴趣和爱好也逐渐的远离我。。。就像地铁里广告上说的,当我正在为生活奔波的时候,生活却逐渐远离了我。。。

周末在阿亮那里会见了小象一行,感觉格外亲切。小象向我展示了他回学校拍的照片。从灰蒙蒙的照片上,我看到了空无一人的西校区。红旗还是在那里飘着,那些教学楼还是在那里立着,只是地上全是落叶,没有人打扫,也没有一个人影。。。

真不敢想像,也许下次回石家庄,再想去看看这个校区,能看到的也许是高楼,也许是体育场,也许是别的什么吧。。。

关于大学的记忆,就这样,被强迫留在了梦里,再也找不到它的故居,它的发源地。。。