0%

MySQL 数据库设计总结

参考文献: MySQL 数据库设计总结

常用规则

规则 1:一般情况可以选择MyISAM存储引擎,如果需要事务支持必须使用InnoDB存储引擎。

注意:MyISAM存储引擎 B-tree索引有一个很大的限制:参与一个索引的所有字段的长度之和不能超过 1000 字节。另外MyISAM 数据和索引是分开,而InnoDB的数据存储是按聚簇(cluster)索引有序排列的,主键是默认的聚簇(cluster)索引,因此MyISAM虽然在一般情况下,查询性能比InnoDB高,但InnoDB的以主键为条件的查询性能是非常高的。

规则 2:命名规则。

  1. 数据库和表名应尽可能和所服务的业务模块名一致
  2. 服务与同一个子模块的一类表应尽量以子模块名(或部分单词)为前缀或后缀
  3. 表名应尽量包含与所存放数据对应的单词
  4. 字段名称也应尽量保持和实际数据相对应
  5. 联合索引名称应尽量包含所有索引键字段名或缩写,且各字段名在索引名中的顺序应与索引键在索引中的索引顺序一致,并尽量包含一个类似 idx 的前缀或后缀,以表明期对象类型是索引。
  6. 约束等其他对象也应该尽可能包含所属表或其他对象的名称,以表明各自的关系

规则 3:数据库字段类型定义

  1. 经常需要计算和排序等消耗 CPU 的字段,应该尽量选择更为迅速的字段,如用TIMESTAMP(4 个字节,最小值1970-01-01 00:00:00)代替Datetime(8 个字节,最小值1001-01-01 00:00:00),通过整型替代浮点型和字符型
  2. 变长字段使用varchar,不要使用char
  3. 对于二进制多媒体数据,流水队列数据(如日志),超大文本数据不要放在数据库字段中

规则 4:业务逻辑执行过程必须读到的表中必须要有初始的值。避免业务读出为负或无穷大的值导致程序失败

规则 5:并不需要一定遵守范式理论,适度的冗余,让 Query 尽量减少 Join

规则 6:访问频率较低的大字段拆分出数据表。有些大字段占用空间多,访问频率较其他字段明显要少很多,这种情况进行拆分,频繁的查询中就不需要读取大字段,造成 IO 资源的浪费。

规则 7:大表可以考虑水平拆分。大表影响查询效率,根据业务特性有很多拆分方式,像根据时间递增的数据,可以根据时间来分。以 id 划分的数据,可根据 id%数据库个数的方式来拆分。

一.数据库索引

规则 8:业务需要的相关索引是根据实际的设计所构造 sql 语句的**where 条件**来确定的,
业务不需要的字段不要建索引,不允许在联合索引(或主键)中存在多余的字段。特别是该字段根本不会在条件语句中出现。

规则 9:唯一确定一条记录的一个字段或多个字段要建立主键或者唯一索引,不能唯一确定一条记录,为了提高查询效率建普通索引

规则 10:业务使用的表,有些记录数很少,甚至只有一条记录,为了约束的需要,也要建立索引或者设置主键。

规则 11:对于取值不能重复,经常作为查询条件的字段,应该建唯一索引(主键默认唯一索引),并且将查询条件中该字段的条件置于第一个位置。没有必要再建立与该字段有关的联合索引。

规则 12:对于经常查询的字段,其值不唯一,也应该考虑建立普通索引,查询语句中该字段条件置于第一个位置,对联合索引处理的方法同样。

规则 13:业务通过不唯一索引访问数据时,需要考虑通过该索引值返回的记录稠密度,原则上可能的稠密度最大不能高于 0.2,如果稠密度太大,则不合适建立索引了。

当通过这个索引查找得到的数据量占到表内所有数据的 20%以上时,则需要考虑建立该索引的代价,同时由于索引扫描产生的都是随机 I/O,生成效率比全表顺序扫描的顺序 I/O 低很多。数据库系统优化 query 的时候有可能不会用到这个索引。

规则 14:需要联合索引(或联合主键)的数据库要注意索引的顺序。SQL 语句中的匹配条件也要跟索引的顺序保持一致。

注意:索引的顺势不正确也可能导致严重的后果。

规则 15:表中的多个字段查询作为查询条件,不含有其他索引,并且字段联合值不重复,可以在这多个字段上建唯一的联合索引,假设索引字段为 (a1,a2,…an),则查询条件(a1 op val1,a2 op val2,...am op valm)m<=n,可以用到索引,查询条件中字段的位置与索引中的字段位置是一致的。

规则 16:联合索引的建立原则(以下均假设在数据库表的字段 a,b,c 上建立联合索引(a,b,c))

  1. 联合索引中的字段应尽量满足过滤数据从多到少的顺序,也就是说差异最大的字段应该放在第一个字段
  2. 建立索引尽量与 SQL 语句的条件顺序一致,使 SQL 语句尽量以整个索引为条件,尽量避免以索引的一部分(特别是首个条件与索引的首个字段不一致时)作为查询的条件
  3. Where a=1, where a>=12 and a<15, where a=1 and b<5 , where a=1 and b=7 and c>=40为条件可以用到此联合索引;而这些语句where b=10, where c=221, where b>=12 and c=2则无法用到这个联合索引。
  4. 当需要查询的数据库字段全部在索引中体现时,数据库可以直接查询索引得到查询信息无须对整个表进行扫描(这就是所谓的 key-only),能大大的提高查询效率。
    当 a,ab,abc 与其他表字段关联查询时可以用到索引
  5. 当 a,ab,abc 顺序而不是 b,c,bc,ac 为顺序执行 Order by 或者 group 不要时可以用到索引
  6. 以下情况时,进行表扫描然后排序可能比使用联合索引更加有效
    • a. 表已经按照索引组织好了
    • b. 被查询的数据占所有数据的很多比例。

规则 17:重要业务访问数据表时。但不能通过索引访问数据时,应该确保顺序访问的记录数目是有限的,原则上不得多于 10.

二. Query 语句与应用系统优化

规则 18:合理构造 Query 语句

  1. Insert 语句中,根据测试,批量一次插入 1000 条时效率最高,多于 1000 条时,要拆分,多次进行同样的插入,应该合并批量进行。注意 query 语句的长度要小于 mysqld 的参数 max_allowed_packet

  2. 查询条件中各种逻辑操作符性能顺序是 and,or,in,因此在查询条件中应该尽量避免使用在大集合中使用 in

  3. 永远用小结果集驱动大记录集,因为在 mysql 中,只有 Nested Join 一种 Join 方式,就是说 mysql 的 join 是通过嵌套循环来实现的。通过小结果集驱动大记录集这个原则来减少嵌套循环的循环次数,以减少 IO 总量及 CPU 运算次数

  4. 尽量优化 Nested Join 内层循环。

  5. 只取需要的 columns,尽量不要使用select *

  6. 仅仅使用最有效的过滤字段,where 字句中的过滤条件少为好

  7. 尽量避免复杂的 Join 和子查询

    Mysql 在并发这块做得并不是太好,当并发量太高的时候,整体性能会急剧下降,这主要与 Mysql 内部资源的争用锁定控制有关,MyIsam用表锁,InnoDB好一些用行锁。

规则 19:应用系统的优化

  1. 合理使用cache,对于变化较少的部分活跃数据通过应用层的cache缓存到内存中,对性能的提升是成数量级的。
  2. 对重复执行相同的 query 进行合并,减少 IO 次数。
  3. 事务相关性最小原则

关于本书

许可

本书《 The Little MongoDB Book 》基于 Attribution-NonCommercial 3.0 Unported license. 你无须为本书付款。

你可以自由的复制,分发,修改和传阅本书。但请认可该书属于作者 Karl Seguin,并请勿将本书用于任何商业目的。

你可以在以下链接查看完整的许可文档:

http://creativecommons.org/licenses/by-nc/3.0/legalcode

关于作者

Karl Seguin 在多领域有着丰富经验,他是 .NET 和 Ruby 的开发专家。他也参与贡献 OSS 项目, 还是技术文档撰写人而且偶尔做做演讲。MongoDB 方面,他是 C# MongoDB 库 NoRM 的核心开发者,写有互动入门教程 monglyMongo Web Admin。他用 MongoDB,为休闲游戏开发者写了一个免费服务, mogade.com

Karl 还编写了 The Little Redis Book 1

你可以在 http://openmymind.net 找到他的 Blog,或者通过 @karlseguin 在 Twitter 上关注他。

鸣谢

特别感谢 Perry Neal, 赐予我你的视野,精神,和热情。你赐予了我无尽的力量。感恩。

最新版本

最新的版本由 Asya Kamsky 更新到了 MongoDB 2.6 。本书最新代码可以在这里获得:

http://github.com/karlseguin/the-little-mongodb-book.

中文版本

Karl 在 the-little-mongodb-book 的 Github 链接中给出了 justinyhuangthe-little-mongodb-book-cn 链接。但貌似 justinyhuang 并没有同步更新到 MongoDB 2.6 。内容上也和原文稍微有点出入,并且由于本人水平有限,无法提交自信正确的内容。因此重开一项目。如果你被搜索引擎引导到本工程,在此向你致歉,并希望有能力者且有时间者一同完善和同步本工程。你可以通过我的 邮箱 geminiyellow@gmail.com 来联系我,或者通过 @geminiyellow 在 Twitter 上关注我。

最新中文版本基于 asya999 在 May 29, 2014 提交的 #38 SHA 是:6d4dce8ead6a767e1e8de1b59f714510d36d366f

简介

这章那么短不是我的错,MongoDB 就真的很易学。

都说技术在飞速发展。确实,有接连不断的新技术新方法出现。但是,我一直认为,程序员用到的基础技术的发展却是相当缓慢的。你可以好几年不学习但还能混得下去。令人惊讶的其实是成熟技术的被替换速度。就像在一夜之间,那些长期稳定成熟的技术发现它们不再被开发者关注。

最好的例子就是 NoSQL 技术的发展,以及它对稳定的关系型数据库市场的蚕食。看起来就像,昨天网络还是由 RDBMS 们来驱动的,而今天,就冒出五种左右的 NoSQL 解决案已经证明了它们都是值得拥有的。

虽然这些转变看起来都是一夜之间发生的,实际上他们他们可能花了数年的时间来取得公众的认可。最开始是由一小波开发者和公司在推动。解决方案被不断细化,吸取教训,然后一个新技术就这样诞生了,慢慢的后来者也开始了尝试。再次重申,NoSQL 的许多解决方案并不是为了取代传统的存储方案,而是解决一些特殊需求,填补了传统解决方案的一些空白。

说了那么多,我们第一件应该解决的事情是解释一下什么是 NoSQL。它是一个宽松的概念,不同的人有不同的见解。就个人而言,我通常认为它是数据存储系统的一部分。换而言之,NoSQL (重申, 就我而言),的好处是让你的持久层不需要一个独立的系统。历史上,传统的关系数据库厂商尝试把他们的产品当作一揽子解决方案,NoSQL 倾向于扮演,在特定的工作中充当最好的工具这种角色。因此,你的 NoSQL 架构中还是可以用到关系型数据库,比如说 MySQL,但是可以也可以用 Redis 作为系统中某部分的持久层,或者是用到 Hadoop 来处理大数据。简而言之,NoSQL 就是需要用开放的可代替的意识,使用现有的或者未来的方式和工具来管理你的数据。

你会想知道,MongoDB 是不是适用于这一切。作为一个面向文档数据库,MongoDB 是最通用的 NoSQL 解决案。它可以看成是关系型数据库的代替方案。和关系型数据库一样,它也可以和其他的 NoSQL 解决案搭配在一起更好的工作。MongoDB 有优点也有缺点,我们将会在本书后面的章节中介绍。

开始

本书大部分内容将会专注于 MongoDB 的核心功能。我们会用到 MongoDB 的 shell。因为 shell 不但有助于学习,而且还是个很有用的管理工具。实际代码中你需要用到 MongoDB 驱动。

这也引出了关于 MongoDB 你所需要知道的第一件事: 它的驱动。MongoDB 有各种语言的 官方驱动。这些驱动可以认为是和你所熟悉的各种数据库驱动一样的东西。基于这些驱动,开发社区又创建了更多的语言/框架相关库。比如说,NoRM 是一个 C# 语言库,用 LINQ 实现,而 MongoMapper 是一个 Ruby 库,ActiveRecord-friendly。你可以选择直接对 MongoDB 核心进行开发,或选择高级库。之所以要指出,是因为许多新手都觉得迷惑,为什么这里有官方版本和社区版本 - 前者通常关心和 MongoDB 核心的通讯/连接,而后者有更多的语言和框架的实现。

说到这,我希望你可以在 MongoDB 环境中尝试一下我的例子,并且在尝试解决可能遇到的问题。MongoDB 很容易安装和运行,所以让我们花几分钟把所有的东西运行起来。

  1. 先打开 官方下载页面 ,从你选择的操作系统下面的第一行(推荐稳定版本)下载二进制文件。根据开发实际,你可以选择 32位 或者 64位。

  2. 解压缩文件 (随便你放哪) 然后进入 bin 子目录。现在还不要执行任何命令,只要记住 mongod 用来打开服务进程,mongo 打开客户端 shell - 大部分时间我们将要使用这两个命令。

  3. bin 子目录下创建一个文本文件,命名为 mongodb.config

  4. 在 mongodb.config 中添加一行: dbpath=PATH_TO_WHERE_YOU_WANT_TO_STORE_YOUR_DATABASE_FILES。比如,在 Windows 你可以写 dbpath=c:\mongodb\data ,在 Linux 可能是 dbpath=/var/lib/mongodb/data

  5. 确保你指定的 dbpath 确实存在。

  6. 执行 mongod ,带上参数 --config /path/to/your/mongodb.config

