逸言

如何实现领域驱动设计

| Comments

从Eric Evans写下经典名著Domain-Driven Design: Tackling Complexity in the Heart of Software至今,DDD刚好发展了十年的时间。它几乎成了开发复杂软件系统主要的领域设计方法,既是面向对象(组件)设计的补充,又超越了面向对象(组件)设计。DDD中提出的诸多概念如实体、值对象、聚合等,已经成为了耳熟能详的设计术语。DDD社区的发展也如火如荼,似乎并没有被层出不穷的设计思想所取代,相反,它仍在强劲地发展,吸收了许多新的概念与方法,例如函数式编程思想、Event Source、CQRS等。然而,就我个人所观察到的情况来看,许多项目虽然号称应用了DDD设计,但主要都停留在Eric所谓的“战术设计”层面。即使是战术层面,依旧有许多程序员没有弄明白实体与值对象之间的区别,不知道该怎么定义聚合以及聚合根,更谈不上合理地划分上下文(Context)。

我不明白其中内含的真实原因,只能冒昧地揣测是否DDD显得高高在上?究其原因,会否还是Eric惹的祸,他的那本经典之作美则美矣,却显得有些不接地气?至少,我的阅读感受正是如此。虽然在之后,国内也引进了其他一些与DDD相关的著作,例如Jimmy Nilsson的著作《领域驱动设计与模式实战》。这些书好虽好,却并没有全面深入地阐述领域驱动设计,更谈不上完整地实践,直到Vaughn Vernon的《实现领域驱动设计(Implementing Domain-Driven Design)》的出现。

这本书首先吸引我的是书中的第2章至第4章。虽然书中内容几乎忠实地反映了Eric Evans的DDD理论,但作者却创造性地在一开始就着眼于DDD的战略性设计,包括领域(Domain)、子域(Subdomain)、受限上下文(Unbounded Context)、上下文映射(Context Map)以及架构。以第4章架构为例,书中对DDD经典的分层架构进行了深入探讨与分析,并颠覆性地提出将基础设施层(Infrastructure Layer)置于用户接口层(User Interface Layer)之上。最初读来,简直让我莫名惊诧,然而仔细思索,从依赖倒置原则的角度来分析,实在是合乎情理。坦白说,它彻底解决了之前一直纠缠在我心底的一个问题:若我们视实体为Repository以及数据访问的对象,应将实体置于哪一层?在DDD中,实体对象承担了领域业务行为,但同时又可能通过ORM与数据表产生映射。基础设施层的数据访问对象(即传统的DAO)需要调用这些实体对象。若它处于最底层,则会造成业务行为与基础设施的混合。若将实体与数据映射对象分离,既会造成对象之间的重复,又会导致不好的贫血对象。而将基础设施层放在分层架构的上端,非常巧妙地解决了这一问题。

测试数据准备框架

| Comments

这是我去年写的一个小框架,专为自动化测试准备数据。以我个人的经验,进行自动化测试尤其是单元测试,除了技能的障碍外,最大的障碍有两点:1)难以解除依赖,因而无法为相关功能编写独立的测试;2)数据准备困难,导致编写测试的成本高。在我的一篇博客《推行TDD的思考》中有相关总结。尤其在企业级软件系统中,面对的领域相对复杂,被测接口常常需要输入复杂的数据,然后再返回复杂的数据。在面向对象开发中,这些数据常常被建模为对象。我们该怎么实例化这些对象?在单元测试中,我们常常会引入Builder模式,通过Fluent Interface的方式建立类似DSL的构建接口,以便于自由、流畅而可任意组合的方式,帮助编写测试的人实例化他想要创建的对象。然而,一旦这个对象内嵌了多层,或具有极多的属性时,创建就变得极为艰难了。

在ThoughtWorks的一些项目中,尝试使用Yaml来准备数据。有一个极好的框架snakeyaml可以很好地支持我们处理yaml文件。正是基于此,启发我开发了这样一个小框架Sisyphus。它可以帮助更方便地以各种文件形式来准备数据,并提供了统一的接口。目前,支持的格式为我们最常使用的yaml与json。

