开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

从0到1理解数据库事务(下):隔离级别实现——MVCC与锁

发表于 2019-09-25

这是数据库事务分享的第二篇,上一篇讲解数据库事务并发会产生的问题,这篇会详细讲数据库如何避免这些问题,也就是如何实现隔离,主要是讲两种主流技术方案——MVCC与锁,理解了MVCC与锁,就可以举一反三地看各种数据库并发控制方案,并理解每种实现能解决的问题以及需要开发者自己注意的并发问题,以更好支撑业务开发。

先回顾一下上一篇讨论过的,如果没有隔离或者隔离级别不足,会带来的问题:

  • 脏写(Dirty Write)
  • 脏读(Dirty Read)
  • 不可重复读(Unrepeatable Read)
  • 幻读(Phantom)
  • 读偏差(Read Skew)
  • 写偏差(Write Skew)
  • 丢失更新(Lost Updates)

并发面临的问题

可见,所有问题本质上都是由写造成的,根源都是数据的改变。
读是不改变数据的,因此无论多少读并发,都不会出现冲突,如果所有的事务都只由读组成,那么无论如何调度它们,它们都是可串行化的,因为它们的执行结果,都与某个串行执行的结果相同,但是写会造成数据的改变,稍有不慎,这个并发调度的结果就会与串行调度的结果不符合。

在进行下面的讨论下,先定义好我们描述事务的模型:
我们用 R 表示读(read),用 W 表示写(Write),在操作后跟数字,代表哪个事务在进行操作,在数字后跟括号,代表操作哪个元素
用 R1(A) 表示事务1读元素A,用 R2(A)表示事务2读A

看一下写操作如何造成并发调度与串行执行的结果不符合:

事务1读A的值,并且在此基础上增加50并回写A,但是在回写之前,事务2将A修改为了200,这两个事务按照此调度执行后,A的最终值为150,不符合任何串行调度的结果。
如果串行调度为 事务1 => 事务2,那么A最终应该是200
如果串行调度为 事务2 => 事务1,那么A最终应该是250
由此可见,不同事务间,读-写、写-写都是冲突的,不加控制的写操作,会导致并发调度不可串行化。
一、基于锁实现可串行化


(本节以MySQL InnoDB为基本模型)

1. 读锁与写锁

实现可串行化的基石是控制冲突,强行保证冲突操作的串行化,那么应该遵循以下原则:

  • 读-写应该排队
  • 写-写应该排队

读的时候不能写,写的时候不能读也不能写,但是读的时候可以读,因为读不冲突,于是数据库需要两种锁:

  • 排它锁(exclusive lock)
    又称X锁,这是最好理解的锁,在一般的并发编程中,我们为资源加上的一般都是排它锁,要获取锁,必须是资源处于未被加锁状态,如果有人已经为资源加锁,则需要等待锁释放才能获取锁,这种锁能够保证并发时也能够串行处理某个资源,实现排队的目的。
  • 共享锁(share lock)
    又称S锁,这是比排它锁更加宽松的锁,当一个资源没有被加锁或者当前加锁为共享锁时,可以为它加上共享锁,也就是一个资源可以同时被加无限个共享锁。此时由于资源已经被加锁,虽然可以继续加共享锁,但是不能加排它锁,需要等待资源的锁被完全释放才能获取排它锁。共享锁的目的是为了提高非冲突操作的并发数,同时能够保证冲突操作的排队执行。
    兼容性

这两种锁和读、写是什么关系呢?
读写都会加锁,但是读-读可以并发,写则需要与任何操作排队,所以:

  • 获取记录的共享锁(S锁),则仅允许事务读取它,简单来说共享锁只是读锁,记录被加读锁后,其他记录也可以往上加读锁,也就是大家都可以读。
  • 获取记录的排它锁(X锁),则允许这个事务更新它,排它锁让事务既可以读也可以写,是读写通用的锁,记录被加排他锁后,其他事务不论是想加排它锁还是共享锁,都需要排队等待目前的排它锁释放才能加锁。由于强行排队的特性导致效率比较低,读-读不冲突所以大多数读取都不会加排它锁,不过在MySQL中可以使用SELECT FOR UPDATE语句指定为记录加上排它锁。

通过读写操作加锁,实现了读写、写写的排队,但是靠简单加锁保证的排队,但排队粒度太小,仅仅是操作与操作之间的排队,不足以解决上面图中的不可串行化问题,因为如果事务1读A后马上释放读锁,则事务2可以马上获取到A的写锁,改变A的值,还是会出现上面的不可串行化问题,因此事务需要保证更大粒度的排队——如果一个记录被某个事务读取或者写入,则直到这个事务提交,才能被别的事务修改, 严格两阶段加锁(Strict Two-Phase Locking) 由此诞生。

2. 严格两阶段加锁(Strict Two-Phase Locking)

首先提一句什么是两阶段加锁协议(2PL),它规定事务的加锁与解锁分为2个独立阶段,加锁阶段只能加锁不能解锁,一旦开始解锁,则进入解锁阶段,不能再加锁。
严格两阶段加锁(S2PL)在2PL的基础上规定事务的解锁阶段只能是执行commit或者rollback后,因此S2PL保证了一个事务曾经读取或写入的记录,在此事务commit或rollback前都不会被释放锁,因此不能被其他记录加锁,不会造成记录的改变,由此实现了可串行化。

3. 多粒度加锁与意向锁(Intention Lock)

InnoDB中不止支持行级锁,还支持表级锁,为了兼容多粒度的锁,设计了一种特殊的锁——意向锁(Intention Lock),它本身不具备锁的功能,只承担“指示”功能。
如果要加表级锁,则必须保证行级锁已完全释放,整张表都没有任何锁时,才能为表加上表锁。那么问题来了,怎么判断是否整张表的每一条记录都已经释放锁?
如果通过遍历每条记录的加锁状态,未免效率太低,因此需要意向锁,它只是一个指示牌,告诉数据库,在此粒度之下有没有被加锁,被加了什么锁。就像停车场会在门口立一个牌子指示“车位已满”还是“内有空余”,不需要开车进去一个个车位检查,提高了效率。
InnoDB如果要对一条记录进行加锁,它需要先向表加上意向锁,然后才能对记录加普通锁,获取意向锁失败,则不能继续向下获取锁。

意向锁兼容性矩阵

意向锁之间是完全兼容的,很好理解,因为意向锁只代表事务想向下获取锁,具体是哪条记录不确定,因此意向锁是完全兼容的,即使表上已经被其他事务加了某种意向锁,事务还是能够成功为表加意向锁。
一般我们不会在事务中加表锁,表锁效率太低,我们加的一般是行级锁,行级锁是加在某条特定的记录上,我们称之为记录锁。 这一节的内容主要是对多粒度加锁有个概念,现实中很少用表锁。
上面说的共享锁、排它锁是按照锁兼容性定义,表锁、记录锁(Record Lock)则是按加锁范围定义,根据加锁范围不同,还有其他N种锁,下面会提到一些。

4. 避免幻读(Phantom)

间隙锁(Gap Lock)

考虑一个例子:
事务1执行“SELECT name FROM students WHERE age = 18”返回结果为“张三”,而事务2马上插入一行记录“INSERT INTO students VALUES(“李四”,18)”并提交,事务1再次执行相同的SELECT语句,发现结果变为了“张三”+“李四”,这就是幻读,同一个事务进行的两次相同条件的读取,却读取到了之前没有读到的记录。
有了记录锁虽然可以实现对已存在记录进行并发控制,也就是对于更新、删除操作,再也不会有并发问题,但是无法对插入做并发控制,因为插入操作是对不存在的记录,而还不存在的记录,我们无法为其加记录锁,因此可能会产生幻读现象。
为了解决这个问题,出现了间隙锁,间隙锁也是加在某一条记录上,可是它并不锁住记录本身,它只锁住这条记录与它的上一条记录之间的间隙,防止插入。
如下图所示,如果一张表有主键为1、2、5的三条记录,如果5被加上间隙锁,只会锁住开区间(2,5)间隙,而不会锁住5这条记录本身。

Gap Lock不锁记录,只向前锁间隙

如果事务要插入记录,需要获取插入意向锁(Insert Intention Lock),如果需要插入的间隙有间隙锁,则获取插入意向锁会失败必须进行锁等待,从而实现了阻塞插入。
在可串行化隔离级别,使用锁住间隙去防止插入,从而避免了幻读。

Next-Key Lock

很多时候需要锁住多个间隙以及记录本身,比如执行“SELECT name FROM students WHERE id >= 1”,需要锁住(1,3)、(3,5)、(6、7)以及1、3、5、7四条记录本身:

Students

间隙锁和记录锁是两种锁结构,因此不能合并,如果为3个间隙分别加间隙锁,4条记录分别加记录锁,则会产生7条锁记录,很占用内存,因此MySQL有一种锁称为Next-Key Lock,如果在小红的记录上面加Next-Key Lock,则会锁住(1,3]这个前开后闭的区间,也就是锁住了记录本身+记录之前的间隙,可以发现,Next-Key Lock其实就是Gap Lock + Record Lock。此时锁结构就可以简化成为ID为1的记录加上记录锁+后面连续的3个Next-Key Lock,由于Next-Key Lock类型相同并且连续,可以将它们放入同一个锁记录,最后只有ID为1的记录锁+1个Next-Key Lock。
Next-Key Lock并没有什么特别之处,只是对Record Lock + Gap Lock的一种简化。

5. 举一反三:并发问题之解

1. 脏写(Dirty Write)

方案:事务写记录必须获取排它锁

原理:事务写记录之前获取它的排它锁,同时由于严格两阶段加锁,在事务提交前都不会释放锁,因此完全避免了脏写。

2. 脏读(Dirty Read)

方案:事务写记录必须获取排它锁

原理:当记录被加上排它锁后,是不允许再被加任何锁的,因此任何事务都无法读到其他事务写入还未提交的数据。

3. 不可重复读(Unrepeatable Read)

方案:事务读记录必须加锁(S或X锁均可)

原理:由于事务在读记录时已经为记录上锁,因此其他事务无法再为这条记录上排它锁,因此根本无法修改这条记录,也不会出现不可重复读。

4. 幻读(Phantom)

方案:间隙锁

原理:间隙锁阻塞了插入,因此也不会出现幻读问题。

5. 读偏差(Read Skew)

读偏差需要再稍微解释下,还是用上一篇提到的例子:比如X、Y两个账户余额都为50,他们总和为100,事务A读X余额为50,然后事务B从X转账50到Y然后提交,事务A在B提交后读Y发现余额为100,那么它们总和变成了150,此时事务A读到的数据违反业务一致性,为读偏差。
可以发现,读偏差是由于业务一致性是由多条记录的总状态保证的,在事务A开启并读取了其中某一部分记录后,事务B对A还没有读到的记录进行了修改并且B提交了,此时数据库已经进入了新的一致状态,但是A在B提交后再去读那部分记录,读到了B修改后的数据,虽然此时数据库事实上依旧处于一致状态,但是A却发现多条记录的总状态不符合业务一致性,产生读偏差。
读偏差的本质是因为事务A有一部分是陈旧数据,另一部分是新数据,总状态不一致。

方案:读数据必须获取锁,写数据必须加排它锁

原理:由于事务在读记录时已经加上了锁,那么任何事务都不能再获取排它锁,也就不能更新这条已经被读过的数据,那么对于事务自然不可能存在“陈旧数据”一说,任何被读到的数据,在它提交前都不可能被修改,因此读到的都是最新数据。

6. 写偏差(Write Skew)

上一篇有详细讲到写偏差,这里就不多说,它与读偏差本质相同,都是因为读到的某一部分数据成为了陈旧数据,写偏差使用陈旧数据作为写前提,因此作出了错误判断,写入了业务不一致的结果,因此解决写偏差需要解决陈旧数据问题。

方案:读数据必须获取锁,写数据必须加排它锁

原理:它与写偏差的解决原理完全相同,都是因为加锁强制避免了事务读取过的数据被修改,防止了陈旧数据的出现。

7. 丢失更新(Lost Updates)

丢失更新也在上一篇中有讲到,大概就是事务A先读X,对X进行计算后再写X,但是在写X之前,已经被事务B修改了X的值并提交了,而A不知道,将它认为正确的X值写入,覆盖了事务B的值,此为丢失更新。
丢失更新的本质也是基于陈旧数据做出修改决策,只不过陈旧记录与被修改记录为同一条记录,这是和写偏差的唯一区别。

方案:读数据必须获取锁,写数据必须加排它锁

原理:它与避免读、写偏差完全相同的原理,避免记录成为陈旧记录。

可见,InnoDB中的可串行化隔离级别,基于锁,避免了所有并发问题,是最安全的事务隔离级别,但是在业务开发中并不是每个并发问题我们都可能遇到,由于业务的独特性,可能只会面临某一些并发问题或者可以用其他方式去规避这些并发问题带来的业务损害,而为了避免所有的并发问题去使用锁,明显是个收益很低的选择,有时可以允许某些并发问题,减少锁的使用,提高并发效率,下面会讲到的MVCC就是个很好的替代品。

二、锁的替代——使用MVCC提高并发度

可串行化虽然保证了事务的绝对安全,但是并发度很低,很多操作都需要排队进行,为了提高效率,SQL标准在隔离级别上进行了妥协,由此有了可重复读、读提交的隔离级别,它们都允许部分并发问题,这里先讲可重复读隔离级别。
SQL标准中,可重复读仅仅需要完全避免脏写、脏读、不可重复读三种异常,此时如果再用加锁实现,读-写排队未免效率太低,于是MVCC诞生了。
MVCC全称Multiple Version Concurrency Control,也就是多版本并发控制,重点在多版本,简单来说,它为每个事务生成了一个快照,保证每个事务只能读到自己的快照数据,不论其他事务如何更新一条记录,这个事务所读到的数据都不会产生变化,也就是说,会为一条记录保留多个版本,多个事务读到的版本不同,MVCC代替了读锁,实现了读-写不阻塞。
MVCC的意义只是替代读锁,写依旧是加锁的,这样避免了脏写,下面先讲一下MVCC的实现思路,认识MVCC如何避免并发问题,最后讨论MVCC在并发中的局限性。