以 Windows 用户为例,如果你解压下载文档到 c:\mongodb\ ,并且你创建了 c:\mongodb\data\ ,那么在 c:\mongodb\bin\mongodb.config 你要指定 dbpath=c:\mongodb\data\。 然后你可以在 CMD 执行 mongod 如下命令行 c:\mongodb\bin\mongod --config c:\mongodb\bin\mongodb.config

为省心你可以把 bin 文件夹路径添加到环境变量 PATH 中,可以简化命令。MacOSX 和 Linux 用户方法几乎一样。唯一需要改变的是路径。

希望你现在已经可以启动 MongoDB 了。如果出现异常,仔细阅读一下异常信息 - 服务器对异常的解释做得非常好。

现在你可以执行 mongo (没有 d) ,链接 shell 到你的服务器上了。尝试输入 db.version() 来确认所有都正确执行了。你应该能拿到一个已安装的版本号。

第一章 - 基础知识

我们通过学习 MongoDB 的基本工作原理,开始我们的 MongoDB 之旅。当然,这是学习 MongoDB 的核心,它也能帮助我们回答诸如,MongoDB 适用于哪些场景这些更高层次的问题。

开始之前,这有六个简单的概念我们需要了解一下。

  1. MongoDB中的 database 有着和你熟知的”数据库”一样的概念 (对 Oracle 来说就是 schema)。一个 MongoDB 实例中,可以有零个或多个数据库,每个都作为一个高等容器,用于存储数据。

  2. 数据库中可以有零个或多个 collections (集合)。集合和传统意义上的 table 基本一致,你可以简单的把两者看成是一样的东西。

  3. 集合是由零个或多个 documents (文档)组成。同样,一个文档可以看成是一 row

  4. 文档是由零个或多个 fields (字段)组成。, 没错,它就是 columns

  5. Indexes (索引)在 MongoDB 中扮演着和它们在 RDBMS 中一样的角色。

  6. Cursors (游标)和上面的五个概念都不一样,但是它非常重要,并且经常被忽视,因此我觉得它们值得单独讨论一下。其中最重要的你要理解的一点是,游标是,当你问 MongoDB 拿数据的时候,它会给你返回一个结果集的指针而不是真正的数据,这个指针我们叫它游标,我们可以拿游标做我们想做的任何事情,比如说计数或者跨行之类的,而无需把真正的数据拖下来,在真正的数据上操作。

综上,MongoDB 是由包含 collectionsdatabases 组成的。而 collection 是由 documents组成。每个 document 是由 fields 组成。 Collections 可以被 indexed,以便提高查找和排序的性能。最后,当我们从 MongoDB 获取数据的时候,我们通过 cursor 来操作,读操作会被延迟到需要实际数据的时候才会执行。

那为什么我们需要新的术语(collection vs. table, document vs. row and field vs. column)?为了让看起来更复杂点?事实上,虽然这些概念和关系型数据中的概念类似,但是还是有差异的。核心差异在于,关系型数据库是在 table 上定义的 columns,而面向文档数据库是在 document 上定义的 fields。也就是说,在 collection 中的每个 document 都可以有它自己独立的 fields。因此,对于 collection 来说是个简化了的 table ,但是一个 document 却比一 row 有更多的信息。

虽然这些概念很重要,但是如果现在搞不明白也不要紧。多插几条数据就明白上面说的到底是什么意思了。反正,要点就是,集合不对存储内容严格限制 (所谓的无模式(schema-less))。字段由每个独立的文档进行跟踪处理。这样做的优点和缺点将在下面章节一一讨论。

好了我们开始吧。如果你还没有运行 MongoDB,那么快去运行 mongod 服务和开启 mongo shell。shell 用的是 JavaScript。你可以试试一些全局命令,比如 help 或者 exit。如果要操作当前数据库,用 db ,比如 db.help() 或者 db.stats()。如果要操作指定集合,大多数情况下我们会操作集合而不是数据库,用 db.COLLECTION_NAME ,比如 db.unicorns.help() 或者 db.unicorns.count()

我们继续,输入 db.help(),就能拿到一个对 db 能执行的所有的命令的列表。

