1.MongDB 简介

MongoDB(来自于英文单词“Humongous”,中文含义为“庞大”)是可以应用于各种规模的企业、各个行业以及各类应用程序的开源数据库。作为一个适用于敏捷开发的数据库,MongoDB 的数据模式可以随着应用程序的发展而灵活地更新。与此同时,它也为开发人员 提供了传统数据库的功能:二级索引,完整的查询系统以及严格一致性等等。 MongoDB 能够使企业更加具有敏捷性和可扩展性,各种规模的企业都可以通过使用 MongoDB 来创建新的应用,提高与客户之间的工作效率,加快产品上市时间,以及降低企业成本。

MongoDB 是专为可扩展性,高性能和高可用性而设计的数据库。它可以从单服务器部署扩展到大型、复杂的多数据中心架构。利用内存计算的优势,MongoDB 能够提供高性能的数据读写操作。 MongoDB 的本地复制和自动故障转移功能使您的应用程序具有企业级的可靠性和操作灵活性。

以上内容摘自官网:

1.1 文档型数据库

简而言之,MongoDB是一个免费开源跨平台的 NoSQL 数据库,与关系型数据库不同,MongoDB 的数据以类似于 JSON 格式的二进制文档存储:

{
    name: "我没有三颗心脏",
    age: 22,
}

文档型的数据存储方式有几个重要好处:

  1. 文档的数据类型可以对应到语言的数据类型,如数组类型(Array)和对象类型(Object);
  2. 文档可以嵌套,有时关系型数据库涉及几个表的操作,在 MongoDB 中一次就能完成,可以减少昂贵的连接花销;
  3. 文档不对数据结构加以限制,不同的数据结构可以存储在同一张表;
  4. MongoDB 的文档数据模型和索引系统能有效提升数据库性能;
  5. 复制集功能提供数据冗余,自动化容灾容错,提升数据库可用性;
  6. 分片技术能够分散单服务器的读写压力,提高并发能力,提升数据库的可拓展性;
  7. MongoDB 高性能,高可用性、可扩展性等特点,使其至 2009 年发布以来,逐渐被认可,并被越来越多的用于生产环境中。AWS、GCP、阿里云等云平台都提供了十分便捷的 MongoDB 云服务。

1.2 MongoDB 基础概念

可以使用我们熟悉的 MySQL 数据库来加以对比:

MySQL 基础概念 MongoDB 对应概念
数据库(database) 容器(database)
表(table) 集合(collection)
行(row) 文档(document)
列(column) 域(filed)
索引(index) 索引(index)

也借用一下菜鸟教程的图来更加形象生动的说明一下:

这很容易理解,但是问题在于:我们为什么要引入新的概念呢?(也就是为什么我们要把“表”替换成“集合”,“行”替换成“文档”,“列”替换成“域”呢?)原因在于,其实在 MySQL 这样的典型关系型数据中,我们是在定义表的时候定义列的,但是由于上述文档型数据库的特点,它允许文档的数据类型可以对应到语言的数据类型,所以我们是在定义文档的时候才会定义域的。

也就是说,集合中的每个文档都可以有独立的域。因此,虽说集合相对于表来说是一个简化了的容器,而文档则包含了比行要多得多的信息。

2 搭建环境

怎么样都好,搭建好环境就行,这里以 OS 环境为例,你可以使用 OSX 的 brew 安装 mongodb:

brew install mongodb

在运行之前我们需要创建一个数据库存储目录 /data/db

sudo mkdir -p /data/db

然后启动 mongodb,默认数据库目录即为 /data/db(如果不是,可以使用 --dbpath 指令来指定):

sudo mongd

过一会儿你就能看到你的 mongodb 运行起来的提示:

具体的搭建过程可以参考菜鸟的教程:http://www.runoob.com/mongodb/mongodb-window-install.html

3 基于 Shell 的 CRUD

3.1 连接实例

通过上面的步骤我们在系统里运行了一个 mongodb 实例,接下来通过 mongo 命令来连接它:

mongo [options] [db address] [file names]

由于上面运行的 mongodb 运行在 27017 端口,并且灭有启动安全模式,所以我们也不需要输入用户名和密码就可以直接连接:

mongo 127.0.0.1:27017

或者通过 --host--port 选项指定主机和端口。一切顺利的话,就进入了 mongoDB shellshell 会报出一连串权限警告,不过不用担心,这并不会影响之后的操作。在添加授权用户和开启认证后,这些警告会自动消失。

3.2 CRUD 操作

在进行增删改查操作之前,我们需要先了解一下常用的 shell 命令:

  • db 显示当前所在数据库,默认为 test
  • show dbs 列出可用数据库
  • show tables show collections 列出数据库中可用集合
  • use <database> 用于切换数据库

mongoDB 预设有两个数据库,admin 和 local,admin 用来存放系统数据,local 用来存放该实例数据,在副本集中,一个实例的 local 数据库对于其它实例是不可见的。使用 use 命令切换数据库:

> use admin
> use local
> use newDatabase

可以 use 一个不存在的数据库,当你存入新数据时,mongoDB 会创建这个数据库:

> use newDatabase
> db.newCollection.insert({x:1})
WriteResult({ "nInserted" : 1 })

以上命令向数据库中插入一个文档,返回 1 表示插入成功,mongoDB 自动创建 newCollection 集合和数据库 newDatabase。下面将对增查改删操作进行一个简单的演示。

3.2.1 创建(Create)

MongoDB 提供 insert 方法创建新文档:

  • db.collection.inserOne() 插入单个文档
    WriteResult({ “nInserted” : 1 })
  • db.collection.inserMany() 插入多个文档
  • db.collection.insert() 插入单条或多条文档

我们接着在刚才新创建的 newDatabase 下面新增数据吧:

db.newCollection.insert({name:"wmyskxz",age:22})

根据以往经验应该会觉得蛮奇怪的,因为之前在这个集合中插入的数据格式是 {x:1} 的,而这里新增的数据格式确是 {name:"wmyskxz",age:22} 这个样子的。还记得吗,文档型数据库的与传统型的关系型数据的区别就是在这里!

并且要注意,age:22age:"22" 是不一样的哦,前者插入的是一个数值,而后者是字符串,我们可以通过 db.newCollection.find() 命令查看到刚刚插入的文档:

> db.newCollection.find()
{ "_id" : ObjectId("5cc1026533907ae66490e46c"), "x" : 1 }
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "name" : "wmyskxz", "age" : 22 }

这里有一个神奇的返回,那就是多了一个叫做 _id 的东西,这是 MongoDB 为你自动添加的字段,你也可以自己生成。大部分情况下还是会让 MongoDB 为我们生成,而且默认情况下,该字段是被加上了索引的。

3.2.2 查找(Read)

MongoDB 提供 find 方法查找文档,第一个参数为查询条件:

> db.newCollection.find() # 查找所有文档
{ "_id" : ObjectId("5cc1026533907ae66490e46c"), "x" : 1 }
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "name" : "wmyskxz", "age" : 22 }
> db.newCollection.find({name:"wmyskxz"}) # 查找 name 为 wmyskxz 的文档
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "name" : "wmyskxz", "age" : 22 }
> db.newCollection.find({age:{$gt:20}}) # 查找 age 大于 20 的文档
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "name" : "wmyskxz", "age" : 22 }

上述代码中的$gt对应于大于号>的转义。

第二个参数可以传入投影文档映射数据:

> db.newCollection.find({age:{$gt:20}},{name:1})
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "name" : "wmyskxz" }

上述命令将查找 age 大于 20 的文档,返回 name 字段,排除其他字段。投影文档中字段为 1 或其他真值表示包含,0 或假值表示排除,可以设置多个字段位为 1 或 0,但不能混合使用。

为了测试,我们为这个集合弄了一些奇奇怪怪的数据:

> db.newCollection.find()
{ "_id" : ObjectId("5cc1026533907ae66490e46c"), "x" : 1 }
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "name" : "wmyskxz", "age" : 22 }
{ "_id" : ObjectId("5cc108fb33907ae66490e46e"), "name" : "wmyskxz-test", "age" : 22, "x" : 1, "y" : 30 }

然后再来测试:

> db.newCollection.find({age:{$gt:20}},{name:1,x:1}) 
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "name" : "wmyskxz" }
{ "_id" : ObjectId("5cc108fb33907ae66490e46e"), "name" : "wmyskxz-test", "x" : 1 }
> db.newCollection.find({age:{$gt:20}},{name:0,x:0}) 
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "age" : 22 }
{ "_id" : ObjectId("5cc108fb33907ae66490e46e"), "age" : 22, "y" : 30 }
> db.newCollection.find({age:{$gt:20}},{name:0,x:1})
Error: error: {
    "ok" : 0,
    "errmsg" : "Projection cannot have a mix of inclusion and exclusion.",
    "code" : 2,
    "codeName" : "BadValue"
}

从上面的命令我们就可以把我们的一些想法和上面的结论得以验证,perfect!

除此之外,还可以通过 countskiplimit 等指针(Cursor)方法,改变文档查询的执行方式:

> db.newCollection.find().count()
3
> db.newCollection.find().skip(1).limit(10).sort({age:1})
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "name" : "wmyskxz", "age" : 22 }
{ "_id" : ObjectId("5cc108fb33907ae66490e46e"), "name" : "wmyskxz-test", "age" : 22, "x" : 1, "y" : 30 }

上述查找命令跳过 1 个文档,限制输出 10 个,以 age 子段正序排序(大于 0 为正序,小于 0 位反序)输出结果。最后,可以使用 Cursor 方法中的 pretty 方法,提升查询文档的易读性,特别是在查看嵌套的文档和配置文件的时候:

> db.newCollection.find().pretty()
{ "_id" : ObjectId("5cc1026533907ae66490e46c"), "x" : 1 }
{
    "_id" : ObjectId("5cc102fb33907ae66490e46d"),
    "name" : "wmyskxz",
    "age" : 22
}
{
    "_id" : ObjectId("5cc108fb33907ae66490e46e"),
    "name" : "wmyskxz-test",
    "age" : 22,
    "x" : 1,
    "y" : 30
}

3.2.3 更新(Update)

MongoDB 提供 update 方法更新文档:

  • db.collection.updateOne() 更新最多一个符合条件的文档
  • db.collection.updateMany() 更新所有符合条件的文档
  • db.collection.replaceOne() 替代最多一个符合条件的文档
  • db.collection.update() 默认更新一个文档,可配置 multi 参数,跟新多个文档

update() 方法为例。其格式:

> db.collection.update(
    ,
    ,
    {
        upsert: ,
        multi: 
    }
)

各参数意义:

  • query 为查询条件
  • update 为修改的文档
  • upsert 为真,查询为空时插入文档
  • multi 为真,更新所有符合条件的文档

下面我们测试把 name 字段为 wmyskxz 的文档更新一下试试:

> db.newCollection.update({name:"wmyskxz"},{name:"wmyskxz",age:30})
WriteResult({ "nMatched" : 1, "nUpserted" : 0, "nModified" : 1 })

要注意的是,如果更新文档只传入 age 字段,那么文档会被更新为{age: 30},而不是{name:"wmyskxz", age:30}。要避免文档被覆盖,需要用到 $set 指令,$set 仅替换或添加指定字段:

> db.newCollection.update({name:"wmyskxz"},{$set:{age:30}})

如果要在查询的文档不存在的时候插入文档,要把 upsert 参数设置真值:

> db.newCollection.update({name:"wmyskxz11"},{$set:{age:30}},{upsert:true})

update 方法默认情况只更新一个文档,如果要更新符合条件的所有文档,要把 multi 设为真值,并使用 $set 指令:

> db.newCollection.update({age:{$gt:20}},{$set:{test:"A"}},{multi:true})
WriteResult({ "nMatched" : 3, "nUpserted" : 0, "nModified" : 3 })
> db.newCollection.find()
{ "_id" : ObjectId("5cc1026533907ae66490e46c"), "x" : 1 }
{ "_id" : ObjectId("5cc102fb33907ae66490e46d"), "name" : "wmyskxz", "age" : 30, "test" : "A" }
{ "_id" : ObjectId("5cc108fb33907ae66490e46e"), "name" : "wmyskxz-test", "age" : 22, "x" : 1, "y" : 30, "test" : "A" }
{ "_id" : ObjectId("5cc110148d0a578f03d43e81"), "name" : "wmyskxz11", "age" : 30, "test" : "A" }

3.2.4 删除(Delete)

MongoDB 提供了 delete 方法删除文档:

  • db.collection.deleteOne() 删除最多一个符合条件的文档
  • db.collection.deleteMany() 删除所有符合条件的文档
  • db.collection.remove() 删除一个或多个文档

以 remove 方法为例:

> db.newCollection.remove({name:"wmyskxz11"})
> db.newCollection.remove({age:{$gt:20}},{justOne:true})
> db.newCollection.find()
{ "_id" : ObjectId("5cc1026533907ae66490e46c"), "x" : 1 }
{ "_id" : ObjectId("5cc108fb33907ae66490e46e"), "name" : "wmyskxz-test", "age" : 22, "x" : 1, "y" : 30, "test" : "A" }

MongoDB 提供了 drop 方法删除集合,返回 true 表面删除集合成功:

> db.newCollection.drop()

3.2.5 小结

相比传统关系型数据库,MongoDB 的 CURD 操作更像是编写程序,更符合开发人员的直觉,不过 MongoDB 同样也支持 SQL 语言。MongoDB 的 CURD 引擎配合索引技术、数据聚合技术和 JavaScript 引擎,赋予 MongoDB 用户更强大的操纵数据的能力。

参考文章:简明 MongoDB 入门教程 - https://segmentfault.com/a/1190000010556670

4 MongoDB 数据模型的一些讨论

前置申明:这一部分基于以下链接整理 https://github.com/justinyhuang/the-little-mongodb-book-cn/blob/master/mongodb.md#%E8%AE%B8%E5%8F%AF%E8%AF%81

这是一个抽象的话题,与大多数NoSQL方案相比,在建模方面,面向文档的数据库算是和关系数据库相差最小的。这些差别是很小,但是并不是说不重要。

4.1 没有连接(Join)

您要接受的第一个也是最基本的一个差别,就是 MongoDB 没有连接(join)。我不知道MongoDB不支持某些类型连接句法的具体原因,但是我知道一般而言人们认为连接是不可扩展的。也就是说,一旦开始横向分割数据,最终不可避免的就是在客户端(应用程序服务器)使用连接。且不论MongoDB为什么不支持连接,事实是数据是有关系的,可是MongoDB不支持连接。(译者:这里的关系指的是不同的数据之间是有关联的,对于没有关系的数据,就完全不需要连接。)

为了在没有连接的MongoDB中生存下去,在没有其他帮助的情况下,我们必须在自己的应用程序中实现连接。

基本上我们需要用第二次查询去找到相关的数据。找到并组织这些数据相当于在关系数据库中声明一个外来的键。现在先别管什么独角兽了,我们来看看我们的员工。首先我们创建一个员工的数据(这次我告诉您具体的_id值,这样我们的例子就是一样的了):

db.employees.insert({_id: ObjectId("4d85c7039ab0fd70a117d730"), name: 'Leto'})

然后我们再加入几个员工并把 Leto 设成他们的老板:

db.employees.insert({_id: ObjectId("4d85c7039ab0fd70a117d731"), name: 'Duncan', manager: ObjectId("4d85c7039ab0fd70a117d730")});
db.employees.insert({_id: ObjectId("4d85c7039ab0fd70a117d732"), name: 'Moneo', manager: ObjectId("4d85c7039ab0fd70a117d730")});

(有必要再强调一下,_id可以是任何的唯一的值。在实际工作中你很可能会用到ObjectId, 所以我们在这里也使用它)

显然,要找到Leto的所有员工,只要执行:

db.employees.find({manager: ObjectId("4d85c7039ab0fd70a117d730")})

没什么了不起的。在最糟糕的情况下,为弥补连接的缺失需要做的只是再多查询一次而已,该查询很可能是经过索引了的。

4.1.1 数组和嵌入文档(Embedded Documents)

MongoDB 没有连接并不意味着它没有其他的优势。还记得我们曾说过 MongoDB 支持数组并把它当成文档中的一级对象吗?当处理多对一或是多对多关系的时候,这一特性就显得非常好用了。用一个简单的例子来说明,如果一个员工有两个经理,我们可以把这个关系储存在一个数组当中:

({name: 'Siona', manager: [ObjectId("4d85c7039ab0fd70a117d730"), ObjectId("4d85c7039ab0fd70a117d732")] })

需要注意的是,在这种情况下,有些文档中的 manager 可能是一个向量,而其他的却是数组。在两种情况下,前面的 find 还是一样可以工作:

db.employees.find({manager: ObjectId("4d85c7039ab0fd70a117d730")})

很快您就会发现数组中的值比起多对多的连接表(join-table)来说要更容易处理。

除了数组,MongoDB 还支持嵌入文档。尝试插入含有内嵌文档的文档,像这样:

db.employees.insert({_id: ObjectId("4d85c7039ab0fd70a117d734"), name: 'Ghanima', family: {mother: 'Chani', father: 'Paul', brother: ObjectId("4d85c7039ab0fd70a117d730")}})

也许您会这样想,确实也可以这样做:嵌入文档可以用‘.’符号来查询:

db.employees.find({'family.mother': 'Chani'})

就这样,我们简要地介绍了嵌入文档适用的场合以及您应该怎样使用它。

4.1.2 DBRef

MongoDB 支持一个叫做 DBRef 的功能,许多 MongoDB 的驱动都提供对这一功能的支持。当驱动遇到一个 DBRef 时它会把当中引用的文档读取出来。DBRef 包含了所引用的文档的 ID 和所在的集合。它通常专门用于这样的场合:相同集合中的文档需要引用另外一个集合中的不同文档。例如,文档 1 的 DBRef 可能指向 managers 中的文档,而文档 2 中的 DBRef 可能指向 employees 中的文档。

4.1.3 范规范化(Denormalization)

代替连接的另一种方法就是反规范化数据。在过去,反规范化是为性能敏感代码所设,或者是需要数据快照(例如审计日志)的时候才应用的。然而,随着NoSQL的日渐普及,有许多这样的数据库并不提供连接操作,于是作为规范建模的一部分,反规范化就越来越常见了。这样说并不是说您就需要为每个文档中的每一条信息创建副本。与此相反,与其在设计的时候被复制数据的担忧牵着走,还不如按照不同的信息应该归属于相应的文档这一思路来对数据建模。

比如说,假设您在编写一个论坛的应用程序。把一个 user 和一篇 post 关联起来的传统方法是在 posts 中加入一个 userid 的列。这样的模型中,如果要显示 posts 就不得不读取(连接)users。一种简单可行的替代方案就是直接把 nameuserid 存储在 post 中。您甚至可以用嵌入文档来实现,比如说 user: {id: ObjectId('Something'), name: 'Leto'}。当然,如果允许用户更改他们的用户名,那么每当有用户名修改的时候,您就需要去更新所有的文档了(这需要一个额外的查询)。

对一些人来说改用这种方法并非易事。甚至在一些情况下根本行不通。不过别不敢去尝试这种方法:有时候它不仅可行,而且就是正确的方法。

4.1.4 应该选择哪一种?

当处理一对多或是多对多问题的时候,采用id数组往往都是正确的策略。可以这么说,DBRef 并不是那么常用,虽然您完全可以试着采用这项技术。这使得新手们在面临选择嵌入文档还是手工引用(manual reference)时犹豫不决。

首先,要知道目前一个单独的文档的大小限制是 4MB,虽然已经比较大了。了解了这个限制可以为如何使用文档提供一些思路。目前看来多数的开发者还是大量地依赖手工引用来维护数据的关系。嵌入文档经常被使用,but mostly for small pieces of data which we want to always pull with the parent document。一个真实的例子,我把 accounts 文档嵌入存储在用户的文档中,就像这样:

db.users.insert({name: 'leto', email: '[email protected]', account: {allowed_gholas: 5, spice_ration: 10}})

这不是说您就应该低估嵌入文档的作用,也不是说应该把它当成是鲜少用到的工具并直接忽略。将数据模型直接映射到目标对象上可以使问题变得更加简单,也往往因此而不再需要连接操作。当您知道 MongoDB 允许对嵌入文档的域进行查询并做索引后,这个说法就尤其显得正确了。

4.2 集合:少一些还是多一些?

既然集合不强制使用模式,那么就完全有可能用一个单一的集合以及一个不匹配的文档构建一个系统。以我所见过的情况,大部分的 MongoDB 系统都像您在关系数据库中所见到的那样布局。换句话说,如果在关系数据库中会用表,那么很有可能在 MongoDB 中就要用集合(多对多连接表在这里是一个不可忽视的例外)

当把嵌入文档引进来的时候,讨论就会变得更加有意思了。最常见的例子就是博客系统。是应该分别维护 postscomments 两个集合,还是在每个 post 中嵌入一个 comments 数组?暂且不考虑那个 4MB 的限制(哈姆雷特所有的评论也不超过200KB,谁的博客会比他更受欢迎?),大多数的开发者还是倾向于把数据划分开。因为这样既简洁又明确。

没有什么硬性的规定(呃,除了 4MB 的限制)。做了不同的尝试之后您就可以凭感觉知道怎样做是对的了。

总结

至此已经对 MongoDB 有了一个基本的了解和入门,但是要运用在实际的项目中仍然有许多实践需要自己去完成


按照惯例黏一个尾巴:

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz
分享自己的学习 & 学习资料 & 生活
想要交流的朋友也可以加qq群:3382693

前言:由于导师在我的毕设项目里加了消息系统(本来想水水就过的..),没办法…来稍微研究研究吧..简单简单…

需求分析

我的毕设是一个博客系统,类似于简书这样的,所以消息系统也类似,在用户的消息里包含了有:喜欢和赞、评论、关注、私信这样的一类东西,这样的一个系统应该包含以下的功能:

    1. 当用户评论/关注/点赞时能够通知到被评论/关注/点赞的用户,并生成像如下格式的提示信息(允许取消关注/点赞但不收到通知):

      我没有 关注了 你
      三颗 喜欢了你的文章 《Java消息系统简单设计与实现》
      心脏 评论了你的文章 《Java消息系统简单设计与实现》

    1. 用户之间能够发送/接受私信,不需要像QQ那样建立长连接实现实时通信,但刷新列表能看到新消息,并且界面类似QQ聊天界面一左一右,允许删除私信
    1. 管理员能发送通告,其实就像是用管理员的账号给每一个用户发送私信;
    1. 可以查看关注的用户最新发表的文章,得到类似推送的效果;
    1. 所有消息当然也要标注好消息已读or未读,登录就能得到消息提醒标识好有多少未读消息,像是QQ消息右上角的小红点那样类似;

OK,大致就是以上的功能,那么问题来了:这要怎么设计啊?

进一步分析

其实可以根据上面的需求分析,把上面的消息大致可以分为公告(Announcement)、提醒(Remind)、私信(Message)三类,我们可以大致抽象出一个 通知(Notify) 模型:

发送者 接受者 信息类型 动作类型 通知内容 是否已读 消息创建时间
粉丝1号 我没有三颗心脏 提醒 关注 粉丝1号 关注了 你 xx:xx:xx
粉丝1号 我没有三颗心脏 提醒 喜欢和赞 粉丝1号 喜欢了你的文章 《Java消息系统简单设计与实现》 xx:xx:xx
粉丝1号 我没有三颗心脏 提醒 评论 粉丝1号 评论了你的文章 《Java消息系统简单设计与实现》 xx:xx:xx
粉丝2号 我没有三颗心脏 私信 你收到了来自 粉丝2号 的 1 条私信 xx:xx:xx

上面加了一些数据以便理解,不过话说粉丝1号果然是真爱粉,又关注又喜欢又评论,嘻嘻嘻嘻…

emm.这样的模型能够胜任我们的工作吗?我也不知道..不过根据这个模型能够想出大概的这样的创建通知的逻辑:

似乎看上去也没有什么大问题..不过既然消息内容都可以根据动作类型自动生成的了,加上私信和公告的内容因为长度问题也肯定不保存在这张表里的好,所以我们在设计数据库时干脆把通知内容这条去掉不要,当信息类型是公告或者私信时可以根据这条通知的 id 在相应的表中找到相应的数据就可以了,emm..我觉得可以

顺下去想想其实脑中有了一个大概,这样的模型还容易设计和想到,其实主要的问题还是下面的那些

问题一:单表数据大了怎么办?

如果当用户量上去到一定量的时候,那么这张 通知表 势必会变得巨大,因为不管是我们的公告、提醒还是私信都会在这个通知表上创建一条数据,到时候就会面临查询慢的问题,问题的答案是:我也不知道..

所以我们的规定是:不考虑像简书这样超大用户量,能够应付毕设就好啦..简单设计,嘻嘻嘻..不过也不要太不相信MySQL的性能,还是有一定容纳能力的!

问题二:用户要怎样正确得到自己的未读消息呢?

暴力一点方法是,反正通知表里有用户所有的消息,直接读取完,然后通过是否已读字段就能够找到正确的所有未读消息了,这..这么简单吗?

其实有思考过使用时间或者另建一张保存有最新已读到哪条消息的表,但用户可以选择有一些读有一些不读,这两个似乎都很难达到目的…还是暴力吧

问题三:私信消息该怎么设计?

发送者 接受者 内容 发送时间
粉丝1号 我没有三颗心脏 我是你的真爱粉啊!我要给你生猴子! 2019年1月7日11:34:23
我没有三颗心脏 粉丝1号 已阅…下一个… 2019年1月7日11:34:53

就像 QQ消息 一样嘛,包含一个内容、时间、发送者和接受者,然后前端直接根据时间或者 id 排序生成一左一右的消息对话框,不过比较特殊的一点就是私信是一个双向交流的过程,在一个对话框中我可能既是接受者也是发送者,这也无所谓嘛,稍微分析分析场景:

  • 读取私信列表时:按照接受者和发送者一起查询的原则,也就是查询接受者是自己和发送者是自己的数据,然后根据时间和已读未读来建立私信列表;
  • 读取私信时:这时已经有了明确的接受者和发送者,那就查询所有 发送者是对方接受者是自己 Or 发送者是自己接受者是对方 的数据,然后在前端拼凑出一左一右的聊天框;
  • 发送私信时:先查询之前是否有记录,然后同上建立聊天框,点击发送之后把发送方设为自己接收方设为私信对象,然后在通知表中新建一条未读数据通知私信对象有私信来了;

这完全能满足要求,只不过感觉查询多了些..

数据库设计

简单弄了弄弄..看着挺难受的,不过能简单实现功能,并且为了演示,这里是做了一张user_follow表,表示用户之间的关联关系,点赞和评论与这个类似,就不多弄了..下面给一下建表语句吧:

user表:

CREATE TABLE `user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `username` varchar(50) NOT NULL COMMENT '用户姓名',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;

user_follow表:

CREATE TABLE `user_follow` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `uid` bigint(20) NOT NULL COMMENT '用户ID',
  `follow_uid` bigint(20) NOT NULL COMMENT '关注的用户id',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户关注表,记录了所有用户的关注信息';

notify表:

CREATE TABLE `notify` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `sender_id` bigint(20) NOT NULL COMMENT '发送者用户ID',
  `reciver_id` bigint(20) NOT NULL COMMENT '接受者用户ID',
  `type` varchar(50) NOT NULL COMMENT '消息类型:announcement公告/remind提醒/message私信',
  `is_read` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否已读,0未读,1已读',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间:按当前时间自动创建',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户通知表,包含了所有用户的消息';

message表:

CREATE TABLE `message` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `notify_id` bigint(20) NOT NULL COMMENT '对应通知消息的id',
  `sender_id` bigint(20) NOT NULL COMMENT '发送者用户ID',
  `reciver_id` bigint(20) NOT NULL COMMENT '接受者用户ID',
  `content` varchar(1000) NOT NULL COMMENT '消息内容,最长长度不允许超过1000',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间:按当前时间自动创建',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='私信信息表,包含了所有用户的私信信息';

根据《Java开发手册》5.3 第六条 没有使用任何级联和外键,bingo!

Spring Boot + MyBatis 实例

第一步:基础环境搭建

SpringBoot项目怎么搭就不说了吧,给一给几个关键的配置文件:

pom包依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>1.3.2</version>
</dependency>
<!-- SpringBoot - MyBatis 逆向工程 -->
<dependency>
    <groupId>org.mybatis.generator</groupId>
    <artifactId>mybatis-generator-core</artifactId>
    <version>1.3.6</version>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>5.1.18</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

这里有一个巨坑,耗费了我好半天的时间,不知道为什么我明明引入的 5.1.18 版本的 mysql-connector-java,可 Maven 就是非要给我比较新版本的 8.0.13,这导致了在我使用 MyBatis 逆向工程生成 domain 和 mapper 的过程中出现了以下的问题:

  • 1、提示我数据库连接的驱动名称需要改成com.mysql.cj.jdbc.Driver而不是之前的com.mysql.jdbc.Driver,不然就报错:

Loading class com.mysql.jdbc.Driver'. This is deprecated. The new driver class iscom.mysql.cj.jdbc.Driver’. The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.

  • 2、还需要设置 mysql 的时区,也就是需要将connectionURL属性写成"jdbc:mysql://localhost:3306/test?serverTimezone=UTC"。如果不指定serverTimezone=UTC(还必须大写),将报错:

java.sql.SQLException: The server time zone value ‘?й???????’ is unrecognized or represents more than one time zone. You must configure either the server or JDBC driver (via the serverTimezone configuration property) to use a more specifc time zone value if you want to utilize time zone support.
  at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:695)
  at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:663)

  • 3、逆向工程会去找 MySQL 其他库的相同表名的表,然后生成一堆乱七八糟的东西,还由于找不到主键 id 生成了只含 inser() 方法而不含删除、更新方法的 Mapper 文件;

解决方法就只有自己手动去调低 mysql-connector-java 的版本到 5.xx,还找到一个跟我情况类似:https://blog.csdn.net/angel_xiaa/article/details/52474022

application.properties:

## 数据库连接配置
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/message_system?characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=123456
## MyBatis相关配置
mybatis.type-aliases-package=com.wmyskxz.demo.messagesystem.domain
mybatis.mapper-locations=classpath:mapper/*.xml

在启动类上加上注解:

@EnableTransactionManagement  // 启注解事务管理,等同于xml配置方式的 <tx:annotation-driven />
@MapperScan("com.wmyskxz.demo.messagesystem.dao")
@SpringBootApplication
public class MessageSystemApplication {
        ....
}

第二步:MyBatis 逆向工程

新建【util】包,在下面新建两个类:

MybatisGenerator类:

public class MybatisGenerator {

    public static void main(String[] args) throws Exception {
        String today = "2019-1-7";

        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        Date now = sdf.parse(today);
        Date d = new Date();

        if (d.getTime() > now.getTime() + 1000 * 60 * 60 * 24) {
            System.err.println("——————未成成功运行——————");
            System.err.println("——————未成成功运行——————");
            System.err.println("本程序具有破坏作用,应该只运行一次,如果必须要再运行,需要修改today变量为今天,如:" + sdf.format(new Date()));
            return;
        }

        if (false)
            return;
        List<String> warnings = new ArrayList<String>();
        boolean overwrite = true;
        InputStream is = MybatisGenerator.class.getClassLoader().getResource("generatorConfig.xml").openStream();
        ConfigurationParser cp = new ConfigurationParser(warnings);
        Configuration config = cp.parseConfiguration(is);
        is.close();
        DefaultShellCallback callback = new DefaultShellCallback(overwrite);
        MyBatisGenerator myBatisGenerator = new MyBatisGenerator(config, callback, warnings);
        myBatisGenerator.generate(null);

        System.out.println("生成代码成功,只能执行一次,以后执行会覆盖掉mapper,pojo,xml 等文件上做的修改");

    }
}

OverIsMergeablePlugin类:

/**
 * 解決 MyBatis 逆向工程重复生成覆盖问题的工具类
 */
public class OverIsMergeablePlugin extends PluginAdapter {
    @Override
    public boolean validate(List<String> warnings) {
        return true;
    }

    @Override
    public boolean sqlMapGenerated(GeneratedXmlFile sqlMap, IntrospectedTable introspectedTable) {
        try {
            Field field = sqlMap.getClass().getDeclaredField("isMergeable");
            field.setAccessible(true);
            field.setBoolean(sqlMap, false);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return true;
    }
}

在【resrouces】资源文件下新建逆向工程配置文件【generatorConfig.xml】:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
        PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
        "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>
    <context id="DB2Tables" targetRuntime="MyBatis3">

        <!--避免生成重复代码的插件-->
        <plugin type="com.wmyskxz.demo.messagesystem.util.OverIsMergeablePlugin"/>

        <!-- 是否去除自动生成的代码中的注释 true:是 false:否-->
        <commentGenerator>
            <property name="suppressDate" value="true"/>
            <property name="suppressAllComments" value="true"/>
        </commentGenerator>

        <!--数据库链接地址账号密码-->
        <jdbcConnection driverClass="com.mysql.jdbc.Driver"
                        connectionURL="jdbc:mysql://localhost:3306/message_system?serverTimezone=UTC"
                        userId="root" password="123456">
        </jdbcConnection>
        <!-- 默认 false,把 JDBC DECIMAL 和 NUMERIC 类型解析为 Integer
             为 true 时解析为 java.math.BigDecimal -->
        <javaTypeResolver>
            <property name="forceBigDecimals" value="false"/>
        </javaTypeResolver>
        <!--生成pojo类存放位置-->
        <javaModelGenerator targetPackage="com.wmyskxz.demo.messagesystem.domain" targetProject="src/main/java">
            <!-- enableSubPackages:是否让 schema 作为包的后缀-->
            <property name="enableSubPackages" value="true"/>
            <!-- trimStrings:从数据库返回的值被清理前后的空格 -->
            <property name="trimStrings" value="true"/>
            <!-- 是否对model添加 构造函数 -->
            <property name="constructorBased" value="true"/>
        </javaModelGenerator>
        <!--生成xml映射文件存放位置-->
        <sqlMapGenerator targetPackage="mapper" targetProject="src/main/resources">
            <property name="enableSubPackages" value="true"/>
        </sqlMapGenerator>
        <!--生成mapper类存放位置-->
        <javaClientGenerator type="XMLMAPPER" targetPackage="com.wmyskxz.demo.messagesystem.dao"
                             targetProject="src/main/java">
            <property name="enableSubPackages" value="true"/>
        </javaClientGenerator>

        <!--生成对应表及类名
            tableName:要生成的表名
            domainObjectName:生成后的实例名
            enableCountByExample:Count语句中加入where条件查询,默认为true开启
            enableUpdateByExample:Update语句中加入where条件查询,默认为true开启
            enableDeleteByExample:Delete语句中加入where条件查询,默认为true开启
            enableSelectByExample:Select多条语句中加入where条件查询,默认为true开启
            selectByExampleQueryId:Select单个对象语句中加入where条件查询,默认为true开启
        -->
        <table tableName="user" domainObjectName="User" enableCountByExample="false"
               enableUpdateByExample="false" enableDeleteByExample="false" enableSelectByExample="true"
               selectByExampleQueryId="false" enableDeleteByPrimaryKey="true" enableUpdateByPrimaryKey="true">
            <property name="my.isgen.usekeys" value="true"/>
            <property name="useActualColumnNames" value="false"/>
            <generatedKey column="id" sqlStatement="JDBC"/>
        </table>
        <table tableName="notify" domainObjectName="Notify" enableCountByExample="false"
               enableUpdateByExample="false" enableDeleteByExample="false" enableSelectByExample="true"
               selectByExampleQueryId="false" enableDeleteByPrimaryKey="true" enableUpdateByPrimaryKey="true">
            <property name="my.isgen.usekeys" value="true"/>
            <property name="useActualColumnNames" value="false"/>
            <generatedKey column="id" sqlStatement="JDBC"/>
        </table>
        <table tableName="user_follow" domainObjectName="UserFollow" enableCountByExample="false"
               enableUpdateByExample="false" enableDeleteByExample="false" enableSelectByExample="true"
               selectByExampleQueryId="false" enableDeleteByPrimaryKey="true" enableUpdateByPrimaryKey="true">
            <property name="my.isgen.usekeys" value="true"/>
            <property name="useActualColumnNames" value="false"/>
            <generatedKey column="id" sqlStatement="JDBC"/>
        </table>
        <table tableName="message" domainObjectName="Message" enableCountByExample="false"
               enableUpdateByExample="false" enableDeleteByExample="false" enableSelectByExample="true"
               selectByExampleQueryId="false" enableDeleteByPrimaryKey="true" enableUpdateByPrimaryKey="true">
            <property name="my.isgen.usekeys" value="true"/>
            <property name="useActualColumnNames" value="false"/>
            <generatedKey column="id" sqlStatement="JDBC"/>
        </table>
    </context>
</generatorConfiguration>

运行我们的【MybatisGenerator】类中的 main 方法就能看到自动生成的实体、Xml文件以及 Mapper 类

第三步:Service 层

不给接口了,直接给实现吧,方法都很简单,而且没有做任何的安全限制,只是为了实现简单的消息系统,看效果

UserServiceImpl:

@Service
public class UserServiceImpl implements UserService {
    @Resource
    UserMapper userMapper;
    @Override
    public void addUserByUsername(String username) {
        userMapper.insert(new User(null, username));// 主键自增长.
    }
    @Override
    public User findUserById(Long id) {
        return userMapper.selectByPrimaryKey(id);
    }
}

UserFollowServiceImpl:

@Service
public class UserFollowServiceImpl implements UserFollowService {
    @Resource
    UserFollowMapper userFollowMapper;
    @Autowired
    NotifyService notifyService;
    @Override
    public void userAFollowUserBById(Long userAId, Long userBId) {
        // 先要创建一条提示消息
        notifyService.addNotify(userAId, userBId, "follow");// 关注信息
        UserFollow userFollow = new UserFollow();
        userFollow.setUid(userAId);
        userFollow.setFollowUid(userBId);
        userFollowMapper.insertSelective(userFollow);
    }
    @Override
    public void userAUnfollowUserBById(Long userAId, Long userBId) {
        // 首先查询到相关的记录
        UserFollowExample example = new UserFollowExample();
        example.or().andUidEqualTo(userAId).andFollowUidEqualTo(userBId);
        UserFollow userFollow = userFollowMapper.selectByExample(example).get(0);
        // 删除关注数据
        userFollowMapper.deleteByPrimaryKey(userFollow.getId());
    }
}

NotifyServiceImpl:

@Service
public class NotifyServiceImpl implements NotifyService {
    @Resource
    NotifyMapper notifyMapper;
    @Override
    public int addNotify(Long senderId, Long reciverId, String type) {
        Notify notify = new Notify(null, senderId, reciverId, type, false, null);
        return notifyMapper.insertSelective(notify);// id和creatTime自动生成.
    }
    @Override
    public void readNotifyById(Long id) {
        Notify notify = notifyMapper.selectByPrimaryKey(id);
        notify.setIsRead(true);
        notifyMapper.updateByPrimaryKey(notify);
    }
    @Override
    public List<Notify> findAllNotifyByReciverId(Long id) {
        List<Notify> notifies = new LinkedList<>();
        NotifyExample example = new NotifyExample();
        example.setOrderByClause("`id` DESC");// 按id倒叙,也就是第一个数据是最新的.
        example.or().andReciverIdEqualTo(id);
        notifies.addAll(notifyMapper.selectByExample(example));
        return notifies;
    }
    @Override
    public List<Notify> findAllUnReadNotifyByReciverId(Long id) {
        List<Notify> notifies = new LinkedList<>();
        NotifyExample example = new NotifyExample();
        example.setOrderByClause("`id` DESC");// 按id倒叙,也就是第一个数据是最新的.
        example.or().andReciverIdEqualTo(id).andIsReadEqualTo(false);
        notifies.addAll(notifyMapper.selectByExample(example));
        return notifies;
    }
}

MessageServiceImpl:

@Service
public class MessageServiceImpl implements MessageService {
    @Resource
    MessageMapper messageMapper;
    @Resource
    NotifyService notifyService;
    @Override
    public void addMessage(Long senderId, Long reciverId, String content) {
        // 先创建一条 notify 数据
        Long notifyId = (long) notifyService.addNotify(senderId, reciverId, "message");// message表示私信
        // 增加一条私信信心
        Message message = new Message(null, notifyId, senderId, reciverId, content, null);
        messageMapper.insertSelective(message);// 插入非空项,id/createTime数据库自动生成
    }
    @Override
    public void deleteMessageById(Long id) {
        messageMapper.deleteByPrimaryKey(id);
    }
    @Override
    public Message findMessageByNotifyId(Long id) {
        // 触发方法时应把消息置为已读
        notifyService.readNotifyById(id);
        MessageExample example = new MessageExample();
        example.or().andNotifyIdEqualTo(id);
        return messageMapper.selectByExample(example).get(0);
    }
}

第四步:Controller 层

也很简单,只是为了看效果

UserController:

@RestController
public class UserController {
    @Autowired
    UserService userService;
    @PostMapping("/addUser")
    public String addUser(@RequestParam String username) {
        userService.addUserByUsername(username);
        return "Success!";
    }
    @GetMapping("/findUser")
    public User findUser(@RequestParam Long id) {
        return userService.findUserById(id);
    }
}

UserFollowController :

@RestController
public class UserFollowController {
    @Autowired
    UserFollowService userFollowService;
    @PostMapping("/follow")
    public String follow(@RequestParam Long userAId,
                         @RequestParam Long userBId) {
        userFollowService.userAFollowUserBById(userAId, userBId);
        return "Success!";
    }
    @PostMapping("/unfollow")
    public String unfollow(@RequestParam Long userAId,
                           @RequestParam Long userBId) {
        userFollowService.userAUnfollowUserBById(userAId, userBId);
        return "Success!";
    }
}

NotifyController :

@RestController
public class NotifyController {
    @Autowired
    NotifyService notifyService;
    @PostMapping("/addNotify")
    public String addNotify(@RequestParam Long senderId,
                            @RequestParam Long reciverId,
                            @RequestParam String type) {
        notifyService.addNotify(senderId, reciverId, type);
        return "Success!";
    }
    @PostMapping("/readNotify")
    public String readNotify(@RequestParam Long id) {
        notifyService.readNotifyById(id);
        return "Success!";
    }
    @GetMapping("/listAllNotify")
    public List<Notify> listAllNotify(@RequestParam Long id) {
        return notifyService.findAllNotifyByReciverId(id);
    }
    @GetMapping("/listAllUnReadNotify")
    public List<Notify> listAllUnReadNotify(@RequestParam Long id) {
        return notifyService.findAllUnReadNotifyByReciverId(id);
    }
}

MessageController :

@RestController
public class MessageController {
    @Autowired
    MessageService messageService;
    @PostMapping("/addMessage")
    public String addMessage(@RequestParam Long senderId,
                             @RequestParam Long reciverId,
                             @RequestParam String content) {
        messageService.addMessage(senderId, reciverId, content);
        return "Success!";
    }
    @DeleteMapping("/deleteMessage")
    public String deleteMessage(@RequestParam Long id) {
        messageService.deleteMessageById(id);
        return "Success!";
    }
    @GetMapping("/findMessage")
    public Message findMessage(@RequestParam Long id) {
        return messageService.findMessageByNotifyId(id);
    }
}

第五步:测试

通过 REST 测试工具,可以看到正确的效果,这里就不给出所有的测试了。

总结

以上的项目简单而且没有任何的安全验证,不过能够基本完成我们的需求,还有一些功能没有实现,例如管理员发通告(上面只演示了私信和关注信息),按照上面的系统就直接暴力给每个用户都加一条通知消息,感觉有点自闭..我也不知道怎么设计好..希望有经验的大大能指条路啊!

其实关于这个简单的系统我查了好多好多资料..把自己都看自闭了,后来我干脆把所有网页都关掉,开始用 JPA 自己开始抽象实体,把各个实体写出来并把所有实体需要的数据啊相互之间的关联关系啊写清楚,然后再从自动生成的数据库中找思路…hhh…要不是我 JPA 不是很熟我觉得用 JPA 就能写出来了,不用 JPA 的原因在于一些数据的懒加载不知道怎么处理,还有就是查询语句太复杂,免不了要浪费一些资源…emmm..说到底还是不是特别懂 JPA,下面给一张复杂的用 JPA 建立的 User 实体吧(随手截的..hhh…很乱..):


按照惯例黏一个尾巴:

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz
分享自己的学习 & 学习资料 & 生活
想要交流的朋友也可以加qq群:3382693

传统 JDBC 回顾

JDBC 我们一定不陌生,刚开始学习的时候,我们写过很多很多重复的模板代码:

public Student getOne(int id) {

    String sql = "SELECT id,name FROM student WHERE id = ?";
    Student student = null;
    // 声明 JDBC 变量
    Connection con = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    try {
        // 注册驱动程序
        Class.forName("com.myql.jdbc.Driver");
        // 获取连接
        con = DriverManager.getConnection("jdbc://mysql://localhost:" +
                "3306/student", "root", "root");
        // 预编译SQL
        ps = con.prepareStatement(sql);
        // 设置参数
        ps.setInt(1, id);
        // 执行SQL
        rs = ps.executeQuery();
        // 组装结果集返回 POJO
        if (rs.next()) {
            student = new Student();
            student.setId(rs.getInt(1));
            student.setName(rs.getString(1));
        }
    } catch (ClassNotFoundException | SQLException e) {
        e.printStackTrace();
    } finally {
        // 关闭数据库连接资源
        try {
            if (rs != null && !rs.isClosed()) {
                rs.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        try {
            if (ps != null && !ps.isClosed()) {
                ps.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        try {
            if (con != null && con.isClosed()) {
                con.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
    return student;
}

现在光是看着就头大,并且我还把它完整的写了出来..真恶心!

这还仅仅是一个 JDBC 的方法,并且最主要的代码只有ps = con.prepareStatement(sql);这么一句,而且有很多模板化的代码,包括建立连接以及关闭连接..我们必须想办法解决一下!

优化传统的 JDBC

第一步:创建 DBUtil 类

我想第一步我们可以把重复的模板代码提出来创建一个【DBUtil】数据库工具类:

package util;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class DBUtil {
    static String ip = "127.0.0.1";
    static int port = 3306;
    static String database = "student";
    static String encoding = "UTF-8";
    static String loginName = "root";
    static String password = "root";

    static {
        try {
            Class.forName("com.mysql.jdbc.Driver");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

    public static Connection getConnection() throws SQLException {
        String url = String.format("jdbc:mysql://%s:%d/%s?characterEncoding=%s", ip, port, database, encoding);
        return DriverManager.getConnection(url, loginName, password);
    }
}

这样我们就可以把上面的恶心的代码变成这样:

public Student getOne(int id) {

    String sql = "SELECT id,name FROM student WHERE id = ?";
    Student student = null;
    // 声明 JDBC 变量
    Connection con = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    try {
        // 获取连接
        con = DBUtil.getConnection();
        // 预编译SQL
        ps = con.prepareStatement(sql);
        // 设置参数
        ps.setInt(1, id);
        // 执行SQL
        rs = ps.executeQuery();
        // 组装结果集返回 POJO
        ....
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        // 关闭数据库连接资源
        ....
    }
    return student;
}

也只是少写了一句注册驱动程序少处理了一个异常而已,并没有什么大的变化,必须再优化一下

第二步:使用 try-catch 语句自动关闭资源

自动资源关闭是 JDK 7 中新引入的特性,不了解的同学可以去看一下我之前写的文章:JDK 7 新特性

于是代码可以进一步优化成这样:

public Student getOne(int id) {

    String sql = "SELECT id,name FROM student WHERE id = ?";
    Student student = null;
    // 将 JDBC 声明变量包含在 try(..) 里将自动关闭资源
    try (Connection con = DBUtil.getConnection(); PreparedStatement ps = con.prepareStatement(sql)) {

        // 设置参数
        ps.setInt(1, id);
        // 执行SQL
        ResultSet rs = ps.executeQuery();
        // 组装结果集返回 POJO
        if (rs.next()) {
            student = new Student();
            student.setId(rs.getInt(1));
            student.setName(rs.getString(1));
        }
    } catch (SQLException e) {
        e.printStackTrace();
    }
    return student;
}

这样看着好太多了,但仍然不太满意,因为我们最核心的代码也就只是执行 SQL 语句并拿到返回集,再来再来

再进一步改进 DBUtil 类:

在 DBUtil 类中新增一个方法,用来直接返回结果集:

public static ResultSet getResultSet(String sql, Object[] objects) throws SQLException {

    ResultSet rs = null;
    try (Connection con = getConnection(); PreparedStatement ps = con.prepareStatement(sql)) {

        // 根据传递进来的参数,设置 SQL 占位符的值
        for (int i = 0; i < objects.length; i++) {
            ps.setObject(i + 1, objects[i]);
        }
        // 执行 SQL 语句并接受结果集
        rs = ps.executeQuery();
    }
    // 返回结果集
    return rs;
}

这样我们就可以把我们最开始的代码优化成这样了:

public Student getOne(int id) {

    String sql = "SELECT id,name FROM student WHERE id = ?";
    Object[] objects = {id};
    Student student = null;
    try (ResultSet rs = DBUtil.getResultSet(sql, objects);) {
        student.setId(rs.getInt(1));
        student.setName(rs.getString(1));
    } catch (SQLException e) {
        // 处理异常
        e.printStackTrace();
    }
    return student;
}

wooh!看着爽多了,但美中不足的就是没有把 try-catch 语句去掉,我们也可以不进行异常处理直接把 SQLException 抛出去:

public Student getOne(int id) throws SQLException {

    String sql = "SELECT id,name FROM student WHERE id = ?";
    Object[] objects = {id};
    Student student = null;
    try (ResultSet rs = DBUtil.getResultSet(sql, objects);) {
        student.setId(rs.getInt(1));
        student.setName(rs.getString(1));
    }
    return student;
}

其实上面的版本已经够好了,这样做只是有些强迫症。

  • 我们自己定义的 DBUtil 工具已经很实用了,因为是从模板化的代码中抽离出来的,所以我们可以一直使用

Spring 中的 JDBC

要想使用 Spring 中的 JDBC 模块,就必须引入相应的 jar 文件:

  • 需要引入的 jar 包:
  • spring-jdbc-4.3.16.RELEASE.jar
  • spring-tx-4.3.16.RELEASE.jar

好在 IDEA 在创建 Spring 项目的时候已经为我们自动部署好了,接下来我们来实际在 Spring 中使用一下 JDBC:

配置数据库资源

就像我们创建 DBUtil 类,将其中连接的信息封装在里面一样,我们需要将这些数据库资源配置起来

  • 配置方式:
  • 使用简单数据库配置
  • 使用第三方数据库连接池

我们可以使用 Spring 内置的类来配置,但大部分时候我们都会使用第三方数据库连接池来进行配置,由于使用第三方的类,一般采用 XML 文件配置的方式,我们这里也使用 XML 文件配置的形式:

使用简单数据库配置

首先我们来试试 Spring 的内置类 org.springframework.jdbc.datasource.SimpleDriverDataSource

<bean id="dateSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
    <property name="username" value="root"/>
    <property name="password" value="root"/>
    <property name="driverClass" value="com.mysql.jdbc.Driver"/>
    <property name="url" value="jdbc://mysql://locolhost:3306/student"/>
</bean>

我们来测试一下,先把我们的 JDBC 操作类写成这个样子:

package jdbc;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import pojo.Student;
import javax.sql.DataSource;
import java.sql.*;

@Component("jdbc")
public class JDBCtest {

    @Autowired
    private DataSource dataSource;

    public Student getOne(int stuID) throws SQLException {

        String sql = "SELECT id, name FROM student WHERE id = " + stuID;
        Student student = new Student();
        Connection con = dataSource.getConnection();
        Statement st = con.createStatement();
        ResultSet rs = st.executeQuery(sql);
        if (rs.next()) {
            student.setId(rs.getInt("id"));
            student.setName(rs.getString("name"));
        }
        return student;
    }
}

然后编写测试类:

ApplicationContext context =
        new ClassPathXmlApplicationContext("applicationContext.xml");
JDBCtest jdbc = (JDBCtest) context.getBean("jdbc");
Student student = jdbc.getOne(123456789);
System.out.println(student.getId());
System.out.println(student.getName());

成功取出数据库中的数据:

使用第三方数据库连接池

上面配置的这个简单的数据源一般用于测试,因为它不是一个数据库连接池,知识一个很简单的数据库连接的应用。在更多的时候,我们需要使用第三方的数据库连接,比如使用 C3P0数据库连接池:

<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource">
    <property name="driverClass" value="com.mysql.jdbc.Driver"></property>
    <property name="jdbcUrl" value="jdbc:mysql:///hib_demo"></property>
    <property name="user" value="root"></property>
    <property name="password" value="root"></property>
    <property name="initialPoolSize" value="3"></property>
    <property name="maxPoolSize" value="10"></property>
    <property name="maxStatements" value="100"></property>
    <property name="acquireIncrement" value="2"></property>
</bean>

跟上面的测试差不多,不同的是需要引入相关支持 C3P0 数据库连接池的 jar 包而已。

Jdbc Template

Spring 中提供了一个 Jdbc Template 类,它自己已经封装了一个 DataSource 类型的变量,我们可以直接使用:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">

    <bean id="dataSrouce" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="username" value="root"/>
        <property name="password" value="root"/>
        <property name="driverClass" value="com.mysql.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost:3306/student"/>
    </bean>

    <context:component-scan base-package="jdbc" />

    <bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
        <property name="dataSource" ref="dataSrouce"/>
    </bean>

</beans>

我们来改写一下 JDBC 操作的类:

package jdbc;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
import pojo.Student;
import java.sql.*;

@Component("jdbc")
public class JDBCtest {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public Student getOne(int stuID) throws SQLException {

        String sql = "SELECT id, name FROM student WHERE id = ?";
        Student student = jdbcTemplate.queryForObject(sql, new RowMapper<Student>() {
            @Override
            public Student mapRow(ResultSet resultSet, int i) throws SQLException {
                Student stu = new Student();
                stu.setId(resultSet.getInt("id"));
                stu.setName(resultSet.getString("name"));
                return stu;
            }
        }, 123456789);
        return student;
    }
}

测试类不变,运行可以获得正确的结果:

但是好像并没有简单多少的样子,那我们来看看其他 CRUD 的例子:

/**
 * 增加一条数据
 *
 * @param student
 */
public void add(Student student) {
    this.jdbcTemplate.update("INSERT INTO student(id,name) VALUES(?,?)",
            student.getId(), student.getName());
}

/**
 * 更新一条数据
 *
 * @param student
 */
public void update(Student student) {
    this.jdbcTemplate.update("UPDATE student SET name = ? WHERE id = ?",
            student.getName(), student.getId());
}

/**
 * 删除一条数据
 *
 * @param id
 */
public void delete(int id) {
    this.jdbcTemplate.update("DELETE FROM student WHERE id = ?",
            id);
}

现在应该简单多了吧,返回集合的话只需要稍微改写一下上面的 getOne() 方法就可以了

扩展阅读:官方文档Spring 中 JdbcTemplate 实现增删改查


参考资料:

  • 《Java EE 互联网轻量级框架整合开发》
  • 《Spring 实战》
  • 全能的百度和万能的大脑

扩展阅读:① 彻底理解数据库事务② Spring事务管理详解③ Spring 事务管理(详解+实例)④ 全面分析 Spring 的编程式事务管理及声明式事务管理


欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz
分享自己的学习 & 学习资料 & 生活
想要交流的朋友也可以加qq群:3382693

Spring AOP 简介

如果说 IoC 是 Spring 的核心,那么面向切面编程就是 Spring 最为重要的功能之一了,在数据库事务中切面编程被广泛使用。

AOP 即 Aspect Oriented Program 面向切面编程

首先,在面向切面编程的思想里面,把功能分为核心业务功能,和周边功能。

  • 所谓的核心业务,比如登陆,增加数据,删除数据都叫核心业务
  • 所谓的周边功能,比如性能统计,日志,事务管理等等

周边功能在 Spring 的面向切面编程AOP思想里,即被定义为切面

在面向切面编程AOP的思想里面,核心业务功能和切面功能分别独立进行开发,然后把切面功能和核心业务功能 “编织” 在一起,这就叫AOP

AOP 的目的

AOP能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码降低模块间的耦合度,并有利于未来的可拓展性和可维护性

AOP 当中的概念:

  • 切入点(Pointcut)
    在哪些类,哪些方法上切入(where
  • 通知(Advice)
    在方法执行的什么实际(when:方法前/方法后/方法前后)做什么(what:增强的功能)
  • 切面(Aspect)
    切面 = 切入点 + 通知,通俗点就是:在什么时机,什么地方,做什么增强!
  • 织入(Weaving)
    把切面加入到对象,并创建出代理对象的过程。(由 Spring 来完成)

一个例子

为了更好的说明 AOP 的概念,我们来举一个实际中的例子来说明:

在上面的例子中,包租婆的核心业务就是签合同,收房租,那么这就够了,灰色框起来的部分都是重复且边缘的事,交给中介商就好了,这就是 AOP 的一个思想:让关注点代码与业务代码分离!

实际的代码

我们来实际的用代码感受一下

1.在 Package【pojo】下新建一个【Landlord】类(我百度翻译的包租婆的英文):

package pojo;

import org.springframework.stereotype.Component;

@Component("landlord")
public class Landlord {

    public void service() {
        // 仅仅只是实现了核心的业务功能
        System.out.println("签合同");
        System.out.println("收房租");
    }
}

2.在 Package【aspect】下新建一个中介商【Broker】类(我还是用的翻译…):

package aspect;

import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Component
@Aspect
class Broker {

    @Before("execution(* pojo.Landlord.service())")
    public void before(){
        System.out.println("带租客看房");
        System.out.println("谈价格");
    }

    @After("execution(* pojo.Landlord.service())")
    public void after(){
        System.out.println("交钥匙");
    }
}

3.在 applicationContext.xml 中配置自动注入,并告诉 Spring IoC 容器去哪里扫描这两个 Bean:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">

    <context:component-scan base-package="aspect" />
    <context:component-scan base-package="pojo" />

    <aop:aspectj-autoproxy/>
</beans>

4.在 Package【test】下编写测试代码:

package test;

import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import pojo.Landlord;

public class TestSpring {

    public static void main(String[] args) {

        ApplicationContext context =
                new ClassPathXmlApplicationContext("applicationContext.xml");
        Landlord landlord = (Landlord) context.getBean("landlord", Landlord.class);
        landlord.service();

    }
}

5.执行看到效果:

这个例子使用了一些注解,现在看不懂没有关系,但我们可以从上面可以看到,我们在 Landlord 的 service() 方法中仅仅实现了核心的业务代码,其余的关注点功能是根据我们设置的切面自动补全的。


使用注解来开发 Spring AOP

使用注解的方式已经逐渐成为了主流,所以我们利用上面的例子来说明如何用注解来开发 Spring AOP

第一步:选择连接点

Spring 是方法级别的 AOP 框架,我们主要也是以某个类额某个方法作为连接点,另一种说法就是:选择哪一个类的哪一方法用以增强功能。

    ....
    public void service() {
        // 仅仅只是实现了核心的业务功能
        System.out.println("签合同");
        System.out.println("收房租");
    }
    ....

我们在这里就选择上述 Landlord 类中的 service() 方法作为连接点。

第二步:创建切面

选择好了连接点就可以创建切面了,我们可以把切面理解为一个拦截器,当程序运行到连接点的时候,被拦截下来,在开头加入了初始化的方法,在结尾也加入了销毁的方法而已,在 Spring 中只要使用 @Aspect 注解一个类,那么 Spring IoC 容器就会认为这是一个切面了:

package aspect;

import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Component
@Aspect
class Broker {

    @Before("execution(* pojo.Landlord.service())")
    public void before(){
        System.out.println("带租客看房");
        System.out.println("谈价格");
    }

    @After("execution(* pojo.Landlord.service())")
    public void after(){
        System.out.println("交钥匙");
    }
}
  • 注意: 被定义为切面的类仍然是一个 Bean ,需要 @Component 注解标注

代码部分中在方法上面的注解看名字也能猜出个大概,下面来列举一下 Spring 中的 AspectJ 注解:

注解 说明
@Before 前置通知,在连接点方法前调用
@Around 环绕通知,它将覆盖原有方法,但是允许你通过反射调用原有方法,后面会讲
@After 后置通知,在连接点方法后调用
@AfterReturning 返回通知,在连接点方法执行并正常返回后调用,要求连接点方法在执行过程中没有发生异常
@AfterThrowing 异常通知,当连接点方法异常时调用

有了上表,我们就知道 before() 方法是连接点方法调用前调用的方法,而 after() 方法则相反,这些注解中间使用了定义切点的正则式,也就是告诉 Spring AOP 需要拦截什么对象的什么方法,下面讲到。

第三步:定义切点

在上面的注解中定义了 execution 的正则表达式,Spring 通过这个正则表达式判断具体要拦截的是哪一个类的哪一个方法:

execution(* pojo.Landlord.service())

依次对这个表达式作出分析:

  • execution:代表执行方法的时候会触发
  • * :代表任意返回类型的方法
  • pojo.Landlord:代表类的全限定名
  • service():被拦截的方法名称

通过上面的表达式,Spring 就会知道应该拦截 pojo.Lnadlord 类下的 service() 方法。上面的演示类还好,如果多出都需要写这样的表达式难免会有些复杂,我们可以通过使用 @Pointcut 注解来定义一个切点来避免这样的麻烦:

package aspect;

import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Component
@Aspect
class Broker {

    @Pointcut("execution(* pojo.Landlord.service())")
    public void lService() {
    }

    @Before("lService()")
    public void before() {
        System.out.println("带租客看房");
        System.out.println("谈价格");
    }

    @After("lService()")
    public void after() {
        System.out.println("交钥匙");
    }
}

第四步:测试 AOP

编写测试代码,但是我这里因为 JDK 版本不兼容出现了 BUG….(尴尬…)

这就告诉我们:环境配置很重要…不然莫名其妙的 BUG 让你崩溃…

环绕通知

我们来探讨一下环绕通知,这是 Spring AOP 中最强大的通知,因为它集成了前置通知和后置通知,它保留了连接点原有的方法的功能,所以它及强大又灵活,让我们来看看:

package aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Component
@Aspect
class Broker {

//  注释掉之前的 @Before 和 @After 注解以及对应的方法
//    @Before("execution(* pojo.Landlord.service())")
//    public void before() {
//        System.out.println("带租客看房");
//        System.out.println("谈价格");
//    }
//
//    @After("execution(* pojo.Landlord.service())")
//    public void after() {
//        System.out.println("交钥匙");
//    }

    //  使用 @Around 注解来同时完成前置和后置通知
    @Around("execution(* pojo.Landlord.service())")
    public void around(ProceedingJoinPoint joinPoint) {
        System.out.println("带租客看房");
        System.out.println("谈价格");

        try {
            joinPoint.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }

        System.out.println("交钥匙");
    }
}

运行测试代码,结果仍然正确:


使用 XML 配置开发 Spring AOP

注解是很强大的东西,但基于 XML 的开发我们仍然需要了解,我们先来了解一下 AOP 中可以配置的元素:

AOP 配置元素 用途 备注
aop:advisor 定义 AOP 的通知其 一种很古老的方式,很少使用
aop:aspect 定义一个切面 ——
aop:before 定义前置通知 ——
aop:after 定义后置通知 ——
aop:around 定义环绕通知 ——
aop:after-returning 定义返回通知 ——
aop:after-throwing 定义异常通知 ——
aop:config 顶层的 AOP 配置元素 AOP 的配置是以它为开始的
aop:declare-parents 给通知引入新的额外接口,增强功能 ——
aop:pointcut 定义切点 ——

有了之前通过注解来编写的经验,并且有了上面的表,我们将上面的例子改写成 XML 配置很容易(去掉所有的注解):

<!-- 装配 Bean-->
<bean name="landlord" class="pojo.Landlord"/>
<bean id="broker" class="aspect.Broker"/>

<!-- 配置AOP -->
<aop:config>
    <!-- where:在哪些地方(包.类.方法)做增加 -->
    <aop:pointcut id="landlordPoint"
                  expression="execution(* pojo.Landlord.service())"/>
    <!-- what:做什么增强 -->
    <aop:aspect id="logAspect" ref="broker">
        <!-- when:在什么时机(方法前/后/前后) -->
        <aop:around pointcut-ref="landlordPoint" method="around"/>
    </aop:aspect>
</aop:config>

运行测试程序,看到正确结果:

扩展阅读:Spring【AOP模块】就这么简单关于 Spring AOP(AspectJ)你该知晓的一切(慎独读,有些深度…)

参考资料:

  • 《Java EE 互联网轻量级框架整合开发》
  • 《Java 实战(第四版)》
  • 万能的百度 and 万能的大脑

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz
分享自己的学习 & 学习资料 & 生活
想要交流的朋友也可以加qq群:3382693

装配 Bean 的概述

前面已经介绍了 Spring IoC 的理念和设计,这一篇文章将介绍的是如何将自己开发的 Bean 装配到 Spring IoC 容器中。

大部分场景下,我们都会使用 ApplicationContext 的具体实现类,因为对应的 Spring IoC 容器功能相对强大。

而在 Spring 中提供了 3 种方法进行配置:

  • 在 XML 文件中显式配置
  • 在 Java 的接口和类中实现配置
  • 隐式 Bean 的发现机制和自动装配原则

方式选择的原则

在现实的工作中,这 3 种方式都会被用到,并且在学习和工作之中常常混合使用,所以这里给出一些关于这 3 种优先级的建议:

1.最优先:通过隐式 Bean 的发现机制和自动装配的原则。
基于约定由于配置的原则,这种方式应该是最优先的

  • 好处: 减少程序开发者的决定权,简单又不失灵活。

2.其次:Java 接口和类中配置实现配置
在没有办法使用自动装配原则的情况下应该优先考虑此类方法

  • 好处: 避免 XML 配置的泛滥,也更为容易。
  • 典型场景: 一个父类有多个子类,比如学生类有两个子类,一个男学生类和女学生类,通过 IoC 容器初始化一个学生类,容器将无法知道使用哪个子类去初始化,这个时候可以使用 Java 的注解配置去指定。

3.最后:XML 方式配置
在上述方法都无法使用的情况下,那么也只能选择 XML 配置的方式。

  • 好处: 简单易懂(当然,特别是对于初学者)
  • 典型场景: 当使用第三方类的时候,有些类并不是我们开发的,我们无法修改里面的代码,这个时候就通过 XML 的方式配置使用了。

通过 XML 配置装配 Bean

使用 XML 装配 Bean 需要定义对应的 XML,这里需要引入对应的 XML 模式(XSD)文件,这些文件会定义配置 Spring Bean 的一些元素,当我们在 IDEA 中创建 XML 文件时,会有友好的提示:

一个简单的 XML 配置文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

</beans>

这就只是一个格式文件,引入了一个 beans 的定义,引入了 xsd 文件,它是一个根元素,这样它所定义的元素将可以定义对应的 Spring Bean

装配简易值

先来一个最简单的装配:

<bean id="c" class="pojo.Category">
    <property name="name" value="测试" />
</bean>

简单解释一下:

  • id 属性是 Spring 能找到当前 Bean 的一个依赖的编号,遵守 XML 语法的 ID 唯一性约束。必须以字母开头,可以使用字母、数字、连字符、下划线、句号、冒号不能以 / 开头
    不过 id 属性不是一个必需的属性name 属性也可以定义 bean 元素的名称,能以逗号或空格隔开起多个别名,并且可以使用很多的特殊字符,比如在 Spring 和 Spring MVC 的整合中,就得使用 name 属性来定义 bean 的名称,并且使用 / 开头。

注意: 从 Spring 3.1 开始,id 属性也可以是 String 类型了,也就是说 id 属性也可以使用 / 开头,而 bean 元素的 id 的唯一性由容器负责检查。
如果 idname 属性都没有声明的话,那么 Spring 将会采用 “全限定名#{number}” 的格式生成编号。 例如这里,如果没有声明 “id="c"” 的话,那么 Spring 为其生成的编号就是 “pojo.Category#0”,当它第二次声明没有 id 属性的 Bean 时,编号就是 “pojo.Category#1”,以此类推。

  • class 属性显然就是一个类的全限定名
  • property 元素是定义类的属性,其中的 name 属性定义的是属性的名称,而 value 是它的值。

这样的定义很简单,但是有时候需要注入一些自定义的类,比如之前饮品店的例子,JuickMaker 需要用户提供原料信息才能完成 juice 的制作:

<!-- 配置 srouce 原料 -->
<bean name="source" class="pojo.Source">
    <property name="fruit" value="橙子"/>
    <property name="sugar" value="多糖"/>
    <property name="size" value="超大杯"/>
</bean>

<bean name="juickMaker" class="pojo.JuiceMaker">
    <!-- 注入上面配置的id为srouce的Srouce对象 -->
    <property name="source" ref="source"/>
</bean>

这里先定义了一个 name 为 source 的 Bean,然后再制造器中通过 ref 属性去引用对应的 Bean,而 source 正是之前定义的 Bean 的 name ,这样就可以相互引用了。

  • 注入对象:使用 ref 属性

装配集合

有些时候我们需要装配一些复杂的东西,比如 Set、Map、List、Array 和 Properties 等,为此我们在 Packge【pojo】下新建一个 ComplexAssembly 类:

package pojo;

import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

public class ComplexAssembly {

    private Long id;
    private List<String> list;
    private Map<String, String> map;
    private Properties properties;
    private Set<String> set;
    private String[] array;

    /* setter and getter */
}

这个 Bean 没有任何的实际意义,知识为了介绍如何装配这些常用的集合类:

<bean id="complexAssembly" class="pojo.ComplexAssembly">
    <!-- 装配Long类型的id -->
    <property name="id" value="1"/>

    <!-- 装配List类型的list -->
    <property name="list">
        <list>
            <value>value-list-1</value>
            <value>value-list-2</value>
            <value>value-list-3</value>
        </list>
    </property>

    <!-- 装配Map类型的map -->
    <property name="map">
        <map>
            <entry key="key1" value="value-key-1"/>
            <entry key="key2" value="value-key-2"/>
            <entry key="key3" value="value-key-2"/>
        </map>
    </property>

    <!-- 装配Properties类型的properties -->
    <property name="properties">
        <props>
            <prop key="prop1">value-prop-1</prop>
            <prop key="prop2">value-prop-2</prop>
            <prop key="prop3">value-prop-3</prop>
        </props>
    </property>

    <!-- 装配Set类型的set -->
    <property name="set">
        <set>
            <value>value-set-1</value>
            <value>value-set-2</value>
            <value>value-set-3</value>
        </set>
    </property>

    <!-- 装配String[]类型的array -->
    <property name="array">
        <array>
            <value>value-array-1</value>
            <value>value-array-2</value>
            <value>value-array-3</value>
        </array>
    </property>
</bean>
  • 总结:
  • List 属性为对应的 <list> 元素进行装配,然后通过多个 <value> 元素设值
  • Map 属性为对应的 <map> 元素进行装配,然后通过多个 <entry> 元素设值,只是 entry 包含一个键值对(key-value)的设置
  • Properties 属性为对应的 <properties> 元素进行装配,通过多个 <property> 元素设值,只是 properties 元素有一个必填属性 key ,然后可以设置值
  • Set 属性为对应的 <set> 元素进行装配,然后通过多个 <value> 元素设值
  • 对于数组而言,可以使用 <array> 设置值,然后通过多个 <value> 元素设值。

上面看到了对简单 String 类型的各个集合的装载,但是有些时候可能需要更为复杂的装载,比如一个 List 可以是一个系列类的对象,为此需要定义注入的相关信息,其实跟上面的配置没什么两样,只不过加入了 ref 这一个属性而已:

  • 集合注入总结:
  • List 属性使用 <list> 元素定义注入,使用多个 <ref> 元素的 Bean 属性去引用之前定义好的 Bean
<property name="list">
    <list>
        <ref bean="bean1"/>
        <ref bean="bean2"/>
    </list>
</property>
  • Map 属性使用 <map> 元素定义注入,使用多个 <entry> 元素的 key-ref 属性去引用之前定义好的 Bean 作为键,而用 value-ref 属性引用之前定义好的 Bean 作为值
<property name="map">
    <map>
        <entry key-ref="keyBean" value-ref="valueBean"/>
    </map>
</property>
  • Set 属性使用 <set> 元素定义注入,使用多个 <ref> 元素的 bean 去引用之前定义好的 Bean
<property name="set">
    <set>
        <ref bean="bean"/>
    </set>
</property>

命名空间装配

除了上述的配置之外, Spring 还提供了对应的命名空间的定义,只是在使用命名空间的时候要先引入对应的命名空间和 XML 模式(XSD)文件。

——【① c-命名空间】——

c-命名空间是在 Spring 3.0 中引入的,它是在 XML 中更为简洁地描述构造器参数的方式,要使用它的话,必须要在 XML 的顶部声明其模式:

  • 注意:是通过构造器参数的方式

现在假设我们现在有这么一个类:

package pojo;

public class Student {

    int id;
    String name;

    public Student(int id, String name) {
        this.id = id;
        this.name = name;
    }
    // setter and getter
}

在 c-命名空间和模式声明之后,我们就可以使用它来声明构造器参数了:

<!-- 引入 c-命名空间之前 -->
<bean name="student1" class="pojo.Student">
    <constructor-arg name="id" value="1" />
    <constructor-arg name="name" value="学生1"/>
</bean>

<!-- 引入 c-命名空间之后 -->
<bean name="student2" class="pojo.Student"
      c:id="2" c:name="学生2"/>

c-命名空间属性名以 “c:” 开头,也就是命名空间的前缀。接下来就是要装配的构造器参数名,在此之后如果需要注入对象的话则要跟上 -ref(如c:card-ref="idCard1",则对 card 这个构造器参数注入之前配置的名为 idCard1 的 bean)

很显然,使用 c-命名空间属性要比使用 <constructor-arg> 元素精简,并且会直接引用构造器之中参数的名称,这有利于我们使用的安全性。

我们有另外一种替代方式:

<bean name="student2" class="pojo.Student"
      c:_0="3" c:_1="学生3"/>

我们将参数的名称替换成了 “0” 和 “1” ,也就是参数的索引。因为在 XML 中不允许数字作为属性的第一个字符,因此必须要添加一个下划线来作为前缀。

——【② p-命名空间】——

c-命名空间通过构造器注入的方式来配置 bean,p-命名空间则是用setter的注入方式来配置 bean ,同样的,我们需要引入声明:

然后我们就可以通过 p-命名空间来设置属性:

<!-- 引入p-命名空间之前 -->
<bean name="student1" class="pojo.Student">
    <property name="id" value="1" />
    <property name="name" value="学生1"/>
</bean>

<!-- 引入p-命名空间之后 -->
<bean name="student2" class="pojo.Student" 
      p:id="2" p:name="学生2"/>

我们需要先删掉 Student 类中的构造函数,不然 XML 约束会提示我们配置 <constructor-arg> 元素。

同样的,如果属性需要注入其他 Bean 的话也可以在后面跟上 -ref

    <bean name="student2" class="pojo.Student"
          p:id="2" p:name="学生2" p:cdCard-ref="cdCard1"/>
——【③ util-命名空间】——

工具类的命名空间,可以简化集合类元素的配置,同样的我们需要引入其声明(无需担心怎么声明的问题,IDEA会有很友好的提示):

我们来看看引入前后的变化:

<!-- 引入util-命名空间之前 -->
<property name="list">
    <list>
        <ref bean="bean1"/>
        <ref bean="bean2"/>
    </list>
</property>

<!-- 引入util-命名空间之后 -->
<util:list id="list">
    <ref bean="bean1"/>
    <ref bean="bean2"/>
</util:list>

<util:list> 只是 util-命名空间中的多个元素之一,下表提供了 util-命名空间提供的所有元素:

元素 描述
<util:constant> 引用某个类型的 public static 域,并将其暴露为 bean
<util:list> 创建一个 java.util.List 类型的 bean,其中包含值或引用
<util:map> 创建一个 java.util.map 类型的 bean,其中包含值或引用
<util:properties> 创建一个 java.util.Properties 类型的 bean
<util:property-path> 引用一个 bean 的属性(或内嵌属性),并将其暴露为 bean
<util:set> 创建一个 java.util.Set 类型的 bean,其中包含值或引用

引入其他配置文件

在实际开发中,随着应用程序规模的增加,系统中 <bean> 元素配置的数量也会大大增加,导致 applicationContext.xml 配置文件变得非常臃肿难以维护。

  • 解决方案:让 applicationContext.xml 文件包含其他配置文件即可
    使用 <import> 元素引入其他配置文件

1.在【src】文件下新建一个 bean.xml 文件,写好基础的约束,把 applicationContext.xml 文件中配置的 <bean> 元素复制进去

2.在 applicationContext.xml 文件中写入:

<import resource="bean.xml" />

3.运行测试代码,仍然能正确获取到 bean:


通过注解装配 Bean

上面,我们已经了解了如何使用 XML 的方式去装配 Bean,但是更多的时候已经不再推荐使用 XML 的方式去装配 Bean,更多的时候回考虑使用注解(annotation) 的方式去装配 Bean。

  • 优势:

1.可以减少 XML 的配置,当配置项多的时候,臃肿难以维护
2.功能更加强大,既能实现 XML 的功能,也提供了自动装配的功能,采用了自动装配后,程序猿所需要做的决断就少了,更加有利于对程序的开发,这就是“约定由于配置”的开发原则

在 Spring 中,它提供了两种方式来让 Spring IoC 容器发现 bean:

  • 组件扫描:通过定义资源的方式,让 Spring IoC 容器扫描对应的包,从而把 bean 装配进来。
  • 自动装配:通过注解定义,使得一些依赖关系可以通过注解完成。

使用@Compoent 装配 Bean

我们把之前创建的 Student 类改一下:

package pojo;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component(value = "student1")
public class Student {

    @Value("1")
    int id;
    @Value("student_name_1")
    String name;

    // getter and setter
}

解释一下:

  • @Component注解:
    表示 Spring IoC 会把这个类扫描成一个 bean 实例,而其中的 value 属性代表这个类在 Spring 中的 id,这就相当于在 XML 中定义的 Bean 的 id:<bean id="student1" class="pojo.Student" />,也可以简写成 @Component("student1"),甚至直接写成 @Component ,对于不写的,Spring IoC 容器就默认以类名来命名作为 id,只不过首字母小写,配置到容器中。
  • @Value注解:
    表示值的注入,跟在 XML 中写 value 属性是一样的。

这样我们就声明好了我们要创建的一个 Bean,就像在 XML 中写下了这样一句话:

<bean name="student1" class="pojo.Student">
    <property name="id" value="1" />
    <property name="name" value="student_name_1"/>
</bean>

但是现在我们声明了这个类,并不能进行任何的测试,因为 Spring IoC 并不知道这个 Bean 的存在,这个时候我们可以使用一个 StudentConfig 类去告诉 Spring IoC :

package pojo;
import org.springframework.context.annotation.ComponentScan;

@ComponentScan
public class StudentConfig {
}

这个类十分简单,没有任何逻辑,但是需要说明两点:

  • 该类和 Student 类位于同一包名下
  • @ComponentScan注解:
    代表进行扫描,默认是扫描当前包的路径,扫描所有带有 @Component 注解的 POJO。

这样一来,我们就可以通过 Spring 定义好的 Spring IoC 容器的实现类——AnnotationConfigApplicationContext 去生成 IoC 容器了:

ApplicationContext context = new AnnotationConfigApplicationContext(StudentConfig.class);
Student student = (Student) context.getBean("student1", Student.class);
student.printInformation();

这里可以看到使用了 AnnotationConfigApplicationContext 类去初始化 Spring IoC 容器,它的配置项是 StudentConfig 类,这样 Spring IoC 就会根据注解的配置去解析对应的资源,来生成 IoC 容器了。

  • 明显的弊端:
  • 对于 @ComponentScan 注解,它只是扫描所在包的 Java 类,但是更多的时候我们希望的是可以扫描我们指定的类
  • 上面的例子只是注入了一些简单的值,测试发现,通过 @Value 注解并不能注入对象

@Component 注解存在着两个配置项:

  • basePackages:它是由 base 和 package 两个单词组成的,而 package 还是用了复数,意味着它可以配置一个 Java 包的数组,Spring 会根据它的配置扫描对应的包和子包,将配置好的 Bean 装配进来
  • basePackageClasses:它由 base、package 和 class 三个单词组成,采用复数,意味着它可以配置多个类, Spring 会根据配置的类所在的包,为包和子包进行扫描装配对应配置的 Bean

我们来试着重构之前写的 StudentConfig 类来验证上面两个配置项:

package pojo;
import org.springframework.context.annotation.ComponentScan;

@ComponentScan(basePackages = "pojo")
public class StudentConfig {
}

//  —————————————————— 【 宇宙超级无敌分割线】—————————————————— 
package pojo;

import org.springframework.context.annotation.ComponentScan;

@ComponentScan(basePackageClasses = pojo.Student.class)
public class StudentConfig {
}

验证都能通过,bingo!

  • 对于 【basePackages】 和 【basePackageClasses】 的选择问题:
    【basePackages】 的可读性会更好一些,所以在项目中会优先选择使用它,但是在需要大量重构的工程中,尽量不要使用【basePackages】,因为很多时候重构修改包名需要反复地配置,而 IDE 不会给你任何的提示,而采用【basePackageClasses】会有错误提示。

自动装配——@Autowired

上面提到的两个弊端之一就是没有办法注入对象,通过自动装配我们将解决这个问题。

所谓自动装配技术是一种由 Spring 自己发现对应的 Bean,自动完成装配工作的方式,它会应用到一个十分常用的注解 @Autowired 上,这个时候 Spring 会根据类型去寻找定义的 Bean 然后将其注入,听起来很神奇,让我们实际来看一看:

1.先在 Package【service】下创建一个 StudentService 接口:

package service;

public interface StudentService {
    public void printStudentInfo();
}

使用接口是 Spring 推荐的方式,这样可以更为灵活,可以将定义和实现分离

2.为上面的接口创建一个 StudentServiceImp 实现类:

package service;

import org.springframework.beans.factory.annotation.Autowired;
import pojo.Student;

@Component("studentService")
public class StudentServiceImp implements StudentService {

    @Autowired
    private Student student = null;

     // getter and setter

    public void printStudentInfo() {
        System.out.println("学生的 id 为:" + student.getName());
        System.out.println("学生的 name 为:" + student.getName());
    }
}

该实现类实现了接口的 printStudentInfo() 方法,打印出成员对象 student 的相关信息,这里的 @Autowired 注解,表示在 Spring IoC 定位所有的 Bean 后,这个字段需要按类型注入,这样 IoC 容器就会寻找资源,然后将其注入。

3.编写测试类:

// 第一步:修改 StudentConfig 类,告诉 Spring IoC 在哪里去扫描它:
package pojo;

import org.springframework.context.annotation.ComponentScan;

@ComponentScan(basePackages = {"pojo", "service"})
public class StudentConfig {
}

// 或者也可以在 XML 文件中声明去哪里做扫描
<context:component-scan base-package="pojo" />
<context:component-scan base-package="service" />

// 第二步:编写测试类:
package test;

import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import pojo.StudentConfig;
import service.StudentService;
import service.StudentServiceImp;

public class TestSpring {

    public static void main(String[] args) {
        // 通过注解的方式初始化 Spring IoC 容器
        ApplicationContext context = new AnnotationConfigApplicationContext(StudentConfig.class);
        StudentService studentService = context.getBean("studentService", StudentServiceImp.class);
        studentService.printStudentInfo();
    }
}

运行代码:

  • 再次理解: @Autowired 注解表示在 Spring IoC 定位所有的 Bean 后,再根据类型寻找资源,然后将其注入。
  • 过程: 定义 Bean ——》 初始化 Bean(扫描) ——》 根据属性需要从 Spring IoC 容器中搜寻满足要求的 Bean ——》 满足要求则注入
  • 问题: IoC 容器可能会寻找失败,此时会抛出异常(默认情况下,Spring IoC 容器会认为一定要找到对应的 Bean 来注入到这个字段,但有些时候并不是一定需要,比如日志)
  • 解决: 通过配置项 required 来改变,比如 @Autowired(required = false)

@Autowired 注解不仅仅能配置在属性之上,还允许方法配置,常见的 Bean 的 setter 方法也可以使用它来完成注入,总之一切需要 Spring IoC 去寻找 Bean 资源的地方都可以用到,例如:

/* 包名和import */
public class JuiceMaker {
    ......
    @Autowired
    public void setSource(Source source) {
        this.source = source;
    }
}

在大部分的配置中都推荐使用这样的自动注入来完成,这是 Spring IoC 帮助我们自动装配完成的,这样使得配置大幅度减少,满足约定优于配置的原则,增强程序的健壮性。

自动装配的歧义性(@Primary和@Qualifier)

在上面的例子中我们使用 @Autowired 注解来自动注入一个 Source 类型的 Bean 资源,但如果我们现在有两个 Srouce 类型的资源,Spring IoC 就会不知所措,不知道究竟该引入哪一个 Bean:

<bean name="source1" class="pojo.Source">
    <property name="fruit" value="橙子"/>
    <property name="sugar" value="多糖"/>
    <property name="size" value="超大杯"/>
</bean>
<bean name="source2" class="pojo.Source">
    <property name="fruit" value="橙子"/>
    <property name="sugar" value="少糖"/>
    <property name="size" value="小杯"/>
</bean>

我们可以会想到 Spring IoC 最底层的容器接口——BeanFactory 的定义,它存在一个按照类型获取 Bean 的方法,显然通过 Source.class 作为参数无法判断使用哪个类实例进行返回,这就是自动装配的歧义性。

为了消除歧义性,Spring 提供了两个注解:

  • @Primary 注解:
    代表首要的,当 Spring IoC 检测到有多个相同类型的 Bean 资源的时候,会优先注入使用该注解的类。
  • 问题:该注解只是解决了首要的问题,但是并没有选择性的问题
  • @Qualifier 注解:
    上面所谈及的歧义性,一个重要的原因是 Spring 在寻找依赖注入的时候是按照类型注入引起的。除了按类型查找 Bean,Spring IoC 容器最底层的接口 BeanFactory 还提供了按名字查找的方法,如果按照名字来查找和注入不就能消除歧义性了吗?
  • 使用方法: 指定注入名称为 “source1” 的 Bean 资源
/* 包名和import */
public class JuiceMaker {
    ......
    @Autowired
    @Qualifier("source1")
    public void setSource(Source source) {
        this.source = source;
    }
}

使用@Bean 装配 Bean

  • 问题: 以上都是通过 @Component 注解来装配 Bean ,并且只能注解在类上,当你需要引用第三方包的(jar 文件),而且往往并没有这些包的源码,这时候将无法为这些包的类加入 @Component 注解,让它们变成开发环境中的 Bean 资源。
  • 解决方案:

1.自己创建一个新的类来扩展包里的类,然后再新类上使用 @Component 注解,但这样很 low
2.使用 @Bean 注解,注解到方法之上,使其成为 Spring 中返回对象为 Spring 的 Bean 资源。

我们在 Package【pojo】 下新建一个用来测试 @Bean 注解的类:

package pojo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class BeanTester {

    @Bean(name = "testBean")
    public String test() {
        String str = "测试@Bean注解";
        return str;
    }
}
  • 注意: @Configuration 注解相当于 XML 文件的根元素,必须要,有了才能解析其中的 @Bean 注解

然后我们在测试类中编写代码,从 Spring IoC 容器中获取到这个 Bean :

// 在 pojo 包下扫描
ApplicationContext context = new AnnotationConfigApplicationContext("pojo");
// 因为这里获取到的 Bean 就是 String 类型所以直接输出
System.out.println(context.getBean("testBean"));

@Bean 的配置项中包含 4 个配置项:

  • name: 是一个字符串数组,允许配置多个 BeanName
  • autowire: 标志是否是一个引用的 Bean 对象,默认值是 Autowire.NO
  • initMethod: 自定义初始化方法
  • destroyMethod: 自定义销毁方法

使用 @Bean 注解的好处就是能够动态获取一个 Bean 对象,能够根据环境不同得到不同的 Bean 对象。或者说将 Spring 和其他组件分离(其他组件不依赖 Spring,但是又想 Spring 管理生成的 Bean)

Bean 的作用域

在默认的情况下,Spring IoC 容器只会对一个 Bean 创建一个实例,但有时候,我们希望能够通过 Spring IoC 容器获取多个实例,我们可以通过 @Scope 注解或者 <bean> 元素中的 scope 属性来设置,例如:

// XML 中设置作用域
<bean id="" class="" scope="prototype" />
// 使用注解设置作用域
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)

Spring 提供了 5 种作用域,它会根据情况来决定是否生成新的对象:

作用域类别 描述
singleton(单例) 在Spring IoC容器中仅存在一个Bean实例 (默认的scope)
prototype(多例) 每次从容器中调用Bean时,都返回一个新的实例,即每次调用getBean()时 ,相当于执行new XxxBean():不会在容器启动时创建对象
request(请求) 用于web开发,将Bean放入request范围 ,request.setAttribute(“xxx”) , 在同一个request 获得同一个Bean
session(会话) 用于web开发,将Bean 放入Session范围,在同一个Session 获得同一个Bean
globalSession(全局会话) 一般用于 Porlet 应用环境 , 分布式系统存在全局 session 概念(单点登录),如果不是 porlet 环境,globalSession 等同于 Session

在开发中主要使用 scope="singleton"scope="prototype"对于MVC中的Action使用prototype类型,其他使用singleton,Spring容器会管理 Action 对象的创建,此时把 Action 的作用域设置为 prototype.

扩展阅读:@Profile 注解条件化装配 Bean

Spring 表达式语言简要说明

Spring 还提供了更灵活的注入方式,那就是 Spring 表达式,实际上 Spring EL 远比以上注入方式都要强大,它拥有很多功能:

  • 使用 Bean 的 id 来引用 Bean
  • 调用指定对象的方法和访问对象的属性
  • 进行运算
  • 提供正则表达式进行匹配
  • 集合配置

我们来看一个简单的使用 Spring 表达式的例子:

package pojo;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component("elBean")
public class ElBean {
    // 通过 beanName 获取 bean,然后注入 
    @Value("#{role}")
    private Role role;

    // 获取 bean 的属性 id
    @Value("#{role.id}")
    private Long id;

    // 调用 bean 的 getNote 方法
    @Value("#{role.getNote().toString()}")
    private String note;
    /* getter and setter */
}

与属性文件中读取使用的 “$” 不同,在 Spring EL 中则使用 “#

扩展阅读: Spring 表达式语言

参考资料:

  • 《Java EE 互联网轻量级框架整合开发》
  • 《Java 实战(第四版)》
  • 万能的百度 and 万能的大脑

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz
分享自己的学习 & 学习资料 & 生活
想要交流的朋友也可以加qq群:3382693