框架的开发并没有什么技术含量,但框架提供的功能却是基于实际项目中面临的困难逐步演化出来的。例如框架提供的模板功能,数据分节功能,在一开始并没有想到。正是因为这两个功能,让我觉得这个框架还有一些用处。之所以将这个框架命名为Sisyphus,缘由在于我将测试数据视为西西弗推动的那一块大石头,无法承受的如命运一般的沉重,却又不得不用力去承受,如此往返以致时时刻刻。

ThoughtWorks中国区文集出版

| Comments

这次参加TID会议,居然看到了这本耗时达3年的书《软件开发践行录——ThoughtWorks中国区文集》——终于出版了。很早以前,凯峰就提出这个想法,当时他作为InfoQ中文站原创社区的主编,着实策划了不少优秀的原创技术文章。凯峰作为ThoughtWorks的一份子,很好地搭建了ThoughtWorks实践与社区的分享桥梁。不少由TWer撰写的优秀文章陆续在InfoQ发表。然而,从想法到本书出版经历了如此漫长的时间,却也出乎我意料之外。大约一年前,在我提供了个人信息给负责此事的同事后,就没再关心此事,差不多就要淡忘了。如今看到这本漂亮的汇集了中国区TWer心得体会的小书,真是莫名的惊喜啊。就好似那些珍贵的东西你会忘记,却在再次遇见时,忽然发现这种珍贵一点都没有减少。

无论如何,我都为加入ThoughtWorks感到开心而自豪;我也为能有文章选入这本文集而感到荣幸。这是一本散文集,是我们书写在IT边上的日志,又或是一种对技术的诠释与注解,未必必然美丽,却是心灵的一种悸动,一声叹息,是因为技术的狂热而挑动的心弦。这份执着,更是一种美——必然是美!

Scala学习资源

| Comments

网站

Twitter提供的Scala School:讲解简洁,可以作为快速入门

Twitter编写的如何有效开发Scala的文档——Effective Scala

一个非常棒的Scala网上教程:可以直接在网页上修改程序和运行程序

很好的Scala社区网站:只是最近似乎很少更新

当然,不能忘记了Scala的官方网站提供的文档:这或许可以说是最权威的内容,同时,也会提供最新的内容

我自己整理的Scala编码规范与最佳实践:是我结合项目情况并参考相关书籍和文章,以及个人的体会整理的。内容在不断更新中。若愿意贡献一份力量,可以和我联系,我可以加你为Contributor。

博客

Alvin Alexander的博客:内有诸多Scala文章,Alvin是Scala Cookbook一书的作者

阿里巴巴Hongjiang的博客:有很多成系列的Scala文章

视频与教程

Scala之父Martin Odersky在Scala教学视频。你还可以在国内的这个网站上在线观看,在这个网站上,你还能阅读到Akka文档的中文版。

你还可以通过下载Activator,然后通过运行activator,生成各式各样的Scala开发模板(包括Play、Akka、Spray、Spark)。生成的模板有代码和简明教程。

若想更扎实的掌握函数式编程,可以在学习Scala之前,先学习Heskell。 学习Heskell的在线书:写得简洁易懂,很生动。可以作为heskell的入门书籍

书籍

如果你希望快速地了解Scala的语法,可以阅读《快学Scala》,即Scala for the I’mpatient;但是,如果你希望了解真正的Scala精髓,那么奉劝大家不要阅读此书,而应该阅读Scala宝典,由Martin Odersky亲自撰写的著作Programming in Scala。不要阅读此书的中文版,翻译实在糟糕。

如果你想要深入理解Scala的内在机制,可以阅读Scala in Depth;我的同事诺铁翻译了此书,即日出版。

如果你想了解更多Scala的案例运用,可以阅读Scala Cookbook。书中提供了大量的案例。

如果你想了解Scala的函数式运用,请阅读Paul Chiusano撰写的Functional Programming in Scala

ListBuffer vs List in Scala

| Comments

我们有一个需求,需要在Scala中调用JDBC对数据库进行查询。然后将查询的结果ResultSet放到一个自定义结果类SqlResult中:

case class SqlResult(name: List[String], value: List[List[String]])

