我和Session的不解之“缘”(故事型技术长文)


本文讲述了一路走来对Session的认知。文章有点长,不过是故事型的,应该不枯燥。相信读完也一定会有所收获。

 

 

 

(一)

 

 

“当你登陆系统后,服务器会创建一个Session,保存你的登陆信息,下次再访问时就不需要再登陆。Session可以保存到数据库里或文件里,必要时可以还原出来。”

没错,这就是我十几年前认识的Session。那时正读大学,由于天生对游戏不感兴趣,也没有运动细胞,整天闲得很,唯有学习。

那是一个到处充满着“等等等等,等等等等,等等等等,等”手机铃声的年代。有人会问,iphone呢,估计还在乔布斯的襁褓里精心呵护着的吧。

由于我到大学才算真正接触电脑,而且还是买网卡在宿舍里通过电话线拨号上网,关键还是按时间计费的。

我上网技术本来就很差,再加上上网也不方便,同时受学校环境影响,我就学习.NET了,整天没事就看MSDN(中文离线版的)。

开头的那段话就是我在MSDN里看到的,看过很多遍,所以记忆特别清晰。前半句我能明白,因为我也登陆过系统,确实只需登陆一次,后续就不用登陆了。

但是后半句我就很纳闷,甚至莫名其妙,Session为什么要保存到数据库里呢,这个问题在当时想过很多遍,一直想不通。

不过这也正常,那时候我可能连“文件”这个词都不能很好的理解。更不要提什么“目录”了,你直接告诉我“文件夹”不就行了嘛。

不理解归不理解,但我能记住,只要面试时能balabala说出来,这就够了。

 

 

(二)

 

 

终于毕业了,回到家乡的省会找了一份工作。那个年代的工作是什么呢?

“企业建站大酬宾啦,基础型800,标准型1200,豪华型1600,带会员的外加400。”。

没错,就是用.NET里的ASP.NET(俗称WebForm)开发小型企业网站。

公司非常小,一个美工,一个技术(会asp,.net只懂一点点),一个客服(不是接电话的,是定期打电话回访客户的),一个行政(为啥不叫“前台”呢,因为公司门口没有地方放桌子),再加一个我。

那时的我不叫开发,叫程序员。每天做的事情也不叫写代码,叫“加程序”。当你看到“加程序”这三个字时一定很莫名。我来解释下。

美工做好效果图,客户确认后,会切图做成静态的html页面。我把程序代码加进去(就是把html页面换成.aspx页面),让所有页面“动”起来。估计这就是“加程序”这个叫法的来源,嗯,不错,挺形象的。

可见我这写代码的没一点地位,是我的程序加到人家美工的作品里,人家美工才是大姐大。呜呜。

终于可以使用Session了,不再像大学那会儿总是纸上谈兵。

在.aspx页面对应的C#源代码文件里,有个属性就叫Session(是从System.Web.UI.Page类继承的),可以直接拿来用。

在登陆时把用户信息放入Session,如Session["userName"] = "lixinjie",Session["nickName"] = "李大胖"等。

在登出时把用户信息清除掉,如Session["userName"] = null,Session["nickName"] = null等。

纳尼,就是这样清除的吗?不应该是Session.RemoveAll()或Session.Clear()吗?告诉你,从没这样用过,那时打开SQL Server 2000看到密码竟是用明文存储的,也不要太惊讶哦。

那时刚入行的我也有很多烦恼,为啥静态html页面的布局样式都是好的,等我“加完程序”后页面的布局样式就都乱了。还得让美工帮忙调。

为此我还专门学习div+css布局(为啥不学table布局呢?table土的掉渣,div才高大上呢),貌似最后也没太大改观,布局还是照样乱。

抱着“齐家治国平天下”的我,怎么可能安心在这小公司里天天“加程序”呢。不行,我要辞职,要去大城市,干“大事”。

你们肯定告诉我,辞职要提前一个月提,公司可能要重新招人,做好工作交接什么的。告诉你,那时的我,不存在这些的。你都不知道我大学旷过多少节课,大四时劝室友说,赶紧旷课吧,再不旷就没机会了,马上要毕业了。

下班就跟老板说,我想辞职,第二天就不去了。老板也对得起我,到现在最后一个月的工资还没给我发呢。后来我想,那时都是发现金的,直接发到手上,老板肯定是找不着我所以才不给我发的。嗯,这样想心理平衡多了。

 

 

(三)

 

 

春节后一张前往北京的动车票,我踏上了旅途。那时感觉动车票老贵了,好几百呢。

春节刚过的北京依然寒冷,不过那时2元钱游遍整个北京城还是非常不错的,只是几个月过去了都不知道地面上是啥样子的。

