This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,223 @@
<audio id="audio" title="07 | 解耦是永恒的主题MVC框架的发展" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/2c/fb731fb30307f07a181e7a310dab502c.mp3"></audio>
你好,我是四火。
欢迎进入第二章,本章我们将围绕 MVC 这个老而弥坚的架构模式展开方方面面的介绍,对于基于 Web 的全栈工程师来说,它是我们知识森林中心最茂密的一片区域,请继续打起精神,积极学习和思考。
无论是在 Web 全栈还是整个软件工程领域有很多东西在本质上是相通的。比如我们在前一章提到的“权衡”trade-off我们后面还会反复提到。MVC 作为贯穿本章的主题,今天我们就通过它来引出另一个关键词——解耦。
## JSP 和 Servlet
在我们谈 MVC 之前先来讲一对好朋友JSP 和 Servlet。说它们是好朋友是因为它们经常一起出现而事实上它们还有更为紧密的联系。
### 1. 概念介绍
如果你有使用 Java 作为主要语言开发网站的经历那么你一定听过别人谈论JSP和Servlet。其中Servlet 指的是服务端的一种 Java 写的组件,它可以接收和处理来自浏览器的请求,并生成结果数据,通常它会是 HTML、JSON 等常见格式,写入 HTTP 响应,返回给用户。
至于 JSP它的全称叫做 Java Server Pages它允许静态的 HTML 页面插入一些类似于“&lt;% %&gt;”这样的标记scriptlet而在这样的标记中还能以表达式或代码片段的方式嵌入一些 Java 代码,在 Web 容器响应 HTTP 请求时,这些标记里的 Java 代码会得到执行,这些标记也会被替换成代码实际执行的结果,嵌入页面中一并返回。这样一来,原本静态的页面,就能动态执行代码,并将执行结果写入页面了。
- 第一次运行时系统会执行编译过程并且这个过程只会执行一次JSP 会处理而生成 Servlet 的 Java 代码接着代码会被编译成字节码class文件在 Java 虚拟机上运行。
- 之后每次就只需要执行运行过程了Servlet能够接受 HTTP 请求,并返回 HTML 文本,最终以 HTTP 响应的方式返回浏览器。
这个过程大致可以这样描述:
>
<p>编译过程JSP页面 → Java文件Servlet→ class文件Servlet<br>
运行过程HTTP请求 + class文件Servlet→ HTML文本</p>
### 2. 动手验证
为了更好地理解这个过程,让我们来实际动手操作一遍。
首先,你需要安装两样东西,一样是 [JDKJava Development Kit8](https://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html)是Java的软件开发包另一样是 [Apache Tomcat 9](https://tomcat.apache.org/download-90.cgi)它是一款Web容器也是一款Servlet容器因此无论是静态的 HTML 页面,还是动态的 Servlet、JSP都可以部署在上面运行。
你可以使用安装包安装,也可以使用包管理工具安装(比如 Mac 下使用 Homebrew 安装)。如果你的电脑上已经安装了,只是版本号不同,也是没有问题的。
安装完成以后,打开一个新的命令行窗口,执行一下 java --version 命令,你应该能看到类似以下信息:
```
java -version
java version &quot;1.8.0_162&quot;
Java(TM) SE Runtime Environment (build 1.8.0_162-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.162-b12, mixed mode)
```
这里显示了JREJava Runtime EnvironmentJava 运行时环境)的版本号,以及虚拟机的类型和版本号。
同样地执行catalina version你也能看到Tomcat重要的环境信息
```
catalina version
Using CATALINA_BASE: ...
Using CATALINA_HOME: ...
Using CATALINA_TMPDIR: ...
(以下省略其它的环境变量,以及服务器、操作系统和 Java 虚拟机的版本信息)
```
其中CATALINA_HOME 是 Tomcat 的“家”目录,就是它安装的位置,我们在下面要使用到它。
现在启动Tomcat
```
catalina run
```
在浏览器中访问 [http://localhost:8080/](http://localhost:8080/)你应该能看到Tomcat的主页面
<img src="https://static001.geekbang.org/resource/image/2d/22/2d4916452a0970d27d8bcb4cfe7b4422.jpg" alt="">
接着,我们在 ${CATALINA_HOME}/webapps/ROOT 下建立文件 hello_world.jsp写入
```
Hello world! Time: &lt;%= new java.util.Date() %&gt;
```
接着,访问 [http://localhost:8080/hello_world.jsp](http://localhost:8080/hello_world.jsp),你将看到类似下面这样的文本:
```
Hello world! Time: Sat Jul 27 20:39:19 PDT 2019
```
代码被顺利执行了。可是根据我们学到的原理我们应该能找到这个JSP文件生成的Java和class文件它们应该藏在某处。没错现在进入如下目录${CATALINA_HOME}/work/Catalina/localhost/ROOT/org/apache/jsp你可以看到这样几个文件
```
index_jsp.java
hello_005fworld_jsp.java
index_jsp.class
hello_005fworld_jsp.class
```
你看前两个Java文件就是根据JSP生成的Servlet的源代码后两个就是这个Servlet编译后的字节码。以index开头的文件就是Tomcat启动时你最初看到的主页面而以hello开头的这两个文件则完全来自于我们创建的hello_world.jsp。
现在你可以打开 hello_005fworld_jsp.java如果你有Java基础那么你应该可以看得懂其中的代码。代码中公有类 hello_005fworld_jsp 继承自 HttpJspBase 类,而如果你查看 [Tomcat的API文档](https://tomcat.apache.org/tomcat-9.0-doc/api/org/apache/jasper/runtime/HttpJspBase.html)你就会知道原来它进一步继承自HttpServlet类也就是说这个自动生成的 Java 文件,就是 Servlet。
在117行附近你可以找到我们写在 JSP 页面中的内容,它们以流的方式被写入了 HTTP 响应:
```
out.write(&quot;Hello world! Time: &quot;);
out.print( new java.util.Date() );
out.write('\n');
```
通过自己动手,我想你现在应该更加理解 JSP 的工作原理了。你看JSP和Servlet并不是完全独立的“两个人”**JSP实际工作的时候是以Servlet的形式存在的**,也就是说,前者其实是可以转化成后者的。
### 3. 深入理解
那么问题来了我们为什么不直接使用Servlet而要设计出JSP这样的技术让其在实际运行中转化成Servlet来执行呢
最重要的原因,**从编程范型的角度来看JSP页面的代码多是基于声明式Declarative而Servlet的代码则多是基于命令式Imperative**,这两种技术适合不同的场景。这两个概念,最初来源于编程范型的分类,声明式编程,是去描述物件的性质,而非给出指令,而命令式编程则恰恰相反。
比方说典型的JSP页面代码中只有少数一些scriptlet大部分还是HTML等格式的文本而HTML文本会告诉浏览器这里显示一个按钮那里显示一个文本输入框随着程序员对代码的阅读可以形象地在脑海里勾勒出这个页面的样子这也是声明式代码的一大特点。全栈工程师经常接触到的HTML、XML、JSON和CSS等都是声明式代码。你可能注意到了这些代码都不是使用编程语言写的而是使用标记语言写的但是编程语言其实也有声明式的比如Prolog。
再来说命令式代码在Servlet中它会一条一条语句告诉计算机下一步该做什么这个过程就是命令式的。我们绝大多数的代码都是命令式的。声明式代码是告诉计算机“什么样”而不关注“怎么做”命令式代码则是告诉计算机“怎么做”而不关注“什么样”。
为什么需要两种方式?因为人的思维是很奇特的,**对于某些问题,使用声明式会更符合直觉,更形象,因而更接近于人类的语言;而另一些问题,则使用命令式,更符合行为步骤的思考模式,更严谨,也更能够预知机器会怎样执行**。
计算机生来就是遵循命令执行的因此声明式的JSP页面会被转化成一行行命令式的Servlet代码交给计算机执行。可是你可以想象一下如果HTML那样适合声明式表述的代码程序员使用命令式来手写会是怎样的一场噩梦——代码将会变成无趣且易错的一行行字符串拼接。
## MVC 的演进
我想你一定听过MVC这种经典的架构模式它早在20世纪70年代就被发明出来了直到现在互联网上的大多数网站都是遵从MVC实现的这足以见其旺盛的生命力。MVC模式包含这样三层
- 控制器Controller恰如其名主要负责请求的处理、校验和转发。
- 视图View将内容数据以界面的方式呈现给用户也捕获和响应用户的操作。
- 模型Model数据和业务逻辑真正的集散地。
你可能会想这不够全面啊这三层之间的交互和数据流动在哪里别急MVC在历史上经历了多次演进这三层再加上用户它们之间的交互模型是逐渐变化的。哪怕在今天不同的MVC框架的实现在这一点上也是有区别的。
### 1. JSP Model 1
JSP Model 1 是整个演化过程中最古老的一种请求处理的整个过程包括参数验证、数据访问、业务处理到页面渲染或者响应构造全部都放在JSP页面里面完成。JSP页面既当爹又当妈静态页面和嵌入动态表达式的特性使得它可以很好地容纳声明式代码而JSP的scriptlet又完全支持多行Java代码的写入因此它又可以很好地容纳命令式代码。
<img src="https://static001.geekbang.org/resource/image/ea/66/eaaca09b4681bfaa9d36ee1e5998d966.png" alt="">
### 2. JSP Model 2
在 Model 1 中,你可以对 JSP页面上的内容进行模块和职责的划分但是由于它们都在一个页面上物理层面上可以说是完全耦合在一起因此模块化和单一职责无从谈起。和 Model 1 相比Model 2 做了明显的改进。
- JSP只用来做一件事那就是页面渲染换言之JSP从全能先生转变成了单一职责的页面模板
- 引入JavaBean的概念它将数据库访问等获取数据对象的行为封装了起来成为业务数据的唯一来源
- 请求处理和派发的活交到纯Servlet手里它成为了MVC的“大脑”它知道创建哪个JavaBean准备好业务数据也知道将请求引导到哪个JSP页面去做渲染。
<img src="https://static001.geekbang.org/resource/image/a0/f8/a0d15b2dc1e87ebac4aea3526edf38f8.png" alt="">
通过这种方式你可以看到原本全能的JSP被解耦开了分成了三层这三层其实就是MVC的View、Model和Controller。于是殊途同归MVC又一次进入了人们的视野今天的MVC框架千差万别原理上却和这个版本基本一致。
上面提到了一个概念JavaBean随之还有一个常见的概念POJO这是在Java领域中经常听到的两个名词但有时它们被混用。在此我想对这两个概念做一个简短的说明。
- JavaBean其实指的是一类特殊的封装对象这里的“Bean”其实指的就是可重用的封装对象。它的特点是可序列化包含一个无参构造器以及遵循统一的getter和setter这样的简单命名规则的存取方法。
- POJO即Plain Old Java Object还是最擅长创建软件概念的Martin Fowler的杰作。它指的就是一个普通和简单的Java对象没有特殊限制也不和其它类有关联它不能继承自其它类不能实现任何接口也不能被任何注解修饰
所以二者是两个类似的概念通常认为它们之间具备包含关系即JavaBean可以视作POJO的一种。但它们二者也有一些共性比如它们都是可以承载实际数据状态都定义了较为简单的方法概念上对它们的限制只停留在外在表现即内部实现可以不“plain”可以很复杂比如JavaBean经常在内部实现中读写数据库
### 3. MVC的一般化
JSP Model 2 已经具备了MVC的基本形态但是它却对技术栈有着明确限制——Servlet、JSP和JavaBean。今天我们见到的MVC已经和实现技术无关了并且在MVC三层大体职责确定的基础上其中的交互和数据流动却是有许多不同的实现方式的。
不同的MVC框架下实现的MVC架构不同有时即便是同一个框架不同的版本之间其MVC架构都有差异比如ASP.NET MVC在这里我只介绍最典型的两种情况如果你在学习的过程中见到其它类型请不要惊讶重要的是理解其中的原理。
**第一种:**
<img src="https://static001.geekbang.org/resource/image/93/0f/93c35efa9a76e0bd940d30563188c20f.png" alt="">
上图是第一种典型情况这种情况下用户请求发送给Controller而Controller是大总管需要主动调用Model层的接口去取得实际需要的数据对象之后将数据对象发送给需要渲染的ViewView渲染之后返回页面给用户。
在这种情况下Controller往往会比较大因为它要知道需要调用哪个Model的接口获取数据对象还需要知道要把数据对象发送给哪个View去渲染View和Model都比较简单纯粹它们都只需要被动地根据Controller的要求完成它们自己的任务就好了。
**第二种:**
<img src="https://static001.geekbang.org/resource/image/73/bb/73950914ac1904faf5c40602f8f9e0bb.png" alt="">
上图是第二种典型情况请和第一种比较注意到了区别没有这种情况在更新操作中比较常见Controller调用Model的接口发起数据更新操作接着就直接转向最终的View去了View会调用Model去取得经过Controller更新操作以后的最新对象渲染并返回给用户。
在这种情况下Controller相对就会比较简单而这里写操作是由Controller发起的读操作是由View发起的二者的业务对象模型可以不相同非常适合需要CQRSCommand Query Responsibility Segregation命令查询职责分离的场景我在 [[第 08 讲]](https://time.geekbang.org/column/article/141679) 中会进一步介绍 CQRS。
### 4. MVC的变体
MVC的故事还没完当它的核心三层和它们的基本职责发生变化这样的架构模式就不再是严格意义上的MVC了。这里我介绍两种MVC的变体MVP和MVVM。
MVP包含的三层为Model、View和Presenter它往往被用在用户的界面设计当中和MVC比起来Controller被Presenter替代了。
- Model的职责没有太大的变化依然是业务数据的唯一来源。
- View变成了纯粹的被动视图它被动地响应用户的操作来触发事件并将其转交给Presenter反过来它的视图界面被动地由Presenter来发起更新。
- Presenter变成了View和Model之间的协调者Middle-man它是真正调度逻辑的持有者会根据事件对Model进行状态更新又在Model层发生改变时相应地更新View。
<img src="https://static001.geekbang.org/resource/image/b7/f2/b7e11bd5f911392cd2f1d51018b043f2.png" alt="">
MVVM是在MVP的基础上将职责最多的Presenter替换成了ViewModel它实际是一个数据对象的转换器将从Model中取得的数据简化转换为View可以识别的形式返回给View。View和ViewModel实行双向绑定成为命运共同体即View的变化会自动反馈到ViewModel中反之亦然。关于数据双向绑定的知识我还会在 [[第 10 讲]](https://time.geekbang.org/column/article/143834) 中详解。
<img src="https://static001.geekbang.org/resource/image/10/a8/1058b10f1bd38f518b483f7046c294a8.png" alt="">
## 总结思考
今天我们学习了 JSP 和 Servlet 这两个同源技术的本质,它们是分别通过声明式和命令式两种编程范型来解决同一问题的体现,接着围绕解耦这一核心,了解了 MVC 的几种形式和变体。
- JSP Model 1请求处理的整个过程全部都耦合在JSP页面里面完成
- JSP Model 2MVC 分别通过 JavaBean、JSP 和 Servlet 解耦成三层;
- MVC 的常见形式一:数据由 Controller 调用 Model 来准备,并传递给 View 层;
- MVC 的常见形式二Controller 发起对数据的修改,在 View 中查询修改后的数据并展示,二者分别调用 Model
- MVPPresenter 扮演协调者,对 Model 和 View 实施状态的更新;
- MVVMView 和 ViewModel 实行数据的双向绑定,以自动同步状态。
好,现在提两个问题,检验一下今天的学习成果:
- 我们介绍了JSP页面和Servlet在编程范型上的不同这两个技术有着不同的使用场景你能举出例子来说明吗
- 在介绍MVC的一般化时我介绍了两种典型的MVC各层调用和数据流向的实现你工作或学习中使用过哪一种还是都没使用过而是第三种
MVC是本章的核心内容在这之后的几讲中我会对MVC逐层分解今天的内容先打个基础希望你能真正地理解和消化这将有助于之后的学习。欢迎你在留言区和我讨论
## 扩展阅读
- 【基础】专栏文章中的例子有时会涉及到 Java 代码,如果你对 Java 很不熟悉,可以参考廖雪峰 Java 教程中“[快速入门](https://www.liaoxuefeng.com/wiki/1252599548343744/1255883772263712)”的部分,它很短小,但是覆盖了专栏需要的 Java 基础知识。
- 【基础】W3Cschool上的 [Servlet教程](https://www.w3cschool.cn/servlet/)和 [JSP教程](https://www.w3cschool.cn/jsp/),如果你对这二者完全不了解,那我推荐你阅读。在较为系统的教程中,这两个算较为简洁的,如果觉得内容较多,可以挑选其中的几个核心章节阅读。
- 如果你顺利地将文中介绍的Tomcat启动起来了并且用的也是9.x版本那么你可以直接访问 [http://localhost:8080/examples/](http://localhost:8080/examples/)里面有Tomcat自带的很多典型和带有源码的例子有JSP的例子也有Servlet的例子还有WebSocket的例子由于我们前一章已经学过了WebSocket这里你应该可以较为顺利地学习

View File

@@ -0,0 +1,202 @@
<audio id="audio" title="08 | MVC架构解析模型Model篇" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/67/e6a6474539e43fc4f39c60cfe32da467.mp3"></audio>
你好,我是四火。
在上一讲中,我们了解了 MVC 这个老而弥坚的架构模式,而从这一讲开始,连同第 09、10 讲共计 3 篇,我将分别展开介绍 MVC 三大部分内容。今天我要讲的就是第一部分——模型Model
## 概念
首先我们要了解的是,我们总在谈“模型”,那到底什么是模型?
简单说来,**模型就是当我们使用软件去解决真实世界中各种实际问题的时候,对那些我们关心的实际事物的抽象和简化**。比如我们在软件系统中设计“人”这个事物类型的时候,通常只会考虑姓名、性别和年龄等一些系统用得着的必要属性,而不会把性格、血型和生辰八字等我们不关心的东西放进去。
更进一步讲我们会谈领域模型Domain Model。“领域”两个字显然给出了抽象和简化的范围不同的软件系统所属的领域是不同的比如金融软件、医疗软件和社交软件等等。如今领域模型的概念包含了比其原本范围定义以外更多的内容**我们会更关注这个领域范围内各个模型实体之间的关系**。
MVC 中的“模型”,说的是“模型层”,它正是由上述的领域模型来实现的,可是当我们讲这一层的时候,它包含了模型上承载的实实在在的业务数据,还有不同数据间的关联关系。因此,**我们在谈模型层的时候,有时候会更关心领域模型这一抽象概念本身,有时候则会更关心数据本身**。
## 贫血模型和充血模型
第一次听到“贫血模型”“充血模型”这两个词的时候,可能你就会问了,什么玩意儿?领域模型还有贫血和充血之说?
其实,这两兄弟是 Martin Fowler 造出来的概念。要了解它们,得先知道这里讲的“血”是什么。
这里的“血”,就是逻辑。它既包括我们最关心的业务逻辑,也包含非业务逻辑 。因此,**贫血模型Anemic Domain Model意味着模型实体在设计和实现上不包含或包含很少的逻辑。**通常这种情况下逻辑是被挪了出去由其它单独的一层代码比如这层代码是“Service”来完成。
严格说起来,贫血模型不是面向对象的,因为对象需要数据和逻辑的结合,这也是贫血模型反对者的重要观点之一。如果主要逻辑在 Service 里面,这一层对外暴露的接口也在 Service 上,那么事实上它就变成了面向“服务”的了,而模型实体,实际只扮演了 Service API 交互入参出参的角色,或者从本质上说,它只是遵循了一定封装规则的容器而已。
**这时的模型实体,不包含逻辑,但包含状态,而逻辑被解耦到了无状态 Service 中。**既然没有了状态Service 中的方法,就成为过程式代码的了。请注意,不完全面向对象并不代表它一定“不好”,事实上,在互联网应用设计中,贫血模型和充血模式都有很多成功的使用案例,且非常常见。
比如这样的一个名为 Book 的类:
```
public class Book {
private int id;
private boolean onLoan;
public int getId() {
return this.id;
}
public void setId(int id) {
this.id = id;
}
public boolean isOnLoan() {
return this.loan;
}
public void setOnLoan(boolean onLoan) {
this.onLoan = onLoan;
}
}
```
你可以看到,它并没有任何实质上的逻辑在里面,方法也只有简单的 getters 和 setters 等属性获取和设置方法,它扮演的角色基本只是一个用作封装的容器。
那么真正的逻辑,特别是业务逻辑在哪里呢?有这样一个 Service
```
public class BookService {
public Book lendOut(int bookId, int userId, Date date) { ... }
}
```
这个 lendOut 方法表示将书从图书馆借出,因此它需要接收图书 id 和 用户 id。在实现中可能需要校验参数可能需要查询数据库可能需要将从数据源获得的原始数据装配到返回对象中可能需要应用过滤条件这里的内容就是逻辑。
现在我们再来了解一下充血模型Rich Domain Model。**在充血模型的设计中,领域模型实体就是有血有肉的了,既包含数据,也包含逻辑,具备了更高程度的完备性和自恰性**,并且,充血模型的设计才是真正面向对象的。在这种设计下,我们看不到 XXXService 这样的类了,而是通过操纵有状态的模型实体类,就可以达到数据变更的目的。
```
public class Book {
private int id;
private boolean onLoan;
public void lendOut(User user, Date date) { ... }
... // 省略属性的获取和设置方法
}
```
在这种方式下lendOut 方法不再需要传入 bookId因为它就在 Book 对象里面存着呢;也不再需要传出 Book 对象作为返回值,因为状态的改变直接反映在 Book 对象内部了,即 onLoan 会变成 true。
也就是说Book 的行为和数据完完全全被封装的方法控制起来了,中间不会存在不应该出现的不一致状态,因为任何改变状态的行为只能通过 Book 的特定方法来进行,而它是可以被设计者严格把控的。
而在贫血模型中就做不到这一点,一是因为数据和行为分散在两处,二是为了在 Service 中能组装模型,模型实体中本不该对用户开放的接口会被迫暴露出来,于是整个过程中就会存在状态不一致的可能。
但是请注意,**无论是充血模型还是贫血模型,它和 Model 层做到何种程度的解耦往往没有太大关系。**比如说这个 lendOut 方法,在某些设计中,它可以拆分出去。对于贫血模型来说,它并非完全属于 BookService可以拿到新建立的“借书关系”的服务中去比如
```
public class LoanService {
public Loan add(int bookId, int userId, Date date) { ... }
}
```
这样一来借书的关系就可以单独维护了借书行为发生的时候Book 和 User 两个实体对应的数据都不需要发生变化,只需要改变这个借书关系的数据就可以了。对于充血模型来说,一样可以做类似拆分。
## 内部层次划分
软件的耦合和复杂性问题往往都可以通过分层解决,模型层内部也一样,但是我们需要把握其中的度。**层次划分过多、过细,并不利于开发人员严格遵从和保持层次的清晰,也容易导致产生过多的无用样板代码,从而降低开发效率。**下面是一种比较常见的 Model 层,它是基于贫血模型的分层方式。
<img src="https://static001.geekbang.org/resource/image/f6/46/f6e1b220a80716532ac6dd54cb1b9f46.png" alt="">
在这种划分方式下,每一层都可以调用自身所属层上的其它类,也可以调用自己下方一层的类,但是不允许往上调用,即依赖关系总是“靠上面的层”依赖着“靠下面的层”。最上面三层是和业务模型实体相关的,而最下面一层是基础设施服务,和业务无关。从上到下,我把各层依次简单介绍一下:
- 第一层 Facade提供粗粒度的接口逻辑上是对 Service 功能的组合。有时候由于事务需要跨多个领域模型的实体控制,那就适合放在这里。举例来说,创建用户的时候,我们同时免费赠送一本电子书给用户,我们既要调用 UserService 去创建用户对象,也要调用 SubscriptionService 去添加一条订购(赠送)记录,而这两个属于不同 Service 的行为需要放到一处 Facade 类里面做统一事务控制。在某些较小系统的设计里面Service 和 Facade 这两层是糅合在一起的。
- 第二层 Service前面已经介绍了通常会存放仅属于单个领域模型实体的操作。
- 第三层数据访问层,在某些类型的数据访问中需要,比如关系型数据库,这里存放数据库字段和模型对象之间的 ORMObject-Relational Mapping对象关系映射关系。
- 第四层基础设施层,这一层的通用性最好,必须和业务无关。某些框架会把基础设施的工作给做了,但有时候也需要我们自己实现。比如 S3Service存放数据到亚马逊的分布式文件系统。
## CQRS 模式
你也许听说过数据库的读写分离,其实,在模型的设计中,也有类似读写分离的机制,其中最常见的一种就叫做 CQRSCommand Query Responsibility Segregation命令查询职责分离
一般我们设计的业务模型会同时被用作读查询模式和写命令模式但是实际上这两者是有明显区别的在一些业务场景中我们希望这两者被分别对待处理那么这种情况下CQRS 就是一个值得考虑的选项。
为什么要把命令和查询分离?我举个例子来说明吧,比如这样的贫血模型:
```
class Book {
private long id;
private String name;
private Date publicationDate;
private Date creationDate;
... // 省略其它属性和 getter/setter 方法
}
```
那么,相应地,就有这样一个 BookService
```
class BookService {
public Book add(Book book);
public Pagination&lt;Book&gt; query(Book book);
}
```
这个接口提供了两个方法:
一个叫做 add 方法,接收一个 book 对象,这个对象的 name 和 publicationDate 属性会被当做实际值写入数据库,但是 id 会被忽略,因为 id 是数据库自动生成的具备唯一性creationDate 也会被忽略,因为它也是由数据库自动生成的,表示数据条目的创建时间。写入数据库完成后,返回一个能够反映实际写入库中数据的 Book 对象,它的 id 和 creationDate 都填上了数据库生成的实际值。
你看,这个方法,实际做了两件事,一件是插入数据,即写操作;另一件是返回数据库写入的实际对象,即读操作。
另一个方法是 query 方法,用于查询,这个 Book 入参,被用来承载查询参数了 。比方说,如果它的 author 值为“Jim”表示查询作者名字为“Jim”的图书返回一个分页对象内含分页后的结果列表。
但这个方法,其实有着明显的问题。这个问题就是,查询条件的表达,并不能用简单的业务模型很好地表达。换言之,这个模型 Book能用来表示写入却不适合用来表示查询。为什么呢
比方说,你要查询出版日期从 2018 年到 2019 年之间的图书,你该怎么构造这个 Book 对象?很难办对吧,因为 Book 只能包含一个 publicationDate 参数,这种“难办”的本质原因,是模型的不匹配,即这个 Book 对象根本就不适合用来做查询调用的模型。
在清楚了问题以后,解决方法 CQRS 就自然而然产生了。**简单来说CQRS 模式下,模型层的接口分为且只分为两种:**
- **命令Command它不返回任何结果但会改变数据的状态。**
- **查询Query它返回结果但是不会改变数据的状态。**
也就是说,它把命令和查询的模型彻底分开了。上面的例子 ,使用 CQRS 的方式来改写一下,会变成这样:
```
class BookService {
public void add(Book book);
public Pagination&lt;Book&gt; query(Query bookQuery);
}
```
你看,在 add 操作的时候,不再有返回值;而在 query 操作的时候,入参变成了一个 Query 对象,这是一个专门的“查询对象”,查询对象里面可以放置多个查询条件,比如:
```
Query bookQuery = new Query(Book.class);
query.addCriteria(Criteria.greaterThan(&quot;publicationDate&quot;, date_2018));
query.addCriteria(Criteria.lessThan(&quot;publicationDate&quot;, date_2019));
```
读到这里,不知道你有没有联想到这样两个知识点:
第一个知识点,在 [[第 04 讲]](https://time.geekbang.org/column/article/136795) 我们学习 REST 风格的时候,我们把 HTTP 的请求从两个维度进行划分,是否幂等,以及是否安全。**按照这个角度来考量CQRS 中的命令可能是幂等的例如对象更新也可能不是幂等的例如对象创建但一定是不安全的CQRS 中的查询,一定是幂等的,且一定是安全的。**
第二个知识点,在 [[第 07 讲]](https://time.geekbang.org/column/article/140196) 我们学习 MVC 的一般化其中的“第二种”典型情况时Controller 会调用 Model 层的,执行写入操作;而 View 层会调用 Model 层,执行只读操作——看起来这不就是最适合 CQRS 的一种应用场景吗?
所以说,技术确实都是相通的。
## 总结思考
今天我们详细剖析了 MVC 架构中 Model 层的方方面面,并结合例子理解了贫血模型和充血模型的概念和特点,还介绍了一种典型的模型层的内部层次划分方法,接着介绍了 CQRS 这种将命令和查询行为解耦开的模型层设计方式。
其中,贫血模型和充血模型的理解是这一讲的重点,这里我再强调一下:
- 贫血模型,逻辑从模型实体中剥离出去,并被放到了无状态的 Service 层中,于是状态和逻辑就被解耦开了;
- 充血模型,它既包含数据,也包含逻辑,具备了更高程度的完备性和自恰性。
最后,我来提两个问题,一同检验下今天的学习成果吧:
- 请回想一下,在你经历过的项目中,使用过什么 MVC 框架Model 层的代码是遵循贫血模型还是充血模型设计的?
- 在文中应用 CQRS 模式的时候add 方法不再返回 Book 对象,这样一来,方法调用者就无法知道实际插入的 Book 对象的 id 是什么,就无法在下一步根据 id 去数据库查询出这个 Book 对象了。那么,这个问题该怎么解决呢?
好,今天的内容就到这里,有余力还可以了解下扩展阅读的内容。有关今天的知识点,如果你在实际的工作经历中遇到过,其实是非常适合拿来一起比较的。可以谈谈你在这方面的经验,也可以分享你的不同理解,期待你的想法!
## 扩展阅读
- [AnemicDomainModel](https://martinfowler.com/bliki/AnemicDomainModel.html)Martin Fowler 写的批评贫血模型的文章,他自己提出了贫血和充血的概念,因此我们可以到概念的源头去,看看他做出批评的理由是什么。
- 【基础】在模型层我们经常会和数据库打交道SQL 是这部分的基础,如果你完全不了解 SQL可以阅读 W3school 上的 [SQL 基础教程](https://www.w3school.com.cn/sql/index.asp)(左侧目录中的基础教程,内容简短)。
- 文中提到了查询对象Query Object这其实是一种常见的设计模式文中举例说明了是怎么使用的但是如果你想知道它是怎么实现的可以阅读 [Query Object Pattern](https://www.sourcecodeexamples.net/2018/04/query-object-pattern.html) 这篇文章,上面有很好的例子。

View File

@@ -0,0 +1,226 @@
<audio id="audio" title="09 | MVC架构解析视图View篇" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/64/e7411431a19c961211f27d1da0a16764.mp3"></audio>
你好,我是四火。
今天我们继续学习 MVC 架构,主要内容就是 MVC 架构的第二部分——视图View
## 概念
首先,我想问一问,什么是视图?有程序员说是界面,有程序员说是 UIUser Interface这些都对但是都不完整。
我认为**MVC 架构中的视图是指将数据有目的、按规则呈现出来的组件**。因此,如果返回和呈现给用户的不是图形界面,而是 XML 或 JSON 等特定格式组织呈现的数据,它依然是视图,而用 MVC 来解决的问题,也绝不只是具备图形界面的网站或者 App 上的问题。
## 页面聚合技术
虽然视图的定义实际更宽泛,但是我们平时讲到的视图,多半都是指“页面”。这里,就不得不提花样繁多的页面聚合技术了。
回想一下,之前我们在讲 Model 层的时候,是怎样解耦的?我们的办法就是继续分层,或是模块化;而对于 View 层来说,我们的办法则是拆分页面,分别处理,最终聚合起来。具体来说,这里提到的页面聚合,是指将展示的信息通过某种技术手段聚合起来,并形成最终的视图呈现给用户。页面聚合有这样两种典型类型。
- **结构聚合:指的是将一个页面中不同的区域聚合起来,这体现的是分治的思想。**例如一个页面,具备页眉、导航栏、目录、正文、页脚,这些区域可能是分别生成的,但是最后需要把它们聚合在一起,再呈现给用户。
- **数据-模板聚合:指的是聚合静态的模板和动态的数据,这体现的是解耦的思想。**例如有的新闻网站首页整个页面的 HTML 是静态的,用户每天看到的样子都是差不多的,但每时每刻的新闻列表却是动态的,是不断更新的。
请注意这两者并不矛盾,很多网站的页面都兼具这两种聚合方式。
### 服务端和客户端聚合方式的比较
客户端聚合技术的出现远比服务端晚,因为和服务端聚合不同,这种聚合方式对于客户端的运算能力,客户端的 JavaScript 技术,以及浏览器的规范性都有着明确的要求。但是,客户端聚合技术却是如今更为流行的技术,其原因包括:
**架构上,客户端聚合达成了客户端-服务端分离和模板-数据聚合这二者的统一,这往往可以简化架构,保持灵活性。**
比如说,对于模板和静态资源(如脚本、样式、图片等),可以利用 CDNContent Delivery Network内容分发网络技术从网络中距离最近的节点获取以达到快速展示页面的目的而动态的数据则可以从中心节点异步获取速度会慢一点但保证了数据的一致性。数据抵达浏览器以后再完成聚合静态和动态的资源可以根据实际情况分别做服务端和客户端的优化比如浏览器适配、缓存等等。如下图
<img src="https://static001.geekbang.org/resource/image/c9/7b/c9877f90430f892d1b5e3d8fbb94a47b.png" alt="">
你看上面这个例子,浏览器在上海,模板和静态资源从本地的上海节点获取,而数据异步从北京的中心节点获取。这种方式下,静态资源的访问是比较快的,而为了保证一致性,数据是从北京的中心节点获取的,这个速度会慢一些。在模板抵达浏览器以后,先展示一个等待的效果,并等待数据返回。在数据也抵达浏览器以后,则立即通过 JavaScript 进行客户端的聚合,展示聚合后的页面。
如果我们使用服务端聚合,就必须在服务端同时准备好模板和数据,聚合形成最终的页面,再返回给浏览器。整个过程涉及到的处理环节更多,架构更为复杂,而且同样为了保证一致性,数据必须放在北京节点,那么模板也就被迫从北京节点取得,聚合完成之后再返回,这样用户的等待时间会更长,用户也会看到浏览器的进度条迟迟完不成。见下图:
<img src="https://static001.geekbang.org/resource/image/6c/62/6ccf2cb3605d0dbfa32e790157ca5a62.png" alt="">
**资源上,客户端聚合将服务器端聚合造成的计算压力,分散到了客户端。**可是实际上,这不只是计算的资源,还有网络传输的资源等等。比如说,使用服务端聚合,考虑到数据是会变化的,因而聚合之后的报文无法被缓存;而客户端聚合则不然,通常只有数据是无法被缓存,模板是可以被缓存起来的。
但是,**客户端聚合也有它天然的弊端。其中最重要的一条,就是客户端聚合要求客户端具备一定的规范性和运算能力**。这在现在多数的浏览器中都不是问题,但是如果是手机浏览器,这样的问题还是很常见的,由于操作系统和浏览器版本的不同,考虑聚合逻辑的兼容性,客户端聚合通常对终端适配有更高的要求,需要更多的测试。
在实际项目中我们往往能见到客户端聚合和服务端聚合混合使用。具体来说Web 页面通常主要使用客户端聚合,而某些低端设备页面,甚至 Wap 页面(常用于较为低端的手机上)则主要使用服务端聚合。下面我们就来学习一些具体的聚合技术。
### 常见的聚合技术
**1. iFrame 聚合**
iFrame 是一种最为原始和简单的聚合方式,也是 CSIClient Side Includes客户端包含的一种典型方式现在很多门户网站的广告投放依然在使用。具体实现只需要在 HTML 页面中嵌入这样的标签即可:
```
&lt;iframe src=&quot;https://...&quot;&gt;&lt;/iframe&gt;
```
这种方式本质上是给当前页面嵌入了一个子页面,对于浏览器来说,它们是完全独立的两个页面。其优势在于,不需要考虑跨域问题,而且如果这个子页面出了问题,往往也不会影响到父页面的展示。
不过,这种方式的缺点也非常明显,也是因为它们是两个独立的页面。比如子页面和父页面之间的交互和数据传递往往比较困难,再比如预留 iFrame 的位置也是静态的,不方便根据 iFrame 实际的内容和浏览器的窗口情况自适应并动态调整占用位置和大小,再比如对搜索引擎的优化不友好等等。
**2. 模板引擎**
模板引擎是最完备、最强大的解决方案,无论客户端还是服务端,都有许许多多优秀的模板引擎可供选择。比如 [Mustache](http://mustache.github.io/),它不但可以用作客户端,也可以用作服务端的聚合,这是因为它既有 JavaScript 的库,也有后端语言,比如 Java 的库,再比如非常常用的 [Underscore.js](https://underscorejs.org/),性能非常出色。
某些前端框架,为了达到功能或性能上的最优,也会自带一套自己的模板引擎,比如 AngularJS我在下一章讲前端的时候会介绍。
**在使用模板引擎的时候,需要注意保持 View 层代码职责的清晰和纯粹**,这在全栈项目开发的过程中尤为重要。负责视图,就只做展示的工作,不要放本该属于 Model 层的业务逻辑,也不要干请求转发和流程控制等 Controller 的活。回想上一讲我们学的 JSP 模板,就像 JSP Model 1 一样,功能多未必代表着模板引擎的优秀,有时候反而是留下了一个代码耦合的后门。
**3. Portlet**
Portlet 在早几年的门户应用Portal中很常见它本身是一种 Web 的组件,每个 Portlet 会生成一个标记段,多个 Portlets 生成的标记段可以最终聚集并嵌入到同一个页面上,从而形成一个完整的最终页面。
技术上Portlet 可以做到远程聚合(服务端),也可以做到本地聚合(客户端),数据来源的业务节点可以部署得非常灵活,因此在企业级应用中也非常常见。
Java 的 Portlet 规范经历了[三个版本](https://en.wikipedia.org/wiki/Java_Portlet_Specification),详细定义了 Portlet 的生命周期、原理机制、容器等等方方面面。从最终的呈现来看,网站应用 Portlet 给用户的体验就像是在操作本地计算机一样,多个窗口层叠或平铺在桌面,每个窗口都是独立的,自包含的,并且可以任意调整位置,改变布局和大小。
如今 Portlet 因为其实现的复杂性、自身的限制,和较陡峭的学习曲线,往往显得比较笨重,因此应用面并不是很广泛。
<img src="https://static001.geekbang.org/resource/image/03/01/036c657fa6fff4d4f3ba4ce91364bc01.jpg" alt="">(上图来自 JBoss 的官方文档:[Portlet development](https://docs.jboss.org/gatein/portal/3.4.0.Final/reference-guide/en-US/html/chap-Reference_Guide-Portlet_development.html),上图代表了一个页面,上面的每一个窗口都分别由一个 Portlet 实现)
**4. SSI**
还记得前面讲过的 CSI客户端包含吗与之相对的自然也有服务端包含——SSI Server Side Includes。它同样是一种非常简单的服务端聚合方式大多数流行的 Web 服务器都支持 SSI 的语法。
比如下面这样的一条“注释”,从 HTML 的角度来讲,它确实是一条普通的注释,但是对于支持 SSL 的服务器来说,它就是一条特殊的服务器端包含的指令:
```
&lt;!--#include file=&quot;extend.html&quot; --&gt;
```
## 模板引擎的工作机制
前面已经提及了一些常见的页面聚合技术,但是模板引擎始终是最常用的,也自然是其中的重点。下面我们就结合一个小例子来一窥模板引擎的工作机制。
还记得 [[第 07 讲]](https://time.geekbang.org/column/article/140196) 介绍的 JSP 工作原理吗?类似的,模板引擎把渲染的工作分为编译和执行两个环节,并且只需要编译一次,每当数据改变的时候,模板并没有变,因而反复执行就可以了。
只不过这次,**我们在编译后生成的目标代码,不再是 class 文件了,而是一个 JavaScript 的函数**。因此我们可以尽量把工作放到预编译阶段去,生成函数以后,原始的模板就不再使用了,后面每次需要执行和渲染的时候直接调用这个函数传入参数就可以了。
比如这样的 [Handlebars](https://handlebarsjs.com/) 模板,使用一个循环,要在一个表格中列出图书馆所有图书的名字和描述:
```
&lt;table&gt;
{{#each books}}
&lt;tr&gt;
&lt;td&gt;
{{this.name}}
&lt;/td&gt;
&lt;td&gt;
{{this.desc}}
&lt;/td&gt;
&lt;/tr&gt;
{{/each}}
&lt;/table&gt;
```
接着,模板被加载到变量 templateContent 里面,传递给 Handlebars 来进行编译,编译的结果是一个可执行的函数 func。编译过程完成后就可以进行执行的过程了func 接受一个图书列表的入参,输出模板执行后的结果。这两个过程如下:
```
var func = Handlebars.compile(templateContent);
var result = func({
books : [
{ name : &quot;A&quot;, desc : &quot;...&quot; },
{ name : &quot;B&quot;, desc : &quot;...&quot; }
]
});
```
如果我们想对这个 func 一窥究竟,我们将看到类似这样的代码:
```
var buffer = &quot;&quot;, stack1, functionType=&quot;function&quot;, escapeExpression=this.escapeExpression, self=this;
function program1(depth0,data) {
var buffer = &quot;&quot;, stack1;
buffer += &quot;\n &lt;tr&gt;\n &lt;td&gt;&quot;
+ escapeExpression(((stack1 = depth0.name),typeof stack1 === functionType ? stack1.apply(depth0) : stack1))
+ &quot;&lt;/td&gt;\n &lt;td&gt;&quot;
+ escapeExpression(((stack1 = depth0.desc),typeof stack1 === functionType ? stack1.apply(depth0) : stack1))
+ &quot;&lt;/td&gt;\n &lt;/tr&gt;\n &quot;;
return buffer;
}
buffer += &quot;\n&lt;table&gt;\n &quot;;
stack1 = helpers.each.call(depth0, depth0.books, {hash:{},inverse:self.noop,fn:self.program(1, program1, data),data:data});
if(stack1 || stack1 === 0) { buffer += stack1; }
buffer += &quot;\n&lt;/table&gt;\n&quot;;
return buffer;
```
我们不需要对上面代码的每一处都了解清楚,但是可以看到一个大概的执行步骤,模板被编译后生成了一个字符串拼接的方法,即模板本身的字符串,去拼接实际传出的数据:
- 由于模板中定义了一个循环,因此方法 program1 在循环中被调用若干次;
- 对于 td 标签中间的数据,会判断是直接拼接,还是作为方法递归调用,拼接其返回值。
## 总结思考
今天我们学习了 MVC 架构中的视图层View对于五花八门的页面聚合技术我们需要抓住其本质和前面学习 Model 层的解耦一样,应对软件复杂性的问题,绝招别无二致,就是“拆分”。
**无论是分层、分模块,还是分离静态模板和动态数据,当我们定义了不同的拆分方法,我们就把一个复杂的东西分解成组成单一、职责清晰的几个部分,分别处理以后,再聚合起来,不同的聚合方法正是由这些不同的拆分方法所决定的**
往后我们还会学习别的解耦技术,到那时请你回想我们本章到目前为止学过的这些有关“拆分”的方法,再放到一起比较,我相信你会有更多感悟。
在今天的选修课堂和扩展阅读之前,我先来提两个问题:
- 你在工作中是否使用过什么模板引擎,能说说当初在做技术选型时为什么选择了它吗?
- 有朋友说,服务端聚合已经过时了,现在的网站都应该在客户端完成聚合的,你同意这个观点吗?
好,对于上面的问题,以及今天的内容,你有什么想法?欢迎在留言区和我讨论。
## 选修课堂:动手使用 HTML 5 的模板标签
文中我介绍了一些模板技术还有一点你要知道HTML 5 引入了模板标签,自此之后,我们终于可以不依赖于任何第三方库,在原生 HTML 中直接使用模板了。下面就让我们来动手实践一下吧。
打开 Chrome 的开发者工具,选择 Console 标签:
<img src="https://static001.geekbang.org/resource/image/98/0c/986ead9ff6440fd6e36075f9b5dc310c.png" alt="">
然后,让我们先来检验一下你的浏览器是否支持 HTML 5 模板,即 template 标签。请执行:
```
'content' in document.createElement('template')
```
你应该能看到“true”这就意味着你的浏览器是支持的。这是因为content 是 HTML 5 的 template 标签特有的属性,用于放置原模板本身的内容。
接着,请在硬盘上创建一个 HTML 文件 template.html写入如下内容
```
&lt;!doctype html&gt;
&lt;html&gt;
&lt;div&gt;1&lt;/div&gt;
&lt;div&gt;3&lt;/div&gt;
&lt;template id=&quot;t&quot;&gt;
&lt;div&gt;2&lt;/div&gt;
&lt;/template&gt;
&lt;/html&gt;
```
使用 Chrome 打开它你应该只能看到分别显示为“1”和“3”的两行而 template 标签内的内容,由于是模板的关系,被浏览器自动忽略了。
我们再打开 Chrome 的开发者工具,选择 Console 标签,这次敲入这样两行命令:
```
rendered = document.importNode(document.getElementById(&quot;t&quot;).content, true);
document.getElementsByTagName(&quot;div&quot;)[0].append(rendered);
```
这表示找到 id 为“t”的模板节点根据其中的内容来创建一个节点接着把这个节点安插到第一个 div 的标签后面。
这时你应该看能看到三行分别为“1”、“2”和“3”。
## 扩展阅读
- 【基础】今天的内容我们正式和几个 HTML 的标签见面了,如果你对 HTML 也不太熟悉的话,请一定学习一下,作为前端基础的三驾马车之一(另两驾是 CSS 和 JavaScript我们以后会经常和它们见面的。首推 MDN 的教程,有一篇比较短的 [HTML 基础](https://developer.mozilla.org/zh-CN/docs/Learn/Getting_started_with_the_web/HTML_basics),也有更为[详尽的展开](https://developer.mozilla.org/zh-CN/docs/Learn/HTML)。
- 【基础】文中也涉及到了一点点 JavaScript 的基础知识,如果你对于 JavaScript 还不了解,那么我推荐你阅读 MDN 的 [JavaScript 教程](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript)中的快速入门部分。
- 对于文中模板引擎以外的三种聚合方式,我也给你找了可选的阅读材料,你可以跟随自己的兴趣选择:对于 iFrame 聚合,[iframe, or not, that is the question](https://krasimirtsonev.com/blog/article/iframe-or-not-that-is-the-question) 这篇文章介绍了和使用 script 标签来写入页面内容比起来,使用 iFrame 的优劣;对于 Portlet 聚合,请参阅 [Java Portlet Specification](https://en.wikipedia.org/wiki/Java_Portlet_Specification) 词条,你将看到 Portlet 规范从 1.0 到 3.0 的改进;对于 SSI请参阅维基百科的[服务器端内嵌](https://zh.wikipedia.org/wiki/%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%AB%AF%E5%86%85%E5%B5%8C)词条,上面介绍了一些它常用的指令。

View File

@@ -0,0 +1,313 @@
<audio id="audio" title="10 | MVC架构解析控制器Controller篇" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/48/00bf947de2e1d39e17c80f8bfe6fb448.mp3"></audio>
你好,我是四火。
今天我们继续学习 MVC 架构,主要内容就是 MVC 架构的第三部分——控制器Controller
控制器用于接收请求,校验参数,调用 Model 层获取业务数据,构造和绑定上下文,并转给 View 层去渲染。也就是说,控制器是 MVC 的大脑,它知道接下去该让谁去做什么事。控制器层是大多数 MVC 框架特别愿意做文章的地方,我相信你可能耳闻、了解,甚至熟练使用过一些 MVC 框架了。
那么与其去抽象地学习这一层的重要概念、原理,或是单纯地学习这些框架在这一层略显乏味的具体配置,我想我们今天“不走寻常路”一次,把这两者结合起来——**我们来比较 Servlet、Struts 和 Spring MVC 这三种常见的技术和 MVC 框架,在控制器层的工作路数,以及和业务代码整合配置的方式,看看任这些框架形式千变万化,到底有哪些其实是不变的“套路”呢?**
随着请求到达控制器,让我们顺着接下去的请求处理流程,看看控制器会通过怎样的步骤,履行完它的职责,并最终转到相应的视图吧。
## 1. 路径映射和视图指向
我们不妨把 MVC 架构的控制器想象成一个黑盒。当 HTTP 请求从客户端送达的时候,这个黑盒要完成一系列使命,那么它就有一个入口路由和一个出口路由:
- **入口路由就是路径映射,根据配置的规则,以及请求 URI 的路径,找到具体接收和处理这个请求的控制器逻辑;**
- **出口路由就是视图指向,根据配置的规则,以及控制器处理完毕后返回的信息,找到需要渲染的视图页面。**
这两件事情,我们当然可以使用原始的 if-else 来完成,但是一般的 MVC 都提供了更清晰和独立的解决方案。
我们还是从老朋友 Servlet 开始讲起,在 Tomcat 的 web.xml 中,我们可以配置这样的路径映射:
```
&lt;servlet&gt;
&lt;servlet-name&gt;BookServlet&lt;/servlet-name&gt;
&lt;servlet-class&gt;com.xxx.xxx.BookServlet&lt;/servlet-class&gt;
&lt;/servlet&gt;
&lt;servlet-mapping&gt;
&lt;servlet-name&gt;BookServlet&lt;/servlet-name&gt;
&lt;url-pattern&gt;/books&lt;/url-pattern&gt;
&lt;/servlet-mapping&gt;
```
你看,对于路径映射,一旦请求是 /books 这种形式的,就会被转到 BookServlet 里去处理。而对于视图指向Servlet 是通过代码完成的,比如:
```
request.getRequestDispatcher(&quot;/book.jsp&quot;).forward(request, response);
```
但是Servlet 路径映射的表达式匹配不够灵活,而且配置过于冗长;而视图指向更是完全通过代码调用来完成,视图的位置信息完全耦合在控制器主代码逻辑中,而且也并没有体现出配置的集中、清晰的管理优势。于是现今的 MVC 框架都提供了一套自己的映射匹配逻辑,例如 [Struts 2](https://struts.apache.org/index.html)
```
&lt;action name=&quot;books&quot; class=&quot;xxx.xxx.BookAction&quot;&gt;
&lt;result name=&quot;success&quot; type=&quot;dispatcher&quot;&gt;/success.jsp&lt;/result&gt;
&lt;result name=&quot;input&quot; ... /&gt;
&lt;/action&gt;
```
其中name=“books” 这样的配置就会将 /books 的请求转给 BookAction。至于接下来的两个 result 标签,是根据控制器返回的视图名来配对具体的视图页面,也就是说,一旦 BookAction 处理完毕,通过返回的视图名字,请求可以被转发给相应的视图。
这个路径映射的配置是简单一些了,可是都需要放在一个其它位置的、单独的 XML 中配置。不过Java 5 开始支持注解,因此许多 MVC 框架都开始支持使用注解来让这样的配置变得更加轻量,也就是将路径映射和它所属的控制器代码放在一起。见下面 Struts 的例子:
```
public class BookAction extends ActionSupport {
@Action(value=&quot;/books&quot;, results={
@Result(name=&quot;success&quot;,location=&quot;/book.jsp&quot;)
})
public String get() {
...
return &quot;success&quot;;
}
}
```
代码依然很好理解,当以 /books 为路径的 GET 请求到来时,会被转给 BookAction 的 get 方法。在控制器的活干完之后,根据返回的名称 success下一步请求就会转到视图 /book.jsp 中去。
你看,对于路径映射和视图指向,为了不把这样的信息和主流程代码耦合在一起,上面讲了两种实现方法,它们各有优劣:
- 放到配置文件中,好处是所有的映射都在一个文件里,方便管理。但是对于任何一个控制器逻辑,要寻找它对应的配置信息,需要去别的位置(即上文的 XML 中)寻找。**这是一种代码横向分层解耦的方式,即分层方式和业务模块无关,或者说二者是“正交”的**,这种方式我在 [[第 11 讲]](https://time.geekbang.org/column/article/143882) 讲解 IoC控制反转时会继续介绍。
- 使用注解,和控制器逻辑放在一起,好处是映射本身是和具体的控制器逻辑放在一起,当然,它们并非代码层面的耦合,而是通过注解的方式分离开。坏处是,如果需要考察所有的映射配置,那么就没有一个统一的文件可供概览。**这是一种代码纵向分层解耦的方式,也就是说,配置是跟着业务模块走的**。
无论使用以上哪一种方法,本质上都逃不过需要显式配置的命运。但无论哪种方法,其实都已经够简单了,可历史总是惊人的相似,总有帮“难伺候”的程序员,还是嫌麻烦!于是就有人想出了一个“终极偷懒”的办法——免掉配置。
这就需要利用 **CoC 原则Convention over Configuration即规约优于配置**。比如,在使用 [Spring MVC](https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html) 这个 MVC 框架时,声明了 ControllerClassNameHandlerMapping 以后,对于这样没有配置任何映射信息的方法,会根据 Controller 类名的规约来完成映射:
```
public class BooksController extends AbstractController {
@Override
protected ModelAndView handleRequestInternal() throws Exception {
...
}
}
```
在使用 /books 去访问的时候,请求就会被自动转交给定义好的控制器逻辑。
你看,规约优于配置看起来可以省掉很多工作对不对?没错!但是任何技术都有两面性,**CoC虽然省掉了一部分实际的配置工作却没有改变映射匹配的流程本身也不能省掉任何为了理解规约背后的“隐性知识”的学习成本**。而且,规约往往只方便于解决最常见的配置,也就意味着,**当需要更灵活的配置时,我们还是会被迫退化回显式配置**。
## 2. 请求参数绑定
请求被送到了指定的控制器方法,接下去,需要从 HTTP 请求中把参数取出来,绑定到控制器这一层,以便使用。**整个控制器的流程中,有两次重要的数据绑定,这是第一次,是为了控制器而绑定请求数据**,后面在视图上下文构造这一步中还有一次绑定,那是为了视图而进行的。
和路径映射的配置一样,最先被考虑的方式,一定是用编程的方法实现的。比如在 Servlet 中,可以这样做:
```
request.getParameter(&quot;name&quot;)
```
这并没有什么稀奇的对不对想想我们前面学习的处理方法参数应该能通过某种配置方式自动注入到控制器的对象属性或者方法参数中吧一点都没错并且Struts 和 Spring MVC 各有各的做法,二者加起来,就恰巧印证了这句话。
还记得前面 Struts 的那个例子吗?给 BookAction 设置一个和参数同名的属性,并辅以规则的 get/set 方法,就能将请求中的参数自动注入。更强大的地方在于,如果这个属性是个复杂对象,只要参数按照规约命名了,那么它也能够被正确处理:
```
public class BookAction extends ActionSupport {
private Page page;
public void setPage { ... }
public Page getPage { ... }
}
```
在这种设定下,如果 URI 是:
```
/books?page.pageSize=1&amp;page.pageNo=2&amp;page.orderBy=desc
```
那么pageSize、pageNo 和 orderBy 这三个值就会被设置到一个 Page 对象中,而这个 Page 对象则会被自动注入到 BookAction 的实例中去。
再来看看 Spring MVC 使用注解的方式来处理,和 URL 的结构放在一起观察,这种方式显然更为形象直观:
```
@RequestMapping(&quot;/{category}/books&quot;)
public ModelAndView get(@PathVariable(&quot;category&quot;) String category, @RequestParam(&quot;author&quot;) String author){ ... }
```
在这种配置下,如果 URI 是:
```
/comic/books?author=Jim
```
那么,分类 comic 就会作为方法参数 category 的值传入,而作者 Jim 就会作为方法参数 author 的值传入。
## 3. 参数验证
参数验证的操作因为和请求对象密切相关因此通常都是在控制器层完成的。在参数验证没有通过的情况下往往会执行异常流程转到错误页面返回失败请求。Struts 提供了一个将参数验证解耦到配置文件的办法,请看下面的例子:
```
&lt;validators&gt;
&lt;field name=&quot;name&quot;&gt;
&lt;field-validator type=&quot;requiredstring&quot;&gt;
&lt;param name=&quot;trim&quot;&gt;true&lt;/param&gt;
&lt;message&gt;书名不得为空&lt;/message&gt;
&lt;/field-validator&gt;
&lt;field-validator type=&quot;stringlength&quot;&gt;
&lt;param name=&quot;maxLength&quot;&gt;100&lt;/param&gt;
&lt;param name=&quot;minLength&quot;&gt;1&lt;/param&gt;
&lt;message&gt;书名的长度必须在 1~100 之间&lt;/message&gt;
&lt;/field-validator&gt;
&lt;/field&gt;
&lt;/validators&gt;
```
这就是一个非常简单的参数验证的规则,对于属性 name 定义了两条规则,一条是不得为空,另一条是长度必须在 1~100 之间,否则将返回错误信息。
类似的Struts 也提供了基于注解的参数验证方式,上面的例子,如果使用注解来实现,就需要将注解加在自动注入参数的 set 方法处。代码见下:
```
@RequiredFieldValidator(trim = true, message = &quot;书名不得为空.&quot;)
@StringLengthFieldValidator(minLength = &quot;1&quot;, maxLength = &quot;100&quot;, message = &quot;书名的长度必须在 1~100 之间&quot;)
void setName(String name) { ... }
```
## 4. 视图上下文绑定
在控制器中,我们经常需要将数据传入视图层,它可能会携带用户传入的参数,也可能会携带在控制器中查询模型得到的数据,而这个传入方式,就是**将数据绑定到视图的上下文中。这就是我刚刚提到过的控制器层两大绑定中的第二个**。
如果是使用 Servlet那么我们一般可以用 setAttribute 的方法将参数设置到 request 对象中,这样在视图层就可以相应地使用 getAttribute 方法把该参数的值取出来。
```
request.setAttribute(&quot;page&quot;, xxx);
```
对于 Struts 来说,它的方法和前面说的请求参数绑定统一了,即可以将想传递的值放到 Action 的对象属性中,这种方式绑定的属性,和请求参数自动绑定的属性没有什么区别,在视图层都可以直接从上下文中取出来。
接着前面 BookAction 的例子,绑定了一个 Page 对象,那么在视图层中就可以使用 OGNLObject-Graph Navigation Language对象导航图语言表达式直接取得
```
&lt;p&gt;第 ${page.pageNo} 页&lt;/p&gt;
```
对于 Spring MVC则是需要在控制器方法中传入一个类型为 Model 的对象,同时将需要绑定的对象通过调用 addAttribute 来完成绑定,这个过程和 Servlet 是类似的。
## 总结思考
今天我们学习了 MVC 架构中的控制器层,整个控制器的逻辑比较密集,从请求抵达,到转出到视图层去渲染,控制器的逻辑通常包括下面这几步,但是,严格说起来,下面这些步骤的任何一步,根据实际情况,都是可以省略的。
<img src="https://static001.geekbang.org/resource/image/0a/34/0a0f9ad88c34ab5922e57e2b55ff6834.png" alt="">
我们对比了在原生 Servlet、Struts 框架和 Spring MVC 框架下,上面各个步骤的实现,希望你能够感悟到其中的“套路”。
是的,具体某一个框架的配置使用,是很容易学习和掌握的,这当然很好,但那只是死的知识,而这也只是机械记忆。而当我们去思考同一个框架中实现同一个特性的不同方法,或者是不同框架实现同一个特性的不同方法时,我们就会慢慢体会到技术的有趣之处。
因为我们会去思考,这些不同的“玩法”比较起来,各有什么优缺点,在实际应用中应该怎么去权衡和选择,甚至去想,如果让我去设计一个类似的特性,都有哪些办法可以实现。
好,下面我们就来检验一下今天所学的知识,请思考下面这样两个问题:
- 我们提到了 MVC 框架中,两种常见的配置方式,一种是将配置放在横向解耦的单独一层,另一种是将配置和业务模块放在一起。你更喜欢哪一种,为什么?
- 在上面的图中,我列出了控制器层常见的六大步骤。那么,回想你经历过的项目,是将怎样的代码逻辑放在了控制器层呢?
对于今天学习的内容,对于思考题,以及通过比较学习“套路”的方式,如果你有想法,不妨和我在留言区一起讨论吧。
## 选修课堂:动手实现一个简单的 MVC 系统
这一章我们一直在学习 MVC不动手实践是不行的。我们要使用 Servlet + JSP + JavaBean 这种相对原始的方法来实现一个最简单的 MVC 系统。
还记得我们在 [[第 07 讲]](https://time.geekbang.org/column/article/140196) 中动手跑起来的 Tomcat 吗?现在请打开 Tomcat 的安装目录,设置好环境变量 CATALINA_HOME以便于我们后面使用正确的 Tomcat 路径。以我的电脑为例:
```
export CATALINA_HOME=/usr/local/Cellar/tomcat/9.0.22/libexec
```
我们打开 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/web.xml在 这个结束标签前,添加如下子标签:
```
&lt;servlet&gt;
&lt;servlet-name&gt;BookServlet&lt;/servlet-name&gt;
&lt;servlet-class&gt;BookServlet&lt;/servlet-class&gt;
&lt;/servlet&gt;
&lt;servlet-mapping&gt;
&lt;servlet-name&gt;BookServlet&lt;/servlet-name&gt;
&lt;url-pattern&gt;/books&lt;/url-pattern&gt;
&lt;url-pattern&gt;/books/*&lt;/url-pattern&gt;
&lt;/servlet-mapping&gt;
```
注意这里配置了两个 URL 映射,/books 和 /books/{bookId} 两种类型的请求会全部映射到我们将建立的 Servlet 中。
在配置好 Servlet 的映射之后,进入 ${CATALINA_HOME}/webapps/ROOT/WEB-INF并创建一个名为 classes 的文件夹,接着在这个文件夹下建立一个名为 BookServlet.java 的文件,并编辑它:
```
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class BookServlet extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String category = request.getParameter(&quot;category&quot;);
request.setAttribute(&quot;categoryName&quot;, category);
request.getRequestDispatcher(&quot;/book.jsp&quot;).forward(request, response);
}
}
```
嗯,其实代码逻辑很简单,把 URL 中的 category 参数的值取出来,给一个新名字 categoryName 并传给 book.jsp。
好,接下来我们就要把上面的 Java 源文件编译成 class 文件了,执行:
```
javac BookServlet.java -classpath ${CATALINA_HOME}/lib/servlet-api.jar
```
其中 servlet-api.jar 是 Tomcat 中存放的编译运行 Servlet 所必须的类库。这样,你应该能看到在 classes 目录下生成了 BookServlet.class 文件。
接着,在 ${CATALINA_HOME}/webapps/ROOT 下建立 book.jsp并写入
```
&lt;jsp:useBean id=&quot;date&quot; class=&quot;java.util.Date&quot; /&gt;
Category name: &lt;%=request.getAttribute(&quot;categoryName&quot;) %&gt;, date: ${date.getYear()+1900}-${date.getMonth()+1}-${date.getDate()}
```
第一行表示创建并使用一个 Date 类型的 JavaBean第二行在显示结果的时候category 使用了 JSP 特有的 scriptlet 的表达式,而日期则使用了 OGNL 表达式。注意 Date 对象返回的年份是以 1900 年为基准的偏移量,因此需要加上 1900而返回的月份是从 0 开始往后排的,因此需要加上修正值 1。
好了,大功告成,我们快来执行 Tomcat 看看结果吧!启动 Tomcat
```
catalina run
```
打开浏览器,访问:
```
http://localhost:8080/books?category=art
```
如果你看到类似如下字样,那么,恭喜你,成功了!现在,你可以回想一下刚才的实现,这些代码该怎样对应到 MVC 各个部分呢?
```
Category name: art, date: 2019-8-5
```
## 扩展阅读
- 对于 [Struts](https://struts.apache.org/index.html) 和 [Spring MVC](https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html),文中已经给出了官方链接,如果你想阅读简洁的中文版教程,可以看看这个 [Struts 2 教程](https://doc.yonyoucloud.com/doc/wiki/project/struts-2/architecture.html)和这个 [Spring MVC 教程](https://www.w3cschool.cn/wkspring/9pf81ha5.html)。
- 文中提到了使用 ControllerClassNameHandlerMapping 来贯彻“规约优于配置”的思想,达到对具体的映射免配置的目的,如果你感兴趣的话,[Spring MVC - Controller Class Name Handler Mapping Example](https://www.tutorialspoint.com/springmvc/springmvc_controllerclassnamehandlermapping) 这篇文章有很好的介绍。
- [OGNL 语言介绍与实践](https://www.ibm.com/developerworks/cn/opensource/os-cn-ognl/index.html),文中提到了 OGNL 表达式,感兴趣的话这篇文章是很好的入门。

View File

@@ -0,0 +1,256 @@
<audio id="audio" title="11 | 剑走偏锋:面向切面编程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/62/03/6231377dea572588da9d013820afd003.mp3"></audio>
你好,我是四火。
今天我们要接触一个和 MVC 密切相关的能带来思维模式改变的编程范型——面向切面编程AOPAspect Oriented Programming
## “给我一把锤子,满世界都是钉子”
我记得曾经有这样一个相当流行的观点,是说,编程语言只需要学习一门就够了,学那么多也没有用,因为技术是一通百通的,别的编程语言可以说是大同小异。我相信至今抱有这种观点的程序员也不在少数。
可惜,事实远没有那么美好。这个观点主要有两处值得商榷:
- 其一,不同的技术,在一定程度上确实是相通的,可是,技术之间的关联性,远不是“一通百通”这四个简简单单的字能够解释的。妄想仅仅凭借精通一门编程语言,就能够自动打通其它所有编程语言的任督二脉,这是不现实的。
- 其二,通常来说,说编程语言大同小异其实是很不客观的。编程语言经过了长时间的发展演化,如今已经发展出非常多的类型,用作编程语言分类标准之一的编程范型也可谓是百花齐放。
因此我们要学习多种编程语言,特别是那些能带来新的思维模式的编程语言。现在,把这个观点泛化到普遍的软件技术上,也一样适用。我们都知道要“一切从实际出发”,都知道要“具体问题具体分析”,可是,**在眼界还不够开阔的时候,特别是职业生涯的早期,程序员在武器库里的武器还非常有限的时候,依然无法避免“给我一把锤子,满世界都是钉子”,在技术选择的时候眼光相对局限。**
所以我们要学习全栈技术,尤其是要学习这些不一样,但一定层面上和已掌握知识相通的典型技术。今天我们要学习的这项在 MVC 框架中广泛使用的技术,是和面向对象编程一类层面的编程范型,叫做面向切面编程。
互联网有许多功能,如果使用传统的基于单个请求处理流程的方式来编码,代码就会非常繁琐,而使用 AOP 的方式,代码可以得到很大程度上的简化。希望通过今天的学习,你的武器库里,能够多一把重型机枪。
## AOP 的概念
**面向切面编程是一种通过横切关注点Cross-cutting Concerns分离来增强代码模块性的方法它能够在不修改业务主体代码的情况下对它添加额外的行为。**
不好理解吗?没关系,我们来对它做进一步的说明。
首先需要明确的是AOP 的目标是增强代码模块性,也就是说,本质上它是一种“解耦”的方法,在这方面它和我们之前介绍的分层等方法是类似的,可是,它分离代码的角度与我们传统、自然的模块设计思路截然不同。
我们来看下面这样一个例子,对于图书馆系统来说,有许多业务流程,其中借书和还书是最典型的两条。对于这些业务流程来说,从图书系统接收到请求开始,需要完成若干个步骤,但这些步骤都有一些“共性”,比如鉴权,比如事务控制:
<img src="https://static001.geekbang.org/resource/image/f1/2c/f17118dd2cb4f0132c76fd152a3c062c.png" alt="">
那么,如果我们按照自然的思考方式,我们会把代码按照流程分解成一个一个的步骤,在每个步骤完成的前后添加这些“共性”逻辑。可是这样,这些逻辑就会散落在代码各处了,即便我们把它们按照重复代码抽取的原则,抽出来放到单独的方法中,这样的方法的“调用”还是散落在各处,无论是对软件工程上的可维护性,还是代码阅读时对于业务流程的专注度,都是不利的。
藉由 AOP 则可以有效地解决这些问题,对于图中横向的业务流程,我们能够保持它们独立不变,而把鉴权、事务这样的公共功能,彻底拿出去,放到单独的地方,这样整个业务流程就变得纯粹和干净,没有任何代码残留的痕迹,就好像武林高手彻底隐形了一般,但是,功能却没有任何丢失。就好比面条一般顺下来的业务流程,水平地切了几刀,每一刀,都是一个 AOP 的功能实现。
我们可能在 Java 的世界中谈论 AOP 比较多,但请注意,它并不是 Java 范畴的概念,它不依赖于任何框架,也和编程语言本身无关。
## Spring 中的应用
[Spring](https://spring.io/) 作为一个应用程序框架,提供了对于 AOP 功能上完整的支持,下面让我们通过例子来学习。还记得我们在 [[第08 讲]](https://time.geekbang.org/column/article/141679) 中举例介绍的将图书借出的方法吗?
```
public class BookService {
public Book lendOut(String bookId, String userId, Date date) { ... (0) }
}
```
现在,我们要给很多的业务方法以 AOP 的方式添加功能,而 lendOut 就是其中之一。定义一个 TransactionAspect 类:
```
public class TransactionAspect {
public void doBefore(JoinPoint jp) { ... (1) }
public void doAfter(JoinPoint jp) { ... (2) }
public void doThrowing(JoinPoint jp, Throwable ex) { ... (3) }
public void doAround(ProceedingJoinPoint pjp) throws Throwable {
... (4)
pjp.proceed();
... (5)
}
}
```
你看,我给每一处可以实现的代码都用数字做了标记。我们希望在 doBefore 方法中添加事务开始逻辑doAfter 方法中添加事务结束的提交逻辑doThrowing 方法中添加事务失败的回滚逻辑,而在 doAround 方法中业务执行前后添加日志打印逻辑,其中的 pjp.proceed() 方法表示对原方法的调用。
接着,我们需要写一些 XML 配置,目的就是把原方法和 AOP 的切面功能连接起来。配置片段如下:
```
&lt;bean id=&quot;bookService&quot; class=&quot;xxx.BookService&quot;&gt;&lt;/bean&gt;
&lt;bean id=&quot;transactionAspect&quot; class=&quot;xxx.TransactionAspect&quot;&gt;&lt;/bean&gt;
&lt;aop:config&gt;
&lt;aop:pointcut expression=&quot;execution(* xxx.BookService.*(..))&quot; id=&quot;transactionPointcut&quot;/&gt;
&lt;aop:aspect ref=&quot;transactionAspect&quot;&gt;
&lt;aop:before method=&quot;doBefore&quot; pointcut-ref=&quot;transactionPointcut&quot;/&gt;
&lt;aop:after-returning method=&quot;doAfter&quot; pointcut-ref=&quot;transactionPointcut&quot;/&gt;
&lt;aop:after-throwing method=&quot;doThrowing&quot; pointcut-ref=&quot;transactionPointcut&quot; throwing=&quot;ex&quot;/&gt;
&lt;aop:around method=&quot;doAround&quot; pointcut-ref=&quot;transactionPointcut&quot;/&gt;
&lt;/aop:aspect&gt;
&lt;/aop:config&gt;
```
在这段配置中,前两行分别是对 BookService 和 TransactionAspect 这两个 Bean 的声明,接下来在 aop:config 中,我们定义了 pointcut 的切面匹配表达式,表示要捕获 BookService 的所有方法,并在 aop:aspect 标签内定义了我们希望实施的 AOP 功能。
在实际执行的过程中,如果没有异常抛出,上述这些逻辑的执行顺序将是:
```
(1) → (4) → (0) → (5) → (2)
```
## 实现原理
讲了 AOP 怎样配置,怎么表现,现在我要来讲讲它的实现原理了。通过这部分内容,希望你可以搞清楚,为什么不需要对代码做任何改动,就可以在业务逻辑的流水中切一刀,插入我们想要执行的其它逻辑呢?
对于常见的实现,我们根据其作用的不同时间阶段进行分类,有这样两种:
**编译期间的静态织入,又称为编译时增强。**织入Weaving指的是将切面代码和源业务代码链接起来的过程。[AspectJ](https://www.eclipse.org/aspectj/) 就是这样一个面向切面的 Java 语言扩展,称呼其为语言的“扩展”,就是因为它扩展了 Java 语言的语法,需要特定的编译器来把 AspectJ 的代码编译成 JVM 可识别的 class 文件。
**运行期间的动态代理,又称为运行时增强。**这种方式是在程序运行时,依靠预先创建或运行时创建的代理类来完成切面功能的。比如 JDK 基于接口的动态代理技术,或 [CGLib](https://github.com/cglib/cglib/wiki) 基于类的代理对象生成技术就属于这一种。
Spring AOP 默认支持的是后者——运行期间的动态代理。至于具体实现,通常来说,我们应该优先考虑使用 JDK 的动态代理技术;但是如果目标类没有实现接口,我们只能退而求其次,使用 CGLib。
动态代理的方式由于在运行时完成代理类或代理对象的创建,需要用到 Java 的拦截、反射和字节码生成等技术,因此运行时的性能表现往往没有静态织入好,功能也有较多限制,但是由于使用起来简便(不需要语言扩展,不需要特殊的编译器等),它的实际应用更为广泛。
## 控制反转 IoC
通过 AOP 我们知道,某些问题如果我们换个角度来解决,会很大程度地简化代码。现在,让我们来了解在 Spring 中另一个经常和面向切面编程一起出现的概念——控制反转。控制反转是一种设计思想,也是通过“换个角度”来解决问题的。
控制反转IoC即 Inversion of Control言下之意指的是把原有的控制方向掉转过来了。在我们常规的程序流程中对象是由主程序流程创建的例如在业务流程中使用 new 关键字来创建依赖对象。
但是,当我们使用 Spring 框架的时候,**Spring 把对象创建的工作接管过来,它作为对象容器,来负责对象的查找、匹配、创建、装配,依赖管理,等等。而主程序流程,则不用关心对象是怎么来的,只需要使用对象就可以了。**我们还是拿 BookService 举例子:
```
public class BookService {
@Autowired
private BookDao bookDao;
@Autowired
private LoanDao loanDao;
public Book lendOut(String bookId, String userId, Date date) {
bookDao.update( ... );
loanDao.insert( ... );
}
}
```
比如 BookService 的借出方法,假如它的实现中,我们希望:
- 调用数据访问对象 bookDao 的方法来更新被借书的状态;
- 调用借阅行为的访问对象 loanDao 来增加一条借阅记录。
在这种情况下,我们可以通过 @Autowired 注解,让容器将实际的数据访问对象注入进来,主程序流程不用关心“下一层”的数据访问对象到底是怎么创建的,怎么初始化的,甚至是怎么注入进来的,而是直接用就可以了,因为这些对象都已经被 Spring 管理起来了。
如果这些注入的对象之间还存在依赖关系初始化它们的顺序就至关重要了可是在这种情况下Service 层依然不用关心,因为 Spring 已经根据代码或配置中声明的依赖关系自动确定了。总之Service 层的业务代码,只管调用其下的数据访问层的方法就好了。
读到这里,你可能会回想起前文 AOP 的内容,和 IoC 似乎有一个共同的特点:都是**为了尽可能保证主流程的纯粹和简洁**,而将这些不影响主流程的逻辑拿出去,只不过这两种技术,“拿出去”的是不同的逻辑。值得注意的是,对象之间的依赖关系,各层之间的依赖关系,并没有因为 IoC 而发生任何的改变。
**IoC 在实现上包含两种方式一种叫做依赖查找DLDependency Lookup另一种叫做依赖注入DIDependency Injection。**二者缺一不可Spring 容器做到了两者,就如同上面的例子,容器需要先查找到 bookDao 和 loanDao 所对应的对象,再把它们注入进来。当然,我们平时听到的更多是第二种。
有了一个大致的感受,那么 IoC 到底能带来什么好处呢?我觉得主要有这样两个方面:
- **资源统一配置管理。**这个方面很好,但并不是 IoC 最大的优势,因为,如果你不把资源交给容器管理,而是自己建立一个资源管理类来管理某项资源,一样可以得到“统一管理”的所有优势。
- **业务代码不再包含依赖资源的访问逻辑,因此资源访问和业务流程的代码解耦开了。**我觉得这里的“解耦”才是 IoC 最核心的优势,它让各层之间的依赖关系变得松散。就如同上面的代码例子一样,如果哪一天我想把它依赖的 bookDao 和 loanDao 替换掉(比如,我想为 Service 层做测试Service 一行代码都不用改,它压根都不需要知道。
## 总结思考
今天我们一起学习了面向切面编程,从学习概念,熟悉配置,到了解实现原理,希望你对于 AOP 已经有了一个清晰的认识,在未来设计和开发系统的时候,无论技术怎样演进,框架怎么变化,始终知道什么时候需要它,并能够把它从你的武器库中拿出来使用。
现在我来提两个问题,我们一起讨论吧:
- 你过去的项目中有没有应用 AOP 的例子,能说说吗?
- 我介绍了 AOP 的优点,但却没有提到它的缺点,但其实任何技术都是有两面性的,你觉得 AOP 的缺点都有哪些呢?
## 选修课堂:实践 AOP 的运行时动态代理
我们学习了 AOP 的实现原理,知道其中一种办法是通过 JDK 的动态代理技术来实现的。现在,我们就来写一点代码,用它实现一个小例子。
首先,请你准备好一个项目文件夹,我们会在其中创建一系列文件。你可以使用 Eclipse 来管理项目,也可以自己建立一个独立的文件夹,这都没有关系。
现在建立 BookService.java这次我们把 BookService 定义为一个接口,包含 lendOut 方法,同时也创建它的实现 BookServiceImpl
```
import java.text.MessageFormat;
import java.util.Date;
interface BookService {
void lendOut(String bookId, String userId, Date date);
}
class BookServiceImpl implements BookService {
@Override
public void lendOut(String bookId, String userId, Date date) {
System.out.println(MessageFormat.format(&quot;{0}: The book {1} is lent to {2}.&quot;, date, bookId, userId));
}
}
```
然后,我们建立一个 ServiceInvocationHandler.java在这里我们可以定义代理对象在对原对象的方法调用前后添加的额外逻辑
```
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
class ServiceInvocationHandler implements InvocationHandler {
private Object target;
public ServiceInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println(&quot;Before...&quot;);
Object result = method.invoke(this.target, args);
System.out.println(&quot;After...&quot;);
return result;
}
}
```
接着,我们建立一个 Client.java 类,作为程序的起点,通过动态代理的方式来调用源代码中的 lendOut 方法:
```
import java.lang.reflect.Proxy;
import java.util.Date;
public class Client {
public static void main(String[] args) throws Exception {
BookService bookService = (BookService) Proxy.newProxyInstance(
BookService.class.getClassLoader(),
new Class[]{ BookService.class },
new ServiceInvocationHandler(new BookServiceImpl())
);
bookService.lendOut(&quot;123&quot;, &quot;456&quot;, new Date());
}
}
```
你看,我们创建了一个动态代理对象,并赋给 bookService这个代理对象实际是会调用 BookServiceImpl 的,但调用的前后打印了额外的日志。并且,这个代理对象也实现自 BookService 接口,因此,对于 BookService 的使用者来说,它实际并不知道调用到的是 BookServiceImpl 还是它的代理对象。请看图示:
<img src="https://static001.geekbang.org/resource/image/c3/9e/c39ca70be876a106e90e73d6946d849e.png" alt="">
好,现在我们把这些代码编译一下:
```
javac BookService.java ServiceInvocationHandler.java Client.java
```
你应该能看到它们的 class 文件分别生成了。
最后,执行 Client 的 main 方法,就能看到相应的执行结果,它显示 lendBook 方法前后的 AOP 的逻辑被实际执行了:
```
java Client
Before...
8/10/19 11:42 AM: The book 123 is lent to 456.
After...
```
## 扩展阅读
- Spring 官方文档中[关于 AOP 的教程](https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html#aop),如果你希望看到中文版,那么互联网上有不少对于这部分的翻译,只不过对应的 Spring 版本不同,内容大致是一样的,比如[这一篇](https://blog.csdn.net/wengcheng_k/article/details/79952358)。
- [Comparing Spring AOP and AspectJ](https://www.baeldung.com/spring-aop-vs-aspectj),这是一篇关于静态织入和动态代理这两种 AOP 方式比较的文章。
- 对于 AspectJ如果想一瞥其扩展的语法语义维基百科的[词条](https://en.wikipedia.org/wiki/AspectJ)就足矣;如果想了解某些细节,请参阅[官方文档](https://www.eclipse.org/aspectj/doc/released/adk15notebook/index.html)。

View File

@@ -0,0 +1,251 @@
<audio id="audio" title="12 | 唯有套路得人心谈谈Java EE的那些模式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/aa/37/aa892b05b72d63d383174a5e21421237.mp3"></audio>
你好,我是四火。
本章我们以 MVC 架构为核心,已经介绍了很多设计模式,今天我们将进一步泛化,谈论更多的 Java EE 模式。这些模式,就是我们在搭建全栈架构、设计的工作过程中,不断总结和应用的“套路”。
## 背景和概念
我相信很多人都接触过面向对象模式,可是,模式是个通用词,面向对象只是其中的一个分支而已。事实上,我们本章的重点 MVC 本身就是一种典型的模式,介绍过的 CQRS 是模式,学习过的 AOP、IoC这些其实也都是模式。
因此,和其它领域的技术相比,作为全栈工程师的我们,更有机会接触到各种模式。这些模式可以帮助我们在设计开发工作中拓宽思路,使用精巧的代码结构来解决实际问题。
说到这里,你可能会问,为什么这次谈论模式的时候,要使用 Java EE 这个编程语言前缀?模式不是应该和语言无关吗?
一点都没错,模式就是和语言无关的,但是,诞生模式最多的温床,就是 Java 语言。
世界上没有任何一门语言,像 Java 一样,几乎一直被黑,但是生态圈一直在壮大,且在工业界具备如此统治力。**很多人说Java 是一门平庸的语言,这可能没错,但是它对于底层细节的封装和语言本身的难度做到了很好的平衡**,它不一定会有多精巧、多出彩,但是新手也可以顺利完成工作,且不容易写出破坏性强、其他人难以接手的代码,这对于要规模、要量产的工业界来说,简直是超级福音。
**使用 Java 的人可以快速上手,也可以把精力专注在高层的架构和设计上面,这就是为什么使用 Java 的人往往对模式特别敏感的原因。**
当然,语言本身的江湖地位也和生态圈密切相关,更先进、更合理的语言一直在出现,但要把整个语言推翻另起炉灶,其难度可想而知,毕竟一门语言还涉及到社区、厂商、开源库、标准等等。
在互联网的战场上,我们一直能看到类似的例子,比如在前端领域 JavaScript 就是一个相对“草率”,有着诸多缺陷的语言,在它之后有许多更先进的语言尝试把它替代(比如 Google 强推的 [Dart](https://dart.dev/)),但是这件事情是极其困难的。
那么,什么是 Java EE为什么是 Java EE
Java EE全称为 Java Platform Enterprise Edition即 Java 平台企业版,是 Java 的三大平台之一,另两大是 Java SE标准版和 Java ME微型版。企业市场对软件的需求和大众市场是完全不同的尤其是在互联网的早些时候对吞吐量、数据规模和服务质量等都有着更高级别的要求而且企业花钱多因而带来的回报也高得多。
但如今这个特点已经有了变化,但是从当时开始逐步确立下来的企业级规范和技术标准,直到现在还在广泛使用,并不断发展。它使得 Java EE 平台孕育了比其它语言和平台更多的软件架构和设计思想,而这些优秀的思想,以及通用的“套路”,在这个过程中不断被程序员总结成“模式”。
## 概览
Java EE 的模式涉及的面非常广泛,下图是来自经典的 [Core J2EE Patterns: Best Practices and Design Strategies](http://www.corej2eepatterns.com/Patterns2ndEd/index.htm) 一书,对我们从宏观上理解 Java EE 模式有一定的指导意义。但是请不要以为这就是一个完整的 Java EE 的模式列表,它只是列出了在当时比较常见的那一些而已。
<img src="https://static001.geekbang.org/resource/image/c6/e0/c62a84e1d65327d3d8a3cba204be07e0.gif" alt="">
从图中我们可以看到这些“核心模式”大致分为呈现层Presentation Tier绿色部分、业务层Business Tier紫色部分和集成层Integration Tier红色部分三大部分模式之间有的通过实线箭头连接表示着不同模式之间的单向关联关系有的通过虚线箭头连接表示着模式之间“使用包含”的依赖关系。
这里面的内容其实有很多在本章已经涉及到了,比如 Front Controller 和 Business Object但是我还想补充和细化其中的两个模式它们在网站开发的项目中非常常用Intercepting Filter 和 Data Access Object。
## 拦截过滤器
拦截过滤器Intercepting Filter正如图中的“Apply zero or more”和 Servlet 规范所述一样,应当具备一个链式结构。这个链式结构中的每个过滤器,互相之间应当是一个互不依赖的松耦合关系,以便于组合容易。这个过滤器链条,出现的位置通常在控制器 Front Controller 之前,在还没有进行到 Servlet 的 URL 映射前,请求需要先通过它的过滤逻辑。
### Tomcat 中配置过滤器
还记得我们在 [[第 10 讲]](https://time.geekbang.org/column/article/143834) 的加餐中使用 Servlet、JSP 和 JavaBean 实现的简单 MVC 系统吗?现在,让我们来动动手,添加一个基于 URL 映射的过滤器。
首先,打开 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/web.xml在我们原本的 BookServlet 配置前,添加如下内容:
```
&lt;filter&gt;
&lt;filter-name&gt;AuthFilter&lt;/filter-name&gt;
&lt;filter-class&gt;AuthFilter&lt;/filter-class&gt;
&lt;/filter&gt;
&lt;filter-mapping&gt;
&lt;filter-name&gt;AuthFilter&lt;/filter-name&gt;
&lt;url-pattern&gt;/*&lt;/url-pattern&gt;
&lt;/filter-mapping&gt;
&lt;filter&gt;
&lt;filter-name&gt;BookFilter&lt;/filter-name&gt;
&lt;filter-class&gt;BookFilter&lt;/filter-class&gt;
&lt;/filter&gt;
&lt;filter-mapping&gt;
&lt;filter-name&gt;BookFilter&lt;/filter-name&gt;
&lt;url-pattern&gt;/books/*&lt;/url-pattern&gt;
&lt;/filter-mapping&gt;
```
你看,为了显示过滤器链的效果,我们配置了两个过滤器,第一个 AuthFilter 用来对所有的请求实施权限控制,因此 URL 使用 /* 匹配所有请求;第二个 BookFilter 我们希望它只对访问图书的请求实施权限控制。
现在建立 AuthFilter创建 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/classes/AuthFilter.java写入
```
import javax.servlet.*;
import java.util.logging.Logger;
import java.io.IOException;
public class AuthFilter implements Filter {
private Logger logger = Logger.getLogger(AuthFilter.class.getName());
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
logger.info(&quot;Check permission...&quot;);
chain.doFilter(request, response);
}
}
```
这个用于鉴权的过滤器,现在只打印日志,未来我们可以加入真正的鉴权逻辑。
接着建立 BookFilter创建 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/classes/BookFilter.java写入
```
import javax.servlet.*;
import java.io.IOException;
import java.util.logging.Logger;
import java.util.concurrent.atomic.AtomicInteger;
public class BookFilter implements Filter {
private Logger logger = Logger.getLogger(BookFilter.class.getName());
private AtomicInteger count = new AtomicInteger();
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
logger.info(&quot;Add book accessing count...&quot;);
int current = this.count.incrementAndGet();
request.setAttribute(&quot;count&quot;, current);
chain.doFilter(request, response);
}
}
```
在这个过滤器中 ,我们先打印了日志,接着创建了一个计数器,使用 AtomicInteger 而不是 int 这个原语类型的目的是为了正确处理在多线程情况下并发计数器的情形,再把当前对 books 请求的计数放到 request 中。
编译一下:
```
javac AuthFilter.java BookFilter.java -classpath ${CATALINA_HOME}/lib/servlet-api.jar
```
我们再回到曾经建立的 ${CATALINA_HOME}/webapps/ROOT/book.jsp在页面尾部添加一行输出计数器的计数
```
Count: ${count}
```
现在启动 Tomcat
```
catalina run
```
最后在浏览器中访问 [http://localhost:8080/books?category=art](http://localhost:8080/books?category=art),你将看到类似这样的输出,并且每刷新一次页面,这个计数就加 1。
```
Category name: art, date: 2019-8-11 Count: 1
```
再回到控制台,你应该能看到类似这样的日志,从中可见过滤器的调用顺序:
```
11-Aug-2019 11:08:50.131 INFO [http-nio-8080-exec-1] AuthFilter.doFilter Check permission...
11-Aug-2019 11:08:50.132 INFO [http-nio-8080-exec-1] BookFilter.doFilter Add book accessing count...
```
好,动手实践暂时就先到这里。就如同上面的例子这样,过滤器也是可以配置映射关系的,并且,在过滤器中,我们可以实现一组不同类型请求的处理所共有的逻辑。学到这里,不知道你有没有联想到一个相关的,且在这一讲之前我们才学过的模式?对,它就是 AOP过滤器本质上就是面向切面编程这种模式的一种子模式。
### Struts 的拦截器
Struts 提供了拦截器Interceptor这样功能更加强大的组件对于一些常见的功能它已经预置了数十种常见的拦截器比如异常、参数验证、文件上传和国际化支持等等既包括预处理Action 执行之前也包括后处理Action 执行之后)的拦截逻辑,只需要配置使用即可。
举例来说,如果定义了这样一个的拦截器栈,它包含了两个拦截器,一个是异常拦截器,一个是校验拦截器,并且配置了 ping 方法不需要经过拦截器的校验,这两个拦截器组合成为 commonInterceptorStack 这个拦截器栈:
```
&lt;interceptor-stack name=&quot;commonInterceptorStack&quot;&gt;
&lt;interceptor-ref name=&quot;exception&quot;/&gt;
&lt;interceptor-ref name=&quot;validation&quot;&gt;
&lt;param name=&quot;excludeMethods&quot;&gt;ping&lt;/param&gt;
&lt;/interceptor-ref&gt;
&lt;/interceptor-stack&gt;
```
配置完毕后就可以使用了,对于一个控制器层的 bookAction我们规定请求必须经过一个 alias 拦截器,和刚才定义的 commonInterceptorStack 拦截器栈:
```
&lt;action name=&quot;bookAction&quot; class=&quot;BookAction&quot;&gt;
&lt;interceptor-ref name=&quot;alias&quot;/&gt;
&lt;interceptor-ref name=&quot;commonInterceptorStack&quot;/&gt;
&lt;/action&gt;
```
## 数据访问对象
我们在 [[第 08 讲]](https://time.geekbang.org/column/article/141679) 中介绍持久层框架的时候,已经谈到了 DAOData Access Object今天让我们进一步学习一下。
**DAO 本质上是能够为某种特定数据持久化的机制提供抽象结构的对象。**虽然我们谈论 DAO 基本上是默认这里的数据持久化的介质就是数据库但需要明确的是实际上并没有这样的约束。换句话说DAO 可以把数据持久化到数据库中,但也可以持久化到文件里,甚至会以网络请求的方式把数据持久化到某个远程服务中去。
数据访问对象最大的好处依然是我们反复强调的“解耦”,业务代码不需要关心数据是怎样持久化的。在测试其上方的 Service 层的时候,只要把实际的 DAO 替换成“桩代码”,就可以不实际执行持久化逻辑而完成测试;如果哪一天希望更换 DAO 的实现,例如把关系数据库存储改为更一般的键值存储,其上方的 Service 层不修改逻辑就可以实现。
但事物都有两面性DAO 也不是完美的,比如说,**多加一层就会从额外的抽象层次上带来软件的复杂性它经常和“抽象泄露Leaky Abstraction”这样的现象联系起来**。
这里是说,理想状况下,程序员只需要关心“某一抽象层之上”的逻辑和调用,这也是我们分层的一大好处。可是,现实总是和理想有距离的,一旦抽象之下的部分出错,程序员很可能必须去了解和深入这部分的内容,这就违背了抽象分层的初衷,但是在很多情况下这是不可避免的,这也是整个软件体系日渐复杂,我们需要学习的内容越来越多的原因之一。
## 总结思考
今天我们了解了 Java EE 的各种模式,并且重点学习了拦截过滤器这个模式。模式的学习有一个特点,在理论学习的基础上,我们需要反复地实践强化,以及反复地思考。可以说,实践和思考这二者缺一不可。如果只有实践而没有思考,就没有办法灵活地将理论应用在复杂的实际项目中;如果只有思考而没有实践,那么到实际动手的时候还是很难顺利地实施想法。
对于今天的内容,留两个问题:
- 我们介绍了 DAO 层的两面性,那么,在你经历的项目中,你是怎样访问数据存储设施(例如文件和数据库)的,能说说吗?
- 今天我们学到了,通过使用基于 URL 映射的过滤器,是可以给业务代码增加 AOP 的切面逻辑的。那么,为什么我们还需要之前所介绍的,通过匹配代码类和方法的表达式来嵌入切面逻辑的方式呢?
有道是,技术进程多风雨,唯有套路得人心。回看本章,从 MVC 开始,我们一直都在和“模式”打交道,不知道你是不是认真学习了,是不是收获一些代码设计上的“套路”了呢?
## 选修课堂MyBatis vs Hibernate
在 DAO 的设计过程中我们经常需要处理模型实体对象和关系数据库的表记录之间的双向转换问题怎样将对象的属性和关联关系映射到数据库表上去有许多持久化框架都给出了自己的解决办法今天我就来介绍两种最经典的解决思路MyBatis 和 Hibernate。
**MyBatis 的思路是使用 XML 或注解的方式来配置 ORM把 SQL 用标签管理起来,但不关心,也不干涉实际 SQL 的书写。**
在这种思路下框架轻量,很容易集成,又因为我们可以使用 SQL 所有的特性,可以写存储过程,也可以写 SQL 方言Dialect所以灵活度相当高。当然灵活也意味着在具体实现功能的时候你需要做得更多不但需要关心模型层、SQL还需要关心这二者怎样映射起来具体包括
- 请求参数映射,即模型的值怎样映射到 SQL 语句的变参里面;
- 返回值映射,即怎样将数据库查询的返回记录映射到模型对象。
我们来看一个最简单的 XML 配置片段:
```
&lt;mapper namespace=&quot;xxx.BookDAO&quot;&gt;
&lt;insert id=&quot;add&quot; parameterType=&quot;Book&quot;&gt;
insert into BOOKS(NAME, DESC) values(#{name}, #{desc})
&lt;/insert&gt;
&lt;select id=&quot;get&quot; resultType=&quot;Book&quot; parameterType=&quot;java.lang.String&quot;&gt;
select * from BOOKS where ID=#{id}
&lt;/select&gt;
&lt;/mapper&gt;
```
你看SQL 原模原样地写在了配置文件里面。对于写入语句,比如这里的 insert需要显式告知参数类型 Book 对象,接着就可以直接使用 Book 的 name 和 desc 对应的 get 方法来获得具体值并注入 SQL了。对于简单的对象默认的映射规则就可以解决问题反之也可以在 XML 中定义映射规则。
**Hibernate 则是另一种思路,如果你已经习惯于和模型层打交道,那么它就将 SQL 层对你隐藏起来了。**换言之,你只需要写模型代码和 HQLHibernate Query Language这种面向对象的查询语言就可以了至于 SQL 的生成,框架可以帮你完成。
这种方式的一大好处就是具体数据库的透明性,你今天使用的数据库是 MySQL明天就可以换成 Oracle并且不用改代码。在分析设计的时候你只需要做自己习惯的模型驱动编程就可以了。
但值得注意的是Hibernate是把双刃剑有利也有弊。它也带来了很多问题比如较高的学习曲线在出现问题的时候无论是功能问题还是性能问题它需要更多的知识储备来进行问题的定位和性能的调试。
MyBatis 和 Hibernate 到这就讲解清楚了,再总结延伸一下。
从框架本身的角度来说Hibernate 提供的特性远比 MyBatis 更丰富、更完整。如果你是一位有着一定 ORM 经验的程序员,那么 Hibernate 很可能会使你的开发效率更高。
可对于一个新项目而言,在技术选型的过程中,如果你的团队中没有非常多的经验丰富的程序员,我通常建议持久层的框架不要去考虑 Hibernate。简单说来就是因为它的“水比较深”。我相信大多数程序员朋友还是更习惯于实打实地接触 SQL流程到哪一步执行了什么语句该怎么调整都十分清晰和直接。
## 扩展阅读
- 文中提到了 Java EE 平台的一系列标准和技术,维基百科上有一个[简单的列表](https://zh.wikipedia.org/wiki/Jakarta_EE#%E7%BB%84%E4%BB%B6)供参考。
- [Core J2EE Patterns: Best Practices and Design Strategies](http://www.corej2eepatterns.com/Patterns2ndEd/index.htm) 这本书对于你学习 Java EE 的模式会提供不错的指导性帮助,属于权威之一,但是内容比较抽象,如果你在设计方面有一定追求,它是很好的阅读材料。好几年前我曾经读过纸质的第一版,但这是第二版,已经可以在网上公开阅读。
- 如果对于文中提到的 Struts 拦截器感兴趣请看Struts 官方文档中[对于拦截器的介绍](https://struts.apache.org/core-developers/interceptors.html)。
- 文中提到了抽象泄露的概念,如果你感兴趣的话,请阅读 [The Law of Leaky Abstractions](https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/),作者 Joel Spolsky 就是那本著名的《软件随想录》的作者。
- [MyBatis 的官网](http://www.mybatis.org/mybatis-3/zh/index.html)是的MyBatis 的教程我就推荐官网上的,清晰简洁,而且具备中文版,不需要去找什么第三方的资料了;如果是需要中文的 Hibernate 入门资料,我推荐 W3Cschool 上的 [Hibernate 教程](https://www.w3cschool.cn/hibernate/)。

View File

@@ -0,0 +1,111 @@
<audio id="audio" title="13 | 特别放送:选择比努力更重要" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/a2/b4d93306155321792e3bca6bfd932ba2.mp3"></audio>
你好,我是四火。
又到了一章的末尾,特别放送时间。专栏上线后的这几周,我在留言区回答了一些问题,有一些是技术上的问题,也有一些是非技术上的问题。尽管在 [[开篇词]](https://time.geekbang.org/column/article/134212) 和 [[学习路径]](https://time.geekbang.org/column/article/134216) 中我已经介绍了全栈工程师的角色、重要性和学习方法,但是依然见到不少困惑和疑问,**其中一个问题反复出现,那就是面对那么多的软件技术,总有一种“学不过来”的感觉,为此感到焦虑和担忧**。尤其是对于全栈工程师而言,这个话题更是被放大了。
颇为遗憾的是,这几年来,我见到了一些相当有经验的做着 Web 全栈开发的程序员,他们还依然走在一条埋头苦干,不断堆积知识,单纯靠量取胜的路上。可是,我认为,学习是需要选择的,并且,选择比努力更重要。
今天的特别放送,我就来聊一聊,我是怎么认识这个问题的,希望能给你带来一点参考意义。
## 两个小故事
第一个故事,微软的测试团队改革。陆奇是一个程序员从技术做起,进而翻身的典范。最初他入职雅虎的时候只是一个普通的工程师,十多年后 ,他以执行副总裁的身份,不但牵头打造了 Bing 搜索,还完成了几项意义深远的改革,其中一项,就是合并开发和测试这两个原本独立的部门,大幅裁剪专职测试人员,让工程师做更多的事。
这样一来,有一些擅长使用内部测试工具进行测试的工程师,就慢慢丢掉工作了,原因很简单,他们更多的只是熟练工,而缺乏技术上的竞争力。**听起来,这似乎是微软内部组织架构变动和工具、技术栈封闭的锅**。
其实,合并开发和测试团队,对于许多软件公司,特别是互联网公司来说,是一个基本不需要讨论的事情。除了一些面向互联网用户的、交互较为复杂的团队,还配有专职测试(即便是这样的测试,为了节省成本,也常常外包给合同工去做了),绝大多数团队,都已经变成了软件工程师一肩挑的局面了,而测试只是被合并的一部分专职角色,其实还有线上运维等等。对于大型互联网企业来说,一个事实就是,工程师的全面性越来越强,这是一个不可辩驳的趋势。
第二个故事,是我在几个月前,参与面试一位有着超过 15 年 Web 开发经验的工程师,这件事就发生在五轮面试之后的讨论会上(这里涉及到的面试流程我曾在 [[第06 讲]](https://time.geekbang.org/column/article/139370) 中介绍过)。
对于这样一个比较资深的工程师岗五个面试官在讨论会上产生了巨大的分歧。招聘经理也就是负责吃饭聊天那一轮面试的人反馈非常正面说此人经验丰富谈吐得当leadship 层面也没有问题Bartender技术负责人重点考察了项目经验和系统设计反馈也颇为不错经验丰富而且能够从很多角度去分析和思考问题可其余的三轮面试面试官反映却大相径庭主要问题出在白板代码部分总的来说代码生疏书写也缺乏条理其中有一轮都没有写完代码写完的两轮也是 bug 满地,问题频出。最后,在争论之后达成一致,可以通过,但是只能勉强给一个比预期职位级别低一级的 offer。
作为一个技术岗位的工程师,编码能力是一个硬指标,因此这也是照章办事,不得已而为之的。只不过,我们私下里讨论,应聘者很可能是有足够的编码技巧的,只不过有一段时间没有写代码了,确实有些可惜。但是,猜想也只是猜想而已。
其实,故事有很多,但是这两个故事是我精心挑选的。二者分别对应的启发是,工程师在技术学习的时候,需要遵循的两个主要思路。
- 第一,技术是分级的,具体说,是分短命和长寿的,也是分表面和本质的。我们要学习各种技术,但是我们要把足够的精力放到长寿的技术以及技术本质上。这就是第一个故事带来的启发。
- 第二,基础知识和能力的训练需要长期坚持,无论是在工作中,还是工作以外。这就是第二个故事带来的启发。
## 技术的分级
下面我展开来讲第一个思路,技术分级。
我们都听过“技术无贵贱”的说法,但这并不代表我们要“无区别”地学习技术。工程师,说白了最重要的就是“工程能力”,就是应用工程化的思想和技术,去解决实际的问题。我把工程能力粗略地分成这样四个级别,请注意,**它们都必不可少**,这个分级也不严密,但是我们能从中看到一个大致的趋势:
<img src="https://static001.geekbang.org/resource/image/12/e4/12471848fea52f2c04ef7f676bab62e4.png" alt="">
- 比如说,稳定性上,从左到右逐渐降低,越靠右往往寿命越短。
- 比如说,学习难度上,不一定,但是总体来说从左到右逐渐降低。
- 比如说,针对性上,往往从左到右逐渐增加,即越往右就越是针对具体的问题。
下面我来具体说说图中的这几种能力。
软能力,比如我们对程序员要求最多的沟通能力、学习能力和抽象能力。哪怕再过几十年,这部分基本也是不会变的。
模式和思想,比如分而治之的思想,模块化的思维,客户端/服务端模式。这部分也相当稳定,可能随着技术的革新,也会有一些以往不受重视的模式或思想被列为重点,举例来说,十年前,我们可能很少谈论分布式的思想。
语言和平台,比如 Java 语言JVM 平台。这部分其实变化就非常大了,有的语言已经几十年了,有的则是几年热度之后就消退了。普遍来说,语言平台要比语言本身稳定一些。
框架和库,这部分差异也非常大,但是总体来说,基本就是最短命的。如果要花费大量的时间和心血去深究某一个框架和库,那么你会希望它要么相当有代表性和典型意义,要么有足够长远的未来。
对于这几种能力,还有几点我要强调说明一下。
首先,请不要误解我的意思。每一级的能力都是有各自价值的,**我们在解决实际问题的时候,需要使用到每一级的技术**。举例来说,要设计实现一个网站,全栈工程师需要发挥沟通能力、合作能力,需要利用在模式和思想方面的经验和认识,选择合适的语言和平台,最后再选择相应的框架和库来完成它。
其次,我们当然希望图中靠左侧的“长寿”的能力得到进步,但每一级能力的培养,都需要通过对具体技术、业务的学习积累来实现,也就是说,**这些分级之间是不冲突的,反而是互相促进的**。通过这两章的学习,特别是从具体技术到通用模式这样的方式,也许你已经体会到了这一点。
那么现在,有了上面的说明,你是否已经清楚,该做出怎样的侧重了呢?
显然,我们应该明确的一点是,把几乎全部的精力都投入到这个分级的右端,换言之,**把大量的时间都花在记忆一个接一个的框架和库上面,妄图靠数量取胜,不可取**。有时更可悲的是,就连生硬的记忆本身也不讨好——都不用等到技术淘汰了,它们还都是公司内部的框架和库,离开公司就一文不值了。
因为,这只是单纯地记忆,没有比较、分析、思考这些能让这个分级均衡发展的行为。而这,我认为正是系统的技术学习中最大的误区。
我举一个真实的例子来说明这个现象,有位程序员朋友学习前端技术,最开始使用 Backbone.js后来陆续用过 AngularJS 以及 React现在准备转向 Vue.js。看起来似乎很勤奋但是他自己却说他依然只是停留在上头说用什么自己就跟着学什么的程度自己也没有什么想法总之越学越麻木……他自己还说“不知道何时是个头”。
接着,我想展开讲一下其它几个具体技术学习中的典型误区,而这些误区,在我看来也都和是否努力无关,本质上也都属于不恰当的“选择”。
**1. 过于关注配置使用,忽略原理和场景分析。**
这一点我觉得是最容易在迈入职场没多久的程序员身上见到的问题,非常普遍。老板一个命令下来,心里慌得很,就想着怎么把问题搞定,而有一些问题是知识迷局一类的,比如,怎样配置整合 SSHStruts、Spring 和 Hibernate
这类问题光靠想是没法得出结论的,于是通过各种搜索、查文档、试错等等方式,花了很长时间,好不容易搞定了问题,完成了需求,又紧锣密鼓地去接下一个任务去了。**可是呢,对于刚才使用到的技术,付出了那么多,却只收获了这一个小小的迷局的解**。这投入产出比低不说,人还是很健忘的,这些具体的配置和使用,只要不用,很快就会忘掉的,下次在遇到类似的问题,可能已经忘记了这些配置,于是又要重复这一行为。
**2. 过于关注编程语言的语法和语言技巧,忽略语言思考和书写时的思维模式。**
比如说JavaScript 语言,里面有很多坑,但是有一些坑是语言本身的不良设计造成的,知道当然好,但也不要因此沾沾自喜。但是对于一个写惯了 C++、Java 等后端静态语言的程序员来说JavaScript 异步编程、函数成为一等公民等等这些颠覆以往编程思维模式的特性,才是学习这门新语言一个收获颇丰的地方(我们会在下一章介绍)。换言之,我们要写 JavaScript就要写真正的 JavaScript——而不是写 Java再按字面翻译成 JavaScript。
**3. 过于关注具体实现逻辑,忽略了对于设计的思考和权衡。**
我看到很多程序员朋友都热衷于研究源码,看完一个库,再看下一个,有些人甚至以读过源码的数量为荣,张口闭口就是“你读过多少源码”。
阅读源码当然是一件好事,但是请不要认为研究源码的目的只是“知道怎么实现”。毕竟,每个人的时间精力都是有限的,读源码尤其耗时。既然要读,就要读得有所收获,而不是凭空指望“读书百遍,其义自见”。比如说,阅读的时候,要抓住主干,去思考里面的设计思路,也就是所谓的“代码骨干”,忽略那些次要的边边角角。
**4. 直接学习模式和思想,脱离了具体实践。**
我已经介绍了模式和思想学习的好处,但是对于那些抽象的理论和概念,在没有实践的基础上,是很难真正“消化吸收”的。最好的方法是动手做一做,如果时间有限,至少也要阅读和了解它们都被哪些具体技术采用怎样的方式实现了。
## 基础知识和能力
下面我来说说第二个思路,基础知识和能力。
即便在同一级的技术上,我们的学习也应该是有轻重缓急的。我们的项目中需要的技术,当然要学,但是我们心里需要清楚那些所谓的“基础”。生活不可能完美,工作也是,工作中学当然很好,但**很遗憾的是,有很多“基础”光靠工作中学是很难有较大进步的**。比方说,算法和数据结构。
我认为,数据结构、算法、网络等等这些,都是基础知识,如果工作中的强化不够,工作以外的学习和训练还是需要的。如果你的基础不够扎实,特别是“非科班”出身的话,它就更加重要了。比较好的一点是,这些相对于“设计能力”“问题解决能力”等等来说,还是要好学很多。
**除了基础知识,还有基础能力。最重要的,就是编程能力。**值得庆幸的是,我相信大部分程序员都会在工作中有足够的编程时间,因此,只要专注、带着思考去写代码,认真对待代码评审,编程能力是会随着经验的增加而逐渐提升的。
最后,你正在学习的这个专栏,涉及了大量的全栈技术相关的知识,但是从内容设计上你应该可以看出,我希望能更多地介绍其中的“套路”,也就是上图中的模式和思想,有些是同类技术所共同有的(比如前几讲介绍过的 IoC有些则是跨技术类型但却依然共有的比如幂等性和安全性
## 总结思考
今天的特别放送,我结合了自己的积累,介绍了我们该怎样做出选择,来应对这个“学不过来”的话题,特别是介绍了一些常见的学习误区,希望你能从我的答案中有所收获。
今天就不放特定的思考题了,因为我觉得,这一类主观性特别强的话题,很容易有不同的认识,不需要拿问题来引导思考方向了,不加限定地思考会更好。欢迎你在下面的留言区谈谈自己的看法,我们一起讨论。
## 扩展阅读
- 有一篇文章,[Stop Learning Frameworks](https://sizovs.net/2018/12/17/stop-learning-frameworks/),观点比较朴实,也比较偏激,在程序员群体内掀起了轩然大波,有朋友翻译了,译文在[这里](https://zhuanlan.zhihu.com/p/52814937)。
- 关于传统关系数据库技术淘汰的事情,以前我写过一点小[体会](https://www.raychase.net/3689),可供参考。