SqlResult的第一个构造函数参数存储的是数据表的列名,第二个参数存储数据表的行记录。由于ResultSet是Java中的一个对象,并不支持Scala的常用集合操作,因此这种转换是有必要的。我引入了隐式类(放在一个package object中)来完成这个转换,在转换过程中,由于需要对ResultSet进行遍历,因而引入了一个结果集List。默认情况下,Scala的List是immutable的,因此将其声明为var:

package object db {
     implicit class ResultSetUtil(rs: ResultSet) {
          private val columnCount = rs.getMetaData.getColumnCount

          def rows: List[List[String]] = {
               var valueList: List[List[String]] = List()
               while (rs.next()) {
                    val oneLine = (1 to columnCount).map(rs.getString).toList
                    valueList = oneLine :: valueList
               }
               valueList.reverse
          }

          def columns: List[String] = {
               (1 to columnCount).map(rs.getMetaData.getColumnName).toList
          }
     }
}

有了这个隐式转换,操作ResultSet就变简单了:

def query(sql: String): SqlResult = {
    stmt = conn.createStatement()
    rs = stmt.executeQuery(sql)
    SqlResult(rs.columns, rs.rows)
}

由于在前面的实现中,我初始化了一个Immutable的List,因此只能使用::添加每行从ResultSet得到的记录,然后再赋值给valueList。::方法只能将后加入的元素放到List的头部。所以在遍历完毕后,还需要做一个reverse操作。

Magic Scala(1): Call by Name

| Comments

在Scala中,调用函数有两种形式:Call by value(按值调用)和call by name(按名称调用)。若是call by value,会先计算参数的值,然后再传递给被调用的函数;若是call by name,参数会到实际使用的时候才计算。例如:

val logEnable = false

def log(msg: String) =
    if (logEnable) println(msg)

val MSG = "programing is running"

log(MSG + 1 / 0)

此时的log函数是call by value。因此在调用log函数时,会先计算传入的参数,此时会计算MSG + 1/0。由于表达式中有0作为被除数,因此会抛出异常:

Exception in thread "main" java.lang.ArithmeticException: / by zero
        at Main$.main(scala-script352098905369979205.scala:16)
        at Main.main(scala-script352098905369979205.scala)
exit value is 1
Program exited.

如果修改log的定义为:

def log(msg: => String) =
     if (logEnable) println(msg)

当调用log函数:log(MSG + 1/0)时,它首先并不会计算MSG + 1/0表达式,而是先执行log的函数体,即判断logEnable的值。此时logEnable值为false,此时就不会执行该分支println(msg)。既然不会执行println,就不会计算MSG + 1/0。因此就不会抛出异常。

再看另外一个例子。首先定义一个函数:

def getOneWhatever():Int = {
     println("calling getOneWhatever")
     1
}

然后,再定义两个函数,分别用call by value和call by name的方式:

def callByValue(x: Int) = {
     println("x1=" + x)
     println("x2=" + x)
}

def callByName(x: => Int) = {
     println("x1=" + x)
     println("x2=" + x)
}

如果执行callByValue(getOneWhatever()),则结果为:

calling getOneWhatever
x1=1
x2=1

若执行callByName(getOneWhatever()),则结果为:

calling getOneWhatever
x1=1
calling getOneWhatever
x2=1

注意看二者的区别,采用by name的方式,getOneWhatever函数被执行了两次,这是因为在callByName函数中,传入的参数被调用了两次。

Spark的硬件配置

| Comments

从MapReduce的兴起,就带来一种思路,就是希望通过大量廉价的机器来处理以前需要耗费昂贵资源的海量数据。这种方式事实上是一种架构的水平伸缩模式——真正的以量取胜。毕竟,以现在的硬件发展来看,CPU的核数、内存的容量以及海量存储硬盘,都慢慢变得低廉而高效。然而,对于商业应用的海量数据挖掘或分析来看,硬件成本依旧是开发商非常关注的。当然最好的结果是:既要马儿跑得快,还要马儿少吃草。