1. MVCC实现原理

版本链(Undo Log)

在MVCC中,每条记录都有多个版本,串成了一个版本链,也就是说,记录被UPDATE时并不是In Place Update,而是将记录复制然后修改存一份到版本链,被DELET时,也不是马上从文件删除,而是将记录标记为被删除,它也是版本链的一环。
在InnoDB中每条记录中都有2个隐藏列,1个是trx_id,一个是roll_pointer。

一条记录的版本链

  • trx_id代表这条记录版本是被哪个事务创建的,数据库有一个全局的事务ID分配器,它一定是递增的,新的事务ID一定不会和旧的事务ID重复。
  • roll_pointer是连接版本链的指针。
Read View

MVCC中最常听到的概念就是快照,其实快照只是最终结果,而不是实现方式,快照 = 版本链 + Read View。
MVCC并不是将表中所有的记录都为这个事务冻结了一份快照,而是在事务执行第一条语句时时生成了一个叫做Read View的数据结构,注意,Read View是事务执行语句时才会生成的,仅仅执行start transaction是不会生成Read View的。
Read View保存着以下信息:

Read View

Read View结合版本链使用,当事务读取某条记录时,会根据此事务的Read View判断此记录的哪个版本是这个事务可见的:

  1. 如果记录的trx_id与creator_trx_id相同,则代表这个版本是此事务创建的,可以读取。
  2. 如果记录的trx_id小于min_trx_id,代表这个版本是此事务生成Read View之前就已经创建的,可以读取。
  3. 如果记录的trx_id大于等于max_trx_id,代表这个版本是此事务生成Read View之后开启的事务创建的,一定不能被读取。
  4. 如果记录的trx_id处于min_trx_id与max_trx_id之间,则判断trx_id是否在m_ids中,如果不在,则代表这个版本是此事务生成Read View时已经提交的,可以读取。

有了版本链和Read View,即使其他事务修改了记录,先生成Read View的事务也不会读到,只要Read View不改变,每次读到的版本一定相同。MySQL中可重复读和读提交级别都基于MVCC,区别只是生成Read View的时机不同,可重复读级别是在事务执行第一个SQL时生成Read View,而读提交级别是在事务每执行一条SQL时都会重新生成Read View。

2. MVCC的局限性

MVCC取代了读锁的位置,它不阻塞写入虽然有提高效率的优势,但是同时也无法防止所有并发问题。

1. MVCC能避免幻读吗

事务是无法读到Read View生成后别的事务产生的记录版本,因此可以在不加间隙锁的情况下也不会读到别的事务的插入,那MVCC能避免幻读吗?
先说结论:MVCC不可以避免幻读。
导致这个问题的根本原因是:InnoDB将Update、Insert、Delete都视为特殊操作,特殊操作对记录进行的是当前读(Current Read),也就是会读取最新的记录,也就是说Read View只对SELECT语句起作用。
如果users表中有id为1、2、3共3条记录,事务A先读,事务B插入一条记录并提交,事务A更新被插入的记录是可以成功的,因为UPDATE是进行当前读,更新时可以读到id为4的记录存在,因此可以成功更新,事务A成功更新id为4的记录后,将在id为4的记录版本链上新增一条事务A的版本,因此事务A再次SELECT,就可以名正言顺地读到这条记录,符合Read View规则,但产生了幻读。

幻读

如果要避免幻读,可以使用MVCC+间隙锁的方式。

2. 无法避免Read Skew与Write Skew

由于MVCC中读-写互不阻塞,因此事务读取的快照可能已经过期,读到的可能已经成为陈旧数据,因此可能出现Read Skew与Write Skew。

3. 无法避免丢失更新

还是由于读-写不阻塞的特性:
R1(A) => R2(A) => W2(A) => W1(A)
事务1读出的A值已经过期,但是它不知道,还是根据旧的A值去更新A,最后覆盖了事务2的写入。
在Postgrel中,Repeatable Read级别就已经避免了丢失更新,因为它使用MVCC+乐观锁,如果事务1去写入A,存储引擎检测到A值已经在事务1开启后被别的事务修改过,则会报错,阻止事务1的写入。单纯的MVCC并不能防止丢失更新,需要配合其他机制。

三、事务更佳实践

在进行业务开发时应该先了解项目使用的数据库的事务隔离级别以及其原理、表现,然后根据事务实现原理去思考更好的编码方式。

1. 避免死锁

语句顺序不同导致死锁

这种情况大家一定很熟悉了:

死锁

因此建议在不同的业务中,尽量统一操作相同记录语句的顺序。

索引顺序不同导致死锁

锁都是加在索引上的(这里最好先理解一下B+Tree索引),所以一条SQL如果涉及多个索引,会为每个索引加锁,比如有一张users表(id,user_name,password),主键为id,在user_name上有一个唯一索引(Unique Index),以下语句:

UPDATE users SET user_name = ‘j.huang@aftership.com’ WHERE id = 1;

这条语句中涉及到了id与user_name两个索引,InnoDB是索引组织表,主键是聚簇索引,因此记录是存在主键聚簇索引结构中的,那么这条SQL的加锁顺序为:

  1. 为表加上IX锁
  2. 为主键加上X锁
  3. 为索引user_name加上X锁

此时如果另一条事务执行如下语句:

UPDATE users SET password = ‘123’ WHERE user_name = ‘j.huang@aftership.com’;

则可能产生死锁。
原因大家可以先思考一下。
这条语句的加锁顺序是:

  1. 找到user_name为‘j.huang@aftership.com’的索引,加X锁
  2. 为表加IX锁
  3. 为主键加X锁

他们都会对同一个主键索引加锁和同一个二级索引,但是加锁顺序不同,因此可能造成死锁,这种情况很难避免,MySQL中可以通过SHOW ENGINE INNODB STATUS查看InnoDB的死锁检测情况。

2. 避免不必要的事务

其实很多业务场景并不需要事务,比如说领取优惠券,并不需要开启一个Serializable级别的事务去SELECT优惠券剩余数量,判断是否有余量,再UPDATE领取优惠券,完全可以一条语句解决:

UPDATE coupons SET balance = balance - 1 WHERE id = 1 and balance >= 1;

语句返回后判断更新行数,如果更新行数为1,则代表领取成功,更新行数为0,代表没有符合条件的记录,领取失败。
(注意:这里只考虑领取优惠券的场景,如果业务还需要将优惠券写入users表等其他一系列操作,就需要根据业务需求放入事务)

3. 避免将不必要的SELECT放入事务

首先应该理解将SELECT放入事务的意义是什么?

  1. 需要读取事务自己的版本,则必须将SELECT放入事务
  2. 需要依赖SELECT结果作为其他语句的前提,此时不止要把SELECT放入事务,还必须保证事务是Serializable级别的

如果不是以上两个原因,则SELECT是没有必要放入事务的,比如下单一件产品,如果只是SELECT它的product_name去写入orders表,这种非强一致要求的数据,没有必要放入事务,因为product_name即使被改变了,写入order的product_name是1秒前的旧数据,也是可以接受的。

4. 不要迷信事务

很多开发者误以为将SELECT放入事务,将结果作为判断条件或者写入条件是安全的,其实根据隔离级别不同,是不一定的,举个例子:

  1. SELECT users表某个用户等级信息,如果是钻石会员,则为他3倍积分
  2. 将算出的积分UPDATE到user_scores表

将这两条语句放入事务也不一定是安全的,这取决于事务的实现,如果是InnoDB的Repeatable Read级别,那么这个事务是不安全的,因为SELECT读到的是快照,在UPDATE之前,其他事务可能就已经修改了user的等级信息,他可能已经不满足3倍积分条件,而此时再去UPDATE user_scores表,这个事务是个业务不安全的事务。
因此,要先了解事务,再去使用,否则容易用错。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

不要再问React Hooks能否取代 Redux 了

发表于 2019-09-24

许多同事一直问我一些类似的问题:

“如果我们在项目中使用hooks,我们是否还需要Redux?”

“React Hooks会不会使Redux太过时了?我能不能用Hooks来做所有Redux能做的事呢?”

在Google中搜索会发现,大家经常问这些问题。

“React Hooks是否会取代Redux?”,最简单的回答是“不一定”。

更细致但礼貌的答案是“嗯,那取决于你正在做的项目类型”。

我更倾向于告诉大家的答案是“我不确定你是否知道你在说什么”。有几个原因可以说明,为什么“React Hooks是否会取代Redux”是一个本质上有缺陷的问题。首先:

Redux一直是非强制性的

通过Dan Abramov(Redux的创造者之一)的一篇文章【You Might Not Need Redux】可以看出,如果你不需要使用它,则无需替换任何东西。

Redux是一个JavaScript库,并且如果你用的是React(另一个JavaScript库),那么为了使用Redux,你还需要在应用中加载一个React-Redux的JavaScript库。在项目中使用依赖库会增加打包体积,这会增加你应用的加载时间。基于这个原因,你不应该使用一些库,像jQuery、Redux、MobX(另一个状态管理库),甚至是React,除非你有明确的理由要使用它们。

当大家问到“是否hooks会替代Redux”,他们似乎经常觉得,他们的React应用需要使用其中一种。事实并非如此,如果你正在写的应用没有很多状态需要被储存,或者你的组件结构很简单,可以避免过度的prop传递,以及基于React本身提供的特性,你的状态已经足够可控了,不管有没有hooks,这些情况使用状态管理就没有多大意义了。

即使你确实有许多的状态,或者有像老树根一样扭曲分叉的React组件结构,你仍然不需要状态管理库。Prop传递可能很麻烦,但是React给了你许多状态管理选项,并且hooks绝对可以帮你很好地组织状态。Redux是一个轻量级的库,但是它的设置很复杂,增加了打包体积,并且很多地方需要权衡。有很多原因可以说明,为什么你应该选择不在项目中使用它,并且这些原因很有说服力。

你并不总是需要Redux,这也是在说,你依然有许多理由去使用它的。如果你的项目在一开始就使用了Redux,那么它可能是一个很好的理由,无论它是否做了这些:组织(应用状态的可预测性、单一的数据流,在复杂的应用中很有用)、中间件、Redux的强有力的开发工具和调试能力。如果你有使用Redux的理由,它不会因为React Hooks变得无效。如果你之前需要Redux,那么你现在仍然需要。这是因为:

React Hooks和Redux并没有试图解决同样的问题

Redux是一个状态管理库,Hooks是React最近更新的部分特性,让你的函数组件可以做类组件能做的事情。

所以不使用类组件来写React应用突然会让状态管理库变得过时了呢?

当然不会!

通过文档可以看出,React Hooks被开发出来主要是这三个理由:

  • 难以复用类组件之间的逻辑
  • 生命周期中经常包含一些莫名其妙的不相关逻辑
  • 类组件难以被机器和人理解

注意,没有一条理由的动机直接表明要做一些与状态管理相关的事情。

话说如此,React Hooks确实提供了一些选择去管理应用的状态。尤其是useState、useReducer和useContext方法,提供来新的方式去维护你的状态,这被证明比先前React提供的选项更好、更有条理。

但是这些hooks并不是什么新东西或神奇的东西,并且它们也没有使状态管理过时,因为事实是:

React Hooks并没有让你的应用可以做一些以前做不到的事情

那就对了,你现在可以写函数组件来做一些以前只能用类组件来做的事情,但是这些函数组件并不能做一些类组件做不到的事情,除了可以更好地组织和复用代码的能力。它们不一定让你的应用更好,而是让开发者的体验更好。

useState和useReducer只是管理组件状态的方法,并且它们的工作原理同类组件的this.state和this.setState是一样的,你仍然需要传递你的props。

useContext是大家认为在Redux板上钉钉的特性,因为它可以让你在组件之间共享应用的状态,而不需要通过prop传递,但是它也没有真正的做任何新的事情。context API现在是React的一部分,useContext仅仅是让你不用包裹也可以使用context。并且有一些开发这用context来管理整个应用的状态,这不是设计context的目的。通过文档可以看出:

Context is designed to share data that can be considered “global” for a tree of React components, such as the current authenticated user, theme, or preferred language. Context是为了共享数据而被设计出来的,可以认为是React组件树的“全局”,比如当前已授权的用户、主题或者首选的语言。

换句话说,就是那些预计不会频繁更新的东西。

文档中也建议有节制地使用context,因为“它会使得组件难以复用”。他们也提醒开发者,如果开发者不小心,context很容易触发不必要的重复渲染。

我见过项目成功地使用React Context来管理应用状态,这是有可能的,也不失为一种选择。但是状态管理并不完全是context被设计出来去做的事情,而且Redux和其他状态管理库被设计出来就是为了处理这种特定的目的。

此外,React Hooks也绝不意味着Redux的消亡,因为如果你看一眼React-Redux最近更新的文档,你会明白:

React-Redux也有自己的hooks

没错,React Hooks正在帮助React-Redux恢复活力并移除来它的一些痛点,与“替代”的说法相差甚远。

我在另一篇文章中对React-Redux进行了深入研究,这里要说的重点。在hooks之前,你必须定义mapStateToProps和mapDispatchToProps两个函数,并且用connect包裹你的组件来创建一个高阶组件,它会传递dispatch方法和部分Redux贮存的状态,这些状态是你在mapping函数中指定作为props传递到组件中的。

让我们来看一个非常简单的计数器应用的例子(太简单甚至都不需要Redux,但是这里主要是为了展示一些信息)。假设我们已经定义了Redux store和increment、decrement两个action creator(完整的源码在这里)。