顺便说一句:因为这是一个 JavaScript shell,如果你输入的命令漏了 (),你会看到这个命令的源码,而不是执行这个命令。我提一下,是为了避免你执行漏了括号的命令,拿到一个以 function (...){ 开头的返回的时候,觉得神奇不可思议。比如说,如果你输入 db.help (不带括号), 你会看到 help 方法的内部实现。

首先我们用全局的 use 来切换数据库,继续,输入 use learn。这个数据库实际存在与否完全没有关系。我们在里面生成集合的时候, learn 数据库会自动建起来。现在,我们在一个数据库里面了,你可以开始尝试一下数据库命令,比如 db.getCollectionNames()。执行之后,你会得到一个空数组 ([ ])。因为集合是无模式的,我们不需要特地去配置它。我们可以简单的插入一个文档到一个新的集合。像这样,我们用 insert 命令,在文档中插入:

db.unicorns.insert({name: 'Aurora',
	gender: 'f', weight: 450})

这行命令对集合 unicorns 执行了 insert 命令,并传入一个参数。MongoDB 内部用二进制序列化 JSON 格式,称为 BSON。外部,也就是说我们多数情况应该用 JSON,就像上面的参数一样。然后我们执行 db.getCollectionNames() ,我们将能拿到两个集合: unicornssystem.indexes。在每个数据库中都会有一个 system.indexes 集合,用来保存我们数据的的索引信息。

你现在可以对用 unicorns 执行 find 命令,然后返回文档列表:

db.unicorns.find()

请注意,除你指定的字段之外,会多出一个 _id 字段。每个文档都会有一个唯一 _id 字段。你可以自己生成一个,或者让 MongoDB 帮你生成一个 ObjectId 类型的。多数情况下,你会乐意让 MongoDB 帮你生成的。默认的 _id 字段是已被索引的 - 这就说明了为什么会有 system.indexes 集合。你可以看看 system.indexes:

db.system.indexes.find()

你可以看到索引的名字,被索引的数据库和集合,以及在索引中的字段。

现在,回到我们关于数组无模式的讨论中来。往 unicorns 插入一个完全不同的文档,比如:

db.unicorns.insert({name: 'Leto',
	gender: 'm',
	home: 'Arrakeen',
	worm: false})

然后,再用 find 列出文档。等我们理解再深入一点的时候,将会讨论一下 MongoDB 的有趣行为。到这里,我希望你开始理解,为什么那些传统的术语在这里不适用了。

掌握选择器(Selector)

除了我们介绍过的六个概念,在开始讨论更深入的话题之前,MongoDB 还有一个应该掌握的实用概念:查询选择器。MongoDB 的查询选择器就像 SQL 语句里面的 where 一样。因此,你会在对集合的文档做查找,计数,更新,删除的时候用到它。选择器是一个 JSON 对象,最简单的是就是用 {} 匹配所有的文档。如果我们想找出所有母独角兽,我们可以用 {gender:'f'}

开始深入学习选择器之前,让我们先做些准备。首先,把刚才我们插入 unicorns 集合的数据删除,通过: db.unicorns.remove({})。现在,再插入一些用来演示的数据 (你不会手打吧):

db.unicorns.insert({name: 'Horny',
	dob: new Date(1992,2,13,7,47),
	loves: ['carrot','papaya'],
	weight: 600,
	gender: 'm',
	vampires: 63});
db.unicorns.insert({name: 'Aurora',
	dob: new Date(1991, 0, 24, 13, 0),
	loves: ['carrot', 'grape'],
	weight: 450,
	gender: 'f',
	vampires: 43});
db.unicorns.insert({name: 'Unicrom',
	dob: new Date(1973, 1, 9, 22, 10),
	loves: ['energon', 'redbull'],
	weight: 984,
	gender: 'm',
	vampires: 182});
db.unicorns.insert({name: 'Roooooodles',
	dob: new Date(1979, 7, 18, 18, 44),
	loves: ['apple'],
	weight: 575,
	gender: 'm',
	vampires: 99});
db.unicorns.insert({name: 'Solnara',
	dob: new Date(1985, 6, 4, 2, 1),
	loves:['apple', 'carrot',
		'chocolate'],
	weight:550,
	gender:'f',
	vampires:80});
db.unicorns.insert({name:'Ayna',
	dob: new Date(1998, 2, 7, 8, 30),
	loves: ['strawberry', 'lemon'],
	weight: 733,
	gender: 'f',
	vampires: 40});
db.unicorns.insert({name:'Kenny',
	dob: new Date(1997, 6, 1, 10, 42),
	loves: ['grape', 'lemon'],
	weight: 690,
	gender: 'm',
	vampires: 39});
db.unicorns.insert({name: 'Raleigh',
	dob: new Date(2005, 4, 3, 0, 57),
	loves: ['apple', 'sugar'],
	weight: 421,
	gender: 'm',
	vampires: 2});
db.unicorns.insert({name: 'Leia',
	dob: new Date(2001, 9, 8, 14, 53),
	loves: ['apple', 'watermelon'],
	weight: 601,
	gender: 'f',
	vampires: 33});
db.unicorns.insert({name: 'Pilot',
	dob: new Date(1997, 2, 1, 5, 3),
	loves: ['apple', 'watermelon'],
	weight: 650,
	gender: 'm',
	vampires: 54});
db.unicorns.insert({name: 'Nimue',
	dob: new Date(1999, 11, 20, 16, 15),
	loves: ['grape', 'carrot'],
	weight: 540,
	gender: 'f'});
db.unicorns.insert({name: 'Dunx',
	dob: new Date(1976, 6, 18, 18, 18),
	loves: ['grape', 'watermelon'],
	weight: 704,
	gender: 'm',
	vampires: 165});

现在我们有数据了,我们可以开始来学习掌握选择器了。{field: value} 用来查找那些 field 的值等于 value 的文档。 {field1: value1, field2: value2} 相当于 and 查询。还有 $lt, $lte, $gt, $gte$ne 被用来处理 小于,小于等于,大于,大于等于,和不等于操作。比如,获取所有体重大于700磅的公独角兽,我们可以这样:

db.unicorns.find({gender: 'm',
	weight: {$gt: 700}})
//or (not quite the same thing, but for
//demonstration purposes)
db.unicorns.find({gender: {$ne: 'f'},
	weight: {$gte: 701}})

$exists 用来匹配字段是否存在,比如:

db.unicorns.find({
	vampires: {$exists: false}})

会返回一条文档。’$in’ 被用来匹配查询文档在我们传入的数组参数中是否存在匹配值,比如:

db.unicorns.find({
	loves: {$in:['apple','orange']}})

会返回那些喜欢 apple 或者 orange 的独角兽。

如果我们想要 OR 而不是 AND 来处理选择条件的话,我们可以用 $or 操作符,再给它一个我们要匹配的数组:

db.unicorns.find({gender: 'f',
	$or: [{loves: 'apple'},
		  {weight: {$lt: 500}}]})

上面的查询会返回那些喜欢 apples 或者 weigh 小于500磅的母独角兽。

在我们最后两个例子里面有个非常赞的特性。你应该已经注意到了,loves 字段是个数组。MongoDB 允许数组作为基本对象(first class objects)处理。这是个令人难以置信的超赞特性。一旦你开始用它,你都不知道没了它你怎么活下去了。最有趣的是,基于数组的查询变得非常简单: {loves: 'watermelon'} 会把文档中 loves 中有 watermelon 的值全部查询出来。

除了我们介绍的这些,还有更多可用的操作。所有这些都记载在 MongoDB 手册上的 Query Selectors 这一章。我们介绍的仅仅是那些你学习时所需要用到的,同时也是你最经常用到的操作。

我们已经学习了选择器是如何配合 find 命令使用的了。还大致介绍了一下如何配合 remove 命令使用,count 命令虽然没介绍,不过你肯定知道应该怎么做,而 update 命令,之后我们会花多点时间来详细学习它。

MongoDB 为我们的 _id 字段生成的 ObjectId 可以这样查询:

db.unicorns.find(
	{_id: ObjectId("TheObjectId")})

小结

我们还没有看到 update , 或是能拿来做更华丽事情的 find。不过,我们已经安装好 MongoDB 并运行起来了, 简略的介绍了一下 insertremove 命令 (完整版也没比我们介绍的多什么)。 我们还介绍了 find 以及了解了 MongoDB selectors 是怎么一回事。 我们起了个很好的头,并为以后的学习奠定了坚实基础。 信不信由你,其实你已经掌握了学习 MongoDB 所必须的大多数知识 - 它真的是易学易用。 我强烈建议你在继续学习之前在本机上多试试多玩玩。 插入不同的文档,可以试试看在不同的集合中,习惯一下使用不同的选择器。试试 find, countremove。 多试几次之后,你会发现原来看起来那么格格不入的东西,用起来居然水到渠成。

第二章 - 更新

在第一章,我们介绍了 CRUD 的四分之三(create, read, update 和 delete) 操作。这章,我们来专门来讨论我们跳过的那个操作: updateUpdate 有些独特的行为,这是为什么我们把它独立成章。

Update: 覆盖还是 $set

最简单的情况, update 有两个参数: 选择器 (where) 和需要更新字段的内容。假设 Roooooodles 长胖了,你会希望我们这样操作:

db.unicorns.update({name: 'Roooooodles'},
	{weight: 590})

(如果你已经把 unicorns 集合玩坏了,它已经不是原来的数据了的话,再执行一次 remove 删除所有数据,然后重新插入第一章中所有的代码。)

现在,如果你查一下被更新了的记录:

db.unicorns.find({name: 'Roooooodles'})

你会发现 update 的第一个惊喜,没找到任何文档。因为我们指定的第二个参数没有使用任何的更新选项,因此,它 replace 了原始文档。也就是说, update 先根据 name 找到一个文档,然后用新文档(第二个参数)覆盖替换了整个文档。这和 SQL 的 update 命令的完全不一样。在某些情况下,这非常理想,可以用于某些完全动态更新上。但是,如果你只希望改变一个或者几个字段的值的时候,你应该用 MongoDB 的 $set 操作。继续,让我们来更新重置这个丢失的数据:

db.unicorns.update({weight: 590}, {$set: {
	name: 'Roooooodles',
	dob: new Date(1979, 7, 18, 18, 44),
	loves: ['apple'],
	gender: 'm',
	vampires: 99}})

这里不会覆盖新字段 weight 因为我们没有指定它。现在让我们来执行:

db.unicorns.find({name: 'Roooooodles'})

我们拿到了期待的结果。因此,在最开始的时候,我们正确的更新 weight 的方式应该是:

db.unicorns.update({name: 'Roooooodles'},
	{$set: {weight: 590}})

Update 操作符

除了 $set,我们还可以用其他的更新操作符做些有意思的事情。所有的更新操作都是对字段起作用 - 所以你不用担心整个文档被删掉。比如,$inc 可以用来给一个字段增加一个正/负值。假设说 Pilot 获得了非法的两个 vampire kills 点,我们可以这样修正它:

db.unicorns.update({name: 'Pilot'},
	{$inc: {vampires: -2}})

假设 Aurora 忽然长牙了,我们可以给她的 loves 字段加一个值,通过 $push 操作:

db.unicorns.update({name: 'Aurora'},
	{$push: {loves: 'sugar'}})

MongoDB 手册的 Update Operators 这章,可以查到更多可用的更新操作符的信息。

Upserts

update 还有一个最大的惊喜,就是它完全支持 upserts。所谓 upsert 更新,即在文档中找到匹配值时更新它,无匹配时向文档插入新值,你可以这样理解。要使用 upsert 我们需要向 update 写入第三个参数 {upsert:true}

一个最常见的例子是网站点击计数器。如果我们想保存一个实时点击总数,我们得先看看是否在页面上已经有点击记录,然后基于此再决定执行更新或者插入操作。如果省略 upsert 选项(或者设为 false),执行下面的操作不会带来任何变化:

db.hits.update({page: 'unicorns'},
	{$inc: {hits: 1}});
db.hits.find();

但是,如果我们加上 upsert 选项,结果会大不同:

db.hits.update({page: 'unicorns'},
	{$inc: {hits: 1}}, {upsert:true});
db.hits.find();

由于没有找到字段 page 值为 unicorns的文档,一个新的文档被生成插入。当我们第二次执行这句命令的时候,这个既存的文档将会被更新,且 hits 会被增加到 2。

db.hits.update({page: 'unicorns'},
	{$inc: {hits: 1}}, {upsert:true});
db.hits.find();

批量 Updates

关于 update 的最后一个惊喜,默认的,它只更新单个文档。到目前为止,我们的所有例子,看起来都挺符合逻辑的。但是,如果你执行一些像这样的操作的时候:

db.unicorns.update({},
	{$set: {vaccinated: true }});
db.unicorns.find({vaccinated: true});

你肯定会希望,你所有的宝贝独角兽都被接种疫苗了。为了达到这个目的, multi 选项需要设为 true:

db.unicorns.update({},
	{$set: {vaccinated: true }},
	{multi:true});
db.unicorns.find({vaccinated: true});

小结

本章中我们介绍了集合的基本 CRUD 操作。我们详细讲解了 update 及它的三个有趣的行为。 首先,如果你传 MongoDB 一个文档但是不带更新操作, MongoDB 的 update 会默认替换现有文档。因此,你通常要用到 $set 操作 (或者其他各种可用的用于修改文档的操作)。 其次, update 支持 upsert 操作,当你不知道文档是否存在的时候,非常有用。 最后,默认情况下, update 只更新第一个匹配文档,因此当你希望更新所有匹配文档时,你要用 multi

第三章 - 掌握查询

在第一章中我们对 find 命令做了一个初步的了解。除了 selectors 以外 find 还有更丰富的功能。我们已经说过,find 返回的结果是一个 cursor。我们将进一步看看它到底是什么意思。

字段选择

在开始 cursors 的话题之前,你应该知道 find 有第二个可选参数,叫做 “projection”。这个参数是我们要检索或者排除字段的列表。比如,我们可以仅查询返回独角兽的名字而不带别的字段:

db.unicorns.find({}, {name: 1});

默认的,_id 字段总是会返回的。我们可以通过这样显式的把它从返回结果中排除 {name:1, _id: 0}

除了 _id 字段,你不能把检索和排除混合使用。仔细想想,这是有道理的。你只能显式的检索或者排除某些字段。

排序(Ordering)

到目前位置我已经提到好多次, find 返回的是一个游标,它只有在需要的时候才会执行。但是,你在 shell 中看确实到的是 find 被立刻执行了。这只是 shell 的行为。 我们可以通过一个 find 的链式方法,观察到 cursors 的真正行为。我们来看看 sort。我们指定我们希望排序的字段,以 JSON 方式,其中 1 表示升序 -1 表示降序。比如:

//heaviest unicorns first
db.unicorns.find().sort({weight: -1})

//by unicorn name then vampire kills:
db.unicorns.find().sort({name: 1,
	vampires: -1})

就像关系型数据库那样,MongoDB 允许对索引进行排序。我们再稍后将详细讨论索引。那,你应该知道的是,MongoDB 对未经索引的字段进行排序是有大小限制的。就是说,如果你试图对一个非常大的没有经过索引的结果集进行排序的话,你会得到个异常。有些人认为这是一个缺点。说实话,我是多希望更多的数据库可以有这种能力去拒绝未经优化的查询。(我不是把每个 MongoDB 的缺点硬说成优点,但是我已经看够了那些缺乏优化的数据库了,我真心希望他们能有一个 strict-mode。)

分页(Paging)

对结果分页可以通过 limitskip 游标方法来实现。比如要获取第二和第三重的独角兽,我们可以这样:

db.unicorns.find()
	.sort({weight: -1})
	.limit(2)
	.skip(1)

通过 limitsort 的配合,可以在对非索引字段进行排序时避免引起问题。

计数(Count)

shell 中可以直接对一个集合执行 count ,像这样:

db.unicorns.count({vampires: {$gt: 50}})

实际上,count 是一个 cursor 的方法,shell 只是简单的提供了一个快捷方式。以不提供快捷方式的方法来执行的时候需要这样(在 shell 中同样可以执行):

db.unicorns.find({vampires: {$gt: 50}})
	.count()

小结

使用 findcursors 非常简单。还讲了一些我们后面章节会用到的或是非常特殊情况才用的命令,不过不管怎样,现在,你应该已经非常熟练使用 mongo shell 以及理解 MongoDB 的基本原则了。

第四章 - 数据建模

让我们换换思维,对 MongoDB 进行一个更抽象的理解。介绍一些新的术语和一些新的语法是非常容易的。而要接受一个以新的范式来建模,是相当不简单的。事实是,当用新技术进行建模的时候,我们中的许多人还在找什么可用的什么不可用。在这里我们只是开始新的开端,而最终你需要去在实战中练习和学习。

与大多数 NoSQL 数据库相比,面向文档型数据库和关系型数据库很相似 - 至少,在建模上是这样的。但是,不同点非常重要。

No Joins

你需要适应的第一个,也是最根本的区别就是 mongoDB 没有链接(join) 。我不知道 MongoDB 中不支持链接的具体原因,但是我知道链接基本上意味着不可扩展。就是说,一旦你把数据水平扩展,无论如何你都要放弃在客户端(应用服务器)使用链接。事实就是,数据 关系, 但 MongoDB 不支持链接。

没别的办法,为了在无连接的世界生存下去,我们只能在我们的应用代码中自己实现链接。我们需要进行二次查询 find ,把相关数据保存到另一个集合中。我们设置数据和在关系型数据中声明一个外键没什么区别。先不管我们那美丽的 unicorns 了,让我们来看看我们的 employees。 首先我们来创建一个雇主 (我提供了一个明确的 _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")})

这没什么神奇的。在最坏的情况下,大多数的时间,为弥补无链接所做的仅仅是增加一个额外的查询(可能是被索引的)。

数组和内嵌文档

MongoDB 不支持链接不意味着它没优势。还记得我们说过 MongoDB 支持数组作为文档中的基本对象吗?这在处理多对一(many-to-one)或者多对多(many-to-many)的关系的时候非常方便。举个简单的例子,如果一个工人有两个管理者,我们只需要像这样存一下数组:

db.employees.insert({_id: ObjectId(
	"4d85c7039ab0fd70a117d733"),
	name: 'Siona',
	manager: [ObjectId(
	"4d85c7039ab0fd70a117d730"),
	ObjectId(
	"4d85c7039ab0fd70a117d732")] })

有趣的是,对于某些文档,manager 可以是单个不同的值,而另外一些可以是数组。而我们原来的 find 查询依旧可用:

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

你会很快就发现,数组中的值比多对多链接表(many-to-many join-tables)要容易处理得多。

数组之外,MongoDB 还支持内嵌文档。来试试看向文档插入一个内嵌文档,像这样:

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

像你猜的那样,内嵌文档可以用 dot-notation 查询:

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

我们只简单的介绍一下内嵌文档适用情况,以及你怎么使用它们。

结合两个概念,我们甚至可以内嵌文档数组:

db.employees.insert({_id: ObjectId(
	"4d85c7039ab0fd70a117d735"),
	name: 'Chani',
	family: [ {relation:'mother',name: 'Chani'},
		{relation:'father',name: 'Paul'},
		{relation:'brother', name: 'Duncan'}]})

反规范化(Denormalization)

另外一个代替链接的方案是对你的数据做反规范化处理(denormalization)。从历史角度看,反规范化处理是为了解决那些对性能敏感的问题,或是需要做快照的数据(比如说审计日志)。但是,随着日益增长的普及的 NoSQL,对链接的支持的日益丧失,反规范化作为规范化建模的一部分变得越来越普遍了。这不意味着,应该对你文档里的每条数据都做冗余处理。而是说,与其对冗余数据心存恐惧,让它影响你的设计决策,不如在建模的时候考虑什么信息应当属于什么文档。

比如说,假设你要写一个论坛应用。传统的方式是通过 posts 中的 userid 列,来关联一个特定的 user 和一篇 post 。这样的建模,你没法在显示 posts 的时候不查询 (链接到) users。一个代替案是简单的在每篇 post 中把 nameuserid 一起保存。你可能要用到内嵌文档,比如 user: {id: ObjectId('Something'), name: 'Leto'}。是的,如果你让用户可以更新他们的名字,那么你得对所有的文档都进行更新(一个多重更新)。

适应这种方法不是对任何人都那么简单的。很多情况下这样做甚至是无意义的。不过不要害怕去尝试。它只是在某些情况下不适用而已,但在某些情况下是最好的解决方法。

你的选择是?

在处理一对多(one-to-many)或者多对多(many-to-many)场景的时候,id 数组通常是一个正确的选择。但通常,新人开发者在面对内嵌文档和 “手工” 引用时,左右为难。

首先,你应该知道的是,一个独立文档的大小当前被限制在 16MB 。知道了文档的大小限制,挺宽裕的,对你考虑怎么用它多少有些影响。在这点上,看起来大多数开发者都愿意手工维护数据引用关系。内嵌文档经常被用到,大多数情况下多是很小的数据块,那些总是被和父节点一起拉取的数据块。现实的例子是为每个用户保存一个 addresses ,看起来像这样:

db.users.insert({name: 'leto',
	email: 'leto@dune.gov',
	addresses: [{street: "229 W. 43rd St",
	            city: "New York", state:"NY",zip:"10036"},
	           {street: "555 University",
	            city: "Palo Alto", state:"CA",zip:"94107"}]})

这并不意味着你要低估内嵌文档的能力,或者仅仅把他们当成小技巧。把你的数据模型直接映射到你的对象,这会使得问题更简单,并且通常也不需要用到链接了。尤其是,当你考虑到 MongoDB 允许你对内嵌文档和数组的字段进行查询和索引时,效果特别明显。

大而全还是小而专的集合?

由于对集合没做任何的强制要求,完全可以在系统中用一个混合了各种文档的集合,但这绝对是个非常烂的主意。大多数 MongoDB 系统都采用了和关系型数据库类似的结构,分成几个集合。换而言之,如果在关系型数据库中是一个表,那么在 MongoDB 中会被作成一个集合 (many-to-many join tables being an important exception as well as tables that exist only to enable one to many relationships with simple entities)。

当你把内嵌文档考虑进来的时候,这个话题会变的更有趣。常见的例子就是博客。你是应该分成一个 posts 集合和一个 comments 集合呢,还是应该每个 post 下面嵌入一个 comments 数组? 先不考虑那个 16MB 文档大小限制 ( 哈姆雷特 全文也没超过 200KB,所以你的博客是有多人气?),许多开发者都喜欢把东西划分开来。这样更简洁更明确,给你更好的性能。MongoDB 的灵活架构允许你把这两种方式结合起来,你可以把评论放在独立的集合中,同时在博客帖子下嵌入一小部分评论 (比如说最新评论) ,以便和帖子一同显示。这遵守以下的规则,就是你到想在一次查询中获取到什么内容。

这没有硬性规定(好吧,除了16MB限制)。尝试用不同的方法解决问题,你会知道什么能用什么不能用。

小结

本章目标是提供一些对你在 MongoDB 中数据建模有帮助的指导, 一个新起点,如果愿意你可以这样认为。在一个面向文档系统中建模,和在面向关系世界中建模,是不一样的,但也没多少不同。你能得到更多的灵活性并且只有一个约束,而对于新系统,一切都很完美。你唯一会做错的就是你不去尝试。

第五章 - MongoDB 适用场景

现在你应该有感觉,何时何地把 MongoDB 融入你现有的系统是最棒的了。这有超多的新的类似的存储技术,肯定会让你在选择的时候晕头转向。

对我来说,最重要的教训,跟 MongoDB 无关,是说你不用再依赖单一的解决案来处理你的数据了。毫无疑问,一个单一的解决案有明显的优势,对于许多项目来说 - 或者说大多数 - 单一解决案是一个明智的选择。意思不是说你 必须 使用不同的技术,而是说你 可以。 只有你自己才知道,引进新技术是否利大于弊。

说了那么多,我希望你到目前为止学到知识让你觉得 MongoDB 是一个通用的解决案。我们已经提到很多次了,面向文档的数据库和关系型数据库有很多方面类似。因此,与其绕开这些相同点,不如我们可以简单的这样认为, MongoDB 是关系型数据库的一个代替案。比如说用 Lucene 作为关系型数据库的全文检索索引的加强,或者用 Redis 作为持久型 key-value 存储,MongoDB 就是用来保存你的数据的。

注意,我没有说用 MongoDB 取代 关系型数据库,而是 代替 案。它能做的有很多工具也能做。有些事情 MongoDB 可以做的更好,另外一些 MongoDB 做得差点。我们来进一步来讨论一下。

无模式(Flexible Schema)

面向文档数据库经常吹嘘的一个好处就是,它不需要一个固定的模式。这使得他们比传统的数据库表要灵活得多。我同意无模式是一个很不错的特性,但不是大多数人说的那样。

人们讲到无模式的时候,好像你就会把一堆乱七八糟的数据统统存起来一样。确实有些领域有些数据用关系型数据库来建模很痛苦,不过我觉得这些都是不常见的特例。无模式是酷,可是大多数情况下你的数据结构还是应当好好设计的。真正需要处理混乱时是不错,比如当你添加一个新功能的时候,不过事实是,大多数情况下,一个空列基本可以解决问题。

对我来说,动态模式的真正好处在于无需很多设置以及可以降低在 OOP 中使用的阻力。这在你使用静态语言的时候尤其明显。我在 C# 和 Ruby 中用过 MongoDB ,差异非常明显。Ruby 的动态特性以及它的流行的 ActiveRecord 实现,已经大幅降低面向对象/关系开发之间差异所带来的阻力。这不是说 MongoDB 和 Ruby 不配,而是是说它们太配了。真的,我觉得许多 Ruby 开发者眼中的的 MongoDB 只是有些许改进而已,而在 C# 或者 Java 开发者眼中,MongoDB 带来的是处理数据交互方式的翻天覆地变化。

假设从驱动开发者角度来看这个问题。你想保存一个对象?把它串行化成 JSON (严格来说是 BSON, 不过差不多) 然后把它传给 MongoDB。不需要做任何属性映射或者类型映射。这种简单性的好处就这样传递给了你,终端开发者。

写操作(Writes)

MongoDB 可以胜任的一个特殊角色是在日志领域。有两点使得 MongoDB 的写操作非常快。首先,你可以选择发送了写操作命令之后立刻返回,而无须等到操作完成。其次,你可以控制数据持久性的写行为。这些设置,加上,可以定义一个成功的提交,需要在多少台服务器上成功拿到你的数据之后才算成功,并且每个写操作都是可设置, 这就给予你很高的权限用以控制写性能和数据持久性。

除了这些性能因素,日志数据还是这样一种数据集,用无模式集合更有优势。最后,MongoDB 还提供了 受限集合(capped collection)。到目前为止,所有我们默认创建的集合都是普通集合。我们可以通过 db.createCollection 命令来创建一个受限集合并标记它的限制:

//limit our capped collection to 1 megabyte
db.createCollection('logs', {capped: true,
	size: 1048576})

当我们的受限集合到达 1MB 上限的时候,旧文档会被自动清除。另外一种限制可以基于文档个数,而不是大小,用 max 标记。受限集合有一些非常有趣的属性。比如说,你可以更新文档但是你不能改变它的大小。插入顺序是被设置好了的,因此不需要另外提供一个索引来获取基于时间的排序,你可以 “tail” 一个受限集合,就和你在 Unix 中通过 tail -f <filename> 来处理文件一样,获取最新的数据,如果存在数据的话,而不需要重新查询它。

如果想让你的数据 “过期” ,基于时间而不是整个集合的大小,你可以用 TTL 索引 ,所谓 TTL 是 “time-to-live” 的缩写。

持久性(Durability)

在 1.8 之前的版本,MongoDB 不支持单服务器持久性。就是说,如果一个服务器崩溃了,可能会导致数据的丢失或者损坏。解决案是在多服务器上运行 MongoDB 副本 (MongoDB 支持复制)。日志(Journaling)是 1.8 版追加的一个非常重要的功能。从 2.0 版的 MongoDB 开始,日志是默认启动的,该功能允许快速恢复服务器,比如遭遇到了服务器崩溃或者停电的情况。

持久性在这里只是提一下,因为围绕 MongoDB 过去缺乏单服务器持久的问题,人们取得了众多成果。这个话题在以后的 Google 检索中也许还会继续出现。但是关于缺少日志功能这一缺点的信息,都是过时了的。

真正的全文检索是在最近加入到 MongoDB 中的。它支持十五国语言,支持词形变化(stemming)和干扰字(stop words)。除了原生的 MongoDB 的全文检索支持,如果你需要一个更强大更全面的全文检索引擎的话,你需要另找方案。

事务(Transactions)

MongoDB 不支持事务。这有两个代替案,一个很好用但有限制,另外一个比较麻烦但灵活。

第一个方案,就是各种原子更新操作。只要能解决你的问题,都挺不错。我们已经看过几个简单的了,比如 $inc$set。还有像 findAndModify 命令,可以更新或删除文档之后,自动返回修改过的文档。

第二个方案,当原子操作不能满足的时候,回到两段提交上来。对于事务,两段提交就好像给链接手工解引用。这是一个和存储无关的解决方案。两段提交实际上在关系型数据库世界中非常常用,用来实现多数据库之间的事务。 MongoDB 网站 有个例子 演示了最典型的场合 (资金转账)。通常的想法是,把事务的状态保存到实际的原子更新的文档中,然后手工的进行 init-pending-commit/rollback 处理。

MongoDB 支持内嵌文档以及它灵活的 schema 设计,让两步提交没那么痛苦,但是它仍然不是一个好处理,特别是当你刚开始接触它的时候。

数据处理(Data Processing)

在2.2 版本之前的 MongoDB 依赖 MapReduce 来解决大部分数据处理工作。在 2.2 版本,它追加了一个强力的功能,叫做 aggregation framework or pipeline,因此你只要对那些尚未支持管道的,需要使用复杂方法的,不常见的聚合使用 MapReduce。下一章我们将看看聚合管道和 MapReduce 的细节。现在,你可以把他们想象成功能强大的,用不同方法实现的 group by (打个比方)。对于非常大的数据的处理,你可能要用到其他的工具,比如 Hadoop。值得庆幸的是,这两个系统是相辅相成的,这里有个 MongoDB connector for Hadoop

当然,关系型数据库也不擅长并行数据处理。MongoDB 有计划在未来的版本中,改善增加处理大数据集的能力。

地理空间查询(Geospatial)

一个很强大的功能就是 MongoDB 支持 geospatial 索引。这允许你保存 geoJSON 或者 x 和 y 坐标到文档,并查询文档,用如 $near 来获取坐标集,或者 $within 来获取一个矩形或圆中的点。这个特性最好通过一些可视化例子来演示,所以如果你想学更多的话,可以试试看 5 minute geospatial interactive tutorial

工具和成熟度

你应该已经知道这个问题的答案了,MongoDB 确实比大多数的关系型数据要年轻很多。这个问题确实是你应当考虑的,但是到底有多重要,这取决于你要做什么,怎么做。不管怎么说,一个好的评估,不可能忽略 MongoDB 年轻这一事实,而可用的工具也不是很好 (虽然成熟的关系型数据库工具有些也非常渣!)。举个例子,它缺乏对十进制浮点数的支持,在处理货币的系统来说,明显是一个问题 (尽管也不是致命的) 。

积极的一方面,它为大多数语言提供了驱动,协议现代而简约,开发速度相当快。MongoDB 被众多公司用到了生产环境中,虽然有所担心,但经过验证后,担心很快就变成了过去。

小结

本章要说的是,MongoDB,大多数情况下,可以取代关系型数据库。它更简单更直接;更快速并且通常对应用开发者的约束更少。不过缺乏事务支持也许值得慎重考虑。当人们说起 MongoDB 在新的数据库阵营中到底处在什么位置? 时,答案很简单: 中庸(2)。

第六章 - 数据聚合

聚合管道(Aggregation Pipeline)

聚合管道提供了一种方法用于转换整合文档到集合。你可以通过管道来传递文档,就像 Unix 的 “pipe” 一样,将一个命令的输出传递到另第二个,第三个,等等。

最简单的聚合,应该是你在 SQL 中早已熟悉的 group by 操作。我们已经看过 count() 方法,那么假设我们怎么才能知道有多少匹公独角兽,有多少匹母独角兽呢?

db.unicorns.aggregate([{$group:{_id:'$gender',
	total: {$sum:1}}}])

在 shell 中,我们有 aggregate 辅助类,用来执行数组的管道操作。对于简单的对某物进行分组计数,我们只需要简单的调用 $group。这和 SQL 中的 GROUP BY 完全一致,我们用来创建一个新的文档,以 _id 字段表示我们以什么来分组(在这里是以 gender) ,另外的字段通常被分配为聚合的结果,在这里,我们对匹配某一性别的各文档使用了 $sum 1 。你应该注意到了 _id 字段被分配为 '$gender' 而不是 'gender' - 字段前面的 '$' 表示,该字段将会被输入的文档中的有同样名字的值所代替,一个占位符。

我们还可以用其他什么管道操作呢?在 $group 之前(之后也很常用)的一个是 $match - 这和 find 方法完全一样,允许我们获取文档中某个匹配的子集,或者在我们的结果中对文档进行筛选。

db.unicorns.aggregate([{$match: {weight:{$lt:600}}},
	{$group: {_id:'$gender',  total:{$sum:1},
	  avgVamp:{$avg:'$vampires'}}},
	{$sort:{avgVamp:-1}} ])

这里我们介绍另外一个管道操作 $sort ,作用和你想的完全一致,还有和它一起用的 $skip$limit。以及用 $group 操作 $avg

MongoDB 数组非常强大,并且他们不会阻止我们往保存中的数组中写入内容。我们需要可以 “flatten” 他们以便对所有的东西进行计数:

db.unicorns.aggregate([{$unwind:'$loves'},
 	{$group: {_id:'$loves',  total:{$sum:1},
 	unicorns:{$addToSet:'$name'}}},
  	{$sort:{total:-1}},
  	{$limit:1} ])

这里我们可以找出独角兽最喜欢吃的食物,以及拿到喜欢这种食物的独角兽的名单。 $sort$limit 的组合能让你拿到 “top N” 这种查询的结果。

还有另外一个强大的管道操作叫做 $project (类似于 find),不但允许你拿到指定字段,还可以根据现存字段进行创建或计算一个新字段。比如,可以用数学操作,在做平均运算之前,对几个字段进行加法运算,或者你可以用字符串操作创建一个新的字段,用于拼接现有字段。

这只是用聚合所能做到的众多功能中的皮毛, 2.6 的聚合拥有了更强大的力量,比如聚合命令可以返回结果集的游标(我们已经在第一章学过了) 或者可以将结果写到另外一个新集合中,通过 $out 管道操作。你可以从 MongoDB 手册 得到关于管道操作和表达式操作更多的例子。

MapReduce

MapReduce 分两步进行数据处理。首先是 map,然后 reduce。在 map 步骤中,转换输入文档和输出一个 key=>value 对(key 和/或 value 可以很复杂)。然后, key/value 对以 key 进行分组,有同样的 key 的 value 会被收入一个数组中。在 reduce 步骤中,获取 key 和该 key 的 value 的数组,生成最终结果。map 和 reduce 方法用 JavaScript 来编写。

在 MongoDB 中我们对一个集合使用 mapReduce 命令。 mapReduce 执行 map 方法, reduce 方法和 output 指令。在我们的 shell 中,我们可以创建输入一个 JavaScript 方法。许多库中,支持字符串方法 (有点丑)。第三个参数设置一个附加参数,比如说我们可以过滤,排序和限制那些我们想要分析的文档。我们也可以提供一个 finalize 方法来处理 reduce 步骤之后的结果。

在你的大多数聚合中,也许无需用到 MapReduce , 但如果需要,你可以读到更多关于它的内容,从 我的 blogMongoDB 手册

小结

在这章中我们介绍了 MongoDB 的 聚合功能(aggregation capabilities)。 一旦你理解了聚合管道(Aggregation Pipeline)的构造,它还是相对容易编写的,并且它是一个聚合数据的强有力工具。 MapReduce 更难理解一点,不过它强力无边,就像你用 JavaScript 写的代码一样。

第七章 - 性能和工具

在这章中,我们来讲几个关于性能的话题,以及在 MongoDB 开发中用到的一些工具。我们不会深入其中的一个话题,不过我们会指出每个话题中最重要的方面。

索引(Index)

首先我们要介绍一个特殊的集合 system.indexes ,它保存了我们数据库中所有的索引信息。索引的作用在 MongoDB 中和关系型数据库基本一致: 帮助改善查询和排序的性能。创建索引用 ensureIndex :

// where "name" is the field name
db.unicorns.ensureIndex({name: 1});

删除索引用 dropIndex:

db.unicorns.dropIndex({name: 1});

可以创建唯一索引,这需要把第二个参数 unique 设置为 true:

db.unicorns.ensureIndex({name: 1},
	{unique: true});

索引可以内嵌到字段中 (再说一次,用点号) 和任何数组字段。我们可以这样创建复合索引:

db.unicorns.ensureIndex({name: 1,
	vampires: -1});

索引的顺序 (1 升序, -1 降序) 对单键索引不起任何影响,但它会在使用复合索引的时候有所不同,比如你用不止一个索引来进行排序的时候。

阅读 indexes page 获取更多关于索引的信息。

Explain

需要检查你的查询是否用到了索引,你可以通过 explain 方法:

db.unicorns.find().explain()

输出告诉我们,我们用的是 BasicCursor (意思是没索引), 12 个对象被扫描,用了多少时间,什么索引,如果有索引,还会有其他有用信息。

如果我们改变查询索引语句,查询一个有索引的字段,我们可以看到 BtreeCursor 作为索引被用到填充请求中去:

db.unicorns.find({name: 'Pilot'}).explain()

复制(Replication)

MongoDB 的复制在某些方面和关系型数据库的复制类似。所有的生产部署应该都是副本集,理想情况下,三个或者多个服务器都保持相同的数据。写操作被发送到单个服务器,也即主服务器,然后从它异步复制到所有的从服务器上。你可以控制是否允许从服务器上进行读操作,这可以让一些特定的查询从主服务器中分离出来,当然,存在读取到旧数据的风险。如果主服务器异常关闭,从服务中的一个将会自动晋升为新的主服务器继续工作。另外,MongoDB 的复制不在本书的讨论范围之内。

分片(Sharding)

MongoDB 支持自动分片。分片是实现数据扩展的一种方法,依靠在跨服务器或者集群上进行数据分区来实现。一个最简单的实现是把所有的用户数据,按照名字首字母 A-M 放在服务器 1 ,然后剩下的放在服务器 2。谢天谢地,MongoDB 的拆分能力远比这种分法要强。分片不在本书的讨论范围之内,不过你应当有分片的概念,并且,当你的需求增长超过了使用单一副本集的时候,你应该考虑它。

尽管复制有时候可以提高性能(通过将长时间查询隔离到从服务器,或者降低某些类型的查询的延迟),但它的主要目的是维护高可用性。分片是扩展 MongoDB 集群的主要方法。把复制和分片结合起来实现可扩展和高可用性的通用方法。

状态(Stats)

你可以通过 db.stats() 查询数据库的状态。基本上都是关于数据库大小的信息。你还可以查询集合的状态,比如说 unicorns 集合,可以输入 db.unicorns.stats()。基本上都是关于集合大小的信息,以及集合的索引信息。

分析器(Profiler)

你可以这样执行 MongoDB profiler :

db.setProfilingLevel(2);

启动之后,我们可以执行一个命令:

db.unicorns.find({weight: {$gt: 600}});

然后检查 profiler:

db.system.profile.find()

输出会告诉我们:什么时候执行了什么,有多少文档被扫描,有多少数据被返回。

你要停止 profiler 只需要再调用一次 setProfilingLevel ,不过这次参数是 0。指定 1 作为第一个参数,将会统计那些超过 100 milliseconds 的任务. 100 milliseconds 是默认的阈值,你可以在第二个参数中,指定不同的阈值时间,以 milliseconds 为单位:

//profile anything that takes
//more than 1 second
db.setProfilingLevel(1, 1000);

备份和还原

在 MongoDB 的 bin 目录下有一个可执行文件 mongodump 。简单执行 mongodump 会链接到 localhost 并备份你所有的数据库到 dump 子目录。你可以用 mongodump --help 查看更多执行参数。常用的参数有 --db DBNAME 备份指定数据库和 --collection COLLECTIONNAME 备份指定集合。你可以用 mongorestore 可执行文件,同样在 bin 目录下,还原之前的备份。同样, --db--collection 可以指定还原的数据库和/或集合。 mongodumpmongorestore 使用 BSON,这是 MongoDB 的原生格式。

比如,来备份我们的 learn 数据库导 backup 文件夹,我们需要执行(在控制台或者终端中执行该命令,而不是在 mongo shell 中):

mongodump --db learn --out backup

如果只还原 unicorns 集合,我们可以这样做:

mongorestore --db learn --collection unicorns \
	backup/learn/unicorns.bson

值得一提的是, mongoexportmongoimport 是另外两个可执行文件,用于导出和从 JSON/CSV 格式文件导入数据。比如说,我们可以像这样导出一个 JSON:

mongoexport --db learn --collection unicorns

CSV 格式是这样:

mongoexport --db learn \
	--collection unicorns \
	--csv --fields name,weight,vampires

注意 mongoexportmongoimport 不一定能正确代表数据。真实的备份中,只能使用 mongodumpmongorestore 。 你可以从 MongoDB 手册中读到更多的 备份须知

小结

在这章中我们介绍了 MongoDB 的各种命令,工具和性能细节。我们没有涉及所有的东西,不过我们已经把常用的都看了一遍。MongoDB 的索引和关系型数据库中的索引非常类似,其他一些工具也一样。不过,在 MongoDB 中,这些更易于使用。

总结

你现在应该有足够的能力开始在真实项目中使用 MongoDB 了。虽然 MongoDB 远不止我们学到的这些内容,但是你要作的下一步是,把学到的知识融会贯通,熟悉我们需要用到的功能。MongoDB website 有许多有用的信息。官网的 MongoDB user group 是个问问题的好地方。

NoSQL 不光是为需求而生,它同时还是不断尝试创新的成果。不得不承认,我们的领域是不断前行的。如果我们不尝试,一旦失败,我们就绝不会取得成功。就是这样的,我认为,这是让你在职业生涯一路走好的方法。


1 :中文版本 the-little-redis-book

2 :参考 justinyhuang 的翻译。MongoDB 属于 NoSQL,但是和传统关系型数据库类似,且较为通用。

多表关联

内联接 外联接 左外联接 右外连接 全外连接 自然联接

笛卡尔

笛卡尔(Descartes)乘积又叫直积。假设集合 A={a,b},集合 B={0,1,2},则两个集合的笛卡尔积为

1
2
3
4
5
6
7
8
{
(a,0),
(a,1),
(a,2),
(b,0),
(b,1),
(b,2)
}

可以扩展到多个集合的情况。

内连接

1
select * from a,b where a.x = b.x; //内连接

1
select * from a inner join b on a.x=b.x; //内连接

效果是一样的,都是计算笛卡尔积,对上面笛卡尔积的每一条记录看它是否满足限制条件,如果满足,则它在结果集中。

外连接

当外连接,不加任何条件时,也会计算笛卡尔积。如:

1
select * from a left join b on 1=1;

交叉连接(CROSS JOIN)

没有 WHERE 子句,它返回连接表中所有数据行的笛卡尔积
先返回 左表所有行,左表行在与右表行一一组合,等于两个表相乘.

1
select * from a cross join b where a.x=b.x;

等同于

1
select * from a inner join b on a.x=b.x;

MySQL 中不存在交叉连接

避免笛卡尔积的方法

由于笛卡尔积的结果集是各个查询表规模之积,往往是数量级的差别。

转换为子查询

假设存在如下三个表:

查询方式一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SELECT DISTINCT
u.user_id AS recommendUserId
FROM
user_info u
WHERE
1 = 1
AND u.user_id IN (
SELECT
a.entity_id
FROM
address_info a
WHERE
a.city_code = 10010
)

查询方式二:

1
2
3
4
5
6
7
8
SELECT DISTINCT
u.user_id AS recommendUserId
FROM
user_info u,
address_info a
WHERE
1 = 1
AND a.city_code = '10010'

对比发现: ‘查询方式一’使用了子查询,查询是分两步进行的,第一步先从address_info选出一个结果,然后在user_info中查询,规模为user_info的大小。
查询方式二查询规模是笛卡尔积,即user_infoaddress_info规模之积。

内连接

内连 接只保留交叉积中满足连接条件的那些行。如果某行在一个表中存在,但在另一个表中不存在,则结果表中不包括该信息。

外连接

左外连 接包括内连 接和左表中未包括在内连 接中的那些行。

右外连 接包括内连 接和右表中未包括在内连 接中的那些行。

全外连 接包括内连 接以及左表和右表中未包括在内连 接中的行。
内连 接一般是检索两个表里连接字段都存在的数据。
左连接的意思是,查询左(语句前面)表里的所有内容,无论右边表里有没有。右边表里没有的内容用 NULL 代替。
右连接和左连接相反。

左外连接

右外连接

全外连接

交叉连接


[参考文献]:

  1. SQL 中—-SELECT 语句中内连接,左连接,右连接,自连接和全连接

使用

配置

集群

原理

Quartz是一个大名鼎鼎的Java版开源定时调度器,功能强悍,使用方便。

核心概念

Quartz的原理不是很复杂,只要搞明白几个概念,然后知道如何去启动和关闭一个调度程序即可。

  1. Job

    表示一个工作,要执行的具体内容。此接口中只有一个方法
    void execute(JobExecutionContext context)

  2. JobDetail

    JobDetail表示一个具体的可执行的调度程序,Job是这个可执行程调度程序所要执行的内容,另外JobDetail还包含了这个任务调度的方案和策略。

  3. Trigger代表一个调度参数的配置,什么时候去调。

  4. Scheduler代表一个调度容器,一个调度容器中可以注册多个JobDetail和Trigger。当Trigger与JobDetail组合,就可以被Scheduler容器调度了。

应用

1
2
3
4
5
6
7
8
9
10
11
public class SimpleQuartzJob implements Job {

public SimpleQuartzJob() {
}

@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
System.out.println("In SimpleQuartzJob - executing its JOB at "
+ new Date() + " by " + context.getTrigger().getDescription());
}
}

普通

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

import java.util.Date;

/**
* quartz定时器测试
*/
public class MyJob implements Job {
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
System.out.println(new Date() + ": doing something...");
}
}

