前言
不积跬步无以至千里,不积小流无以成江海
各位好,骚气的我又来了。猜猜今天带来了什么小姐姐,呸,是带来了什么精彩的文章。 自从上次写了一篇面经之后,收获了很多支持我的粉丝。当然,在粉丝的强烈要求下,还想继续看小姐姐😍 当然,按照我的习惯,,肯定是给大家找到了很多的小姐姐,奈何技术不够,人家网站不让我用爬虫去抓,所以啊,这次只能放一些小姐姐了。 其实我的初衷是在大家阅读的同时也能身心愉悦,享受小姐姐带来的乐趣。奈何,,程序员闷骚男太多,都喜欢小姐姐,不喜欢文章。我就只有这样给大家奉上写好的文章,顺便插入一些小姐姐图。 话不多说,还是开始我们今天的主题。给我的同事妹妹讲一讲MyBatis运行原理。同事妹妹是同事是个妹妹,而不是我同事的妹妹,,别把我想的那么坏。😏
(深夜十二点,我正在激情的LOL中,突然一个电话响起) 妹妹:周哥周哥,你睡了吗? 我:啊,没呢,在看书 妹妹:我今天学习MyBatis源码的时候遇到点问题,你能帮帮我吗? 我:嗯…这么晚了,要不明天吧 妹妹:啊,,可是我今晚上就想弄清楚这个运行原理咋办 我:那行吧,你把电脑打开,我给你远程讲解吧 (远程了十分钟,终于因为网络原因,连不上而放弃了) 妹妹:周哥,你把你的住址发给我,我过来找你 我:这么晚了,不太好吧(快来,快来) 妹妹:诶呀,没事儿的,我都不怕,你怕什么,难道你会吃了我吗? 我:那怎么会,来吧,地址我发定位给你 (二十分钟后…咚咚咚) 我:来了 妹妹:(上身穿着T恤,下身穿着牛仔裤加拖鞋)周哥,实在不好意思,这么晚还来打扰你 我:没事儿,为了学习,可以理解(弟弟别闹) 妹妹:那我们就开始吧
老规矩,图片镇楼… 
MyBatis的分层架构图
ps:这里参考了尚硅谷MyBatis课程的架构图
我:你说你想要了解MyBatis的源码,那先看看这个架构图吧 妹妹:好  妹妹:这个架构图说明了什么啊 我:我给你解释一下哈,其实,我们在看源码的时候,可以大概的把框架抽象成四层。从下到上,分别是引导层、框架支撑层、展示层以及接口层。每一层其实就是对应了我们的每一个步骤。我们想想写的第一个HelloWorld程序。
InputStream in = Resources.getResourceAsStream("mybatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder();
SqlSessionFactory factory = builder.build(in);
SqlSession session = factory.openSession();
UserDao userDao = session.getMapper(UserDao.class);
User users = userDao.findUserById(1);
System.out.println(users);
session.close();
in.close();
我:我们这样来看,第一行代码,其实是加载我们写的配置文件。官方也给我们说了,如果我们不喜欢使用XML配置文件的形式,也可以参考java代码的形式对吧。 妹妹:哦,对,这里我在官网看到过,我给你翻一翻。  我:嗯,对的,就是这里。那我们接着往上看,看框架支撑层。这儿为什么要称作框架支撑层呢,你知道吗? 妹妹:是不是这些图块里面写的东西就是支撑MyBatis这个框架的主体部分呀 我:(摸了摸她的头)你真聪明,大致就是这个意思。我们知道,其实这个框架最主要的还是去解析我们写好的mybatis-config.xml配置文件,然后从里面取出我们写的configuration标签里面的所有内容,包括我们的事务管理这些。 我:那你看上面的两层,你大概能猜出来意思吗? 妹妹:嗯,我猜一下,这上面的这些东西是不是就是用来执行我们SQL的主体部分啊 我:对的。我们再去解析完这个配置文件之后,会去加载一个叫做Executor的类,然后这个类作为我们的执行器,然后从Configuration类中取出我们的SQL语句,这个语句就是我们写的mapper.xml文件中的语句,将这些语句执行完成之后,放在ResultSetHandler中去处理。ResultSet你熟悉吗? 妹妹:哦,这个我熟悉,就是jdbc里面的那个吗? 我:嗯,对的。待会儿我们看看源码,其实会发现,他底层就是用的jdbc里面的方法。 妹妹:好的。那也就是说,最后我们的结果集,其实还是类似于jdbc的做法吗? 我:对的。那你再猜猜,MyBatis是怎么让我们写的Mapper接口还能执行我们写的SQL语句呢? 妹妹:(想了5秒钟)是不是代理呀。 我:对的,就是代理模式。其实在MyBatis底层中,会有一个Map用来装我们的mapper,然后给我们的mapper生成一个代理对象,用代理对象去执行我们的方法。 我:其实,我们要执行语句,并不一定非要写mapper.xml文件,还可以去实现我们的接口来完成查询。所以,MyBatis这里就使用了代理模式。 妹妹:哦,我大概懂了。 我:你知道MyBatis中的拦截器吗? 妹妹:这个我知道,MyBatis的分页插件就是用的拦截器做的。 我:对,待会儿我们去看源码的时候,就能看到里面其实还用了一个责任链模式,来对我们的执行条件进行增强,给我们的Executor中添加拦截器。 妹妹:哇哦,周哥你好厉害。 我:(摸了摸自己头发)😁嘿嘿,哪儿有,这些都是比较基础的东西。那我们先看看源码吧。
ps:为了大家调试方便,最好还是跟着我一起来点一点源码,写写注释什么的(注释需要大家单独下载源码包,github上有,我这儿就不发了)。给大家准备了一份mybatis-helloworld的程序,就是入门级的那种,很简单。  可以直接下载,0积分,SQL文件也在里面。 点我下载mybatis-helloworld
SqlSessionFactory初始化过程
我:刚才给你讲的那些都听懂了吧 妹妹:嗯,听懂了。 我:那我们就开始讲源码了哦。再讲源码之前,你得答应我一件事 妹妹:什么啊 我:讲完了之后,你作为报答,你得亲我一下 妹妹:啊…那好吧,不过呢,你得讲懂了才行,不然的话,我就报警说你调戏良家妇女 我:那必须的(嘿嘿)
我:首先,我们得先看看第一个比较重要的东西,叫做SqlSessionFactory。这个听名字就知道,这个肯定是生产SqlSession的,那怎么来生产的呢? 我:来看看这行代码
InputStream in = Resources.getResourceAsStream("mybatis-config.xml");
SqlSessionFactoryBuilder builder = new SqlSessionFactoryBuilder().build(in);
妹妹:这里,看名字,是不是像用的建造者模式。 我:嗯,对的,这里我们猜一下,其实里面就是用的一个内部类去实现的一个建造者模式,然后来构造我们的SqlSession。我们先点进去源码看看他的build()方法。  我:其实这里很简单啊,他最终其实是调用了有三个参数的方法,然后去new了一个XML配置文件的解析器用来解析我们的mybatis-config.xml文件,解析完成之后,最终是返回了一个DefaultSqlSessionFactory对象。 妹妹:嗯,这里我知道,那在build(parser.parse())这行代码又做了什么呢? 我:这里也正是我们要讲的,我们点进去看看。 
我:看到了吗,其实这个方法,最主要的还是解析我们的mybatis-config.xml文件。在parse()方法中,是调用了一个parseConfiguration()方法,你看到那个 /configuration 像什么,知道吗? 妹妹:哦,这个我知道,就是我们写的那个最外层的 <configuration> 标签是吧。  我:对,我们再看看他调的那个parseConfiguration()方法。  我:看到这些解析节点的方法熟悉吗? 妹妹:哦,我懂了,就是在这里去解析的我们写的那一大堆节点对吧。 我:对的,你看吧,我们写的每一个配置文件中的节点属性都会在这里进行解析。我们再看看最下面的那一排mapperElement()方法,这儿,其实就是解析我们的<mappers> 节点,我们再点进去看看。  我:这儿主要是看我红框框里面的部分。我们分析一下流程。在最上面有个if判断,会去判断我们的<mapper>节点是不是为空,当然,写的肯定不会为空,如果为空就直接报错了。然后又会走下一个if判断。其实这儿的判断主要就是去看你的<mappers> 中的<mapper> 中的属性是不是写的package,当然,我们个人习惯其实都是写resource标签对吧。所以我这儿就不去分析上面的判断,其实和下面相比也就是少了几个流程而已。 我:我们看一下代码,其实这里就还是分析我们写的resource属性中的mapper.xml文件,然后我们再去看看mapperParser.parse()方法;  ps:这个地方注意我蓝色框框中的代码,我先不分析,留个坑,等会儿回来看。 我:你看我红色框框中标注的方法,猜一下,像不像是去解析我们写的mapper.xml中的节点属性,调用的一个方法。 我:我们接着再往下点,看看解析我们的mapper文件是怎么去解析的。  我:你看这儿,像不像是我们写的增删查改的方法标签。 妹妹:哦,我猜一下,这里是不是去解析那些方法标签啊,就是这个。 、 我:对的,你真聪明,其实就是解析我们的这些标签。 我:然后我们继续看看他的方法是干了些什么。  妹妹:哦,到这里,其实就是去解析我们的那些<select> 标签了是吧。 我:对的,我们继续往下看。  我:我们点进去这个方法之后,会发现,他是将我们写的所有属性全都放在了一个叫做MappedStatement的东西去装的。 妹妹:这个MappedStatement又是干嘛的啊。 我:这里啊,这里其实就是引出来了一个大东西,我们点进去方法看看。  我:其实走到这儿,我们就能大概猜出来了,我们所有的东西,都是在一个叫做Configuration类中放着的。而且,一个MappedStatement就是一个增删改查标签。我们写的标签中的所有东西其实都在这里面存着的。 妹妹:我打个断点看看  妹妹:哇,真的欸,这么神奇的吗 我:其实,到这里,我们前面看的那个mapper的解析就已经解析完成了,我们的所有信息都放在configuration中。再回到之前的那个地方,解析/configuration 的地方。(小伙伴别觉得晕哦)  我:到这里,configuration中就已经保存了我们所有的信息了。 妹妹:哇,你真棒。这么复杂你都理清楚了。 我:欸,这些都是小意思。其实啊,这里我们得去注意debug中的一个很重要的属性  我:看见了吗,这里维护了一个HashMap,里面的key存的是我们的UserDao接口,value存的是我们的代理工厂。还记得我们上面有个蓝色的框框吗,那里我是不是说了有个坑,其实就是在这里去解决的。 我:先看看这个mapperRegistery是什么  我:是不是很奇怪,这里居然有一个HashMap,而且这个map中的k和v就是保存的我们debug中的那些属性。但是这里面的值是哪儿来的呢? 妹妹:不知道,,你给我讲一下嘛。 我:还记得前面有个蓝色框框的代码在哪儿吗?其实就是解析我们mybatis-config.xml中<mappers> 节点中的mapper.xml文件。我们看看那儿的代码是怎么走的。  我:看这儿,熟悉吗? 妹妹:嗯,这个是反射吗? 我:对的,他会去利用我们写的resource属性里面的路径去反射我们的类,然后生成一个代理对象。再放进去我们的configuration中。 妹妹:那这儿就是会加到那个map中去吗? 我:嘿嘿,我不告诉你,我们接着往下看。   我:其实在这儿,就是我们的map中的属性进行赋值。所以啊,我们debug出来的结果,就是在这儿进行赋值的。 妹妹:哦,原来是这样啊。难怪,我一直都没看到这个mapper是怎么去生成出来的。 我:这下你搞明白了吧。所以啊,这个就是我们build()方法走的全部流程。 妹妹:那我刚才看到他build()方法执行完了之后返回了一个new DefaultSqlSessionFactory(config)。 我:嗯,你记性可以啊。我们看看。  我:其实啊,这个DefaultSqlSessionFactory还是去实现了SqlSessionFactory接口。所以,我们写的代码里面的SqlSessionFactory就是这么来的。 妹妹:哇,周哥你真厉害。这么绕你都给我讲明白了。 我:嘿嘿,小问题嘛,你要记得你刚才答应的哦。 妹妹:😳我答应了什么啊。 我:亲我啊 妹妹:哎呀,你坏死了。我先去上个厕所哈,周哥,你先去喝口水,我们再接着往下讲 我:好,去吧(弟弟别闹)
ps:至此,第一个build()方法就讲解完毕,看到这里的小伙伴也可以好好的理一下流程。我们马上就继续往下讲。分析清楚这个到底是怎么执行的。在这里也奉上妹妹的一张生活照。 
openSession()方法获取SqlSession()对象
在上面的方法中,我们已经获取到了SqlSessionFactory,里面存放了我们的MappedStatement。经过debug我们发现,其实在第一行代码中,他最主要的就是去解析我们的mybatis-config.xml文件,然后通过解析的标签里面去找到他的mapper文件,再去将我们mapper文件中写的namespace的路径通过反射去加载到一个hashMap中进行存储,然后再去解析我们的sql语句,最后放在我们的MappedStatement中去。 那么下面的这个方法,其实最主要的还是通过我们上面拿到的SqlSessionFactory去获取到我们的SqlSession,通过这个SqlSession去执行我们的sql语句。 废话还是不多说了,我们继续。 ps:顺便提一句,如果是完整的看完了这个,那么后面这个结局肯定是会翻转的,各位lsp就别藏着了,乖乖看完。
妹妹:周哥,那下面这个SqlSession session = factory.openSession();又是干嘛的啊。 我:来,坐我旁边,我们接着看。 我:点进去源码我们看看,到底是做了个什么事情。   我:其实从这儿,可以简单的看出来一些东西。上面的方法最终是走到了这里,调用了openSessionFromDataSource()方法。里面传入了三个参数
ExecutorType:执行器类型
TransactionIsolationLevel:事务的隔离级别
autoCommit:是否自动提交
我:那么这三个参数的值是什么呢?我们来debug跑一下看看。  我:其实我们去看看MyBatis的官方文档,使用的默认执行器就是SIMPLE 妹妹:哦,我看到这个四大对象的一个,是Executor 我:对的,其实在这里,他就会将我们上面获取到的configuration去new一个Executor执行器,然后将我们的事务信息,以及是否自动提交给放进去。 妹妹:那他这个newExecutor()方法是怎么执行的? 我:嗯,那我们点进去看看。 
我:这儿,就是去判断我们的类型,因为我们之前传入的是SIMPLE类型,所以会走else里面的代码块。并且,会去判断一下我们有没有使用缓存。 我:我们再去看一下这里的缓存,到底是怎么去做的。  我:他这个地方的大概意思其实就是,将我们的执行器用缓存去包装一层。那么在后面进行查询的时候,就可以直接去调这里的查询,而不是去一次性跑整个逻辑了。就比如这里:  妹妹:哦,我懂了,其实这里就类似于我们的那个一级缓存是吧。 我:嗯,对的。我们再接着往下看。看一下缓存执行完了之后又做了什么?  我:在这个地方,他又调用了一个方法,叫做拦截器链。你知道这个方法是干嘛的吗? 妹妹:不知道,是干嘛的呀 我:其实在这一步就已经非常重要了。我们知道,MyBatis中可以自己写插件,插件的原理其实就是拦截器对吧。那么在这个地方,他就会将我么写的拦截器去放在我们的executor中去包装一层。我们去看看这个方法。  我:看见没,这里就会调用拦截器的方法,去将我们写的拦截器和Executor进行一次组装。这里其实也就是我们后面要用的分页插件的原理。 妹妹:哦,我懂了。分页其实就是写了个拦截器,然后放在这里,会去拦截到我们的executor,因为executor中有我们的那些sql信息,所以这里的拦截器就会去拦截我们的sql然后进行重写是吧 我:嗯,对的。真聪明 妹妹:😳哎呀,你别夸我了,继续往下讲。 我:好。那这里的Executor封装了一层拦截器之后,就会开始做返回。继续debug看看下面的流程。  我:到这里,基本上获取SqlSession的流程就走完了。我们大概想一下这块做了什么事情。 妹妹:我来说。这一块呢,就是会去根据我们上面获取到的Configutarion,从里面取出我们的当前环境,以及事务,执行器类型,再去对这些进行一次封装成一个Executor。再然后呢,给这个Executor中去把我们写的拦截器也给放进去,用的是一个拦截器链。最后,再进行返回,那么返回回来的这个Executor里面就存放了我们的拦截器以及执行器类型,事务控制这些。再把这个executor和configuration放在我们的SqlSession中,这样,就能拿到全部的信息了,我说的对吗? 我:嗯,还不错,你还是听懂了。那我们接着往下继续分析? 妹妹:好,来吧。 我:哦(😍) 妹妹:你在想啥呢?不正经,快继续讲 我:嘿嘿,逗一逗你嘛。
ps:到这里,我们获取SqlSession就已经分析完了,总结呢,也在妹妹说的话里面,如果有哪儿不清楚的,就在下方留言,我看到了会一一回复的。悄咪咪的告诉你们,刚才妹妹又发了几张生活照给我,我给你们看看。   
getMapper()运行原理解析
妹妹:那下面这一行getMapper()又是怎么执行的呢? 我:我们在最开始的时候是不是说过,MyBatis是用了一个代理类来帮我们的接口做的一个动态代理。那么,这儿的执行其实就是通过我们mapper的名字,去那个map中拿到我们生成出来的动态代理类。我们往下看看   我:其实在这里我们可以看到,他传了两个参数进去,一个是我们的mapper接口,另一个就是我们的SqlSession。在刚才的时候我们说过,这个SqlSession里面维护了一个configuration和Executor。那也就是说,此时在这一步,是有我们的全部配置信息的。自然而然,在maps中就能拿到我们放进去的动态代理类。  我:它将我们的SqlSession传进去了newInstance()方法,在这里,就是调用了JDK动态代理的代理对象,创建了一个代理类再进行返回。 我:所以在最后进行返回的是一个mapper的代理对象。我们根据多态的原则,可以使用接口去进行接收。 妹妹:哦,其实这个地方拿到的就是反射生成出来的代理类对吧。 我:嗯,对的。所以,这个地方还是比较重要的。 妹妹:那下面那个查询方法又是怎么去执行的呢? 我:那个查询方法,其实就是整个MyBatis的结尾了。我们待会儿就能看到,他其实还是用的jdbc的调用。 妹妹:那我们继续往下看吧 我:好,那我就继续了哦,这一块分析的时候有点绕,你要是觉得难受就说停(呸,我不是这个意思,别想偏)。
MyBatis执行查询的真正流程分析
我:我们先打个断点,看看会怎么去执行。  我:我们知道,既然是代理类,那么肯定是会执行invoke()方法的。在第一行这个Object.class.equals()是什么意思你知道吗? 妹妹:嗯,不知道。 我:你看他的参数,其实就是比较我们的这个方法所在的类是不是一个Object类,因为有些方法是toString()方法对吧(所有的类都默认继承了Object),所以在这里会进行一个判断,但是我们这儿执行的是自己写的接口类,所以这里并不会去执行。 我:他会去执行下面的一行代码,就是将我们的method包装成一个MyBatis认识的MapperMethod  我:然后,继续调用execute()方法,传入了两个参数,分别是sqlSession和args。我们知道,sqlSession里面放了我们所有的信息,args呢,其实就是我们方法中的参数值(我传的是1)  我:然后我们继续往下看看执行流程。  我:其中,会去获取到我们当前执行的命令类型,因为我们是select,那么会走下面的case SELECT。然后在里面的if判断,就会去看我们当前返回的是什么类型。因为我们返回的是一个实体类。所以会进入到我们的else语句里面去继续执行。 我:进入到else里面之后,我们再看看 我:第一行就是将我们的参数转成sql语句中的参数。不妨可以进去看看。  我:点进去之后会发现还执行了一个getNamedParams()方法,来到这里,就会去判断我们的args是一个还是多个,将参数进行一个封装再返回。 我:我们回到之前那里,看看将参数封装好之后返回的是什么?
result = sqlSession.selectOne(this.command.getName(), param)

我:它继续调用了这一行代码传入了当前的方法的名字和封装好的参数,我们继续往下看。  我:在selectOne()方法中,其实还是调用了selectList()方法,无非就是可以做一个复用。如果结果只有一个,那么就拿到list中的第一个值就是我们要的结果,我们再点进去看看这个执行方法是怎么执行的  我:我们值卡说过,每一个MappedStatement里面装了我们每一个sql执行的详细信息。  我:在这里拿到信息之后,再去调用了一个 var5 = this.executor.query(ms, this.wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); 方法,我们再点进去看看。  我:在这里去拿到一个BoundSql对象,里面就存放了我们所有的信息。  我:然后往下,去执行我们的缓存查询。  我:然后再往下继续执行query()方法  我:他会先去判断我们有没有开启缓存,这里的缓存其实是一个二级缓存,我们并没有开启。所以会走return 语句
return this.delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
继续往下执行query()方法  我:这儿的代码有点长,我们慢慢来分析。 我:这代码的意思大概就是,他会先去检查我们的二级缓存中是否有值,如果没有值的话,它会继续往下执行  我:其实所以在这里,最终还是调用了一个queryFromDatabase()方法。我们点进去这个方法看一下  我:在这儿,去给本地缓存放一个key,那个key就是我们上面的那一大串值。然后去调用一个doQuery()方法,调用完成之后,将本地缓存中的key移除掉,然后又把key存进去,这次存的值就是我们查询的结果。我们继续点进去doQuery()看看执行结果  我:看第一行,其实底层还是使用的是原生jdbc,然后这儿又出现了一个新的东西,四大对象之一,StatementHandler 妹妹:好晕啊,这么绕 我:别急,马上就快结束了。我们接着往下看 我:这个StatementHandler就能创建除Statement对象,这个Statement对象就是我们原生jdbc中的东西。看见了prepareStatement()吗,熟悉吗? 妹妹:这个我知道,以前jdbc学过。 我:我们来看一下这个StatementHandler是怎么创建的。   我:其实在我们的<select> 标签中,是可以有一个statement属性的 我:这个地方其实就是去判断我们这儿写的是什么属性。默认其实就是PREPARED,在官网中也有说明。   我:在这里创建完成之后,用了拦截器将我们的statementHandler进行一次封装。 ps:这里创建的PrepareStatement详细的预编译不在此次分析目标中,有兴趣的朋友可以自行去看看。 我:然后我们回到之前的地方。  我:然后我们继续点进去query()方法。  我:然后其实到这里就已经执行完了。底层逻辑还是封装的jdbc的c查询方法。 ps:它下面的执行逻辑还是继续用的ResultSetHandler去将结果集进行一次封装,我这儿就不详细说了,不然的越来越绕。 妹妹:啊,太绕了,我感觉它这个底层逻辑都是一层套一层的执行啊。 我:对啊,MyBatis的强大之处就是在这里。
个人分享与源码总结
源码暂时就先分析到这里,我们在阅读源码的过程中,一步一步的引出来了MyBatis的四大对象。
在第一次阅读源码的时候,我个人建议是先debug通读一遍,然后去下载官方源码,在分析的过程中慢慢的写好注释,一步一步的循序渐进。
当然,如果你能找到一个好妹妹听你讲的话,那自然是更好,如果找不到的话,不妨去参考一下小黄鸭调试法(自行百度)。
总结: mybatis运行原理:
- 通过加载mybatis全局配置文件以及mapper映射文件初始化configuration对象
和Executor对象(通过全局配置文件中的defaultExecutorType初始化); - 创建一个defaultSqlSession对象,将configuration对象和Executor对象注入给
defaulSqlSession对象中; - defaulSqlSession通过getMapper()获取mapper接口的代理对象mapperProxy
(mapperProxy中包含defaultSQLSession对象) - 执行增删改查:
1)通过defaulSqlSession中的属性Executor创建statementHandler对象; 2)创建statementHandler对象的同时也创建parameterHandler和 resultSetHandler; 3) 通过parameterHandler设置预编译参数及参数值; 4)调用statementHandler执行增删改查; 5)通过resultsetHandler封装查询结果
故事番外
ps:还记得我上面说的吗,故事会进行一个反转。嘿嘿,来了哦。
妹妹:讲了一个半小时,终于讲完了呀,好累(伸个懒腰) 我:(瞟了一眼,吞口水)嗯,我的嘴巴也讲干了,我去喝个水 (5秒钟过后) 妹妹:giegie,要是你的女朋友知道这么晚我们俩在一个房间,还这么费力的给我讲题,她不会生气吧。 我:我的好妹妹,我没有女朋友。 妹妹:啊,那要不(😚) 我:你还记得最开始答应我的吗? 妹妹:啊,我答应了你什么 我:好啊,你不认账。 妹妹:哪儿有。你把眼睛闭上 我:(mua) 妹妹:哼,这个就算是给你今晚上给我讲题的报答了。 我:嗯,这么晚了,要不我送你回去吧。 妹妹:好啊(起身),哎呀,我钥匙好像忘带了,这个点了,开锁师傅估计也不会接电话了吧 我:那怎么办 妹妹:要不我在沙发将就一晚上吧 我:那怎么行,我睡沙发,你去睡床。 妹妹:可是… 我:别可是了,去睡吧,这么晚了。还好明天周六,不上班。 妹妹:好吧,那我先去睡了哦。 (半夜三点钟,沙发上多了一个人,一看,是妹妹来了) 我:嗯,你干嘛,这么晚了 妹妹:别说话,吻我。 … … … … (苍茫的天涯是我的爱,绵绵的青山脚下花正开) 我:wk,八点了。(睁眼一看,哪里有妹妹)tmd,做了个梦啊。
ps:别急,没这么快就结束的。 (地铁十分钟,到公司了) 我:啊,终于下班了(伸个懒腰)。 妹妹:周哥,今晚上你有事吗? 我:嗯,没啥事儿啊,咋了。 妹妹:那个,反正明天是周六,今晚上我来找你,你能帮我讲一下MyBatis源码吗? 我:(wk)啊,没问题,行 … … … … (未完待续)
|