Spark相对于Hadoop的MapReduce而言,确乎要跑得迅捷许多。然而,Spark这种In-Memory的计算模式,是否在硬件资源尤其是内存资源的消耗上,要求更高呢?我既找不到这么多机器,也无法租用多台虚拟instance,再没法测评的情况下,只要寻求Spark的官方网站,又或者通过Google搜索。从Spark官方网站,Databricks公司Patrick Wendell的演讲以及Matei Zaharia的Spark论文,找到了一些关于Spark硬件配置的支撑数据。

Spark与存储系统

如果Spark使用HDFS作为存储系统,则可以有效地运用Spark的standalone mode cluster,让Spark与HDFS部署在同一台机器上。这种模式的部署非常简单,且读取文件的性能更高。当然,Spark对内存的使用是有要求的,需要合理分配它与HDFS的资源。因此,需要配置Spark和HDFS的环境变量,为各自的任务分配内存和CPU资源,避免相互之间的资源争用。

若HDFS的机器足够好,这种部署可以优先考虑。若数据处理的执行效率要求非常高,那么还是需要采用分离的部署模式,例如部署在Hadoop YARN集群上。

Spark对磁盘的要求

Spark是in memory的迭代式运算平台,因此它对磁盘的要求不高。Spark官方推荐为每个节点配置4-8块磁盘,且并不需要配置为RAID(即将磁盘作为单独的mount point)。然后,通过配置spark.local.dir来指定磁盘列表。

Spark对内存的要求

Spark虽然是in memory的运算平台,但从官方资料看,似乎本身对内存的要求并不是特别苛刻。官方网站只是要求内存在8GB之上即可(Impala要求机器配置在128GB)。当然,真正要高效处理,仍然是内存越大越好。若内存超过200GB,则需要当心,因为JVM对超过200GB的内存管理存在问题,需要特别的配置。

内存容量足够大,还得真正分给了Spark才行。Spark建议需要提供至少75%的内存空间分配给Spark,至于其余的内存空间,则分配给操作系统与buffer cache。这就需要部署Spark的机器足够干净。

考虑内存消耗问题,倘若我们要处理的数据仅仅是进行一次处理,用完即丢弃,就应该避免使用cache或persist,从而降低对内存的损耗。若确实需要将数据加载到内存中,而内存又不足以加载,则可以设置Storage Level。Spark提供了三种Storage Level:MEMORY_ONLY(这是默认值),MEMORY_AND_DISK,以及DISK_ONLY。

关于数据的持久化,Spark默认是持久化到内存中。但它也提供了三种持久化RDD的存储方式:

  • in-memory storage as deserialized Java objects

  • in-memory storage as serialised data

  • on-disk storage

第一种存储方式性能最优,第二种方式则对RDD的展现方式(Representing)提供了扩展,第三种方式则用于内存不足时。

然而,在最新版(V1.0.2)的Spark中,提供了更多的Storage Level选择。一个值得注意的选项是OFF_HEAP,它能够将RDD以序列化格式存储到Tachyon中。相比MEMORY_ONLY_SER,这一选项能够减少执行垃圾回收,使Spark的执行器(executor)更小,并能共享内存池。Tachyon是一个基于内存的分布式文件系统,性能远超HDFS。Tachyon与Spark同源同宗,都烙有伯克利AMPLab的印记。目前,Tachyon的版本为0.5.0,还处于实验阶段。

注意,RDDs是Lazy的,在执行Transformation操作如map、filter时,并不会提交Job,只有在执行Action操作如count、first时,才会执行Job,此时才会进行数据的加载。当然,对于一些shuffle操作,例如reduceByKey,虽然仅是Transformation操作,但它在执行时会将一些中间数据进行持久化,而无需显式调用persist()函数。这是为了应对当节点出现故障时,能够避免针对大量数据进行重计算。要计算Spark加载的Dataset大小,可以通过Spark提供的Web UI Monitoring工具来帮助分析与判断。

Spark的RDD是具有分区(partition)的,Spark并非是将整个RDD一次性加载到内存中。Spark针对partition提供了eviction policy,这一Policy采用了LRU(Least Recently Used)机制。当一个新的RDD分区需要计算时,如果没有合适的空间存储,就会根据LRU策略,将最少访问的RDD分区弹出,除非这个新分区与最少访问的分区属于同一个RDD。这也在一定程度上缓和了对内存的消耗。