调用的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//1、创建JobDetial对象
JobDetail jobDetail = new JobDetail();
//设置工作项
jobDetail.setJobClass(MyJob.class);
jobDetail.setName("MyJob_1");
jobDetail.setGroup("JobGroup_1");

//2、创建Trigger对象
SimpleTrigger strigger = new SimpleTrigger();
strigger.setName("Trigger_1");
strigger.setGroup("Trigger_Group_1");
strigger.setStartTime(new Date());
//设置重复停止时间,并销毁该Trigger对象
java.util.Calendar c = java.util.Calendar.getInstance();
c.setTimeInMillis(System.currentTimeMillis() + 1000 * 1L);
strigger.setEndTime(c.getTime());
strigger.setFireInstanceId("Trigger_1_id_001");
//设置重复间隔时间
strigger.setRepeatInterval(1000 * 1L);
//设置重复执行次数
strigger.setRepeatCount(3);

//3、创建Scheduler对象,并配置JobDetail和Trigger对象
SchedulerFactory sf = new StdSchedulerFactory();
Scheduler scheduler = null;
try {
scheduler = sf.getScheduler();
scheduler.scheduleJob(jobDetail, strigger);
//4、并执行启动、关闭等操作
scheduler.start();

} catch (SchedulerException e) {
e.printStackTrace();
}
// try {
// //关闭调度器
// scheduler.shutdown(true);
// } catch (SchedulerException e) {
// e.printStackTrace();
// }