太令人烦恼了!如果我们不必包裹组件到高阶组件中,就可以让组件取到Redux store的值,这样不是更友好吗?是的,这就是hooks出现的原因。Hooks就是为了复用代码和消除由于高阶组件产生的“嵌套地狱”。下面是一个相同的组件,使用React-Redux hooks转换成函数组件。

是不是很漂亮?简而言之,useSelector让你可以保存部分Redux store的值到你的组件。useDispatch更简单,它仅仅为你提供了一个dispatch函数,你可以用它来发送状态更新到Redux store。最棒的是,你不再需要写这些丑陋的mapping函数和用connect函数来包裹组件。现在,一切都很好地包含在你的组件中,它更简洁,因此更容易阅读,并且更有条理。重点是:

没有必要比较React Hooks和Redux孰优孰劣

毫无疑问,这两项技术可以很好地互补。React Hooks不会替代Redux,它们仅仅为你提供来新的、更好的方式去组织你的React应用。如果你最终决定使用Redux来管理状态,可以让你编写更好的连接组件。

所以,请不要再问“React Hooks是否会取代Redux?”。

相反,开始问自己“我正在制作什么样的应用?我需要什么样的状态管理?Redux可以用吗,还是有些过度使用呢?hooks可以用吗,还是应该用类组件?如果我决定使用Redux和React Hooks(或者MobX和React Hooks,或者Redux和jQuery,不用React——这些都是有效的选择,取决于你正在做的事情),那么我怎样可以使这些技术互补并且和谐共处呢?”。

原文链接:英文:Max González 译文:连城
zhuanlan.zhihu.com/p/81126574

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring StateMachine 状态机引擎在项目中的

发表于 2019-09-24

背景

每次用到的时候新创建一个状态机,太奢侈了,官方文档里面也提到过这点。

而且创建出来的实例,其状态也跟当前订单的不符;spring statemachine暂时不支持每次创建时指定当前状态,所以对状态机引擎实例的持久化,就成了必须要考虑的问题。(不过在后续版本有直接指定状态的方式,这个后面会写)

扩展一下

这里扩展说明一下,状态机引擎的持久化一直是比较容易引起讨论的,因为很多场景并不希望再多存储一些中间非业务数据,之前在淘宝工作时,淘宝的订单系统tradeplatform自己实现了一套workflowEngine,其实说白了也就是一套状态机引擎,所有的配置都放在xml中,每次每个环节的请求过来,都会重新创建一个状态机引擎实例,并根据当前的订单状态来设置引擎实例的状态。

workflowEngine没有做持久化,私下里猜测下这样实现的原因:1、淘系数据量太大,一天几千万笔订单,额外的信息存储就要耗费很多存储资源;2、完全自主开发的状态机引擎,可定制化比较强,根据自己的业务需要可以按自己的需要处理。

而反过来,spring statemachine并不支持随意指定初始状态,每次创建都是固定的初始化状态,其实也只是有好处的,标准版流程,而且可以保证安全,每个节点都是按照事先定义好的流程跑下来,而不是随意指定。所以,状态机引擎实例的持久化,我们这次的主题,那就继续聊下去吧。

持久化

spring statemachine 本身支持了内存、redis及db的持久化,内存持久化就不说了,看源码实现就是放在了hashmap里,平时也没谁项目中可以这么奢侈,啥啥都放在内存中,而且一旦重启…..😓。下面详细说下利用redis进行的持久化操作。

依赖引入

spring statemachine 本身是提供了一个redis存储的组件的,在1.2.10.RELEASE版本中,这个组件需要通过依赖引入,同时需要引入的还有序列化的组件kyro、data-common:

gradle引入依赖 (build.gradle 或者 libraries.gradle,由自己项目的gradle组织方式来定):

1
2
3
4
复制代码compile 'org.springframework.statemachine:spring-statemachine-core:1.2.10.RELEASE'
compile 'org.springframework.statemachine:spring-statemachine-data-common:1.2.10.RELEASE'
compile 'org.springframework.statemachine:spring-statemachine-kyro:1.2.10.RELEASE'
compile 'org.springframework.statemachine:spring-statemachine-redis:1.2.10.RELEASE'

当然如果是maven的话,一样的,pom.xml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码<dependencies>
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-core</artifactId>
<version>1.2.10.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-data-common</artifactId>
<version>1.2.10.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-kyro</artifactId>
<version>1.2.10.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-redis</artifactId>
<version>1.2.10.RELEASE</version>
</dependency>
</dependencies>
先把持久化的调用轨迹说明下

spring-statemachine-持久化.png

说明:

spring statemachine持久化时,采用了三层结构设计,persister —>persist —>repository。

  • 其中persister中封装了write和restore两个方法,分别用于持久化写及反序列化读出。
  • persist只是一层皮,主要还是调用repository中的实际实现;但是在这里,由于redis存储不保证百分百数据安全,所以我实现了一个自定义的persist,其中封装了数据写入db、从db中读取的逻辑。
  • repository中做了两件事儿
    • 序列化/反序列化数据,将引擎实例与二进制数组互相转换
    • 读、写redis
详细的实现
Persister
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码import org.springframework.beans.factory.annotation.Autowire;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.StateMachinePersist;
import org.springframework.statemachine.persist.StateMachinePersister;
import org.springframework.statemachine.redis.RedisStateMachinePersister;

@Configuration
public class BizOrderRedisStateMachinePersisterConfig {

@Autowired
private StateMachinePersist bizOrderRedisStateMachineContextPersist;

@Bean(name = "bizOrderRedisStateMachinePersister",autowire = Autowire.BY_TYPE)
public StateMachinePersister<BizOrderStatusEnum, BizOrderStatusChangeEventEnum,String> bizOrderRedisStateMachinePersister() {
return new RedisStateMachinePersister<>(bizOrderRedisStateMachineContextPersist);
}

}

这里采用官方samples中初始化的方式,通过@Bean注解来创建一个RedisStateMachinePersister实例,注意其中传递进去的Persist为自定义的bizOrderRedisStateMachineContextPersist

Persist
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
113
114
115
116
117
118
119
120
121
122
123
124
125
复制代码import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.messaging.MessageHeaders;
import org.springframework.statemachine.StateMachineContext;
import org.springframework.statemachine.StateMachinePersist;
import org.springframework.statemachine.kryo.MessageHeadersSerializer;
import org.springframework.statemachine.kryo.StateMachineContextSerializer;
import org.springframework.statemachine.kryo.UUIDSerializer;
import org.springframework.statemachine.redis.RedisStateMachineContextRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException;
import java.util.Base64;
import java.util.UUID;

@Component("bizOrderRedisStateMachineContextPersist")
public class BizOrderRedisStateMachineContextPersist implements StateMachinePersist<BizOrderStatusEnum, BizOrderStatusChangeEventEnum, String> {

@Autowired
@Qualifier("redisStateMachineContextRepository")
private RedisStateMachineContextRepository<BizOrderStatusEnum, BizOrderStatusChangeEventEnum> redisStateMachineContextRepository;

@Autowired
private BizOrderStateMachineContextRepository bizOrderStateMachineContextRepository;

// 加入存储到DB的数据repository, biz_order_state_machine_context表结构:
// bizOrderId
// contextStr
// curStatus
// updateTime

/**
* Write a {@link StateMachineContext} into a persistent store
* with a context object {@code T}.
*
* @param context the context
* @param contextObj the context ojb
* @throws Exception the exception
*/
@Override
@Transactional
public void write(StateMachineContext<BizOrderStatusEnum, BizOrderStatusChangeEventEnum> context, String contextObj) throws Exception {

redisStateMachineContextRepository.save(context, contextObj);
// save to db
BizOrderStateMachineContext queryResult = bizOrderStateMachineContextRepository.selectByOrderId(contextObj);

if (null == queryResult) {
BizOrderStateMachineContext bosmContext = new BizOrderStateMachineContext(contextObj,
context.getState().getStatus(), serialize(context));
bizOrderStateMachineContextRepository.insertSelective(bosmContext);
} else {
queryResult.setCurOrderStatus(context.getState().getStatus());
queryResult.setContext(serialize(context));
bizOrderStateMachineContextRepository.updateByPrimaryKeySelective(queryResult);
}
}

/**
* Read a {@link StateMachineContext} from a persistent store
* with a context object {@code T}.
*
* @param contextObj the context ojb
* @return the state machine context
* @throws Exception the exception
*/
@Override
public StateMachineContext<BizOrderStatusEnum, BizOrderStatusChangeEventEnum> read(String contextObj) throws Exception {

StateMachineContext<BizOrderStatusEnum, BizOrderStatusChangeEventEnum> context = redisStateMachineContextRepository.getContext(contextObj);
//redis 访缓存击穿
if (null != context && BizOrderConstants.STATE_MACHINE_CONTEXT_ISNULL.equalsIgnoreCase(context.getId())) {
return null;
}
//redis 为空走db
if (null == context) {
BizOrderStateMachineContext boSMContext = bizOrderStateMachineContextRepository.selectByOrderId(contextObj);
if (null != boSMContext) {
context = deserialize(boSMContext.getContext());
redisStateMachineContextRepository.save(context, contextObj);
} else {
context = new StateMachineContextIsNull();
redisStateMachineContextRepository.save(context, contextObj);
}
}
return context;
}

private String serialize(StateMachineContext<BizOrderStatusEnum, BizOrderStatusChangeEventEnum> context) throws UnsupportedEncodingException {
Kryo kryo = kryoThreadLocal.get();
ByteArrayOutputStream out = new ByteArrayOutputStream();
Output output = new Output(out);
kryo.writeObject(output, context);
output.close();
return Base64.getEncoder().encodeToString(out.toByteArray());
}

@SuppressWarnings("unchecked")
private StateMachineContext<BizOrderStatusEnum, BizOrderStatusChangeEventEnum> deserialize(String data) throws UnsupportedEncodingException {
if (StringUtils.isEmpty(data)) {
return null;
}
Kryo kryo = kryoThreadLocal.get();
ByteArrayInputStream in = new ByteArrayInputStream(Base64.getDecoder().decode(data));
Input input = new Input(in);
return kryo.readObject(input, StateMachineContext.class);
}

private static final ThreadLocal<Kryo> kryoThreadLocal = new ThreadLocal<Kryo>() {

@SuppressWarnings("rawtypes")
@Override
protected Kryo initialValue() {
Kryo kryo = new Kryo();
kryo.addDefaultSerializer(StateMachineContext.class, new StateMachineContextSerializer());
kryo.addDefaultSerializer(MessageHeaders.class, new MessageHeadersSerializer());
kryo.addDefaultSerializer(UUID.class, new UUIDSerializer());
return kryo;
}
};
}

说明:

  1. 如果只是持久化到redis中,那么BizOrderStateMachineContextRepository相关的所有内容均可删除。不过由于redis无法承诺百分百的数据安全,所以我这里做了两层持久化,redis+db
  2. 存入redis中的数据默认采用kryo来序列化及反序列化,RedisStateMachineContextRepository中实现了对应代码。但是spring statemachine默认的db存储比较复杂,需要创建多张表,参加下图:

jpa-table.png

这里需要额外创建5张表,分别存储ActionGuardStateStateMachineTransition,比较复杂。

  1. 所以这里创建了一张表bizorderstatemachinecontext,结构很简单:bizOrderId,contextStr,curStatus,updateTime,其中关键是contextStr,用于存储与redis中相同的内容
Repository

有两个repository,一个是spring statemachine提供的redisRepo,另一个则是项目中基于mybatis的repo,先是db-repo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码   import org.apache.ibatis.annotations.Param;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface BizOrderStateMachineContextRepository {

int deleteByPrimaryKey(Long id);

BizOrderStateMachineContext selectByOrderId(String bizOrderId);

int updateByPrimaryKey(BizOrderStateMachineContext BizOrderStateMachineContext);

int updateByPrimaryKeySelective(BizOrderStateMachineContext BizOrderStateMachineContext);

int insertSelective(BizOrderStateMachineContext BizOrderStateMachineContext);

int selectCount(BizOrderStateMachineContext BizOrderStateMachineContext);

List<BizOrderStateMachineContext> selectPage(@Param("BizOrderStateMachineContext") BizOrderStateMachineContext BizOrderStateMachineContext, @Param("pageable") Pageable pageable);

}

然后是redisRepo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码   import org.springframework.beans.factory.annotation.Autowire;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.statemachine.redis.RedisStateMachineContextRepository;

@Configuration
public class BizOrderRedisStateMachineRepositoryConfig {

/**
* 接入asgard后,redis的connectionFactory可以通过serviceName + InnerConnectionFactory来注入
*/
@Autowired
private RedisConnectionFactory finOrderRedisInnerConnectionFactory;

@Bean(name = "redisStateMachineContextRepository", autowire = Autowire.BY_TYPE)
public RedisStateMachineContextRepository<BizOrderStatusEnum, BizOrderStatusChangeEventEnum> redisStateMachineContextRepository() {

return new RedisStateMachineContextRepository<>(finOrderRedisInnerConnectionFactory);
}
}
使用方式
1
2
3
4
5
6
7
8
9
10
复制代码    @Autowired
@Qualifier("bizOrderRedisStateMachinePersister")
private StateMachinePersister<BizOrderStatusEnum,BizOrderStatusChangeEventEnum,String> bizOrderRedisStateMachinePersister;

......
bizOrderRedisStateMachinePersister.persist(stateMachine, request.getBizCode());
......
StateMachine<BizOrderStatusEnum, BizOrderStatusChangeEventEnum> stateMachine
= bizOrderRedisStateMachinePersister.restore(srcStateMachine,statusRequest.getBizCode());
......

支持,关于spring statemachine的持久化就交代完了,下面就是最关键的,怎么利用状态机来串联业务,下一节将会详细描述。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【码上开学】Kotlin 的协程用力瞥一眼