Spark对内存的消耗主要分为三部分:1. 数据集中对象的大小;2. 访问这些对象的内存消耗;3. 垃圾回收GC的消耗。一个通常的内存消耗计算方法是:内存消耗大小= 对象字段中原生数据 * (2~5)。 这是因为Spark运行在JVM之上,操作的Java对象都有定义的“object header”,而数据结构(如Map,LinkedList)对象自身也需要占用内存空间。此外,对于存储在数据结构中的基本类型,还需要装箱(Boxing)。Spark也提供了一些内存调优机制,例如执行对象的序列化,可以释放一部分内存空间。还可以通过为JVM设置flag来标记存放的字节数(选择4个字节而非8个字节)。在JDK 7下,还可以做更多优化,例如对字符编码的设置。这些配置都可以在spark-env.sh中设置。

Spark对网络的要求

Spark属于网络绑定型系统,因而建议使用10G及以上的网络带宽。

Spark对CPU的要求

Spark可以支持一台机器扩展至数十个CPU core,它实现的是线程之间最小共享。若内存足够大,则制约运算性能的就是网络带宽与CPU数。

Spark官方利用Amazon EC2的环境对Spark进行了基准测评。例如,在交互方式下进行数据挖掘(Interative Data Mining),租用Amazon EC2的100个实例,配置为8核、68GB的内存。对1TB的维基百科页面查阅日志(维基百科两年的数据)进行数据挖掘。在查询时,针对整个输入数据进行全扫描,只需要耗费5-7秒的时间。如下图所示:

在Matei Zaharia的Spark论文中还给出了一些使用Spark的真实案例。视频处理公司Conviva,使用Spark将数据子集加载到RDD中。报道说明,对于200GB压缩过的数据进行查询和聚合操作,并运行在两台Spark机器上,占用内存为96GB,执行完全部操作需要耗费30分钟左右的时间。同比情况下,Hadoop需要耗费20小时。注意:之所以200GB的压缩数据只占用96GB内存,是因为RDD的处理方式,使得我们可以只加载匹配客户过滤的行和列,而非所有压缩数据。

基于Akka的REST框架Spray

| Comments

基于Akka的REST框架Spray,由于采用Akka提供的Actor模型,写出来的代码与通常的REST有很大的区别。从Spray-Can接收Http请求,到处理具体的HTTP动词以实现业务逻辑,都是通过传递消息的方式。这些消息再交由Akka Actor接收处理。消息皆定义为Scala提供的样例类(Case Class),从而保证消息为immutable。既然如此,当我们在运用Spray时,就需要转换思想,从传统的面向对象中解放出来,充分理解Event、Command,及其传递的Message。这近似于事件驱动(Event Driven),因而在对领域建模时,也需要将Event看做是领域模型的一等公民,并将领域逻辑建模为一种状态机。

我们可以首先根据Http请求,确定需要哪些消息。这样的Request消息几乎与Http动词以及Resource对应,例如:

sealed trait RequestMessage

case class GetCustomer(id: Long) extends RequestMessage
case class DeleteCustomer(id: Long) extends RequestMessage
case class UpdateCustomer(customer: Customer) extends RequestMessage
case class CreateCustomer(dueDate: Date, text: String) extends RequestMessage

现在可以定义一个Actor来响应客户端请求。该Actor要求派生自Akka Actor,同时还要实现Spray提供的HttpService trait。若要支持Json格式,还需实现Spray-Json提供的Json4sSupport。例如:

class CustomerServiceActor extends Actor with HttpService with CustomerRequestCreator with Json4sSupport {
  implicit def actorRefFactory = context

  val json4sFormats = DefaultFormats

  def receive = runRoute(customerRoute)

  val customerRoute =
    path("customers" / LongNumber) {
      id: Long =>
        get {
          rejectEmptyResponse {
            handleRequest {
              GetCustomer(id)
            }
          }
        } ~ put {
          entity(as[Customer]) {
            customer =>
              handleRequest {
                UpdateCustomer(new Customer(id, customer.birthDate, customer.name))
              }
          }
        } ~ delete {
          handleRequest {
            DeleteCustomer(id)
          }
        }
    } ~ path("customers") {
      get {
        handleRequest {
          AllCustomers
        }
      }
    } ~ post {
      entity(as[Customer]) {
        customer =>
          handleRequest {
            CreateCustomer(customer.birthDate, customer.name)
          }
      }
    }

  def handleRequest(message: RequestMessage): Route =
    ctx => customerRequest(ctx, Props[CustomerActor], message)
}

该Actor与其他Akka Actor的不同之处在于它的receive方法调用了Spray提供的runRoute()方法。传入的参数customerRoute是Spray提供的DSL格式的Route。Route中对应支持Http动词。其中,get先调用了Spray提供的rejectEmptyResponse来过滤掉空的响应消息;而post方法则调用entity将url中的消息转换为Customer消息:

case class Customer(id: Long, birthDate: Date, name: String)

在Route中,可以定义多个Path,不同的Path支持不同的Http动词。在接受到请求后,通过handleRequest()方法来处理请求。这里的实现是将RequestMessage消息再转交到了另一个Actor。我会在后面介绍。

Spark概览

| Comments

Spark具有先进的DAG执行引擎,支持cyclic data flow和内存计算。因此,它的运行速度,在内存中是Hadoop MapReduce的100倍,在磁盘中是10倍。如下是对比图:

这样的性能指标,真的让人心动啊!

Spark的API更为简单,提供了80个High Level的操作,可以很好地支持并行应用。它的API支持Scala、Java和Python,并且可以支持交互式的运行Scala与Python。来看看Spark统计Word字数的程序:

file = spark.textFile("hdfs://...")

file.flatMap(line => line.split(" "))
    .map(word => (word, 1))
    .reduceByKey(_ + _)

看看Hadoop的Word Count例子,简直弱爆了,爆表的节奏啊:

public class WordCount {
  public static class TokenizerMapper
       extends Mapper<Object, Text, Text, IntWritable>{

    private final static IntWritable one = new IntWritable(1);
    private Text word = new Text();

    public void map(Object key, Text value, Context context
                    ) throws IOException, InterruptedException {
      StringTokenizer itr = new StringTokenizer(value.toString());
      while (itr.hasMoreTokens()) {
        word.set(itr.nextToken());
        context.write(word, one);
      }
    }
  }

  public static class IntSumReducer
       extends Reducer<Text,IntWritable,Text,IntWritable> {
    private IntWritable result = new IntWritable();


    public void reduce(Text key, Iterable<IntWritable> values,
                       Context context
                       ) throws IOException, InterruptedException {
      int sum = 0;
      for (IntWritable val : values) {
        sum += val.get();
      }
      result.set(sum);
      context.write(key, result);
    }
  }

  public static void main(String[] args) throws Exception {
    Configuration conf = new Configuration();
    String[] otherArgs = new GenericOptionsParser(conf, args).getRemainingArgs();
    if (otherArgs.length != 2) {
      System.err.println("Usage: wordcount <in> <out>");
      System.exit(2);
    }
    Job job = new Job(conf, "word count");
    job.setJarByClass(WordCount.class);
    job.setMapperClass(TokenizerMapper.class);
    job.setCombinerClass(IntSumReducer.class);
    job.setReducerClass(IntSumReducer.class);
    job.setOutputKeyClass(Text.class);
    job.setOutputValueClass(IntWritable.class);
    FileInputFormat.addInputPath(job, new Path(otherArgs[0]));
    FileOutputFormat.setOutputPath(job, new Path(otherArgs[1]));
    System.exit(job.waitForCompletion(true) ? 0 : 1);
  }
}

当然,Hadoop有自己的一套框架,为整个的大数据处理做支持,例如HIVE,例如HDFS。Spark也不逊色,也有自己的SQL框架支持,即Shark,此外还支持流处理、机器学习以及图运算:

Spark并没有自己的分布式存储方案。不过已经有了强悍的HDFS,同为Aparch旗下的Spark又何必再造一个差不多的轮子呢?所以Spark可以很好地与Hadoop集成。例如可以运行在Hadoop 2的YARN集群下,可以读取现有的Hadoop数据。当然,Spark自身也支持standadlone的部署,或者部署到EC2等云平台下。除了可以读取HDFS数据,它还可以读取HBase,Cassandra等NoSQL数据库。这扩大了Spark的适用范围。