另外一种创建的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
try {
// 创建调度器
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
// Create a JobDetail for the Job
JobDetail jobDetail = newJob(SimpleQuartzJob.class).withIdentity("myJob", "group1").build();

// Date runTime = evenMinuteDate(new Date());

Trigger trigger = newTrigger().withIdentity("myTrigger", "group1").startNow()
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(5).withRepeatCount(10)).build();

// Trigger trigger = newTrigger().withIdentity("myTrigger","group1").startAt(RunTime).build();

scheduler.scheduleJob(jobDetail, trigger);

scheduler.start();

// Thread.sleep(60000);
System.out.println("goto shutdown");
scheduler.shutdown(true);
} catch (SchedulerException e) {
e.printStackTrace();
// } catch (InterruptedException e) {
e.printStackTrace();
}

Crontab

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 创建调度器
try {
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
JobDetail jobDetail = newJob(SimpleQuartzJob.class).withIdentity("myJob2", "group1").build();

Trigger trigger = newTrigger().withIdentity("myTrigger3", "group1").withSchedule(CronScheduleBuilder.cronSchedule("0/1 * * * * ?")).build();

scheduler.scheduleJob(jobDetail, trigger);
if (scheduler.isStarted()) {
System.out.println("has started!");
}
scheduler.start();
Thread.sleep(60000);
scheduler.shutdown();

} catch (SchedulerException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}