发表于 2019-09-23

本期作者:

视频:扔物线(朱凯)

文章:LewisLuo(罗宇)

大家好,我是扔物线朱凯。

终于到了协程的一期了。

Kotlin 的协程是它非常特别的一块地方:宣扬它的人都在说协程多么好多么棒,但多数人不管是看了协程的官方文档还是一些网络文章之后又都觉得完全看不懂。而且这个「不懂」和 RxJava 是属于一类的:由于协程在概念上对于 Java 开发者来说就是个新东西,所以对于大多数人来说,别说怎么用了,我连它是个什么东西都没看明白。

所以今天,我就先从「协程是什么」说起。首先还是看视频。不过因为我一直不知道怎么在掘金发视频,所以你可以点击 这里 去哔哩哔哩看视频,可以点击 这里 去 YouTube 看。

这期内容主要是讲一个概念:什么是协程。因为这个概念有点难(其实看完视频你会发现它超级简单),所以专门花一期来讲解。后面的内容会更劲爆,如果你喜欢我的视频,别忘了去 原视频 点个赞投个币,以及关注订阅一下,不错过我的任何新视频!

以下内容来自文章作者 LewisLuo。

码上开学 Kotlin 系列的文章,协程已经是第五期了,这里简单讲一下我们(扔物线和即刻 Android 团队)出品的 Kotlin 上手指南系列文章的一些考量:

  • 官方文档有指定的格式,因为它是官方的,必须面面俱到,写作顺序不是由浅入深,不管你懂不懂,它都得讲。
  • 网上的文章大都是从作者自身的角度出发,真正从读者的需求出发的少之又少,无法抓住读者的痛点,能够读完已属不易。
  • 疲劳度是这一系列的一个重要的衡量指标,文章中如果连续出现大段代码,疲劳度会急剧上升,不容易集中精神,甚至中途放弃。

我们期许基于上述的考量和原则,把技术文章写得更加轻松易读,激发读者学习的兴趣,真正实现「上手」。

协程在 Kotlin 中是非常特别的一部分,和 Java 相比,它是一个新颖的概念。宣扬它的人都在说协程是多么好用,但就目前而言不管是官方文档还是网络上的一些文章都让人难以读懂。

造成这种「不懂」的原因和大多数人在初学 RxJava 时所遇到的问题其实是一致的:对于 Java 开发者来说这是一个新东西。下面我们从「协程是什么」开始说起。

协程是什么

协程并不是 Kotlin 提出来的新概念,其他的一些编程语言,例如:Go、Python 等都可以在语言层面上实现协程,甚至是 Java,也可以通过使用扩展库来间接地支持协程。

当在网上搜索协程时,我们会看到:

  • Kotlin 官方文档说「本质上,协程是轻量级的线程」。
  • 很多博客提到「不需要从用户态切换到内核态」、「是协作式的」等等。

作为 Kotlin 协程的初学者,这些概念并不是那么容易让人理解。这些往往是作者根据自己的经验总结出来的,只看结果,而不管过程就不容易理解协程。

「协程 Coroutines」源自 Simula 和 Modula-2 语言,这个术语早在 1958 年就被 Melvin Edward Conway 发明并用于构建汇编程序,说明协程是一种编程思想,并不局限于特定的语言。

Go 语言也有协程,叫 Goroutines,从英文拼写就知道它和 Coroutines 还是有些差别的(设计思想上是有关系的),否则 Kotlin 的协程完全可以叫 Koroutines 了。

因此,对一个新术语,我们需要知道什么是「标准」术语,什么是变种。

当我们讨论协程和线程的关系时,很容易陷入中文的误区,两者都有一个「程」字,就觉得有关系,其实就英文而言,Coroutines 和 Threads 就是两个概念。

从 Android 开发者的角度去理解它们的关系:

  • 我们所有的代码都是跑在线程中的,而线程是跑在进程中的。
  • 协程没有直接和操作系统关联,但它不是空中楼阁,它也是跑在线程中的,可以是单线程,也可以是多线程。
  • 单线程中的协程总的执行时间并不会比不用协程少。
  • Android 系统上,如果在主线程进行网络请求,会抛出 NetworkOnMainThreadException,对于在主线程上的协程也不例外,这种场景使用协程还是要切线程的。

协程设计的初衷是为了解决并发问题,让 「协作式多任务」 实现起来更加方便。这里就先不展开「协作式多任务」的概念,等我们学会了怎么用再讲。

视频里讲到,协程就是 Kotlin 提供的一套线程封装的 API,但并不是说协程就是为线程而生的。

不过,我们学习 Kotlin 中的协程,一开始确实可以从线程控制的角度来切入。因为在 Kotlin 中,协程的一个典型的使用场景就是线程控制。就像 Java 中的 Executor 和 Android 中的 AsyncTask,Kotlin 中的协程也有对 Thread API 的封装,让我们可以在写代码时,不用关注多线程就能够很方便地写出并发操作。

在 Java 中要实现并发操作通常需要开启一个 Thread :

1
2
3
4
5
6
7
java复制代码☕️
new Thread(new Runnable() {
@Override
public void run() {
...
}
}).start();

这里仅仅只是开启了一个新线程,至于它何时结束、执行结果怎么样,我们在主线程中是无法直接知道的。

Kotlin 中同样可以通过线程的方式去写:

1
2
3
4
kotlin复制代码🏝️
Thread({
...
}).start()

可以看到,和 Java 一样也摆脱不了直接使用 Thread 的那些困难和不方便:

  • 线程什么时候执行结束
  • 线程间的相互通信
  • 多个线程的管理

我们可以用 Java 的 Executor 线程池来进行线程管理:

1
2
3
4
5
kotlin复制代码🏝️
val executor = Executors.newCachedThreadPool()
executor.execute({
...
})

用 Android 的 AsyncTask 来解决线程间通信:

1
2
3
4
5
6
kotlin复制代码🏝️
object : AsyncTask<T0, T1, T2> {
override fun doInBackground(vararg args: T0): String { ... }
override fun onProgressUpdate(vararg args: T1) { ... }
override fun onPostExecute(t3: T3) { ... }
}

AsyncTask 是 Android 对线程池 Executor 的封装,但它的缺点也很明显:

  • 需要处理很多回调,如果业务多则容易陷入「回调地狱」。
  • 硬是把业务拆分成了前台、中间更新、后台三个函数。

看到这里你很自然想到使用 RxJava 解决回调地狱,它确实可以很方便地解决上面的问题。

RxJava,准确来讲是 ReactiveX 在 Java 上的实现,是一种响应式程序框架,我们通过它提供的「Observable」的编程范式进行链式调用,可以很好地消除回调。

使用协程,同样可以像 Rx 那样有效地消除回调地狱,不过无论是设计理念,还是代码风格,两者是有很大区别的,协程在写法上和普通的顺序代码类似。

这里并不会比较 RxJava 和协程哪个好,或者讨论谁取代谁的问题,我这里只给出一个建议,你最好都去了解下,因为协程和 Rx 的设计思想本来就不同。

下面的例子是使用协程进行网络请求获取用户信息并显示到 UI 控件上:

1
2
3
4
5
kotlin复制代码🏝️
launch({
val user = api.getUser() // 👈 网络请求(IO 线程)
nameTv.text = user.name // 👈 更新 UI(主线程)
})

这里只是展示了一个代码片段,launch 并不是一个顶层函数,它必须在一个对象中使用,我们之后再讲,这里只关心它内部业务逻辑的写法。

launch 函数加上实现在 {} 中具体的逻辑,就构成了一个协程。

通常我们做网络请求,要不就传一个 callback,要不就是在 IO 线程里进行阻塞式的同步调用,而在这段代码中,上下两个语句分别工作在两个线程里,但写法上看起来和普通的单线程代码一样。

这里的 api.getUser 是一个挂起函数,所以能够保证 nameTv.text 的正确赋值,这就涉及到了协程中最著名的「非阻塞式挂起」。这个名词看起来不是那么容易理解,我们后续的文章会专门对这个概念进行讲解。现在先把这个概念放下,只需要记住协程就是这样写的就行了。

这种「用同步的方式写异步的代码」看起来很方便吧,那么我们来看看协程具体好在哪。

协程好在哪

开始之前

在讲之前,我们需要先了解一下「闭包」这个概念,调用 Kotlin 协程中的 API,经常会用到闭包写法。

其实闭包并不是 Kotlin 中的新概念,在 Java 8 中就已经支持。

我们先以 Thread 为例,来看看什么是闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码🏝️
// 创建一个 Thread 的完整写法
Thread(object : Runnable {
override fun run() {
...
}
})

// 满足 SAM,先简化为
Thread({
...
})

// 使用闭包,再简化为
Thread {
...
}

形如 Thread {...} 这样的结构中 {} 就是一个闭包。

在 Kotlin 中有这样一个语法糖:当函数的最后一个参数是 lambda 表达式时,可以将 lambda 写在括号外。这就是它的闭包原则。

在这里需要一个类型为 Runnable 的参数,而 Runnable 是一个接口,且只定义了一个函数 run,这种情况满足了 Kotlin 的 SAM,可以转换成传递一个 lambda 表达式(第二段),因为是最后一个参数,根据闭包原则我们就可以直接写成 Thread {...}(第三段) 的形式。

对于上文所使用的 launch 函数,可以通过闭包来进行简化 :

1
2
3
4
kotlin复制代码🏝️
launch {
...
}

基本使用

前面提到,launch 函数不是顶层函数,是不能直接用的,可以使用下面三种方法来创建协程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码🏝️
// 方法一,使用 runBlocking 顶层函数
runBlocking {
getImage(imageId)
}

// 方法二,使用 GlobalScope 单例对象
// 👇 可以直接调用 launch 开启协程
GlobalScope.launch {
getImage(imageId)
}

// 方法三,自行通过 CoroutineContext 创建一个 CoroutineScope 对象
// 👇 需要一个类型为 CoroutineContext 的参数
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
getImage(imageId)
}
  • 方法一通常适用于单元测试的场景,而业务开发中不会用到这种方法,因为它是线程阻塞的。
  • 方法二和使用 runBlocking 的区别在于不会阻塞线程。但在 Android 开发中同样不推荐这种用法,因为它的生命周期会和 app 一致,且不能取消(什么是协程的取消后面的文章会讲)。
  • 方法三是比较推荐的使用方法,我们可以通过 context 参数去管理和控制协程的生命周期(这里的 context 和 Android 里的不是一个东西,是一个更通用的概念,会有一个 Android 平台的封装来配合使用)。

关于 CoroutineScope 和 CoroutineContext 的更多内容后面的文章再讲。

协程最常用的功能是并发,而并发的典型场景就是多线程。可以使用 Dispatchers.IO 参数把任务切到 IO 线程执行:

1
2
3
4
kotlin复制代码🏝️
coroutineScope.launch(Dispatchers.IO) {
...
}

也可以使用 Dispatchers.Main 参数切换到主线程:

1
2
3
4
kotlin复制代码🏝️
coroutineScope.launch(Dispatchers.Main) {
...
}

所以在「协程是什么」一节中讲到的异步请求的例子完整写出来是这样的:

1
2
3
4
5
kotlin复制代码🏝️
coroutineScope.launch(Dispatchers.Main) { // 在主线程开启协程
val user = api.getUser() // IO 线程执行网络请求
nameTv.text = user.name // 主线程更新 UI
}

而通过 Java 实现以上逻辑,我们通常需要这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码☕️
api.getUser(new Callback<User>() {
@Override
public void success(User user) {
runOnUiThread(new Runnable() {
@Override
public void run() {
nameTv.setText(user.name);
}
})
}

@Override
public void failure(Exception e) {
...
}
});

这种回调式的写法,打破了代码的顺序结构和完整性,读起来相当难受。

协程的「1 到 0」

对于回调式的写法,如果并发场景再复杂一些,代码的嵌套可能会更多,这样的话维护起来就非常麻烦。但如果你使用了 Kotlin 协程,多层网络请求只需要这么写:

1
2
3
4
5
6
kotlin复制代码🏝️
coroutineScope.launch(Dispatchers.Main) { // 开始协程:主线程
val token = api.getToken() // 网络请求:IO 线程
val user = api.getUser(token) // 网络请求:IO 线程
nameTv.text = user.name // 更新 UI:主线程
}

如果遇到的场景是多个网络请求需要等待所有请求结束之后再对 UI 进行更新。比如以下两个请求:

1
2
3
kotlin复制代码🏝️
api.getAvatar(user, callback)
api.getCompanyLogo(user, callback)

如果使用回调式的写法,那么代码可能写起来既困难又别扭。于是我们可能会选择妥协,通过先后请求代替同时请求:

1
2
3
4
5
6
kotlin复制代码🏝️
api.getAvatar(user) { avatar ->
api.getCompanyLogo(user) { logo ->
show(merge(avatar, logo))
}
}

在实际开发中如果这样写,本来能够并行处理的请求被强制通过串行的方式去实现,可能会导致等待时间长了一倍,也就是性能差了一倍。

而如果使用协程,可以直接把两个并行请求写成上下两行,最后再把结果进行合并即可:

1
2
3
4
5
6
7
8
9
kotlin复制代码🏝️
coroutineScope.launch(Dispatchers.Main) {
// 👇 async 函数之后再讲
val avatar = async { api.getAvatar(user) } // 获取用户头像
val logo = async { api.getCompanyLogo(user) } // 获取用户所在公司的 logo
val merged = suspendingMerge(avatar, logo) // 合并结果
// 👆
show(merged) // 更新 UI
}

可以看到,即便是比较复杂的并行网络请求,也能够通过协程写出结构清晰的代码。需要注意的是 suspendingMerge 并不是协程 API 中提供的方法,而是我们自定义的一个可「挂起」的结果合并方法。至于挂起具体是什么,可以看下一篇文章。