找了一家公司,还是.NET里的ASP.NET开发网站,没办法,那时我只会这个。后来公司准备撤掉Java部门,搞Java的人集体辞职了,由我来负责所有Java项目的收尾工作。

很奇怪为啥是我负责Java的收尾,我可是以.NET的身份进去的。莫非他们觉得我做.NET水平很次?或难道我给他们说过我会Java?不过我确实会Java,只是会Java语法,看过一些JDK文档而已。

那时正是大夏天,天天往客户那里跑,那一号线坐的我爽歪歪,没有空调,人头攒动,汗流浃背。

终于可以在Java里使用session了。

是这样用的,在登陆时,request.getSession().setAttribute("userName", "lixinjie"),request.getSession().setAttribute("nickName", "李大胖")等。

在程序中如果要使用的话,是这样的request.getSession().getAttribute("nickName")等。

在登出时,是这样的request.getSession().removeAttribute("userName"),request.getSession().removeAttribute("nickName")等,终于知道用remove了。

纳尼,就remove一下就完了吗?不应该再request.getSession().invalidate()一下吗?告诉你,从来没用过。

不是还有一个获取session的方法,带一个布尔参数,指示在session不存在时是否创建一个新的。我表示那时从来没见过这个方法,连api去哪里找都不知道。不像微软,就一个MSDN包括所有的内容,Java的东西,这儿一点,那儿一点,到处都是,零零碎碎的,我很懵。

更别提其它什么和session相关的内容了。那时连打包都不知道,就是把编译后的东西一股脑复制到tomcat下面,就完事了。

对于一个在大学看了很多C、C++,OOP,.NET理论,毕业后就用ASP.NET做过几个小网站,然后半路转Java,还没有人带的我,可想而知。

但我能把工作完成的还可以,因为我理论知识还算丰富啊,毕竟都是web开发嘛,.NET和Java好多东西都相通,只是叫法不一样而已。。

但是我对于Java的进步并不快,很长一段时间我都不知道自己用的这些叫servlet api,更不知道去哪里找,因为JDK里没有。

后来我就离开了那家公司,唯一遗憾的就是没有去传媒大学看过美女,虽然天天坐八通线路过人家学校门口。

 

 

(四)

 

 

给客户做的系统已经运行好几年了(我去的时候系统已经存在了),平时的开发不算太忙。

有一天客户说,因为安全问题,他们的网络做了规划,分核心域和互联网域。数据库及访问数据库的代码放入核心域(那个年代还没有redis什么事),其余的代码放入互联网域,外网只能访问互联网域。

这意味着我们的系统要拆分啊。我去,这整个就像一盘意大利面似的(意大利面那时用来形容asp和jsp的,因为它们的html代码和程序代码混合在一起,很乱),根本拆不动,也没时间啊。

我的一个同事(工作时间比我长)说,就在互联网域放一个Nginx服务器做反向代理,把原来整个项目放入核心域。把外部来的HTTP请求通过Nginx转发到核心域,再把响应转发出来,这不就OK了。

我能明白这个做法,但是感觉跟客户想要的不太一样,管他呢,反正客户只注重结果,不太在意细节。这个事情就这样搞定了。

我谷歌(当时谷歌还是可以随便用的)了反向代理这个词,觉得明明就是个代理,为啥要加个反向呢,不明白。Nginx这个词那时对我来说简直就是个新物种了。

后来客户又提了一个需求,说部署一个tomcat不太合适,万一挂了,系统就不能用了。应该再部署一个,提高整体的可靠性。这不就是现在说的高可用嘛,但是在当时,好像不太能听到高可用这个词。

我同事说这简单,Nginx除了反向代理外,还有负载均衡功能,只要配置一下,后面挂两个tomcat就行了。他搞好后我们开始测试,发现明明已经登陆了为啥还要求登陆啊。

后来通过打印日志发现,登陆时是在第一个tomcat上执行的,后续的请求又发往了第二个tomcat。但它上面没有用户的Session啊,Session在第一个上呢,所以就又要求登陆了。

其实最好是做Session共享,但是那个年代确实很少,而且又是“意面式”的老系统,这种方法行不通。

我同事说只要一个用户第一次请求哪个tomcat,后续永远请求那个tomcat不就行了嘛,只要修改下负载均衡策略,设置为ip hash就行了。

这样成功地解决(避免)了问题。这种处理方式还叫高可用吗,哎呀不管了,只要实现客户的需求就行,反正客户那时也关注不到这个细节。

此时我知道了原来Session是由tomcat创建的,驻留在内存里,每个Session都有一个唯一的Id标识,叫做session id。当用户登陆时,这个session id会被服务器写到cookie里传回客户端。