目前的Spark官方发布还仅仅是0.9的孵化版本,这为它的商用造成一点点阻碍。针对一个新的大数据项目而言,是选用Spark,还是Hadoop,还真的难以抉择。当然,对于我们这种玩技术的,从来都是喜新厌旧,心里自然是偏向Spark了。

阅读的力量

| Comments

阅读,以灵魂融合的方式阅读,或许会战栗,恐惧,喜悦,哭泣,甚至紧张,却可以让你成为阅读的人存在,你既是独立的,又陷入作品中成为你臆想和理解的另一个人。这种阅读,使得你存在。

乔治.斯坦纳(George Steiner)在《语言与沉默(Language And Silence)》的第一篇《人文素养》中如是评价阅读:

那么,请尽可能地与文学同道。一个人读了《伊利亚特》第十四卷(普里阿摩斯夜会阿基琉斯),读了阿廖沙.卡拉玛佐夫跪向星空那一幕,读了《蒙田随笔》的第二十章,读了哈姆雷特对这章的引用,如果他的人生没有改变,他对自己生命的领悟没有改变,他没有用一点点彻底不同的方式大量他行走其中的屋子,打量那些敲门的人,那么,他虽然是用肉眼在阅读,但他的心眼却是盲视。读了《安娜.卡列尼娜》或普鲁斯特的人,在心灵的深处,能不体验到新的虚弱或需求?

……读了卡夫卡的《变形记》,却依然能够无畏地面对镜中的自己,这样的读者,也许从字面上说,能够识文断字,但在最根本的意义上,不过是白丁而已。

阅读确乎常常能打动我,当然,这也要取决于我面对的是何种作品。我的心灵并不坚强,甚而耽于安逸,每当阅读到那种让灵魂战栗或者恐慌的时候,我或许会想着逃离。我阅读卡夫卡《变形记》的感受如此,我几乎要被那种昏暗、恐慌、无助给击倒了,甚至有一种被人扼住喉咙要窒息的感觉。我在代入。我在想象当我在次日清晨突然发现自己变成一种爬虫,会是怎么样?又或者发现自己的爱人变成了丑陋的爬虫,蠕动着可以看到爬动的粘液,我会如何反应?答案是毫无疑问的步入心灵的昏暗层面,我既不能无动于衷,也不能泰然自若。在那一刻,是人生崩溃的感觉。

通常而言,好的小说会让人不忍卒读,而我在阅读《变形记》时,我有一种甩开书本的冲动。我害怕去看结果,甚至害怕想象这种场景;然而,这场景却像有了生命,拥有着执念一般地硬要挤入我的脑袋中来。坦白说,我害怕看到自己隐藏的丑恶,害怕撕开肌肤,入眼一片血淋淋!

我在阅读《蒙田随笔》时,就成为第三者旁观了。那些睿智充满哲思的话语,几乎无法打动我,我就像看着一个智慧老者无语的絮叨,我做出尊敬而认真倾听的样子,心里却在感叹:他,已经不是这个时代了。

大多数时候,当我阅读完一部伟大的作品,尤其是小说,会有一种空虚感。好像自己曾经步入过小说中虚构的世界。那个世界产生的重力如此之大,压着我直不起身;直到走出,突然感觉自己已经适应了那种重量,脚步反而变得虚浮起来。重要的是我的内心会产生寂寞感。当我阅读完《不能承受的生命之轻》、《不朽》时,如是;阅读完《树上的男爵》,也如是。

还有一种感觉就是解脱。小说描述主人公的种种形状,一定是被某种不可知的力量所牵引,规约。这种力量或许可以称之为命运,也可能是自己的性格,又或者是当时那个大时代的集体力量。巧合的是,这种作品多数是以第一人称描述。例如在读完《麦田的守望者》,《洛丽塔》,我几乎要长吁一口气,产生一种如释重负的轻松。

这样的阅读中的我,是否斯坦纳笔下的“白丁”呢?