让复杂的并发代码,写起来变得简单且清晰,是协程的优势。

这里,两个没有相关性的后台任务,因为用了协程,被安排得明明白白,互相之间配合得很好,也就是我们之前说的「协作式任务」。

本来需要回调,现在直接没有回调了,这种从 1 到 0 的设计思想真的妙哉。

在了解了协程的作用和优势之后,我们再来看看协程是怎么使用的。

协程怎么用

在项目中配置对 Kotlin 协程的支持

在使用协程之前,我们需要在 build.gradle 文件中增加对 Kotlin 协程的依赖:

  • 项目根目录下的 build.gradle :
1
2
3
4
5
6
groovy复制代码buildscript {
...
// 👇
ext.kotlin_coroutines = '1.3.1'
...
}
  • Module 下的 build.gradle :
1
2
3
4
5
6
7
8
groovy复制代码dependencies {
...
// 👇 依赖协程核心库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines"
// 👇 依赖当前平台所对应的平台库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines"
...
}

Kotlin 协程是以官方扩展库的形式进行支持的。而且,我们所使用的「核心库」和 「平台库」的版本应该保持一致。

  • 核心库中包含的代码主要是协程的公共 API 部分。有了这一层公共代码,才使得协程在各个平台上的接口得到统一。
  • 平台库中包含的代码主要是协程框架在具体平台的具体实现方式。因为多线程在各个平台的实现方式是有所差异的。

完成了以上的准备工作就可以开始使用协程了。

开始使用协程

协程最简单的使用方法,其实在前面章节就已经看到了。我们可以通过一个 launch 函数实现线程切换的功能:

1
2
3
4
5
kotlin复制代码🏝️
// 👇
coroutineScope.launch(Dispatchers.IO) {
...
}

这个 launch 函数,它具体的含义是:我要创建一个新的协程,并在指定的线程上运行它。这个被创建、被运行的所谓「协程」是谁?就是你传给 launch 的那些代码,这一段连续代码叫做一个「协程」。

所以,什么时候用协程?当你需要切线程或者指定线程的时候。你要在后台执行任务?切!

1
2
3
4
kotlin复制代码🏝️
launch(Dispatchers.IO) {
val image = getImage(imageId)
}

然后需要在前台更新界面?再切!

1
2
3
4
5
6
7
kotlin复制代码🏝️
coroutineScope.launch(Dispatchers.IO) {
val image = getImage(imageId)
launch(Dispatch.Main) {
avatarIv.setImageBitmap(image)
}
}

好像有点不对劲?这不还是有嵌套嘛。

如果只是使用 launch 函数,协程并不能比线程做更多的事。不过协程中却有一个很实用的函数:withContext 。这个函数可以切换到指定的线程,并在闭包内的逻辑执行结束之后,自动把线程切回去继续执行。那么可以将上面的代码写成这样:

1
2
3
4
5
6
7
kotlin复制代码🏝️
coroutineScope.launch(Dispatchers.Main) { // 👈 在 UI 线程开始
val image = withContext(Dispatchers.IO) { // 👈 切换到 IO 线程,并在执行完成后切回 UI 线程
getImage(imageId) // 👈 将会运行在 IO 线程
}
avatarIv.setImageBitmap(image) // 👈 回到 UI 线程更新 UI
}

这种写法看上去好像和刚才那种区别不大,但如果你需要频繁地进行线程切换,这种写法的优势就会体现出来。可以参考下面的对比:

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
kotlin复制代码🏝️
// 第一种写法
coroutineScope.launch(Dispachers.IO) {
...
launch(Dispachers.Main){
...
launch(Dispachers.IO) {
...
launch(Dispacher.Main) {
...
}
}
}
}

// 通过第二种写法来实现相同的逻辑
coroutineScope.launch(Dispachers.Main) {
...
withContext(Dispachers.IO) {
...
}
...
withContext(Dispachers.IO) {
...
}
...
}

由于可以”自动切回来”,消除了并发代码在协作时的嵌套。由于消除了嵌套关系,我们甚至可以把 withContext 放进一个单独的函数里面:

1
2
3
4
5
6
7
8
9
kotlin复制代码🏝️
launch(Dispachers.Main) { // 👈 在 UI 线程开始
val image = getImage(imageId)
avatarIv.setImageBitmap(image) // 👈 执行结束后,自动切换回 UI 线程
}
// 👇
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
...
}

这就是之前说的「用同步的方式写异步的代码」了。

不过如果只是这样写,编译器是会报错的:

1
2
3
4
kotlin复制代码🏝️
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
// IDE 报错 Suspend function'withContext' should be called only from a coroutine or another suspend funcion
}

意思是说,withContext 是一个 suspend 函数,它需要在协程或者是另一个 suspend 函数中调用。

suspend

suspend 是 Kotlin 协程最核心的关键字,几乎所有介绍 Kotlin 协程的文章和演讲都会提到它。它的中文意思是「暂停」或者「可挂起」。如果你去看一些技术博客或官方文档的时候,大概可以了解到:「代码执行到 suspend 函数的时候会『挂起』,并且这个『挂起』是非阻塞式的,它不会阻塞你当前的线程。」

上面报错的代码,其实只需要在前面加一个 suspend 就能够编译通过:

1
2
3
4
5
kotlin复制代码🏝️
//👇
suspend fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
...
}

本篇文章到此结束,而 suspend 具体是什么,「非阻塞式」又是怎么回事,函数怎么被挂起,这些疑问的答案,将在下一篇文章全部揭晓。

练习题

  1. 开启一个协程,并在协程中打印出当前线程名。
  2. 通过协程下载一张网络图片并显示出来。

作者介绍

视频作者

扔物线(朱凯)
  • 码上开学创始人、项目管理人、内容模块规划者和视频内容作者。
  • Android GDE( Google 认证 Android 开发专家),前 Flipboard Android 工程师。
  • GitHub 全球 Java 排名第 92 位,在 GitHub 上有 6.6k followers 和 9.9k stars。
  • 个人的 Android 开源库 MaterialEditText 被全球多个项目引用,其中包括在全球拥有 5 亿用户的新闻阅读软件 Flipboard 。
  • 曾多次在 Google Developer Group Beijing 线下分享会中担任 Android 部分的讲师。
  • 个人技术文章《给 Android 开发者的 RxJava 详解》发布后,在国内多个公司和团队内部被转发分享和作为团队技术会议的主要资料来源,以及逆向传播到了美国一些如 Google 、 Uber 等公司的部分华人团队。
  • 创办的 Android 高级进阶教学网站 HenCoder 在全球华人 Android 开发社区享有相当的影响力。
  • 之后创办 Android 高级开发教学课程 HenCoder Plus ,学员遍布全球,有来自阿里、头条、华为、腾讯等知名一线互联网公司,也有来自中国台湾、日本、美国等地区的资深软件工程师。

文章作者

LewisLuo(罗宇)

LewisLuo(罗宇) ,即刻 Android 工程师。2019 年加入即刻,参与即刻日记功能的开发和迭代及中台基础建设。曾就职于 mobike,负责国际化业务开发。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一线大厂Java面试必问的2大类Tomcat调优

发表于 2019-09-22

一、前言

最近整理了 Tomcat 调优这块,基本上面试必问,于是就花了点时间去搜集一下 Tomcat 调优都调了些什么,先记录一下调优手段,更多详细的原理和实现以后用到时候再来补充记录,下面就来介绍一下,Tomcat 调优大致分为两大类。### 1、Tomcat的自身调优

采用动静分离节约 Tomcat 的性能调整 Tomcat 的线程池调整 Tomcat 的连接器修改 Tomcat 的运行模式禁用 AJP 连接器### 2、JVM的调优

调优Jvm内存
二、Tomcat 自身调优


1、采用动静分离

静态资源如果让 Tomcat 处理的话 Tomcat 的性能会被损耗很多,所以我们一般都是采用:Nginx+Tomcat 实现动静分离,让 Tomcat 只负责 jsp 文件的解析工作,Nginx 实现静态资源的访问。### 2、调优 Tomcat 线程池

打开tomcat的serve.xml,配置Executor,相关参数说明如下。name:给执行器(线程池)起一个名字;namePrefix:指定线程池中的每一个线程的 name 前缀;maxThreads:线程池中最大的线程数量,假设请求的数量超过了 750,这将不是意味着将 maxThreads 属性值设置为 750,它的最好解决方案是使用「Tomcat集群」。也就是说,如果有 1000 请求,两个 Tomcat 实例设置 maxThreads = 500,而不在单 Tomcat 实例的情况下设置 maxThreads=1000。minSpareThreads:线程池中允许空闲的线程数量(多余的线程都杀死);maxIdLeTime:一个线程空闲多久算是一个空闲线程;其他的配置其实阅读官方文档是最好的「见参考链接」。### 3、调优 Tomcat 的连接器 Connector

打开 Tomcat 的 serve.xml,配置 Connector,参数说明如下。)executor:指定这个连接器所使用的执行器(线程池);enableLookups=false:关闭 DNS 解析,减少性能损耗;minProcessors:服务器启动时创建的最少线程数;maxProcessors:最大可以创建的线程数;acceptCount=1000:线程池中的线程都被占用,允许放到队列中的请求数;maxThreads=3000:最大线程数;minSpareThreads=20:最小空闲线程数,这里是一直会运行的线程;与压缩有关系的配置:如果已经对代码进行了动静分离,静态页面和图片等数据就不需要 Tomcat 处理了,那么也就不需要配置在 Tomcat 中配置压缩了;一个完整的配置如下。### 4、通过修改 Tomcat 的运行模式

BIOTomcat8 以下版本,默认使用的就是 BIO「阻塞式IO)」模式。对于每一个请求都要创建一个线程来进行处理,不适合高并发。NIOTomcat8 以上版本,默认使用的就是NIO模式「非阻塞式 IO」。APR全称 Apache Portable Runtime,是Tomcat生产环境运行的首选方式,如果操作系统未安装 APR 或者 APR 路径未指到 Tomcat 默认可识别的路径,则 APR 模式无法启动,自动切换启动 NIO 模式。所以必须要安装 APR 和 Native,直接启动就支持 APR,APR是从操作系统级别解决异步 IO 问题,APR 的本质就是使用 JNI 技术调用操作系统底层的 IO 接口,所以需要提前安装所需要的依赖提升 Tomcat 对静态文件的处理性能,当然也可以采用动静分离。### 5、禁用 AJP 连接器

AJP的全称 Apache JServer Protocol,使用 Nginx+Tomca t的架构,所以用不着 AJP 协议,所以把AJP连接器禁用。三、JVM 调优

Tomcat 是运行在 JVM 上的,所以对 JVM 的调优也是非常有必要的。找到 catalina.sh;)添加;参数设置;

JAVA_OPTS=”-Djava.awt.headless=true -Dfile.encoding=UTF-8-server -Xms1024m -Xmx1024m -XX:NewSize=512m -XX:MaxNewSize=512m -XXermSize=512m -XX:MaxPermSize=512m -XX:+DisableExplicitGC”

调整堆大小的的目的是最小化垃圾收集的时间,以在特定的时间内最大化处理客户的请求。
最后
–

欢迎大家关注我的公众号【程序员追风】,文章都会在里面更新,整理的资料也会放在里面。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

如何用Linux命令行管理网络:11个你必须知道的命令

发表于 2019-09-22

如何用Linux命令行管理网络:11个你必须知道的命令

无论你是要下载文件、诊断网络问题、管理网络接口,还是查看网络的统计数据,都有终端命令可以来完成。这篇文章收集了久经考验靠谱的命令,也收集了几个比较新的命令。

多数命令都可以在图形桌面执行,即使是没什么终端使用经验的Linux用户也会常常执行命令来使用ping或是其它的网络诊断工具。

curl & wget

使用curl或wget命令,不用离开终端就可以下载文件。如你用curl,键入curl -O后面跟一个文件路径。wget则不需要任何选项。下载的文件在当前目录。

1
2
复制代码curl -O website.com/file
wget website.com/file

ping

ping发送ECHO_REQUEST包到你指定的地址。这样你可以很方便确认你的电脑和Internet或是一个指定的IP地址是不是通的。使用-c开关,可以指定发送ECHO_REQUEST包的个数。

1
复制代码ping -c 4 google.com

tracepath & traceroute

tracepath命令和traceroute命令功能类似,但不需要root权限。并且Ubuntu预装了这个命令,traceroute命令没有预装的。tracepath追踪出到指定的目的地址的网络路径,并给出在路径上的每一跳(hop)。如果你的网络有问题或是慢了,tracepath可以查出网络在哪里断了或是慢了。

1
复制代码tracepath example.com

mtr

mtr命令把ping命令和tracepath命令合成了一个。mtr会持续发包,并显示每一跳ping所用的时间。也会显示过程中的任何问题,在下面的示例中,可以看到在第6跳丢了超过20%的包。

1
复制代码mtr howtogeek.com

键入q或是CTRL + C来退出命令。

host

host命令用来做DNS查询。如果命令参数是域名,命令会输出关联的IP;如果命令参数是IP,命令则输出关联的域名。

1
2
复制代码host howtogeek.com
host 208.43.115.82

whois

whois命令输出指定站点的whois记录,可以查看到更多如谁注册和持有这个站点这样的信息。

1
复制代码whois example.com

ifplugstatus

ifplugstatus命令可以告诉你是否有网线插到在网络接口上。这个命令Ubuntu没有预装,通过下面的命令来安装:

1
复制代码sudo apt-get install ifplugd

这个命令可以查看所有网络接口的状态,或是指定网络接口的状态:

1
2
复制代码ifplugstatus
ifplugstatus eth0

命令输出『Link beat detected』(检测到连接心跳)表示有网线插着,如没有则会输出『unplugged』(未插入)。