配置文件

与Spring结合

原理

 

通过研读Quartz的源代码,和本实例,终于悟出了Quartz的工作原理。

 1. scheduler是一个计划调度器容器(总部),容器里面可以盛放众多的JobDetail和trigger,当容器启动后,里面的每个JobDetail都会根据trigger按部就班自动去执行。 
 2. JobDetail是一个可执行的工作,它本身可能是有状态的。 
 3. Trigger代表一个调度参数的配置,什么时候去调。 
 4. 当JobDetail和Trigger在scheduler容器上注册后,形成了装配好的作业(JobDetail和Trigger所组成的一对儿),就可以伴随容器启动而调度执行了。 
 5. scheduler是个容器,容器中有一个线程池,用来并行调度执行每个作业,这样可以提高容器效率。
 6. 将上述的结构用一个图来表示,如下:


[参考文献]:

  1. 深入解读Quartz的原理

<< Java高级软件工程师知识结构

  1. 掌握InputStream、OutputStream、Reader、Writer的继承体系.
  2. 掌握字节流(FileInputStream、DataInputStream、BufferedInputStream、FileOutputSteam、DataOutputStream、BufferedOutputStream)和 字符流(BufferedReader、InputStreamReader、FileReader、BufferedWriter、OutputStreamWriter、PrintWriter、FileWriter), 并熟练运用.
  3. 掌握NIO实现原理及使用方法.

Java IO包括:

序列化

所谓序列化是将内存中Java对象转化为二进制字节数据, 反序列化就是从二进制字节数据转换为内存中的对象.

  • 对象保存到文件: Java序列化将对象转换为二进制字节数据, 可以用于将内存中的对象保存到文件中.
  • 复制对象:由于Java中的对象变量仅仅是栈中的一个指针, 而对象保存在堆内存中. 当clone复制对象时, 只是将对象中的对象变量复制, 而没有复制对象本身. 这被称为**浅复制**;相比之下, 使用序列化的方法, 可以将对象的值转换为二进制字节数组, 再反序列化便可以复制对象的所有内容, 这就是深复制.
  • 自定义序列化

实现的样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Person implements Serializable {
private static final long serialVersionUID = 123l;
private String name;
private int age;
private long birthday;

@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
", birthday=" + birthday +
'}';
}

public Person(String name, int age, long birthday) {
this.name = name;
this.age = age;
this.birthday = birthday;
}
....// getter setter
}

实现要点

Serializable接口

Java是通过实现Serializable接口, 实现的序列化, Serializable接口里面没有任何的方法, 只是个标示接口.

SerialVersionUID

1
private static final long serialVersionUID = ...;

serialVersionUID变量用于标记该类的版本UID.

Java使用一种hash值来快速的区分序列化流中的对象与要转换为的对象是否相同. 该 hash值 是根据给定源文件中几乎所有东西 — 方法名称、字段名称、字段类型、访问修改方法等 — 计算出来的, 序列化将该 hash 值与序列化流中的 hash 值相比较. 这个hash值就是serialVersionUIDserialVersionUID变量可以手动指定, 如果不指定, Java将使用 JDKserialver 命令计算出.

ObjectOutputStream

实际的序列化和反序列化工作是通过ObjectOuputStream和ObjectInputStream来完成的。ObjectOutputStream的writeObject方法可以把一个Java对象写入到流中,ObjectInputStream的readObject方法可以从流中读取一个Java对象。在写入和读取的时候,虽然用的参数或返回值是单个对象,但实际上操纵的是一个对象图,包括该对象所引用的其它对象,以及这些对象所引用的另外的对象。Java会自动帮你遍历对象图并逐个序列化。除了对象之外,Java中的基本类型和数组也是可以通过 ObjectOutputStream和ObjectInputStream来序列化的。
下面的深复制是将内存中的二进制数组作为媒介来复制对象的. 关于ObjectOutputStream详情请看

深复制与浅复制

基础变量和对象的引用变量都是保存在JVM栈内存(stack)之中的, 而对象保存在JVM堆(Heap)内存中.^JVM结构

实现Clonable接口, 并调用对象上的clone方法, 只会将JVM栈内存(stack)复制到新对象之中. 对象的引用变量所引用的对象实体不能使用. 这就是浅复制.

为了可以复制对象的所有的内容, 可以采用将目标序列化到内存中, 转换为二进制数据, 然后再反序列化回对象. 这就是”深复制“.

上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ShallowCopy implements Cloneable, Serializable {
/**
* 浅复制
*/
@Override
protected ShallowCopy clone() throws CloneNotSupportedException {
return (ShallowCopy) super.clone();
}

/**
* 深复制
*/
public ShallowCopy deepClone() throws IOException, ClassNotFoundException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(this);

ByteArrayInputStream bis = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
ShallowCopy shallowCopy = (ShallowCopy) ois.readObject();
return shallowCopy;
}
}

序列化多个目标