下次这个客户端再发起请求时就会把这个cookie带上,tomcat从cookie里解析出session id,然后去自己的所有session里找,如果找到session说明他已经登陆过了,反之则没有,要求他去登陆。

 

 

(五)

 

 

给西部某省客户做的项目运行良好。另外一个省也有相似的需求,就把这个项目拿来改吧改吧进行复用。该上线时突然意识到,原来的那个省人口少,一个tomcat就能搞定,现在这个可是人口相对多的大省,和客户沟通后决定部署8台tomcat。

用户信息是放到session里的,此时真的要实现session共享了。那时项目里已经用了redis,所以就采用tomcat+redis实现session共享的方案。从网上下载了个jar包,放到tomcat下,然后修改配置文件,重启tomcat,进行测试。

登陆redis里查看,发现并没有session。可能是版本问题,就在网上下载其它版本的jar包,进行重试。试了很长时间,依然是不行,我一直在网上查找解决方法。

当时是一个运维小哥在一直操作,他的大哥运维老大一直在旁边站着,因为那时和现在一样,临近春节,那天正好是公司年会,都着急着走呢,可是问题不解决,谁也走不了。

我在网上查到这种方式只支持redis单节点,不支持集群,而我们项目用的是redis集群。我把这个原因告诉了运维小哥,可是没有redis单节点啊。最后没办法了,运维小哥就把集群停掉,单独剥离出来一个节点启动了。

测试后发现redis里终于有session的数据了。于是我说那就使用单节点吧,可是运维老大不同意,说不安全。那就继续想办法吧。

同时其它同事也在测试,发现结果不对。错误信息是redis的问题,我扒拉扒拉项目代码,发现代码是按访问集群的方式写的。最后就是session共享的方式不支持集群,项目里的代码不支持单节点,我去,完全的自我对抗。

能用的session共享方案只有这一种。重新修改项目代码让它支持单节点吧,也不现实,马上就该上线了。此时我都有点懵了。还都等着去参加年会呢。

实在没办法了,运维老大说这样吧,你们的项目代码还是使用redis集群,我再装一个单节点redis,专门让session共享使用。至此,一个“两全其美”的方案终于出炉。

其实之前我就知道这个session共享方案,但自己没有试验过,也没有了解过其原理,只知道添加一个jar包,修改一些配置就可以了。通过这次事情我终于明白了。

就是重新实现tomcat操作session时的一部代码,并通过修改配置文件的方式把自己的实现加进去来替换掉原来的一部分实现。当tomcat创建session后会把它存入redis,获取session时会去redis里把它读出来,修改session后会重新把它更新到redis里。

所以不管你的请求路由到8台tomcat中的哪一个,最终都是读取的redis里的同一个session数据,这就实现了session共享。

tomcat本身也从有状态的变更为无状态的,可以任意扩展节点了。

 

 

(六)

 

 

随着分布式时代的到来,集群化部署是大趋势。需要找一个适合的session共享方案,优先想到的当然是官方的spring session了。

经过一些努力,终于把spring session用到项目中了。开发/测试的时候,一切正常,等部署上线时发现有问题。

明明上一个请求放到session里的东西,在下一个请求去取的时候竟然没有,这也太奇怪了,生平还是头一次遇到。于是就在获取session的代码部分加个打印语句,首先得把session id输出一下吧。

结果还真有惊喜,每次session id都不一样。这说明了什么,每次都是一个新的session啊。肯定不对啊,但是我不会去怀疑spring session本身会有问题,至于为什么不去怀疑呢,以后的文章会慢慢讲出来。

稍稍稳定一下气息,来分析一下。在代码中是这样获取session的,request.getSession(),这个方法的意思是当有session时就返回session,当没有时就创建一个新的session再返回。

既然每次都是不同的session id,说明了每次都是创建了新的session。也就是因为每次当前都没有session所以才会去创建新的。这就奇怪了,我往里存数据时明明都已经有session了,而且数据都存成功了呢。

存数据时session创建成功是事实,取数据时因没有找到那个session而创建新的这也是事实。我们之所以相信能找到那个session,就是因为把session id放到cookie里传到了服务器端,服务器端根据session id找到session。

赶紧查看浏览器,发现cookie正常传给服务器了。但是服务器的行为却是好像没收到。问题肯定出现在中间某个环节。

随后与客户联系发现,网络中有一个Nginx把cookie过滤掉了。所以请求到达服务器端根本就没了cookie,所以不可能找到session,因此只能创建新的session了。

因客户的网络很难调整,幸运的是spring session支持把session id放入header中传输,至此问题得以解决。

spring session支持多种session的存储介质,当然用的最多的应该还是redis。大家都知道长时间不操作的话session是会过期的。我们第一个想到的就是redis的key也支持过期时间啊。

只要把session的过期时间设置成redis的TTL,在访问session时更新这个TTL就行了。当你不访问时,TTL逐渐减少到0,key过期,session也就过期了,看似很不错。

但是为了完整地支持session的特性,spring session肯定要做的更多。就好比一个健壮的程序里面至少有三分之一的代码都是在处理异常情况一样。

一是redis里的key过期后还是存在的,当你再去访问这个key时,redis发现这个key已过期,才会把这个key删除。当然redis也会按一定的算法去发现过期的key并删除它们。因为这个算法不是地毯式的,所以总会有漏网之鱼。

二是session在过期时需要触发过期事件。由于redis的原因,session过期时可能大家都不知道,所以根本无法触发过期事件。

三是即使我们知道了redis的过期,去触发了session的过期事件,由于此时redis的key已经因为过期而被删除了,所以在session的过期事件里已经获取不到session的数据了。

针对前两个问题,spring session里有个定时任务,定时轮询过期的key,删除key,并触发session的过期事件。

针对第三个问题,可以把session的数据和session的过期时间分开存储,即单独用一个key存储session的过期时间,这样session过期时,session的数据还是存在的,等触发完session的过期事件,稍后再让session的数据本身也过期。

 

 

(七)

 

 

有次对一个已有项目的复用,该项目采用spring xml配置,是通过自定义过滤器实现权限控制的,采用apache shiro实现基于redis的session共享。

由于不想让公司的技术历史拖得太长,就把它改造为spring boot的方式。

自定义权限过滤器使用FilterRegistrationBean的方式进行注册,如下图:

 

 

apache shiro使用shiro官方提供的starter和spring boot进行整合,如下图:

 

 

因shiro是用来做session共享的,正常顺序是先执行shiro获取session,然后再执行自定义权限过滤器过滤权限。我也是这么做的,很可惜运行时报错。在网上搜了好长时间,也按照各种方法修改测试,可惜还是不行。

虽然我之前没有使用过shiro,但是我知道spring security的原理,所以shiro应该和它差不太多。这是我对原理层面的认知。

具体的使用方式是完全按照官方文档来的,应该也不会有问题。所以当时我唯一觉得问题出在顺序上了。即先执行了自定义权限过滤器,此时shiro还没执行呢,所以获取不到session,报错了。

可是无论我怎么调整过滤器的注册顺序,都无济于事。实在是不行了,我只能去源码里砰砰运气了。shiro本身肯定是没有问题的,所以就看starter的源码就行了,好在它的源码很少。

因为我知道shiro会注册一个核心的过滤器,所以一定要把它找到才行。不一会功夫,还真被我找着了。如下图:

 

 

乍一看,没有问题呀,使用FilterRegistrationBean注册过滤器,而且将它的顺序设置为1,我的自定义过滤器注册顺序都是好几百呢,肯定在它后面啊。

仔细一看发现它使用了一个注解@ConditionalOnMissingBean,意思就是当没有注册这个类型的bean时我才会注册。和@Bean方法连用时方法的返回类型就是bean的类型。

很显然这里注册的bean类型就是FilterRegistrationBean类型。因为我的自定义权限过滤器就是使用它注册的,这个类型的bean在容器中已经有了,所以starter里已经不会再去注册shiro的核心过滤器了。

当然,这都是分析推论,究竟是不是这个呢,还要去验证。我去容器里查找,发现果然没有注册shiro的这个过滤器。然后把我的自定义权限过滤器注册部分给注释掉,发现shiro的这个过滤器就被注册了。

事实证明我们的分析是对的。只能说我正好碰到了shiro官方starter的钉子上了。因此我就放弃使用starter的集成方式,使用传统的集成方式了。至此,问题得以解决。

之后我也看了shiro的部分源码,直到有一天我看到了有个接口叫SessionDAO,里面竟然是四个CRUD方法,忽然我就明白了,其实session并没有受到优待,shiro只是把它当作数据拿DAO去操作它。

这就说明它可以被CRUD,可以被序列化和反序列化,可以被传输等。它和普通的数据其实并没有什么本质的不同,唯一的区别可能就是它本身的逻辑复杂些,具有超时时间等这些特性。

也许是十几年前我在微软的MSDN上看到了对Session的特别介绍,使我产生了先入为主的思想,一直觉得session是与众不同的,不过至此我对它已经有了新的认识。

你是不是也是这样认为的呢?欢迎留言告诉我。

 

编程新说


用独特的视角说技术

 

posted on 2019-01-21 08:36  编程新说(李新杰)  阅读(1585)  评论(9编辑  收藏  举报