ifconfig

ifconfig用于输出网络接口配置、调优和Debug的各种选项。可以快捷地查看IP地址和其它网络接口的信息。键入ifconfig查看所有启用的网络接口的状态,包括它们的名字。可以指定网络接口的名字来只显示这一个接口的信息。

1
2
复制代码ifconfig
ifconfig eth0

ifdown & ifup

ifdown和ifup命令和运行ifconfig up,ifconfig down的功能一样。给定网络接口的名字可以只禁用或启用这一个接口。需要root权限,所以在Ubuntu上需要使用sudo来运行。

1
2
复制代码sudo ifdown eth0
sudo ifup eth0

在Linux桌面系统上运行这2个命令,很可能会输出出错信息。Linux桌面通过使用网络管理器(NetworkManager)来管理你的网络接口。不过在没有安装网络管理器的服务器版上,这2个命令仍然可用。

如果确实要在命令行上配置网络管理器,用nmcli命令。

dhclient

dhclient命令可以释放你的电脑的IP地址并从DHCP服务器上获得一个新的。需要root权限,所以在Ubuntu上需要sudo。无选项运行命令获取新IP,或指定-r开关来释放当前的IP地址。

1
2
复制代码sudo dhclient -r
sudo dhclient

netstat

netstat命令可以显示网络接口的很多统计信息,包括打开的socket和路由表。无选项运行命令显示打开的socket。

这条命令还有很多功能。比如,netstat -p命令可以显示打开的socket对应的程序。

netstat -s则显示所有端口的详细统计信息。

原文

github.com/oldratlee/t…

个人微信公众号:

个人github:

github.com/jiankunking

个人博客:

jiankunking.com

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

基于LiveData实现事件总线思路和方案

发表于 2019-09-20

前言

当前市面上, 比较常用的事件总线, 仍然是EventBus和RxBus, 早期我曾经写过EventBus源码解析,这两个框架不论是哪个, 开发者都需要去考虑生命周期的处理.而美团给出了个解决方案, 通过LiveData来实现自带生命周期感知能力的事件总线框架. 本篇我们自己撸一个事件总线框架.

LiveData的原理

我们要用LiveData做事件总线, 总需要知道它是什么, 为什么可以用它来实现事件总线.

LiveData可对数据进行观测, 并具有生命周期感知能力, 这就意味着当liveData只会在生命周期处于活跃(inActive)的状态下才会去执行观测动作, 而他的能力赋予不能脱离LifeCycle的范围.

首先我们可以看下LiveData的UML图, 便于对他有个大概的理解

这里我们需要注意的是,LiveData内维护的mVersion表示的是发送信息的版本,每次发送一次信息, 它都会+1, 而ObserverWrapper内维护的mLastVersion为订阅触发的版本号, 当订阅动作生效的时候, 它的版本号会和发送信息的版本号同步.他们初始值都为-1

订阅

LiveData内部存在一个mObservers用来保存相关绑定的所有观察者, 通过LiveData#observe以及LiveData#oberveForever方法, 我们可以进行订阅动作.如果需要与生命周期绑定, 则需要传入LifecycleOwner对象, 将我们的LiveData数据观测者(Observer)包装注册到生命周期的观测者中, 得以接收到生命周期的变更, 并做出及时的对应更新活动, 我们可以看下LiveData的订阅的方法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码@MainThread
public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<? super T> observer) {
assertMainThread("observe");
// 当前绑定的组件(activity or fragment)状态为DESTROYED的时候, 则会忽视当前的订阅请求
if (owner.getLifecycle().getCurrentState() == DESTROYED) {
return;
}
// 转为带生命周期感知的观察者包装类
LifecycleBoundObserver wrapper = new LifecycleBoundObserver(owner, observer);
ObserverWrapper existing = mObservers.putIfAbsent(observer, wrapper);
// 对应观察者只能与一个owner绑定
if (existing != null && !existing.isAttachedTo(owner)) {
throw new IllegalArgumentException("Cannot add the same observer"
+ " with different lifecycles");
}
if (existing != null) {
return;
}
// lifecycle注册
owner.getLifecycle().addObserver(wrapper);
}

针对我们需要监测生命周期的观察者, LiveData将其包装成了LifecycleBoundObserver对象, 它继承于ObserverWrapper, 并最终实现了GenericLifecycleObserver接口, 通过实现GenericLifecycleObserver#onStateChanged方法获取到生命周期状态变更事件.

发送信息

LiveData#setValue和LiveData#postValue的区别在于一个是在主线程发送信息, 而post是在子线程发送信息, post最终通过指定主线程的Handler执行调用setValue, 所以这里主要看下LiveData#setValue

1
2
3
4
5
6
7
8
9
复制代码@MainThread
protected void setValue(T value) {
assertMainThread("setValue");
// 发送版本+1
mVersion++;
mData = value;
// 信息分发
dispatchingValue(null);
}

当调用setValue的时候, 就相当于是LiveData内部维护的可观测数据发生变化, 则直接触发事件分发

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
复制代码void dispatchingValue(@Nullable ObserverWrapper initiator) {
// mDispatchingValue的判断主要是为了解决并发调用dispatchingValue的情况
// 当对应数据的观察者在执行的过程中, 如有新的数据变更, 则不会再次通知到观察者
// 所以观察者内的执行不应进行耗时工作
if (mDispatchingValue) {
mDispatchInvalidated = true;
return;
}
mDispatchingValue = true;
do {
mDispatchInvalidated = false;
if (initiator != null) {
considerNotify(initiator);
initiator = null;
} else {
for (Iterator<Map.Entry<Observer<? super T>, ObserverWrapper>> iterator =
mObservers.iteratorWithAdditions(); iterator.hasNext(); ) {
considerNotify(iterator.next().getValue());
if (mDispatchInvalidated) {
break;
}
}
}
} while (mDispatchInvalidated);
mDispatchingValue = false;
}

最终, 会走到considerNotify方法, 在保证观察者活跃, 并且他的订阅生效数小于发送数的情况下, 最终触发到我们实现的观察方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码private void considerNotify(ObserverWrapper observer) {
if (!observer.mActive) {
return;
}
if (!observer.shouldBeActive()) {
observer.activeStateChanged(false);
return;
}
if (observer.mLastVersion >= mVersion) {
return;
}
observer.mLastVersion = mVersion;
//noinspection unchecked
observer.mObserver.onChanged((T) mData);
}

要注意的是, LiveData#dispatchingValue除了在我们主动更新数据的时候会触发, 在我们的观察者状态变更(inactive->active)的时候, 也会通知到, 这就导致了LiveData必然支持粘性事件

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
复制代码class LifecycleBoundObserver extends ObserverWrapper implements GenericLifecycleObserver {
@Override
public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {
if (mOwner.getLifecycle().getCurrentState() == DESTROYED) {
removeObserver(mObserver);
return;
}
activeStateChanged(shouldBeActive());
}
}
private abstract class ObserverWrapper {
void activeStateChanged(boolean newActive) {
if (newActive == mActive) {
return;
}
// 当observer的状态从active->inactive, 或者inactive->active的时候走以下流程
mActive = newActive;
boolean wasInactive = LiveData.this.mActiveCount == 0;
LiveData.this.mActiveCount += mActive ? 1 : -1;
if (wasInactive && mActive) {
onActive();
}
if (LiveData.this.mActiveCount == 0 && !mActive) {
// 当前liveData维护的观察者都不活跃, 并且目前的观察者也从active->inactive, 会触发onInactive空方法
// 我们可以覆写onInactive来判断livedata所有观察者失效时候的情况, 比如释放掉一些大内存对象
onInactive();
}
// 当observer是从inactive->active的时候
// 需要通知到观察者
if (mActive) {
dispatchingValue(this);
}
}
}

原理总结

我们概括下来, 关于LiveData可以了解如下:

  1. LiveData的观察者可以联动生命周期, 也可以不联动
  2. LiveData的观察者只能与一个LifecycleOwner绑定, 否则会抛出异常
  3. 当观察者的active状态变更的时候
  4. active->inactive : 如果LiveCycler通知OnDestroy, 则移除对应的观察者, 切当所有观察者都非活跃的状态下时, 会触发onInactive
  5. inactive->active: 会通知观察者最近的数据更新(粘性消息)
  6. 除了观察者状态变更时, 会接收到数据更新的通知外, 还有一种就是在活跃的情况下, 通过开发者主动更新数据, 会接收到数据更新的通知.

基于LiveData的事件总线的实现

可以看出, LiveData本身就已经可观测数据更新, 我们通过维护一张eventName-LiveData的哈希表, 就可以得到一个基础的事件总线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码class LiveDataBus {
internal val liveDatas by lazy { mutableMapOf<String, LiveData<*>>() }

@Synchronized
private fun <T>bus(channel: String): LiveData<T>{
return liveDatas.getOrPut(channel){
LiveDataEvent<T>(channel)
} as LiveData<T>
}

fun <T> with(channel: String): LiveData<T>{
return bus(channel)
}

companion object{
private val INSTANCE by lazy { LiveDataBus() }
@JvmStatic
fun get() = INSTANCE
}
}

但是除了粘性事件以外, 我们还需要非粘性事件的支持, 这里有两种做法.

美团是根据覆写observe方法, 反射获取ObserverWrapper.mLastVersion, 在订阅的时候使得初始化的ObserverWrapper.mLastVersion等于LiveData.mVersion, 使得粘性消息无法通过实现(详细可以看下参考1的文章内容)

这里我用了另外一种做法,粘性消息最终会调到Observer#onChanged, 那么我们就干脆将其再进行一层包装, 内部维护实际的订阅消息数, 来判断是否要触发真正的onChanged方法

1
2
3
4
5
6
7
8
9
10
11
复制代码internal open class ExternalObserverWrapper<T>(val observer: Observer<in T>, val liveData: ExternalLiveData<T>): Observer<T>{
// 新建观察者包装类的时候, 内部实际的version直接等于LiveData的version
private var mLastVersion = liveData.version
override fun onChanged(t: T) {
if(mLastVersion >= liveData.version){
return
}
mLastVersion = liveData.version
observer.onChanged(t)
}
}

我们需要覆写observe方法, 将我们包装的观察者传进去

1
2
3
4
5
6
7
复制代码internal class ExternalLiveData<T>(val key: String) : MutableLiveData<T>(){
@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
super.observe(owner, ExternalObserverWrapper(observer, this, owner))
}

}

需要注意的是, LiveData维护的观察者集合变为我们包装后的观察者集合后, 那么对应的移除观察者方法, 我们也需要重新包装传入, 并且需要额外维护一份真正的观察者和包装后的观察者的对应hash表对象, 并在观察者被移除的时候删除对应的内存对象, 防止内存泄漏的产生, 最终的代码如下

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
复制代码internal class ExternalLiveData<T>(val key: String) : MutableLiveData<T>(){
internal var mObservers = mutableMapOf<Observer<in T>, ExternalObserverWrapper<T>>()

@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
val exist = mObservers.getOrPut(observer){
LifecycleExternalObserver(observer, this, owner).apply {
mObservers[observer] = this
owner.lifecycle.addObserver(this)
}
}
super.observe(owner, exist)
}

@MainThread
override fun observeForever(observer: Observer<in T>) {
val exist = mObservers.getOrPut(observer){
AlwaysExternalObserver(observer, this).apply { mObservers[observer] = this }
}
super.observeForever(exist)
}

@MainThread
fun observeSticky(owner: LifecycleOwner, observer: Observer<in T>) {
super.observe(owner, observer)
}

@MainThread
fun observeStickyForever(observer: Observer<in T>){
super.observeForever(observer)
}

@MainThread
override fun removeObserver(observer: Observer<in T>) {
val exist = mObservers.remove(observer) ?: observer
super.removeObserver(exist)
}

@MainThread
override fun removeObservers(owner: LifecycleOwner) {
mObservers.iterator().forEach { item->
if(item.value.isAttachedTo(owner)){
mObservers.remove(item.key)
}
}
super.removeObservers(owner)
}

override fun onInactive() {
super.onInactive()
if(!hasObservers()){
// 当对应liveData没有相关的观察者的时候
// 就可以移除掉维护的LiveData
LiveDataBus.get().liveDatas.remove(key)
}
}
}

internal open class ExternalObserverWrapper<T>(val observer: Observer<in T>, val liveData: ExternalLiveData<T>): Observer<T>{

private var mLastVersion = liveData.version
override fun onChanged(t: T) {
if(mLastVersion >= liveData.version){
return
}
mLastVersion = liveData.version
observer.onChanged(t)
}

open fun isAttachedTo(owner: LifecycleOwner) = false
}

/**
* always active 的观察者包装类
* @param T
* @constructor
*/
internal class AlwaysExternalObserver<T>(observer: Observer<in T>, liveData: ExternalLiveData<T>):
ExternalObserverWrapper<T>(observer, liveData)

/**
* 绑定生命周期的观察者包装类
* @param T
* @property owner LifecycleOwner
* @constructor
*/
internal class LifecycleExternalObserver<T>(observer: Observer<in T>, liveData: ExternalLiveData<T>, val owner: LifecycleOwner): ExternalObserverWrapper<T>(
observer,
liveData
), LifecycleObserver{
/**
* 当绑定的lifecycle销毁的时候
* 移除掉内部维护的对应观察者
*/
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy(){
liveData.mObservers.remove(observer)
owner.lifecycle.removeObserver(this)
}

override fun isAttachedTo(owner: LifecycleOwner): Boolean {
return owner == this.owner
}
}

事件的约束

正如美团后期讨论的改进文章内所说, 当前的事件总线(不论是EventBus还是LiveEventBus)都没有对事件进行约束, 假如A同学以”event1”字符串定义事件名并发送事件, 而B同学勿写成”eventl”字符串订阅事件, 那么这个事件就永远都接收不到了. 另外当上游删除发送的事件相关代码, 订阅方也无从感知到.
基于此, 参考了Retrofit针对于请求的动态代理的做法, 将事件的定义由事件总线框架本身通过动态代理去实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码class LiveDataBus {
fun <E> of(clz: Class<E>): E {
if(!clz.isInterface){
throw IllegalArgumentException("API declarations must be interfaces.")
}
if(0 < clz.interfaces.size){
throw IllegalArgumentException("API interfaces must not extend other interfaces.")
}
return Proxy.newProxyInstance(clz.classLoader, arrayOf(clz), InvocationHandler { _, method, _->
return@InvocationHandler get().with(
// 事件名以集合类名_事件方法名定义
// 以此保证事件的唯一性
"${clz.canonicalName}_${method.name}",
(method.genericReturnType as ParameterizedType).actualTypeArguments[0].javaClass)
}) as E
}
}

开发者需要先定义一个事件, 才可以对它进行相关的发送和订阅的工作.

1
2
3
4
5
6
7
8
复制代码interface LiveEvents {
/**
* 定义一个事件
* @return LiveEventObserver<Boolean> 事件类型
*/
fun event1(): LiveEventObserver<Boolean>
fun event2(): LiveEventObserver<MutableList<String>>
}

然后开发者可以通过以下方式去发送和订阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码private fun sendEvent(){
LiveDataBus
.get()
.of(LiveEvents::class.java)
.event1()
.post(true)
}

private fun observe(){
LiveDataBus
.get()
.of(LiveEvents::class.java)
.event1()
.observe(this, Observer {
Log.i(LOG, it.toString())
})
}

参考

  1. Android消息总线的演进之路:用LiveDataBus替代RxBus、EventBus
  2. Android组件化方案及组件消息总线modular-event实战
  3. 用LiveData实现一个事件总线

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java面试-如何获取客户端真实IP

发表于 2019-09-20

在进行一些小游戏开发时,我们经常比较关注的一个功能便是分享。针对分享,我们希望能根据各个城市或者地区,能有不同的分享文案,辨识地区的功能如果由服务器来完成的话,我们就需要知道客户端的真实IP。今天我们就来看看服务器是如何获取到客户端的真实IP的。

nginx配置

首先,一个请求肯定是可以分为请求头和请求体的,而我们客户端的IP地址信息一般都是存储在请求头里的。如果你的服务器有用Nginx做负载均衡的话,你需要在你的location里面配置X-Real-IP和X-Forwarded-For请求头:

1
2
3
4
5
复制代码        location ^~ /your-service/ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://localhost:60000/your-service/;
}

X-Real-IP

在《实战nginx》中,有这么一句话:

1
复制代码经过反向代理后,由于在客户端和web服务器之间增加了中间层,因此web服务器无法直接拿到客户端的ip,通过$remote_addr变量拿到的将是反向代理服务器的ip地址。

这句话的意思是说,当你使用了nginx反向服务器后,在web端使用request.getRemoteAddr()(本质上就是获取$remote_addr),取得的是nginx的地址,即$remote_addr变量中封装的是nginx的地址,当然是没法获得用户的真实ip的。但是,nginx是可以获得用户的真实ip的,也就是说nginx使用$remote_addr变量时获得的是用户的真实ip,如果我们想要在web端获得用户的真实ip,就必须在nginx里作一个赋值操作,即我在上面的配置:

1
复制代码proxy_set_header        X-Real-IP       $remote_addr;

X-Forwarded-For

X-Forwarded-For变量,这是一个squid开发的,用于识别通过HTTP代理或负载平衡器原始IP一个连接到Web服务器的客户机地址的非rfc标准,如果有做X-Forwarded-For设置的话,每次经过proxy转发都会有记录,格式就是client1,proxy1,proxy2以逗号隔开各个地址,由于它是非rfc标准,所以默认是没有的,需要强制添加。在默认情况下经过proxy转发的请求,在后端看来远程地址都是proxy端的ip 。也就是说在默认情况下我们使用request.getAttribute("X-Forwarded-For")获取不到用户的ip,如果我们想要通过这个变量获得用户的ip,我们需要自己在nginx添加配置:

1
复制代码proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;

意思是增加一个$proxy_add_x_forwarded_for到X-Forwarded-For里去,注意是增加,而不是覆盖,当然由于默认的X-Forwarded-For值是空的,所以我们总感觉X-Forwarded-For的值就等于$proxy_add_x_forwarded_for的值,实际上当你搭建两台nginx在不同的ip上,并且都使用了这段配置,那你会发现在web服务器端通过request.getAttribute("X-Forwarded-For")获得的将会是客户端ip和第一台nginx的ip。

那么$proxy_add_x_forwarded_for又是什么?

$proxy_add_x_forwarded_for变量包含客户端请求头中的X-Forwarded-For与$remote_addr两部分,他们之间用逗号分开。

举个例子,有一个web应用,在它之前通过了两个nginx转发,www.linuxidc.com即用户访问该web通过两台nginx。

在第一台nginx中,使用:

1
复制代码proxy_set_header            X-Forwarded-For $proxy_add_x_forwarded_for;

现在的$proxy_add_x_forwarded_for变量的X-Forwarded-For部分是空的,所以只有$remote_addr,而$remote_addr的值是用户的ip,于是赋值以后,X-Forwarded-For变量的值就是用户的真实的ip地址了。

到了第二台nginx,使用:

1
复制代码proxy_set_header            X-Forwarded-For $proxy_add_x_forwarded_for;

现在的$proxy_add_x_forwarded_for变量,X-Forwarded-For部分包含的是用户的真实ip,$remote_addr部分的值是上一台nginx的ip地址,于是通过这个赋值以后现在的X-Forwarded-For的值就变成了“用户的真实ip,第一台nginx的ip”,这样就清楚了吧。

服务器获取真实IP

代码为:

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
复制代码  public static String getIpAddress(HttpServletRequest request) {
String Xip = request.getHeader("X-Real-IP");
String XFor = request.getHeader("X-Forwarded-For");

if (!Strings.isNullOrEmpty(XFor) && !"unKnown".equalsIgnoreCase(XFor)) {
//多次反向代理后会有多个ip值,第一个ip才是真实ip
int index = XFor.indexOf(",");
if (index != -1) {
return XFor.substring(0, index);
} else {
return XFor;
}
}
XFor = Xip;
if (!Strings.isNullOrEmpty(XFor) && !"unKnown".equalsIgnoreCase(XFor)) {
return XFor;
}
if (Strings.nullToEmpty(XFor).trim().isEmpty() || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("Proxy-Client-IP");
}
if (Strings.nullToEmpty(XFor).trim().isEmpty() || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("WL-Proxy-Client-IP");
}
if (Strings.nullToEmpty(XFor).trim().isEmpty() || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("HTTP_CLIENT_IP");
}
if (Strings.nullToEmpty(XFor).trim().isEmpty() || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (Strings.nullToEmpty(XFor).trim().isEmpty() || "unknown".equalsIgnoreCase(XFor)) {
XFor = request.getRemoteAddr();
}
return XFor;
}

我们来看看各个请求头的含义

X-Real-IP

nginx代理一般会加上此请求头。

X-FORWARDED-FOR

这是一个Squid开发的字段,只有在通过了HTTP代理或者负载均衡服务器时才会添加该项。

Proxy-Client-IP 和 WL-Proxy-Client-IP

这个一般是经过apache http服务器的请求才会有,用apache http做代理时一般会加上Proxy-Client-IP请求头,而WL-Proxy-Client-IP是它的weblogic插件加上的头。

HTTPCLIENTIP

有些代理服务器会加上此请求头。在网上搜了一下,有一个说法是:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码这是普通的 http header,伪造起来很容易,不要轻易信任用户输入。 

curl -H 'client-ip: 8.8.8.8' lidian.club/phpinfo.php | grep _SERVER
你就能看到 _SERVER["HTTP_CLIENT_IP"] 了。

client-ip 和 client-host 是在 NAPT 还没普及的年代,企业内网假设的 http 透明代理,传给服务器的 header,只有极少数厂家用过,从来不是标准,也从来没成为过事实标准。
(大家最熟悉的事实标准就是 x-forwarded-for)

后来出现的 web proxy 也没见用过这个 header。

TCP/IP Illustrated Vol 3 没有讲过这个 header,网上的传言不可信。
可考的最早痕迹出现在2005年,日本一部 Perl/CGI 秘籍(9784798010779,270页)通过 client-ip 与 via 两个 header 屏蔽代理用户访问。

HTTPXFORWARDED_FOR

简称XFF头,它代表客户端,也就是HTTP的请求端真实的IP,只有在通过了HTTP 代理(比如APACHE代理)或者负载均衡服务器时才会添加该项。它不是RFC中定义的标准请求头信息,在squid缓存代理服务器开发文档中可以找到该项的详细介绍。如果有该条信息, 说明您使用了代理服务器,地址就是后面的数值。可以伪造。标准格式如下:X-Forwarded-For: client1, proxy1, proxy2

总结

以上就是我在处理客户端真实IP的方法,如果你有什么意见或者建议,可以在下方留言。

有兴趣的话可以关注我的公众号或者头条号,说不定会有意外的惊喜。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

MySQL多表联合查询语句的编写及效率分析、优化

发表于 2019-09-19

一、多表连接类型


1. 笛卡尔积(交叉连接)
在MySQL中可以为CROSS JOIN或者省略CROSS即JOIN,或者使用’,’ 如:

1
2
3
复制代码SELECT * FROM table1 CROSS JOIN table2   
SELECT * FROM table1 JOIN table2
SELECT * FROM table1,table2
1
2
3
复制代码SELECT * FROM users CROSS JOIN articles;
SELECT * FROM users JOIN articles;
SELECT * FROM users, articles;

由于其返回的结果为被连接的两个数据表的乘积,因此当有WHERE, ON或USING条件的时候一般不建议使用,因为当数据表项目太多的时候,会非常慢。一般使用LEFT [OUTER] JOIN或者RIGHT [OUTER] JOIN

2. 内连接INNER JOIN
在MySQL中把INNER JOIN叫做等值连接,即需要指定等值连接条件在MySQL中CROSS和INNER JOIN被划分在一起。

1
复制代码SELECT * FROM users as u INNER JOIN articles as a where u.id = a.user_id

3. MySQL中的外连接
分为左外连接和右连接,即除了返回符合连接条件的结果之外,还要返回左表(左连接)或者右表(右连接)中不符合连接条件的结果,相对应的使用NULL对应。

例子:
users表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码+----+----------+----------+--------------------+
| id | username | password | email |
+----+----------+----------+--------------------+
| 1 | junxi | 123 | xinlei3166@126.com |
| 2 | tangtang | 456 | xinlei3166@126.com |
| 3 | ceshi3 | 456 | ceshi3@11.com |
| 4 | ceshi4 | 456 | ceshi4@qq.com |
| 5 | ceshi3 | 456 | ceshi3@11.com |
| 6 | ceshi4 | 456 | ceshi4@qq.com |
| 7 | ceshi3 | 456 | ceshi3@11.com |
| 8 | ceshi4 | 456 | ceshi4@qq.com |
| 9 | ceshi3 | 333 | ceshi3@11.com |
| 10 | ceshi4 | 444 | ceshi4@qq.com |
| 11 | ceshi3 | 333 | ceshi3@11.com |
| 12 | ceshi4 | 444 | ceshi4@qq.com |
+----+----------+----------+--------------------+

userinfos表:

1
2
3
4
5
6
7
复制代码+----+-------+--------+-------------+----------------+---------+
| id | name | qq | phone | link | user_id |
+----+-------+--------+-------------+----------------+---------+
| 1 | 君惜 | 666666 | 16616555188 | www.junxi.site | 1 |
| 2 | 糖糖 | 777777 | 17717555177 | www.weizhi.com | 2 |
| 3 | 测试3 | 333333 | 13313333177 | www.ceshi3.com | 3 |
+----+-------+--------+-------------+----------------+---------+

SQL语句:

1
复制代码SELECT * FROM users as u LEFT JOIN userinfos as i on u.id = i.user_id;

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码+----+----------+----------+--------------------+------+-------+--------+-------------+----------------+---------+
| id | username | password | email | id | name | qq | phone | link | user_id |
+----+----------+----------+--------------------+------+-------+--------+-------------+----------------+---------+
| 1 | junxi | 123 | xinlei3166@126.com | 1 | 君惜 | 666666 | 16616555188 | www.junxi.site | 1 |
| 2 | tangtang | 456 | xinlei3166@126.com | 2 | 糖糖 | 777777 | 17717555177 | www.weizhi.com | 2 |
| 3 | ceshi3 | 456 | ceshi3@11.com | 3 | 测试3 | 333333 | 13313333177 | www.ceshi3.com | 3 |
| 4 | ceshi4 | 456 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 5 | ceshi3 | 456 | ceshi3@11.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 6 | ceshi4 | 456 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 7 | ceshi3 | 456 | ceshi3@11.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 8 | ceshi4 | 456 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 9 | ceshi3 | 333 | ceshi3@11.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 10 | ceshi4 | 444 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 11 | ceshi3 | 333 | ceshi3@11.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 12 | ceshi4 | 444 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
+----+----------+----------+--------------------+------+-------+--------+-------------+----------------+---------+

分析:
而users表中的id大于3的用户在userinfos中没有相应的纪录,但是却出现在了结果集中
因为现在是left join,所有的工作以left为准.
结果1,2,3都是既在左表又在右表的纪录4, 5, 6, 7, 8, 9, 10, 11, 12是只在左表,不在右表的纪录

工作原理:
从左表读出一条,选出所有与on匹配的右表纪录(n条)进行连接,形成n条纪录(包括重复的行),如果右边没有与on条件匹配的表,那连接的字段都是null.然后继续读下一条。
引申:
我们可以用右表没有on匹配则显示null的规律, 来找出所有在左表,不在右表的纪录, 注意用来判断的那列必须声明为not null的。
如:
SQL:
(注意:
1.列值为null应该用is null 而不能用=NULL
2.这里i.user_id 列必须声明为 NOT NULL 的.
)

1
复制代码SELECT * FROM users as u LEFT JOIN userinfos as i on u.id = i.user_id WHERE i.user_id is NULL;

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码+----+----------+----------+---------------+------+------+------+-------+------+---------+
| id | username | password | email | id | name | qq | phone | link | user_id |
+----+----------+----------+---------------+------+------+------+-------+------+---------+
| 4 | ceshi4 | 456 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 5 | ceshi3 | 456 | ceshi3@11.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 6 | ceshi4 | 456 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 7 | ceshi3 | 456 | ceshi3@11.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 8 | ceshi4 | 456 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 9 | ceshi3 | 333 | ceshi3@11.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 10 | ceshi4 | 444 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 11 | ceshi3 | 333 | ceshi3@11.com | NULL | NULL | NULL | NULL | NULL | NULL |
| 12 | ceshi4 | 444 | ceshi4@qq.com | NULL | NULL | NULL | NULL | NULL | NULL |
+----+----------+----------+---------------+------+------+------+-------+------+---------+

一般用法:
a. LEFT [OUTER] JOIN:
除了返回符合连接条件的结果之外,还需要显示左表中不符合连接条件的数据列,相对应使用NULL对应

1
复制代码SELECT column_name FROM table1 LEFT [OUTER] JOIN table2 ON table1.column=table2.column

b. RIGHT [OUTER] JOIN:
RIGHT与LEFT JOIN相似不同的仅仅是除了显示符合连接条件的结果之外,还需要显示右表中不符合连接条件的数据列,相应使用NULL对应

1
复制代码SELECT column_name FROM table1 RIGHT [OUTER] JOIN table2 ON table1.column=table2.column

Tips:

  1. on a.c1 = b.c1 等同于 using(c1)
  2. INNER JOIN 和 , (逗号) 在语义上是等同的
  3. 当 MySQL 在从一个表中检索信息时,你可以提示它选择了哪一个索引。
    如果 EXPLAIN 显示 MySQL 使用了可能的索引列表中错误的索引,这个特性将是很有用的。
    通过指定 USE INDEX (key_list),你可以告诉 MySQL 使用可能的索引中最合适的一个索引在表中查找记录行。
    可选的二选一句法 IGNORE INDEX (key_list) 可被用于告诉 MySQL 不使用特定的索引。如:
1
2
3
4
> 复制代码mysql> SELECT * FROM table1 USE INDEX (key1,key2) WHERE key1=1 AND key2=2 AND key3=3;  
> mysql> SELECT * FROM table1 IGNORE INDEX (key3) WHERE key1=1 AND key2=2 AND key3=3;
>
>
二、表连接的约束条件

添加显示条件WHERE, ON, USING

1. WHERE子句

1
复制代码SELECT * FROM table1,table2 WHERE table1.id=table2.id;
1
复制代码SELECT * FROM users, userinfos WHERE users.id=userinfos.user_id;

2. ON

1
2
复制代码SELECT * FROM table1 LEFT JOIN table2 ON table1.id=table2.id;  
SELECT * FROM table1 LEFT JOIN table2 ON table1.id=table2.id LEFT JOIN table3 ON table2.id=table3.id;
1
2
复制代码SELECT * FROM users LEFT JOIN articles ON users.id = articles.user_id;
SELECT * FROM users LEFT JOIN userinfos ON users.id = userinfos.user_id LEFT JOIN articles ON users.id = articles.user_id;

3. USING子句,如果连接的两个表连接条件的两个列具有相同的名字的话可以使用USING

例如:

1
复制代码SELECT FROM LEFT JOIN USING ()

连接多于两个表的情况举例:

1
复制代码SELECT users.username, userinfos.name, articles.title FROM users LEFT JOIN userinfos ON users.id = userinfos.user_id LEFT JOIN articles ON users.id = articles.user_id;

执行结果:

1
2
3
4
5
6
7
8
9
10
复制代码mysql> SELECT users.username, userinfos.name, articles.title FROM users LEFT JOIN userinfos ON users.id = userinfos.user_id LEFT JOIN articles ON users.id = articles.user_id;
+----------+-------+------------+
| username | name | title |
+----------+-------+------------+
| junxi | 君惜 | 中国有嘻哈 |
| tangtang | 糖糖 | 星光大道 |
| ceshi3 | 测试3 | 平凡的真谛 |
| junxi | 君惜 | python进阶 |
| ceshi3 | NULL | NULL |
| ceshi3 | NULL | NULL |

或者

1
复制代码SELECT users.username, userinfos.name, articles.title FROM users LEFT JOIN userinfos ON users.id = userinfos.user_id LEFT JOIN articles ON users.id = articles.user_id WHERE (articles.user_id IS NOT NULL AND userinfos.user_id IS NOT NULL);

执行结果:

1
2
3
4
5
6
7
8
9
10
复制代码mysql> SELECT users.username, userinfos.name, articles.title FROM users LEFT JOIN userinfos ON users.id = userinfos.user_id LEFT JOIN articles ON users.id = articles.user_id WHERE (articles.user_id IS NOT NULL AND userinfos.user_
id IS NOT NULL);
+----------+-------+------------+
| username | name | title |
+----------+-------+------------+
| junxi | 君惜 | 中国有嘻哈 |
| tangtang | 糖糖 | 星光大道 |
| ceshi3 | 测试3 | 平凡的真谛 |
| junxi | 君惜 | python进阶 |
+----------+-------+------------+

或者

1
复制代码SELECT users.username, userinfos.name, articles.title FROM users LEFT JOIN userinfos ON users.id = userinfos.user_id LEFT JOIN articles ON users.id = articles.user_id WHERE (userinfos.name = '君惜');

执行结果:

1
2
3
4
5
6
7
复制代码mysql> SELECT users.username, userinfos.name, articles.title FROM users LEFT JOIN userinfos ON users.id = userinfos.user_id LEFT JOIN articles ON users.id = articles.user_id WHERE (userinfos.name = '君惜');
+----------+------+------------+
| username | name | title |
+----------+------+------------+
| junxi | 君惜 | 中国有嘻哈 |
| junxi | 君惜 | python进阶 |
+----------+------+------------+

另外需要注意的地方 在MySQL中涉及到多表查询的时候,需要根据查询的情况,想好使用哪种连接方式效率更高。

  1. 交叉连接(笛卡尔积)或者内连接 [INNER | CROSS] JOIN
  2. 左外连接LEFT [OUTER] JOIN或者右外连接RIGHT [OUTER] JOIN 注意指定连接条件WHERE, ON,USING.
三、MySQL如何优化LEFT JOIN和RIGHT JOIN

在MySQL中,A LEFT JOIN B join_condition执行过程如下:
1)· 根据表A和A依赖的所有表设置表B。
2)· 根据LEFT JOIN条件中使用的所有表(除了B)设置表A。
3)· LEFT JOIN条件用于确定如何从表B搜索行。(换句话说,不使用WHERE子句中的任何条件)。
4)· 可以对所有标准连接进行优化,只是只有从它所依赖的所有表读取的表例外。如果出现循环依赖关系,MySQL提示出现一个错误。
5)· 进行所有标准WHERE优化。
6)· 如果A中有一行匹配WHERE子句,但B中没有一行匹配ON条件,则生成另一个B行,其中所有列设置为NULL。
7)· 如果使用LEFT JOIN找出在某些表中不存在的行,并且进行了下面的测试:WHERE部分的col_name IS NULL,其中col_name是一个声明为 NOT NULL的列,MySQL找到匹配LEFT JOIN条件的一个行后停止(为具体的关键字组合)搜索其它行。
RIGHT JOIN的执行类似LEFT JOIN,只是表的角色反过来。

连接优化器计算表应连接的顺序。LEFT JOIN和STRAIGHT_JOIN强制的表读顺序可以帮助连接优化器更快地工作,因为检查的表交换更少。请注意这说明如果执行下面类型的查询,MySQL进行全扫描b,因为LEFT JOIN强制它在d之前读取:

1
复制代码SELECT * FROM a,b LEFT JOIN c ON (c.key=a.key) LEFT JOIN d ON (d.key=a.key) WHERE b.key=d.key;

在这种情况下修复时用a的相反顺序,b列于FROM子句中:

1
复制代码SELECT * FROM b,a LEFT JOIN c ON (c.key=a.key) LEFT JOIN d ON (d.key=a.key) WHERE b.key=d.key;

MySQL可以进行下面的LEFT JOIN优化:如果对于产生的NULL行,WHERE条件总为假,LEFT JOIN变为普通联接。
例如,在下面的查询中如果t2.column1为NULL,WHERE 子句将为false:

1
复制代码SELECT * FROM t1 LEFT JOIN t2 ON (column1) WHERE t2.column2=5;

因此,可以安全地将查询转换为普通联接:

1
复制代码SELECT * FROM t1, t2 WHERE t2.column2=5 AND t1.column1=t2.column1;

这样可以更快,因为如果可以使查询更佳,MySQL可以在表t1之前使用表t2。为了强制使用表顺序,使用RIGHT_JOIN。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring AOP应用场景你还不知道?这篇一定要看!

发表于 2019-09-19

回顾一下Spring AOP的知识

为什么会有面向切面编程(AOP)?我们知道Java是一个面向对象(OOP)的语言,但它有一些弊端,比如当我们需要为多个不具有继承关系的对象引入一个公共行为,例如日志、权限验证、事务等功能时,只能在在每个对象里引用公共行为。这样做不便于维护,而且有大量重复代码。AOP的出现弥补了OOP的这点不足。Spring AOP 中设计的一些核心知识,面试问题?1、能说一下Spring AOP用的是哪种设计模式?回答:代理模式。
2、 能简单聊一下你对代理模式的理解吗?代理模式 balabala……,记住一些贴近日常的示例方便理解,如买火车票,Windows 里面的快捷方式…
3、 知道JDK代理和Cglib代理有什么区别?我们不需要创建代理类,JDK 在运行时为我们动态的来创建,JDK代理是接口 balabala若目标类不存在接口,则使用Cglib生成代理,balabala不管是JDK代理还是Cglib代理本质上都是对字节码进行操作,balabala
4、让你实现一个JDK实现动态代理?你的思路是什么?照葫芦画瓢,照猫画虎。Proxy: 定义一个自己的Proxy类InvocationHandler:定义一个自己的InvocationHandler类ClassLoad:自定义类加载器(方便加载我们自己指定的路径下面的类)
上面简单回顾,并抛出一些问题。带着问题阅读,效果杠杠的。回到本文的重点SpringAOP的在实际应用中场景有哪些?


  1. Authentication 权限
  2. Caching 缓存
  3. Context passing 内容传递
  4. Error handling 错误处理
  5. Lazy loading 懒加载
  6. Debugging 调试
  7. logging,tracing,profiling and monitoring 记录跟踪 优化 校准
  8. Performance optimization 性能优化
  9. Persistence 持久化
  10. Resource pooling 资源池
  11. Synchronization 同步
  12. Transactions 事务
  13. Logging 日志

以日志为例

假如没有aop,在做日志处理的时候,我们会在每个方法中添加日志处理,比如

但大多数的日子处理代码是相同的,为了实现代码复用,我们可能把日志处理抽离成一个新的方法。但是这样我们仍然必须手动插入这些方法。)但这样两个方法就是强耦合的,假如此时我们不需要这个功能了,或者想换成其他功能,那么就必须一个个修改。通过动态代理,可以在指定位置执行对应流程。这样就可以将一些横向的功能抽离出来形成一个独立的模块,然后在指定位置插入这些功能。这样的思想,被称为面向切面编程,亦即AOP。)为了在指定位置执行这些横向的功能,需要知道指定的是什么地方。例如上图,方法级别的aop实现,在一个程序执行链条中,把method2称为切点,也就是说在method2执行时会执行横切的功能,那么是在method2之前还是之后呢,又是执行什么呢?这些都由advice(通知)来指定。advice有5种类型,分别是:* Before(前置通知) 目标方法调用之前执行

  • After(后置通知) 目标方法调用之后执行
  • After-returning(返回通知) 目标方法执行成功后执行
  • After-throwing(异常通知) 目标方法抛出异常后执行
  • Around(环绕通知) 相当于合并了前置和后置

把切点和通知合在一起就是切面了,一个切面指定了在何时何地执行何种方法。在spring aop中如此定义这个切面:

1
2
3
4
5
6
7
8
9
10
复制代码@Aspect
@Component
public class HelloAspect {

@Before("execution(* com.test.service.impl.HelloServiceImpl.sayHello(..))")
public void sayHello(){
System.out.println("hello Java编程技术乐园!");
}

}

使用注解@Aspect将某个特定的类声明为切面,这样,该类下的方法就可以声明为横向的功能点后插入到指定位置。使用execution表达式声明在这个切点,格式如下:第一个位置指定了方法的返回值,*号代表任意类型的返回值,然后是所在的类和方法名,星号同样代表任意,就是该类中任意的方法,在上一个例子中方法名是sayHello,则是指定了该类中的sayHello方法。然后最后一个参数是方法入参,因为Java中支持重载,所以这个参数可以帮助你更精确的进行定位。两点表示任意参数类型。这样,execution表达式告诉了程序该在何地执行通知。而被诸如@Before注解修饰的方法就是通知的内容,也就是做什么。总结

我们使用spring aop,有两点需要注意:1、将切面类声明为一个bean2、切点指定的方法所在的类也同样需由spring注入才能生效
最后
–

欢迎大家关注我的公众号【程序员追风】,文章都会在里面更新,整理的资料也会放在里面。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…856857858…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%