如上文所述, 序列化是将对象转换为字节数组, 需要ObjectOutputStreamObjectInputStream实现. 因此, 可以向ObjectOutputStream输出多个对象, 也可以从ObjectInputStream中输入多个对象.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public static void writeObjects(List os, String fileName) {
FileOutputStream outputStream = null;
try {
outputStream = new FileOutputStream(fileName);
ObjectOutputStream stream = new ObjectOutputStream(outputStream);
for (Object o : os) {
stream.writeObject(o);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

public static <T> List<T> readObjects(String fileName, Class<T> clasz) {
FileInputStream inputStream = null;
T result = null;
List<T> list = new ArrayList<T>();
try {
inputStream = new FileInputStream(fileName);
ObjectInputStream in = new ObjectInputStream(inputStream);
Object object = null;
while ((object = in.readObject()) != null) {
result = (T) object;
list.add(result);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return list;
}

序列化允许重构

序列化在保存对象的同时, 也就保存了数据. 序列化也成为保存数据的方式之一. 与数据库、XML或JSON等方式不同, 序列化依赖于对象体. 当变更数据项时, 需要修改类. 序列化是允许重构的.

序列化允许一定数量的类变种, 甚至重构之后也是如此, ObjectInputStream 仍可以很好地将其读出来.
Java Object Serialization 规范可以自动管理的关键任务是:

  • 将新字段添加到类中
  • 将字段从 static 改为非 static
  • 将字段从 transient 改为非 transient

取决于所需的向后兼容程度, 转换字段形式(从非 static 转换为 static 或从非 transient 转换为 transient)或者删除字段需要额外的消息传递.

假设要在上面的Person类增加gender

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Person implements Serializable {
private static final long serialVersionUID = 123l;
private String name;
private int age;
private long birthday;
private int gender;

@Override
public String toString() {
return "PersonV1{" +
"name='" + name + '\'' +
", age=" + age +
", birthday=" + birthday +
", gender=" + gender +
'}';
}

public Person(String name, int age, long birthday, int gender) {
this.name = name;
this.age = age;
this.birthday = birthday;
this.gender = gender;
}
// setter getter .....
}

重构, 需要满足两个条件:

  • serialVersionUID必须相同
  • 类名必须与原类相同

为了使 Java 运行时相信两种类型实际上是一样的, 第二版和随后版本的 Person 必须与第一版有相同的序列化版本 hash(存储为 private static final serialVersionUID 字段). 因此, 我们需要 serialVersionUID 字段, 它是通过对原始(或 V1)版本的 Person 类运行 JDKserialver 命令计算出的.

一旦有了 Person 的 serialVersionUID, 不仅可以从原始对象 Person 的序列化数据创建 PersonV2 对象(当出现新字段时, 新字段被设为缺省值, 最常见的是“null”), 还可以反过来做:即从 PersonV2 的数据通过反序列化得到 Person, 这毫不奇怪.

安全性

Java对象序列化之后的内容格式是公开的。所以可以很容易的从中提取出各种信息。从实现的角度来说,可以从不同的层次来加强序列化的安全性。
对序列化之后的流进行加密。这可以通过CipherOutputStream来实现。实现自己的writeObject和readObject方法,在调用defaultWriteObject之前,先对要序列化的域的值进行加密处理。使用一个SignedObject或SealedObject来封装当前对象,用SignedObject或SealedObject进行序列化。在从流中进行反序列化的时候,可以通过ObjectInputStream的registerValidation方法添加ObjectInputValidation接口的实现,用来验证反序列化之后得到的对象是否合法。

自定义序列化,提高安全性

为了避免保存的数据被恶意利用, 可以通过模糊化需要保密的字段. 此处, hookPerson类中的age字段, 在上面的Person类中添加两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 自定义将对象写入输出流的方法<br/>
* 此处, 判断性别为女, 将年龄左移2个单位
* <code>stream.defaultWriteObject();</code>接下来将所有的对象写入.
*/
private void writeObject(java.io.ObjectOutputStream stream) throws java.io.IOException {
if (gender == 1) {
age = age << 2;
}
stream.defaultWriteObject();
}
/**
* 自定义从输入流读入对象的方法:
* 与写对应, 在读入对象后, 女士的年龄右移2个单位
*/
private void readObject(java.io.ObjectInputStream stream)
throws java.io.IOException, ClassNotFoundException {
stream.defaultReadObject();
if (gender == 1) {
age = age >> 2;
}
}

当调用ObjectInputStream读入对象时, 首先判断是否Entity实现了readObject方法, 如果实现了将调用该方法.

签名与密封

//TODO

如果需要对整个对象进行加密和签名, 最简单的是将它放在一个 javax.crypto.SealedObject 和/或 java.security.SignedObject 包装器中. 两者都是可序列化的, 所以将对象包装在 SealedObject 中可以围绕原对象创建一种 “包装盒”. 必须有对称密钥才能解密, 而且密钥必须单独管理. 同样, 也可以将 SignedObject 用于数据验证, 并且对称密钥也必须单独管理.
结合使用这两种对象, 便可以轻松地对序列化数据进行密封和签名, 而不必强调关于数字签名验证或加密的细节. 很简洁, 是吧?

将代理放在流中

很多情况下, 类中包含一个核心数据元素, 通过它可以派生或找到类中的其他字段. 在此情况下, 没有必要序列化整个对象. 可以将字段标记为transient, 但是每当有方法访问一个字段时, 类仍然必须显式地产生代码来检查它是否被初始化.

如果首要问题是序列化, 那么最好指定一个 flyweight 或代理放在流中. 为原始 Person 提供一个 writeReplace 方法, 可以序列化不同类型的对象来代替它. 类似地, 如果反序列化期间发现一个 readResolve 方法, 那么将调用该方法, 将替代对象提供给调用者.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class PersonProxy implements java.io.Serializable{
public PersonProxy(Person orig){
data = orig.getFirstName() + "," + orig.getLastName() + "," + orig.getAge();
if (orig.getSpouse() != null)
{
Person spouse = orig.getSpouse();
data = data + "," + spouse.getFirstName() + "," + spouse.getLastName() + ","
+ spouse.getAge();
}
}

public String data;
private Object readResolve() throws java.io.ObjectStreamException{
String[] pieces = data.split(",");
Person result = new Person(pieces[0], pieces[1], Integer.parseInt(pieces[2]));
if (pieces.length > 3)
{
result.setSpouse(new Person(pieces[3], pieces[4], Integer.parseInt
(pieces[5])));
result.getSpouse().setSpouse(result);
}
return result;
}
}

public class Person implements java.io.Serializable{
public Person(String fn, String ln, int a){
this.firstName = fn; this.lastName = ln; this.age = a;
}

public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public Person getSpouse() { return spouse; }

private Object writeReplace()
throws java.io.ObjectStreamException{
return new PersonProxy(this);
}

public void setFirstName(String value) { firstName = value; }
public void setLastName(String value) { lastName = value; }
public void setAge(int value) { age = value; }
public void setSpouse(Person value) { spouse = value; }

public String toString(){
return "[Person: firstName=" + firstName +
" lastName=" + lastName +
" age=" + age +
" spouse=" + spouse.getFirstName() +
"]";
}

private String firstName;
private String lastName;
private int age;
private Person spouse;
}

注意, PersonProxy 必须跟踪 Person 的所有数据. 这通常意味着代理需要是 Person 的一个内部类, 以便能访问 private 字段. 有时候, 代理还需要追踪其他对象引用并手动序列化它们, 例如 Person 的 spouse.

这种技巧是少数几种不需要读/写平衡的技巧之一. 例如, 一个类被重构成另一种类型后的版本可以提供一个 readResolve 方法, 以便静默地将被序列化的对象转换成新类型. 类似地, 它可以采用 writeReplace 方法将旧类序列化成新版本.

信任但要验证

认为序列化流中的数据总是与最初写到流中的数据一致, 这没有问题. 但是, 正如一位美国前总统所说的, “信任, 但要验证”.

对于序列化的对象, 这意味着验证字段, 以确保在反序列化之后它们仍具有正确的值, “以防万一”. 为此, 可以实现 ObjectInputValidation接口, 并覆盖 validateObject() 方法. 如果调用该方法时发现某处有错误, 则抛出一个 InvalidObjectException.

声明某个字段不序列化

在默认的序列化实现中,Java对象中的非静态和非瞬时域都会被包括进来,而与域的可见性声明没有关系。这可能会导致某些不应该出现的域被包含在序列化之后的字节数组中,比如密码等隐私信息。由于Java对象序列化之后的格式是固定的,其它人可以很容易的从中分析出其中的各种信息。对于这种情况,一种解决办法是把域声明为瞬时的,即使用transient关键词。另外一种做法是添加一个serialPersistentFields?

域来声明序列化时要包含的域。从这里可以看到在Java序列化机制中的这种仅在书面层次上定义的契约。声明序列化的域必须使用固定的名称和类型。在后面还可以看到其它类似这样的契约。虽然Serializable只是一个标记接口,但它其实是包含有不少隐含的要求。下面的代码给出了
serialPersistentFields的声明示例,即只有firstName这个域是要被序列化的。

1
2
3
private static final ObjectStreamField[] serialPersistentFields = {
new ObjectStreamField("firstName", String.class)
};

定制化序列化Externalizable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* 序列化和反序列化的操作
* */
public class ExternalizableDemo{
public static void main(String[] args) throws Exception{
ser(); // 序列化
dser(); // 反序列话
}

public static void ser() throws Exception{
File file = new File("d:" + File.separator + "hello.txt");
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(
file));
out.writeObject(new Person("rollen", 20));
out.close();
}

public static void dser() throws Exception{
File file = new File("d:" + File.separator + "hello.txt");
ObjectInputStream input = new ObjectInputStream(new FileInputStream(
file));
Object obj = input.readObject();
input.close();
System.out.println(obj);
}
}

class Person implements Externalizable{
public Person(){

}

public Person(String name, int age){
this.name = name;
this.age = age;
}

@Override
public String toString(){
return "姓名:" + name + " 年龄:" + age;
}

// 复写这个方法, 根据需要可以保存的属性或者具体内容, 在序列化的时候使用
@Override
public void writeExternal(ObjectOutput out) throws IOException{
out.writeObject(this.name);
out.writeInt(age);
}

// 复写这个方法, 根据需要读取内容 反序列话的时候需要
@Override
public void readExternal(ObjectInput in) throws IOException,
ClassNotFoundException{
this.name = (String) in.readObject();
this.age = in.readInt();
}

private String name;
private int age;
}

疑问:

  1. 转换成的二进制数据是什么样子?
  2. Enum类型的变量是否能正确的序列化和反序列化?

其他序列化框架

kryo


[参考文献]:

  1. Think in Java
  2. 关于 Java 对象序列化您不知道的 5 件事
  3. Java深度历险(十)——Java对象序列化与RMI

语法

常见的小错误

装箱类型为null时与基本类型比较

1
2
3
4
Integer i = null;
if (i == 1) {
System.out.println("success!");
}

当装箱类型遇到比较运算符的时候,会首先调用相应的方法将装箱类型转换为基本类型,如果装箱类型变量为null,
运行将会抛出java.lang.NullPointerException异常;而对于非装箱类型如String则没有这种情况。

1
2
3
4
String string = null;
if (string == "") {
System.out.println("success!");
}

上文 主要介绍了JDBC的使用和目前框架中的优化等,本上主要介绍一个非常实用的例子: 根据数据表生成Entity

Entity

Entity或者DTO是Java中与数据表相对应的实体,将对象的属性封装有利于数据操作(如从数据库中读取某个实体,对这个实体操作,修改,新增等),根据数据表生成Entity是必须应对的问题(当然可以选择手动敲),目前常用的方法有:

本文编写的工具,直接使用JDBC API编写,不依赖IDE和ORM。

DatabaseMetaData

DatabaseMetaDatajava.sql中提供的关于数据库整体信息的API,这个接口使用驱动程序实现的,数据库的开发商对这个接口提供支持并使用不同的方式实现。在本例中使用的是MySQL数据库,也是由mysql-connector-java.jar提供的。

DatabaseMetaData 提高了数据库的整体信息,包括了数据表的信息。一个重要的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
getColumns

ResultSet getColumns(String catalog,
String schemaPattern,
String tableNamePattern,
String columnNamePattern)
throws SQLException
获取可在指定类别中使用的表列的描述。
仅返回与类别、模式、表和列名称标准匹配的列描述。它们根据 TABLE_CAT、TABLE_SCHEM、TABLE_NAME 和 ORDINAL_POSITION 进行排序。

每个列描述都有以下列:

TABLE_CAT String => 表类别(可为 null
TABLE_SCHEM String => 表模式(可为 null
TABLE_NAME String => 表名称
COLUMN_NAME String => 列名称
DATA_TYPE int => 来自 java.sql.Types 的 SQL 类型
TYPE_NAME String => 数据源依赖的类型名称,对于 UDT,该类型名称是完全限定的
COLUMN_SIZE int => 列的大小。
BUFFER_LENGTH 未被使用。
DECIMAL_DIGITS int => 小数部分的位数。对于 DECIMAL_DIGITS 不适用的数据类型,则返回 Null。
NUM_PREC_RADIX int => 基数(通常为 102
NULLABLE int => 是否允许使用 NULL。
columnNoNulls - 可能不允许使用 NULL 值
columnNullable - 明确允许使用 NULL 值
columnNullableUnknown - 不知道是否可使用 null
REMARKS String => 描述列的注释(可为 null
COLUMN_DEF String => 该列的默认值,当值在单引号内时应被解释为一个字符串(可为 null
SQL_DATA_TYPE int => 未使用
SQL_DATETIME_SUB int => 未使用
CHAR_OCTET_LENGTH int => 对于 char 类型,该长度是列中的最大字节数
ORDINAL_POSITION int => 表中的列的索引(从 1 开始)
IS_NULLABLE String => ISO 规则用于确定列是否包括 null
YES --- 如果参数可以包括 NULL
NO --- 如果参数不可以包括 NULL
空字符串 --- 如果不知道参数是否可以包括 null
SCOPE_CATLOG String => 表的类别,它是引用属性的作用域(如果 DATA_TYPE 不是 REF,则为 null
SCOPE_SCHEMA String => 表的模式,它是引用属性的作用域(如果 DATA_TYPE 不是 REF,则为 null
SCOPE_TABLE String => 表名称,它是引用属性的作用域(如果 DATA_TYPE 不是 REF,则为 null
SOURCE_DATA_TYPE short => 不同类型或用户生成 Ref 类型、来自 java.sql.Types 的 SQL 类型的源类型(如果 DATA_TYPE 不是 DISTINCT 或用户生成的 REF,则为 null
IS_AUTOINCREMENT String => 指示此列是否自动增加
YES --- 如果该列自动增加
NO --- 如果该列不自动增加
空字符串 --- 如果不能确定该列是否是自动增加参数
COLUMN_SIZE 列表示给定列的指定列大小。对于数值数据,这是最大精度。对于字符数据,这是字符长度。对于日期时间数据类型,这是 String 表示形式的字符长度(假定允许的最大小数秒组件的精度)。对于二进制数据,这是字节长度。对于 ROWID 数据类型,这是字节长度。对于列大小不适用的数据类型,则返回 Null。

参数:
catalog - 类别名称;它必须与存储在数据库中的类别名称匹配;该参数为 "" 表示获取没有类别的那些描述;为 null 则表示该类别名称不应该用于缩小搜索范围
schemaPattern - 模式名称的模式;它必须与存储在数据库中的模式名称匹配;该参数为 "" 表示获取没有模式的那些描述;为 null 则表示该模式名称不应该用于缩小搜索范围
tableNamePattern - 表名称模式;它必须与存储在数据库中的表名称匹配
columnNamePattern - 列名称模式;它必须与存储在数据库中的列名称匹配
返回:
ResultSet - 每一行都是一个列描述
抛出:
SQLException - 如果发生数据库访问错误
另请参见:
getSearchStringEscape()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Connection conn = DatabaseUtils.openConnection(); // 得到数据库连接
DatabaseMetaData databaseMetaData = conn.getMetaData();// 获取数据库 MetaData
//获取数据表的表列描述,存放到 ResultSet
ResultSet resultSet = databaseMetaData.getColumns(null, "%", tableName, "%");
while (resultSet.next()) {
String columnName = resultSet.getString("COLUMN_NAME"); //获取列名
String dataType = resultSet.getString("TYPE_NAME"); //列的数据类型
int columnSize = resultSet.getInt("COLUMN_SIZE"); //列的大小
String remarks = resultSet.getString("REMARKS"); //列的备注
colnames.add(getCamelStr(columnName));
colTypes.add(dataType);
colSizes.add(columnSize);
comments.add(remarks);
}

生成类

生成POJO,包含字段和Setter和Getter

生成字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 解析输出属性
*
* @return
*/
private void processAllAttrs(StringBuffer sb) {
for (int i = 0; i < colnames.size(); i++) {
String colName = colnames.get(i);
String colType = colTypes.get(i);
String comment = comments.get(i);
if (comments != null && colName != null) {
sb.append("\t/**\n\t * " + comment + "\n\t **/\n");
}
sb.append("\tprivate " + sqlType2JavaType(colType) + " " + colName + ";\r\n");
}
}

生成Getter和Setter

1
2
3
4
5
6
7
8
9
10
11
12
for (int i = 0; i < colnames.size(); i++) {
String colName = colnames.get(i);
String colType = colTypes.get(i);
sb.append("\tpublic void set" + initcap(colName) + "(" + sqlType2JavaType(colType) + " " + colName
+ "){\r\n");
sb.append("\t\tthis." + colName + "=" + colName + ";\r\n");
sb.append("\t}\r\n\r\n");

sb.append("\tpublic " + sqlType2JavaType(colType) + " get" + initcap(colName) + "(){\r\n");
sb.append("\t\treturn " + colName + ";\r\n");
sb.append("\t}\r\n\r\n");
}

组合成类文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
StringBuffer sb = new StringBuffer();
sb.append("package " + packagePath + ";\r\n\r\n");
if (needUtilPacket) {
sb.append("import java.util.Date;\r\n");
}
if (needSqlPacket) {
sb.append("import java.sql.*;\r\n\r\n\r\n");
}
sb.append("public class " + initcap(tableName) + " {\r\n\r\n");
processAllAttrs(sb);//生成字段
sb.append("\r\n");
generatGetterSetter(sb);//生成getter和setter
sb.append("}\r\n");
System.out.println(sb.toString());
return sb.toString();

【参考文献】

1 ClickHouse

官方文档

2 Clickhouse

offical website github
meetup backup

2.1 why do we need ClickHouse

  • 交互式查询
  • 持续追加数据

Hypothesis
If we have good enough column-oriented DBMS,
we could store all our data in non-aggregated form
(raw pageviews and sessions) and generate all the reports on the fly,
to allow infinite customization.

愿景:
足够好的列式DBMS,可以存储所有非聚合数据(原始的浏览数据和会话),可以在线生成所有的报告,拥有足够的个性化

2.1.1 yandex数据量

  • 30 trillions of rows (as of 2019)

  • 600 servers

  • total throughput of query processing is up to two terabytes per second

2.2 feature

  • column-oriented 列数存储
  • distributed 分布式
  • linearly scalable 线性扩展
  • fault-tolerant 容错
  • data ingestion in realtime 实时数据摄取
  • realtime (sub-second) queries 实时亚秒级查询
  • support of SQL dialect + extensions 支持SQL方言和扩展

2.3 why fast

2.3.1 High level architecture 架构

— Scale-out shared nothing; 横向伸缩无共享

— Massive Parallel Processing; MPP

2.3.2 Data storage optimizations 存储优化

— Column-oriented storage; 列式存储

— Merge Tree;

— Sparse index; 稀疏index

— Data compression; 数据压缩

2.3.3 Algorithmic optimizations 算法优化

Best algorithms in the world…
… are happy to be used in ClickHouse.

— Volnitsky substring search

— Hyperscan and RE2

— SIMD JSON

— HDR Histograms

— Roaring Bitmaps

2.3.4 Low-level optimizations 底层优化

Optimizations for CPU instruction sets
using SIMD processing. 使用SIMD优化CPU指令集

— SIMD text parsing

— SIMD data filtering

— SIMD decompression

— SIMD string operations

2.3.5 Specializations of algorithms…

… and attention to detail:

— uniq, uniqExact, uniqCombined, uniqUpTo;

— quantile, quantileTiming, quantileExact, quantileTDigest, quantileWeighted;

— 40+ specializations of GROUP BY;

— algorithms optimize itself for data distribution:
LZ4 decompression with Bayesian Bandits.

2.3.6 Interfaces

HTTP REST

clickhouse-client

JDBC, ODBC

(new) MySQL protocol compatibility

Python, PHP, Perl, Go,
Node.js, Ruby, C++, .NET, Scala, R, Julia, Rust

1 大数据技术概论

大数据存储管理:

  • 弹性扩展、容错技术
  • 分层存储、索引(Hash、B树、倒排)
  • 弱并发一致性管理

单一计算模式不能涵盖所有
大数据计算需求
• MapReduce并行计算模式
• 分布内存抽象RDD
• 图计算模式
• 流式计算

硬件发展速度

存储墙: CPU性能每两年速度翻倍、硬盘性能每六年速度翻倍

数据访问速度: 磁盘容量增长远快于存储访问带宽

数据分发

并行计算框架发展

1557370798476

1、Java av47103781
2、Spring av47103781
3、Spring Mvc av47176832
4、MyBatis av47228830
5、Spring Boot av47230137
6、Spring Cloud av47228830
7、Dubbo av47009143
8、MySQL av47702905
9、Redis av47423174
10、MongoDB av47425352
11、Zookeeper av47773419
12、Kafka av47773990
13、Linux av47701443
14、Docker av47715282
15、Maven av47382482
16、Git av47701443
17、Jenkins av47714706
18、IDEA av47382482

上一篇 介绍了Java合成方形群头像的方法,方形群头像由于易于定位且不涉及形状填充和拼接,实现起来相对简单,本篇介绍的圆形头像方案则复杂很多。

先看一下圆形头像的样子

圆形头像样例

从例子可以看出有如下几个特性:

  1. 每张图片都是圆形的,涉及图片的剪切和图片填充到形状
  2. 当图片的个数多于1时,就要考虑图片的遮盖。
  3. 图片个数多余1时,需考虑图片的位置:2个是正对角线方向,3、4、5分别是正三角形、正四边形和正五边形

接下来,从上面的三点介绍:

图片的形状填充

在Java图形库(java.awt)中,存在图片形状填充的API,提供了Area工具,通过Area可以方便的实现图片的填充. Area 类可以根据指定的 Shape 对象创建区域几何形状。如果 Shape 还不是封闭的,则显式地封闭几何形状。由 Shape 的几何形状指定的填充规则(奇偶或缠绕)用于确定得到的封闭区域。

Area填充

步骤如下:

  • 创建Shape
  • 创建Area
  • 建立画板,设置与Area的交集
  • 绘制待填充的图片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int objectImageSize = 260;
int originImageSize = 260;
// 1. 创建Shape,这里定义了一个Polygon的Shape
int xpoints[] = { 20, 70, 130, 240 };
int ypoints[] = { 20, 150, 100, 130 };
Polygon polygon = new Polygon(xpoints, ypoints, 4);
// 2. 创建Area,并将Shape添加到Area
Area tempArea = new Area();
tempArea.add(new Area(polygon));
// 3. 建立画板
BufferedImage tempBufferImage = new BufferedImage(objectImageSize, objectImageSize, BufferedImage.TYPE_INT_RGB); // 定义画板的大小和颜色
Graphics2D g2d = tempBufferImage.createGraphics(); // 创建

g2d.setColor(Color.pink);
g2d.fillRect(0, 0, objectImageSize, objectImageSize); //使用颜色填充,作为背景色
// 消除锯齿
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 设置与Area的交集
g2d.setClip(tempArea);
// 4. 绘制待填充的图片:设置了与Area的交集之后,绘制图片将只在交集内绘制
BufferedImage bufferedImage = ImageUtil.resize2("lt.png", originImageSize, originImageSize, true);
g2d.drawImage(bufferedImage, 0, 0, null);
g2d.dispose(); // 关闭绘图区
// 5. 输出绘图区
String format = "JPG";
ImageIO.write(tempBufferImage, format, new File("result.jpg"));

见上面的结果:

Area剪切

Area可以实现区域的交集、并集、互减和组合区域并减去其交集, 本例使用Area互减实现圆形的互减。下面演示

实现月牙形状

1
2
3
4
Ellipse2D.Double circle1 = new Ellipse2D.Double(50, 50, 200, 200);
tempArea.add(new Area(circle1)); // 定义Area 1
Ellipse2D.Double circle2 = new Ellipse2D.Double(100, 100, 200, 200);
tempArea.subtract(new Area(circle2)); // 减去Area 2

看效果

圆形头像

接下来实现圆形头像,基本上就是图片的定位,亲,高中几何学的怎么样?

图形Item坐标信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ItemConfig {
/**
* item的大小, 圆的最小外切矩形
*/
int itemSize;
/**
* 留白区域大小
*/
int whiteSize;
/**
* item起始坐标
*/
int[][] itemLoaction;
/**
* 切口圆的坐标
*/
int[][] subtractLocation;
}

计算坐标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
ItemConfig itemConfig = new ItemConfig();
iconCount = iconCount > 5 ? 5 : iconCount;// 图标最多五个
int borderWidth = 10;
int whiteSize = 10;
int r;
switch (iconCount) {
case 1:
int itemSize = imageSize - 2 * borderWidth;
int x = borderWidth, y = borderWidth;
itemConfig.itemSize = itemSize;
itemConfig.whiteSize = 10;
itemConfig.itemLoaction = new int[1][2];
itemConfig.itemLoaction[0][0] = x;
itemConfig.itemLoaction[0][1] = y;
itemConfig.subtractLocation = null;
break;
case 2:
itemConfig.whiteSize = 10;
r = (int) ((imageSize - 2 * borderWidth + 2 * itemConfig.whiteSize) / (2 + Math.sqrt(2)));
itemConfig.itemSize = r * 2;
itemConfig.itemLoaction = new int[2][2];
itemConfig.itemLoaction[0][0] = borderWidth;
itemConfig.itemLoaction[0][1] = borderWidth;
itemConfig.itemLoaction[1][0] = imageSize - 2 * r - itemConfig.whiteSize - borderWidth;
itemConfig.itemLoaction[1][1] = imageSize - 2 * r - itemConfig.whiteSize - borderWidth;

itemConfig.subtractLocation = new int[2][2];
itemConfig.subtractLocation[0][0] = (int) (itemConfig.itemLoaction[1][0] - 1.5 * itemConfig.whiteSize);
itemConfig.subtractLocation[0][1] = (int) (itemConfig.itemLoaction[1][0] - 1.5 * itemConfig.whiteSize);
itemConfig.subtractLocation[1] = null;

break;
case 3:
itemConfig.whiteSize = whiteSize;
r = (int) ((imageSize - 2 * borderWidth) / (2 + Math.sqrt(3))) + itemConfig.whiteSize / 2;
itemConfig.itemSize = 2 * r;

itemConfig.itemLoaction = new int[3][2];
itemConfig.itemLoaction[0][0] = (imageSize - 2 * borderWidth) / 2 + borderWidth - r;
itemConfig.itemLoaction[0][1] = borderWidth;
itemConfig.itemLoaction[1][0] = borderWidth;
itemConfig.itemLoaction[1][1] = imageSize - borderWidth - 2 * r;
itemConfig.itemLoaction[2][0] = imageSize - borderWidth - 2 * r;
itemConfig.itemLoaction[2][1] = imageSize - borderWidth - 2 * r;

itemConfig.subtractLocation = new int[3][2];
itemConfig.subtractLocation[0][0] = itemConfig.itemLoaction[1][0] - itemConfig.itemLoaction[0][0];
itemConfig.subtractLocation[0][1] = itemConfig.itemLoaction[1][1] - itemConfig.itemLoaction[0][1];
itemConfig.subtractLocation[1][0] = itemConfig.itemLoaction[2][0] - itemConfig.itemLoaction[1][0];
itemConfig.subtractLocation[1][1] = itemConfig.itemLoaction[2][1] - itemConfig.itemLoaction[1][1];
itemConfig.subtractLocation[2][0] = itemConfig.itemLoaction[0][0] - itemConfig.itemLoaction[2][0];
itemConfig.subtractLocation[2][1] = itemConfig.itemLoaction[0][1] - itemConfig.itemLoaction[2][1];
break;
case 4:
whiteSize = 10;
itemConfig.whiteSize = whiteSize;
r = (int) ((imageSize - 2.0 * borderWidth) / 4 + whiteSize);
itemConfig.itemSize = 2 * r;

itemConfig.itemLoaction = new int[4][2];
itemConfig.itemLoaction[0][0] = borderWidth;
itemConfig.itemLoaction[0][1] = borderWidth;
itemConfig.itemLoaction[1][0] = itemConfig.itemLoaction[0][0];
itemConfig.itemLoaction[1][1] = imageSize - borderWidth - whiteSize - 2 * r;
itemConfig.itemLoaction[2][0] = imageSize - borderWidth - whiteSize - 2 * r;
itemConfig.itemLoaction[2][1] = itemConfig.itemLoaction[1][1];
itemConfig.itemLoaction[3][0] = itemConfig.itemLoaction[2][0];
itemConfig.itemLoaction[3][1] = itemConfig.itemLoaction[0][1];

itemConfig.subtractLocation = new int[4][2];
itemConfig.subtractLocation[0][0] = 0;
itemConfig.subtractLocation[0][1] = itemConfig.itemLoaction[1][1] - itemConfig.itemLoaction[0][1];
itemConfig.subtractLocation[1][0] = itemConfig.itemLoaction[2][0] - itemConfig.itemLoaction[1][0];
itemConfig.subtractLocation[1][1] = 0;
itemConfig.subtractLocation[2][0] = 0;
itemConfig.subtractLocation[2][1] = itemConfig.itemLoaction[3][1] - itemConfig.itemLoaction[2][1];
itemConfig.subtractLocation[3][0] = itemConfig.itemLoaction[0][0] - itemConfig.itemLoaction[3][0];
itemConfig.subtractLocation[3][1] = 0;
break;
case 5:
whiteSize = 10;
itemConfig.whiteSize = whiteSize;
r = (int) ((imageSize - 2.0 * borderWidth)
/ (2 * (Math.cos(Math.toRadians(18)) + Math.cos(Math.toRadians(54))) + 2)) + whiteSize;
itemConfig.itemSize = 2 * r;

itemConfig.itemLoaction = new int[5][2];
itemConfig.itemLoaction[0][0] = (imageSize - 2 * borderWidth) / 2 - r + borderWidth;
itemConfig.itemLoaction[0][1] = borderWidth;
itemConfig.itemLoaction[1][0] = borderWidth;
itemConfig.itemLoaction[1][1] = (int) (2 * r * Math.cos(Math.toRadians(54))) + borderWidth;
itemConfig.itemLoaction[2][0] = (int) (borderWidth + 2 * r * Math.sin(Math.toRadians(18)));
itemConfig.itemLoaction[2][1] = imageSize - borderWidth - 2 * r;
itemConfig.itemLoaction[3][0] = (int) (imageSize - borderWidth - 2 * r
- 2 * r * Math.sin(Math.toRadians(18)));
itemConfig.itemLoaction[3][1] = itemConfig.itemLoaction[2][1];
itemConfig.itemLoaction[4][0] = imageSize - borderWidth - 2 * r;
itemConfig.itemLoaction[4][1] = itemConfig.itemLoaction[1][1];

itemConfig.subtractLocation = new int[5][2];
itemConfig.subtractLocation[0][0] = itemConfig.itemLoaction[1][0] - itemConfig.itemLoaction[0][0];
itemConfig.subtractLocation[0][1] = itemConfig.itemLoaction[1][1] - itemConfig.itemLoaction[0][1];
itemConfig.subtractLocation[1][0] = itemConfig.itemLoaction[2][0] - itemConfig.itemLoaction[1][0];
itemConfig.subtractLocation[1][1] = itemConfig.itemLoaction[2][1] - itemConfig.itemLoaction[1][1];
itemConfig.subtractLocation[2][0] = itemConfig.itemLoaction[3][0] - itemConfig.itemLoaction[2][0];
itemConfig.subtractLocation[2][1] = itemConfig.itemLoaction[3][1] - itemConfig.itemLoaction[2][1];
itemConfig.subtractLocation[3][0] = itemConfig.itemLoaction[4][0] - itemConfig.itemLoaction[3][0];
itemConfig.subtractLocation[3][1] = itemConfig.itemLoaction[4][1] - itemConfig.itemLoaction[3][1];
itemConfig.subtractLocation[4][0] = itemConfig.itemLoaction[0][0] - itemConfig.itemLoaction[4][0];
itemConfig.subtractLocation[4][1] = itemConfig.itemLoaction[0][1] - itemConfig.itemLoaction[4][1];
break;
}

绘制圆形头像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
ItemConfig itemConfig = calcLocation(480, 5);
BufferedImage imageNew = new BufferedImage(480, 480, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics2DNew = imageNew.createGraphics();
graphics2DNew.setColor(Color.white);
graphics2DNew.fillRect(0, 0, 480, 480);
graphics2DNew.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphics2DNew.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);

for (int i = 0; i < itemConfig.itemLoaction.length; i++) {
String fileName = urls.get(i);
Area tempArea = new Area();
Ellipse2D.Double circle0 = new Ellipse2D.Double(10, 10, itemConfig.itemSize - 2 * itemConfig.whiteSize,
itemConfig.itemSize - 2 * itemConfig.whiteSize);
tempArea.add(new Area(circle0));

if (itemConfig.subtractLocation != null && itemConfig.subtractLocation[i] != null) {
Ellipse2D.Double circle1 = new Ellipse2D.Double(itemConfig.subtractLocation[i][0],
itemConfig.subtractLocation[i][1], itemConfig.itemSize, itemConfig.itemSize);
tempArea.subtract(new Area(circle1));
}
graphics2DNew.drawImage(fillImageToModel(fileName, itemConfig.itemSize, tempArea),
itemConfig.itemLoaction[i][0], itemConfig.itemLoaction[i][1], null);
}
graphics2DNew.dispose();
return imageNew;

效果上面已经有了…


参考文献: