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

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


  • 首页

  • 归档

  • 搜索

这就是现代php该有的样子(一)

发表于 2018-04-10

本文来自medium—-原文链接; 欢迎作客我们的php&Laravel学习群:109256050

这个标题是不是很自负?没错!尽管我已经使用php工作很多年了,我仍然不能明确的说出这项工作的最佳实践方式和最好的工具,但接下来我要开始试着这样做。

我看到开发者使用php进行开发的工作方式发生了真正的变化,不仅是这门语言随着版本的更新和改进明显的变得越来越成熟和健壮,而且围绕它的整个生态系统正在发生改变。

开发者发布了很多新的工具、第三方库、框架和文章,定义了很多设计模式来使代码更加的优雅和易于理解。一些人在想办法让工作(和你作为开发者的生活)更有效率、简洁和有趣

我并不在新趋势的早期就开始拥抱它们,实际上,我只会采用那些当我确定它们背后有社区支持并且我真的觉得它会提升我的工作的新工具。我经常干的事是努力让我的代码符合最佳实践。

基于这些缘由,我花时间使用像Composer和PHPUnit这样的工具。大概在一年前,我向所有这些闪亮的新事物敞开了我的心。

首先是PSR,然后是Composer、PHPUnit、Travis-ci和一些其他的库和极好的工具。我甚至开始使用IDE(Vim FTW,与Xdebug集成的PHPStrom是一个稳健开发流的必须工具)!

什么是现代?

网页上充斥着大量的关于PHP多么不好、如果你不得不使用PHP代码工作你的生活将多糟糕、这个语言多么的丑和其他的你能想到的文章!

如果你继续与以前遗留代码一起工作,也许你的生活不会那么好,但是如果你有机会在一个新项目上工作,并且能够使用所有的新工具,你将看到我要讨论的新PHP。

我每天都有一些PHP工作上的问题,但是我们不能对语言、社区和生态系统发生的变化视而不见。前面还有很长的路要走,但PHP领域已经成熟了。

我开始为我工作的公司创建内部API的SDK,作为一个宠物项目,并决定遵循最佳实践。他们中的大多数我已经在做了,但是我在做一些事情上做了一些改变。这些变化和我去年学到的都是本文的主题,我称之为现代PHP。

我开始为我工作的公司创建内部API的SDK,仅作为一个兴趣项目并决定遵循最佳实践。他们中的大多数我已经在做了,但是我在做一些事情的方式上做了一些改变。这些变化和我去年学到的知识都是本文的主题,我称之为现代PHP。

让我们来开始工作流

正如我所说,我是这IDE的新手,phpStorm是一个非常非常伟大的软件,我对它一见钟情。这是我的第一个也是唯一的IDE。这是我第一次尝试IDE,我甚至不需要尝试任何其他IDE。

与XDebug的结合非常的完美,PHP名称空间解析,集成Composer,集成GIT,自动补全,代码生成,代码重构等等。

你不是必须使用IDE,实际上,这一点完全是个人爱好。你应该使用任何适合您的需求的工具- Vim,Atom,Emacs,Bracket,NetBeans,phpStorm,Eclipse,等等。这里有两个要点是生产力和效率。您的IDE /文本编辑器必须至少在这两点上帮助您。

对我来说,集成调试器是一个很重要的功能。为大项目编写代码(实际上也适用于小项目),你必须使用一个合用的调试器。让我们忘记那些var_dumps和print_rs。你需要在运行时将这些变量输出出来,分析跟踪栈,设置断点。这些东西是必不可少的并使开发和重构更加容易。

我甚至不知道有其他的选择,XDebug有你需要的一切。你有几分钟时间吗?如果你还没这么做,花一点时间来安装Xdebug,整合进你的IDE或文本编辑器。开始使用正确的工具调试代码。

GitHub是另一个我想让你多加留意的工具。这里的重点是整合。

有几个工具整合与GitHub上你应该开始使用他们。这些工具可以在持续的集成过程中生成度量、运行测试、为您运行作业以及在工作流程中做各种事情。整合是你开始使用GitHub的一个很好的理由。

依赖管理

现代PHP生态系统的另一个要点是依赖管理,而Composer就是做这个工作的工具。

Composer已经5岁了,但在我看来,大量的功能在近几年才采用。也许是因为我不是一个早期拥抱着或者因为PHP开发者都不愿改变。

这个工具提供了一个前端Packagist,它是一个PHP包库,由php库、项目和工具组成,它们的源代码存储在GitHub(或类似的其他地方比如bitbucket)。

我在本文中讨论的所有库,或者可能是您的其中一个项目,可以用简单的下面方法添加到项目中。

$ composer require package_vendor/package_name

如果你不知道某个包的发行方,你可以通过检索包来找到和安装合适的包

$ composer search package_name
如果仅用来管理依赖,Composer会是一个伟大的工具,但是它还做了很多其他的事。花点时间来安装Composer并阅读它的文档。

正确使用命令行接口

我非常喜欢使用CLI接口快速地尝试想法。对我来说,一个伟大的工具是IPython REPL。它可以帮助你补全你的代码,让你轻松定义函数,方便使用文档和其他一些令人惊讶的功能。遗憾的是这个工具是Python的,而不是PHP的。
在PHP世界中,我们有一种叫做“交互模式”的东西,可以通过终端访问,键入下面内容:

1
2
3
复制代码$ php -a
Interactive mode enabled
php >

此时,您处于交互模式,可以开始测试某些东西,它工作了,但是这个工具太不直观了,应为我知道IPython是怎么工作的,所以我尝试了很多次,最后还是放弃使用它了。

幸运的是有一个很酷的新的CLI(命令行界面)和它的名字是Psysh。psysh是一个了不起的工具,充满了有趣的功能,可以使用Composer全局或者本地安装。

对于我来说psysh最好的特征是内嵌文档。访问一个PHP函数的文档不需要转去php.net非常方便。缺点是,在功能完全发挥之前,您需要做一些额外的配置。

安装后,输入以下命令(我用Debian这里,这可能不适合所有人)来让它正常工作

1
2
3
复制代码$ apt-get install php7.1-sqlite3
$ mkdir /usr/local/share/psysh
$ wget http://psysh.org/manual/en/php_manual.sqlite -o /usr/local/share/psysh/php_manual.sqlite

第一条命令不是必须的,如果你已经安装来Sqlite你可以跳过这步。第二条命令创建来一个保存文档的目录,第三条命令下载文档并保存到第二步创建的目录中,注意所有的命令必须以root 用户执行。

然后你可以这样:

去Psysh官网学习更多的关于这个工具的知识

本文转载自: 掘金

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

互联网架构的三板斧

发表于 2018-04-09

【作者简介】本文来自蚂蚁金服技术经理于君泽的分享。于君泽是蚂蚁金服高级技术专家、支付核算技术部负责人、成都研发中心技术团队创建者之一,先后负责或参与过转账类业务、账单类业务、社区支付、开放平台、支付平台、资金核算平台、类营销类支付工具的建设;之前有数年电信业务研发经验,涉及BSS|OSS|针对性营销等平台。推荐一下本文作者的公众号,一个认真、有内涵、但更新不太频繁的技术公众号:TheoryPractice。作者同时也是中生代技术(微信公众号:freshmantechnology)发起人。

本文普及一下传说中的互联网架构三板斧,以便有些场没赶上滴,有些会没参加滴,听完没有来得及消化滴,也能get到技能(学习也是棒棒的)! 有人问了为啥是三板斧,是[三],不是[四],这也是习惯的力量!比如为啥是煎饼果子让西乔姐苦恼了一样。

9fbe56b2fe8dab0d63daa406c9b9bf0c42d489de

可多层包裹的煎饼

a1aea490516b6218dc1e5286e58289fa397cb7ee

可无限扩展的单元化豆腐 Ps:关于[三]的流行参考,百度可得

  • 宅男有三好;Dota、基友、破电脑。

  • 萝莉有三好;柔体、轻音、易推倒。

  • 屌丝有三废;在吗、忙不、早点睡。

  • 女神有三宝;干嘛、呵呵、去洗澡。

    “ 与传统意义上的红包相比,近两年火起来的互联网“红包”,似乎才是如今春节的一大重头戏。春晚直播期间讨论春晚的微博达到5191万条,网友互动量达到1.15亿,网友抢微博红包的总次数超过8亿次。看得见的数据背后,隐藏的是什么看不见的技术呢?”
    按照各家公布的数据,除夕全天微信用户红包总发送量达到10.1亿次,摇一摇互动量达到110亿次,红包峰值发送量为8.1亿次/分钟。而支付宝的红包收发总量达到2.4亿次,参与人数达到6.83亿人次,红包总金额40亿元,峰值为8.83亿次/分钟。春晚直播期间讨论春晚的微博达到5191万条,网友互动量达到1.15亿,网友抢微博红包的总次数超过8亿次。”

本文会以一些公开的内容聊聊万变不离其中的所谓绝招,为了吸引要求,素材主要参考淘宝大秒系统和各家红包系统的情况。

第一板斧

第一板斧:活下来 [Stability]
俗话说身体是革命的本钱,首先要活着。大凡架构还没有舍身成仁的死法,都是好死不如赖活着。关于如何活着,其实是有模式可循的,活着这事在技术界也有一个蛮好听的名字–稳定性(花名 Stability)。稳定性实践笔者印象比较深的有2篇内容,一是由淘宝小邪在2011年分享的[淘宝稳定性实践]。

该材料经墙内搜索百度查询,基本没有别的存档了,唯有百度文库硕果仅存,地址就不贴了。这个slide主要讲了4招:容量规划、集群容灾、依赖降级、运行监控。

84052ecabe69d273aa50b3e8366dededbaee7061

就容灾可以区分为同机房内、同城异机房、异地机房几个层次考虑。

4819880fa575c36103ae48ec4532dd8ca98d68d0

上图机房1种C系统故障切换机房2的情况。

保护自己是很重要的,如下图所示,C挂了,则A调用C超时,如何保障A不被拖累呢?

03aa209d63d819fdfffb0e410c5257e8d358a1eb

阿淘也给出解法,就是stable switch。如下图所示:

4910b41325cdaed990d7b59fb652b9b9a334bda5

随着淘宝、天猫的业务发展,本着更优雅运维的思路,阿淘在稳定性方面的建设肯定有更多精细化,更好、更优雅的做法,但是其本质原则:活着胜过一切!

多个备份、无缝切换、超限限流、断尾求生又称优雅降级等是指导同类互联网应用的常见招数。另外,特别安利一下服务治理,随着微服务兴起,大有不微不能出来见人的姿势。但服务治理是在服务管理中一块重要内容,亘古弥新。如果有上千应用,服务接口不计其数。做一次功能升级,我得知道谁调用了我,我调用了谁;做性能和容量改进,我得知道那条链路是瓶颈;做链路优化,我得通过工具来看那些强依赖可以调整为弱依赖等等。

41d5416a3cc3e2cf2790a742266ab419fe893c4c **一片信手普及的基础文也写得这么干,江湖也不禁为自己点赞了。(话说龙姑娘的红包,啥时候发一下…)

第二篇高大上的ppt是来自国外的一个ppt。**

2d72452f4b5423f77580eaaae6b08bf116457844

c527e7c0a6c8a9fde3d63a854118d7de90800b4a

作者把可用性和稳定性,做了一些区分。可用性偏大局,比如FO,MM模式等。而稳定性强调单区域应用中的手段比如开关,降级等。

2d9c4561a25887d7cd156bf7bde171aa661ff669

第二板斧

第二板斧:简单可扩展(scalability)
啥叫可扩展,就是可以不断加资源以达成更大容量,支撑更高的并发、迎接更多用户。这里的资源可以是应用服务器,也可以是数据库服务器,或者是缓存服务器。

[可扩展Web架构探讨]这个材料中也有对可扩展有所定义。

e3f453532db84769bcb4d5386e1042c22085f3a5

e00949939711e3275b267d4b08ba93cdb3517d6b

这里也提及scalability是系统适应不断增长用户数量的能力。特别提及扩展容易(所有组件都应当简单扩展)、无共享架构(shared arch)。
负载均衡是的作用有几个,一是接入接入保护、失效检测;二是提供在用户和服务器之间做中介,让增减服务器对用户不可见;三是通过负载均衡算法让流量相对均衡的分布。负载均衡有硬件设备也有纯软件比如LVS,负载均衡对于页面请求以及rpc调用都能较均衡的分配,是一个重要的考量因素。
(关于负载均衡的具体内容,此文不赘述)

有一个很古老的文档,叫LiveJournal’s Backend - A history of scaling 叙述了网站的发展历程。

4240b1bb7d95199aa1cafcac6a034f24eeb70ee9

一台server有啥坏处,自不必说了。然后…

0b81e077a80730dc0b0d0d7c18566504478571e3

某一天发展到5台了,3个web server;2台db。使用mysql replication来做复制。

随着用户数的增加,用户需要cluster

ceee24c639671c661f93a2eac3a7a7b221e87ddd

后来因为性能的原因,需要使用cache,包括Mogile Storage等。详情可参见

bd7781c319409743a75c810b7e0d97e2e6df0aca

一个网站的发展后来成了很多架构文章的标配。上更多webserver、上更多dbserver、读写分离、分库分表、上搜索引擎等等。架构之道在合适的时间做合适的决定(tradeoff),运用之道,存乎一心。

第三板斧

第三板斧:拦河大坝、去并发
由于营销活动(创造营销节点、扩大影响力的需要),总有很多产品策划、运营乐此不疲的玩着一个game—在足够集中的时间内比如秒级处理足够多的用户请求,让世界为此狂欢,同时也是彰显技术实力的一次大考。

小米卖着抢号的手机、天猫发明了双11光棍节、微信和支付宝连续2年做着新春红包。营销活动的时候要使用前2板斧,保证可用性和简单可扩展性,同时还要祭出第三板绝杀—拦河大坝、缓存为王、去热点资源的并发。

为啥要拦?很简单,用户很热情(感性),但系统必须得理性!就3个苹果手机,凭啥让几十万人都涌入柜台!在大秒系统一文中许同学就娓娓道来(省得少画个图)……

ec523eac46a95dc3cb5aceba5745d0bbd38ca212

对大流量系统的数据做分层校验也是最重要的设计原则,所谓分层校验就是对大量的请求做成“漏斗”式设计,如上图所示:在不同层次尽可能把无效的请求过滤,“漏斗”的最末端才是有效的请求,要达到这个效果必须对数据做分层的校验,下面是一些原则:

  • 1先做数据的动静分离
  • 2将90%的数据缓存在客户端浏览器
  • 3将动态请求的读数据Cache在Web端
  • 4对读数据不做强一致性校验
  • 5对写数据进行基于时间的合理分片
  • 6对写请求做限流保护
  • 7对写数据进行强一致性校验

将90%的数据缓存在客户端浏览器,将动态请求的读数据cache在web端,还是不够的。在大秒的过程中,存在极端情况,就是请求超过单key所在server的QPS能力。

数据访问热点,比如Detail中对某些热点商品的访问度非常高,即使是Tair缓存这种Cache本身也有瓶颈问题,一旦请求量达到单机极限也会存在热点保护问题。有时看起来好像很容易解决,比如说做好限流就行,但你想想一旦某个热点触发了一台机器的限流阀值,那么这台机器Cache的数据都将无效,进而间接导致Cache被击穿,请求落地应用层数据库出现雪崩现象。这类问题需要与具体Cache产品结合才能有比较好的解决方案,这里提供一个通用的解决思路,就是在Cache的client端做本地Localcache,当发现热点数据时直接Cache在client里,而不要请求到Cache的Server。

数据更新热点,更新问题除了前面介绍的热点隔离和排队处理之外,还有些场景,如对商品的lastmodifytime字段更新会非常频繁,在某些场景下这些多条SQL是可以合并的,一定时间内只执行最后一条SQL就行了,可以减少对数据库的update操作。另外热点商品的自动迁移,理论上也可以在数据路由层来完成,利用前面介绍的热点实时发现自动将热点从普通库里迁移出来放到单独的热点库中。

心得体会

请允许笔者总结一下高并发方案的解决之道。
使用缓存,能越前端缓存的放在前端,这样调用链路最短。
这里的缓存不仅仅是redis、或者memcached,而是local或者climatchent优先的思路,去除对并发资源的依赖。比如[揭秘微信摇一摇背后的技术细节]一文中提到:
按一般的系统实现,用户看到的红包在系统中是数据库中的数据记录,抢红包就是找出可用的红包记录,将该记录标识为match属于某个用户。在这种实现里,数据库是系统的瓶颈和主要成本开销。我们在这一过程完全不使用数据库,可以达到几个数量级的性能提升,同时可靠性有了更好的保障。

  • 1支付系统将所有需要下发的红包生成红包票据文件;
  • 2将红包票据文件拆分后放到每一个接入服务实例中;
  • 3接收到客户端发起摇一摇请求后,接入服务里的摇一摇逻辑拿出一个红包票据,在本地生成一个跟用户绑定的加密票据,下发给客户端;
  • 4客户端拿加密票据到后台拆红包,match后台的红包简化服务通过本地计算即可验证红包,完成抢红包过程。

分拆热点

上文提到,在极端情况下大秒使用了二级缓存,通过拆分key来达到避免超过cache server请求能力的问题。在facebook有一招,就是通过多个key_index(key:xxx#N) ,来获取热点key的数据,其实质也是把key分散。对于非高一致性要求的高并发读还是蛮有效的。如图

则解决之道是:

f7259ff94f6c1e3329ba08ad2cd826830576a080

  • Hot keys are published to all web-servers

  • Each web-server picks an alias for gets

  • – get key:xxx => get key:xxx#N

  • Each web-server deletes all aliases

    微博团队披露:服务端本地缓存,使用nginx本身缓存和服务器的L0缓存,来提升模块的响应速度,做到了90%以上核心接口的响应时间在50ms以内,减少了进程等待时间,提升了服务器的处理速度。

    解决并发有1种基本办法: 分拆!而分拆有两种拆法,
    1拆资源
    一是把资源(resource)拆了,著名的比如ConcurrentHashMap,拆成了16个桶,并发能力大大提高。
    2拆基础数据

在红包应用中,如果还依赖一个共同的基础数据,也可以把这个基础数据拆成多个cell。

预处理

[互动1808亿次,16倍的超越!谈支付宝红包的高并发挑战]一文中如此说:

“在这次春晚活动中,涉及到大量的资源,包括图片、拜年视频等。图片和视频都比较大,十几b到几百kb不等。当在高峰期时,如果瞬间有海量的请求到CDN上,这么大的请求带宽根本扛不住。我们当时预估了大约有几T的带宽需求。

为了能有效地扛住瞬间峰值对CDN的压力,我们对资源进行了有效的管理和控制。首先在客户端预埋了几个缺省资源,万一真不行了,这几个资源可以用。其次尽量提前打开入口,当用户浏览过后,就将资源下载到本地。再次浏览时不再需要远程访问CDN。最后,做了应急预案,高峰期一旦发现流量告警,紧急从视频切换到图片,降低带宽压力,确保不出现带宽不够而发生限流导致的黑屏等体验不好的问题。

最后的结果可以用完美来形容,由于预热充分,带宽虽然很高,但没达到我们的告警值,应急预案也没有使用。

”微信团队也提到:“

在除夕,用户通过摇一摇参与活动,可以摇到红包或其他活动页,这些页面需要用到很多图片、视频或 H5 页面等资源。在活动期间,参与用户多,对资源的请求量很大,如果都通过实时在线访问,服务器的网络带宽会面临巨大压力,基本无法支撑;另外,资源的尺寸比较大,下载到手机需要较长时间,用户体验也会很差。因此,我们采用预先下载的方式,在活动开始前几天把资源推送给客户端,客户端在需要使用时直接从本地加载。”

异步化

江湖传说中有一句话,叫能异步的尽量异步。做活动的时候,资源多宝贵啊,对C端无感但可以容忍的,就让它慢慢做,而此种一般放入到队列当中。

杭州的蘑菇街七公又名小白,是一个热情的朋友。他提及交易服务依赖过多的解决之道。服务依赖过多,会带来管理复杂性增加和稳定性风险增大的问题。试想如果我们强依赖10个服务,9个都执行成功了,最后一个执行失败了,那么是不是前面9个都要回滚掉?这个成本还是非常高的。所以在拆分大的流程为多个小的本地事务的前提下,对于非实时、非强一致性的关联业务写入,在本地事务执行成功后,我们选择发消息通知、关联事务异步化执行的方案。(看看下图,那些可以异步化?)
79521e347bc783446b8b51f3e0863cfa9adbec79
使用队列


拦、拦、拦;之后缓存抗;缓存扛不住的并发分拆;但是还有一个问题,就是极端热点资源在db里,如果并发高还是会出问题。大秒一文中有较好的处理方案,就是排队。Web服务器排队,在db层还做了一个patch排队,真心是业务是最好的老师,不得已何必祭大招!

应用层做排队。按照商品维度设置队列顺序执行,这样能减少同一台机器对数据库同一行记录操作的并发度,同时也能控制单个商品占用数据库连接的数量,防止热点商品占用太多数据库连接。关于详情,可以阅读大秒一文。

总结

总结一下,互联网架构三板斧:

  • 活下来(Stability)

  • 简单可扩展(Scalability)

  • 拦河大坝/去并发

    在第三板斧中又有分层拦截、多级缓存、数据近端、分拆锁、预处理、异步化及队列等pattern。学习原则:灵活运用,不尽信经验。

写这么个长篇,我也是醉醉的了,权当为那些没时间学习、没仔细学习的朋友尽心了。

【引用内容 出处】

《互动1808亿次,16倍的超越!谈支付宝红包的高并发挑战》- QCon公众号

《10亿红包从天而降,揭秘微信摇一摇背后的技术细节》 - InfoQ网站《淘宝大秒系统设计详解》- CSDN《解密微博红包:架构、防刷、监控和资源调度》 - InfoQ网站
http://www.slideshare.net/jboner/scalability-availability-stability-patterns

LiveJournal’s Backend - A history of scaling


中生代技术群微信公众号

![da9312524921e637b684eed7bf3249db58f7badc](https://gitee.com/songjianzaina/juejin_p5/raw/master/img/afbab87064526ab2def550dde7b8f1c2f5a641a802264e6f8dcad9052f6da42c)

本文转载自: 掘金

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

Spring Boot使用Async实现异步调用:Thre

发表于 2018-04-09

上周发了一篇关于Spring Boot中使用@Async来实现异步任务和线程池控制的文章:《Spring Boot使用@Async实现异步调用:自定义线程池》。由于最近身边也发现了不少异步任务没有正确处理而导致的不少问题,所以在本文就接前面内容,继续说说线程池的优雅关闭,主要针对ThreadPoolTaskScheduler线程池。

问题现象

在上篇文章的例子Chapter4-1-3中,我们定义了一个线程池,然后利用@Async注解写了3个任务,并指定了这些任务执行使用的线程池。在上文的单元测试中,我们没有具体说说shutdown相关的问题,下面我们就来模拟一个问题现场出来。

第一步:如前文一样,我们定义一个ThreadPoolTaskScheduler线程池:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码@SpringBootApplication
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}

@EnableAsync
@Configuration
class TaskPoolConfig {

@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskScheduler executor = new ThreadPoolTaskScheduler();
executor.setPoolSize(20);
executor.setThreadNamePrefix("taskExecutor-");
return executor;
}

}

}

第二步:改造之前的异步任务,让它依赖一个外部资源,比如:Redis

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
复制代码@Slf4j
@Component
public class Task {

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Async("taskExecutor")
public void doTaskOne() throws Exception {
log.info("开始做任务一");
long start = System.currentTimeMillis();
log.info(stringRedisTemplate.randomKey());
long end = System.currentTimeMillis();
log.info("完成任务一,耗时:" + (end - start) + "毫秒");
}

@Async("taskExecutor")
public void doTaskTwo() throws Exception {
log.info("开始做任务二");
long start = System.currentTimeMillis();
log.info(stringRedisTemplate.randomKey());
long end = System.currentTimeMillis();
log.info("完成任务二,耗时:" + (end - start) + "毫秒");
}

@Async("taskExecutor")
public void doTaskThree() throws Exception {
log.info("开始做任务三");
long start = System.currentTimeMillis();
log.info(stringRedisTemplate.randomKey());
long end = System.currentTimeMillis();
log.info("完成任务三,耗时:" + (end - start) + "毫秒");
}

}

注意:这里省略了pom.xml中引入依赖和配置redis的步骤

第三步:修改单元测试,模拟高并发情况下ShutDown的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class ApplicationTests {

@Autowired
private Task task;

@Test
@SneakyThrows
public void test() {

for (int i = 0; i < 10000; i++) {
task.doTaskOne();
task.doTaskTwo();
task.doTaskThree();

if (i == 9999) {
System.exit(0);
}
}
}

}

说明:通过for循环往上面定义的线程池中提交任务,由于是异步执行,在执行过程中,利用System.exit(0)来关闭程序,此时由于有任务在执行,就可以观察这些异步任务的销毁与Spring容器中其他资源的顺序是否安全。

第四步:运行上面的单元测试,我们将碰到下面的异常内容。

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
复制代码org.springframework.data.redis.RedisConnectionFailureException: Cannot get Jedis connection; nested exception is redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
at org.springframework.data.redis.connection.jedis.JedisConnectionFactory.fetchJedisConnector(JedisConnectionFactory.java:204) ~[spring-data-redis-1.8.10.RELEASE.jar:na]
at org.springframework.data.redis.connection.jedis.JedisConnectionFactory.getConnection(JedisConnectionFactory.java:348) ~[spring-data-redis-1.8.10.RELEASE.jar:na]
at org.springframework.data.redis.core.RedisConnectionUtils.doGetConnection(RedisConnectionUtils.java:129) ~[spring-data-redis-1.8.10.RELEASE.jar:na]
at org.springframework.data.redis.core.RedisConnectionUtils.getConnection(RedisConnectionUtils.java:92) ~[spring-data-redis-1.8.10.RELEASE.jar:na]
at org.springframework.data.redis.core.RedisConnectionUtils.getConnection(RedisConnectionUtils.java:79) ~[spring-data-redis-1.8.10.RELEASE.jar:na]
at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:194) ~[spring-data-redis-1.8.10.RELEASE.jar:na]
at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:169) ~[spring-data-redis-1.8.10.RELEASE.jar:na]
at org.springframework.data.redis.core.RedisTemplate.randomKey(RedisTemplate.java:781) ~[spring-data-redis-1.8.10.RELEASE.jar:na]
at com.didispace.async.Task.doTaskOne(Task.java:26) ~[classes/:na]
at com.didispace.async.Task$$FastClassBySpringCGLIB$$ca3ff9d6.invoke(<generated>) ~[classes/:na]
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:204) ~[spring-core-4.3.14.RELEASE.jar:4.3.14.RELEASE]
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:738) ~[spring-aop-4.3.14.RELEASE.jar:4.3.14.RELEASE]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:157) ~[spring-aop-4.3.14.RELEASE.jar:4.3.14.RELEASE]
at org.springframework.aop.interceptor.AsyncExecutionInterceptor$1.call(AsyncExecutionInterceptor.java:115) ~[spring-aop-4.3.14.RELEASE.jar:4.3.14.RELEASE]
at java.util.concurrent.FutureTask.run(FutureTask.java:266) [na:1.8.0_151]
at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.access$201(ScheduledThreadPoolExecutor.java:180) [na:1.8.0_151]
at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:293) [na:1.8.0_151]
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) [na:1.8.0_151]
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) [na:1.8.0_151]
at java.lang.Thread.run(Thread.java:748) [na:1.8.0_151]
Caused by: redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
at redis.clients.util.Pool.getResource(Pool.java:53) ~[jedis-2.9.0.jar:na]
at redis.clients.jedis.JedisPool.getResource(JedisPool.java:226) ~[jedis-2.9.0.jar:na]
at redis.clients.jedis.JedisPool.getResource(JedisPool.java:16) ~[jedis-2.9.0.jar:na]
at org.springframework.data.redis.connection.jedis.JedisConnectionFactory.fetchJedisConnector(JedisConnectionFactory.java:194) ~[spring-data-redis-1.8.10.RELEASE.jar:na]
... 19 common frames omitted
Caused by: java.lang.InterruptedException: null
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.reportInterruptAfterWait(AbstractQueuedSynchronizer.java:2014) ~[na:1.8.0_151]
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2088) ~[na:1.8.0_151]
at org.apache.commons.pool2.impl.LinkedBlockingDeque.pollFirst(LinkedBlockingDeque.java:635) ~[commons-pool2-2.4.3.jar:2.4.3]
at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:442) ~[commons-pool2-2.4.3.jar:2.4.3]
at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:361) ~[commons-pool2-2.4.3.jar:2.4.3]
at redis.clients.util.Pool.getResource(Pool.java:49) ~[jedis-2.9.0.jar:na]
... 22 common frames omitted

如何解决

原因分析

从异常信息JedisConnectionException: Could not get a resource from the pool来看,我们很容易的可以想到,在应用关闭的时候异步任务还在执行,由于Redis连接池先销毁了,导致异步任务中要访问Redis的操作就报了上面的错。所以,我们得出结论,上面的实现方式在应用关闭的时候是不优雅的,那么我们要怎么做呢?

解决方法

要解决上面的问题很简单,Spring的ThreadPoolTaskScheduler为我们提供了相关的配置,只需要加入如下设置即可:

1
2
3
4
5
6
7
8
9
复制代码@Bean("taskExecutor")
public Executor taskExecutor() {
ThreadPoolTaskScheduler executor = new ThreadPoolTaskScheduler();
executor.setPoolSize(20);
executor.setThreadNamePrefix("taskExecutor-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
return executor;
}

说明:setWaitForTasksToCompleteOnShutdown(true)该方法就是这里的关键,用来设置线程池关闭的时候等待所有任务都完成再继续销毁其他的Bean,这样这些异步任务的销毁就会先于Redis线程池的销毁。同时,这里还设置了setAwaitTerminationSeconds(60),该方法用来设置线程池中任务的等待时间,如果超过这个时候还没有销毁就强制销毁,以确保应用最后能够被关闭,而不是阻塞住。

完整示例:

读者可以根据喜好选择下面的两个仓库中查看Chapter4-1-4项目:

  • Github:https://github.com/dyc87112/SpringBoot-Learning/
  • Gitee:https://gitee.com/didispace/SpringBoot-Learning/

如果您对这些感兴趣,欢迎star、follow、收藏、转发给予支持!

本文转载自: 掘金

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

机器学习之神经网络识别手写数字(纯python实现)

发表于 2018-04-08

最近在学习机器学习有关神经网络的部分,其中有两份很好的参考资料,链接如下:

Neural Networks and Deep Learning

colah.github.io

前者是关于神经网络和深度学习的一份简单的入门资料,后者是关于神经网络的一系列文章。
前者涉及到神经网络基本概念及其公式的介绍, 搭配手写数字识别的算法,对初学者非常友好。
不过我建议在阅读之前,先看一下更适合入门的视频,链接如下:

深度学习入门

这篇文章我打算在作者已有python2算法的基础上,对其进行python3的转换,同时做出自己的理解,算是加深印象。

神经网络基本结构

1
2
3
4
5
6
7
复制代码class Network(object):
def __init__(self, sizes):
self.num_layers = len(sizes)
self.sizes = sizes
self.biases = [np.random.randn(y, 1) for y in sizes[1:]]
self.weights = [np.random.randn(y, x)
for x, y in zip(sizes[:-1], sizes[1:])]

如上,sizes举例如下,[2, 3, 1]。表示该神经网络有3层,分别为输入层,隐藏层和输出层。
对于该数据集来说,每张图片为28 * 28像素,共计784个像素,输出为10个神经元,取其中最大值作为预测数字。
因此 net = Network([784, 30, 10])表示该神经网络有3层,输入层有784个神经元,输出层有10个神经元。

数据加载

1
2
3
4
5
复制代码def load_data():
f = gzip.open('./data/mnist.pkl.gz', 'rb')
training_data, validation_data, test_data = pickle.load(f, encoding='latin1')
f.close()
return (training_data, validation_data, test_data)

从文件中读取该数据集进行处理。

1
2
3
4
5
6
7
8
9
10
复制代码def load_data_wrapper():
tr_d, va_d, te_d = load_data()
training_inputs = [np.reshape(x, (784, 1)) for x in tr_d[0]]
training_results = [vectorized_result(y) for y in tr_d[1]]
training_data = zip(training_inputs, training_results)
validation_inputs = [np.reshape(x, (784, 1)) for x in va_d[0]]
validation_data = zip(validation_inputs, va_d[1])
test_inputs = [np.reshape(x, (784, 1)) for x in te_d[0]]
test_data = zip(test_inputs, te_d[1])
return (training_data, validation_data, test_data)

经过以上两步处理,training_data包含数据50000组数据,每组数据包含向量分别如下:
前者是784个像素点的灰度值,后者包含10个值,对应该图片的正确分类值。

1
2
3
4
5
6
7
8
9
10
11
复制代码//表示分类为5.
[ 0.],
[ 0.],
[ 0.],
[ 0.],
[ 0.],
[ 1.],
[ 0.],
[ 0.],
[ 0.],
[ 0.]

利用如下方法可以将矩阵转为图片查看:

1
2
3
4
5
6
复制代码    c = tr_d[0][0]
s = np.reshape(c, (784, 1))
img = s.reshape((28, 28))
new_im = Image.fromarray(img)
# print(s)
new_im.show()

以上,数据准备完毕。

基本的概念,比如sigmoid激活函数,随机梯度下降,前向传播算法和反向传播算法,代码中会有实现,有疑问可以参考上述资料。

随机梯度下降

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
复制代码    def SGD(self, training_data, epochs, mini_batch_size, eta, test_data=None):
"""
desc: 随机梯度下降
:param training_data: list of tuples (x,y)
:param epochs: 训练次数
:param mini_batch_size: 随机的最小集合
:param eta: learning rate: 学习速率
:param test_data: 测试数据,有的话会评估算法,但会降低运行速度
:return:
"""
if test_data:
test_data = list(test_data)
n_test = len(test_data)
training_data = list(training_data)
n = len(training_data)
for j in range(epochs):
random.shuffle(training_data)
mini_batches = [
training_data[k: k + mini_batch_size]
for k in range(0, n, mini_batch_size)
]
for mini_batch in mini_batches:
self.update_mini_batch(mini_batch, eta)
if test_data:
print("Epoch {}: {} / {}".format(
j, self.evaluate(test_data), n_test))
else:
print("Epoch {} complete".format(j))

参数的解释如上。
对于每一次迭代,首先打乱数据集,根据随机梯度下降给定的最小batch数据集,将训练数据分开进行。对于每一个batch数据集,用学习速率进行更新。如果有测试集,则评估算法的准确性。评估算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码    def evaluate(self, test_data):
"""
评估测试集的准确性
:param test_data:
:return:
"""
test_results = [(np.argmax(self.feedforward(x)), y)
for (x, y) in test_data]
return sum(int(x == y) for (x, y) in test_results)

def feedforward(self, a):
"""return the output of the network if "a" is input"""
for b, w in zip(self.biases, self.weights):
a = sigmoid(np.dot(w, a) + b)
return a

对于测试数据集,feedforward函数根据随机梯度下降中更新得到的biases, weights计算10个值的预测输出。np.argmax()函数得到10个值中的最大值,即使预测的输出值。
判断是否相等并统计,看共计多少个预测准确。
如上,让我们忽略update_mini_batch()函数

1
2
3
4
5
复制代码if __name__ == '__main__':
training_data, validation_data, test_data = \
mnist_loader.load_data_wrapper()
net = Network([784, 30, 10])
net.SGD(training_data, 30, 10, 3.0, test_data)

初始化神经网络,建立3层,输入层784个神经元,隐藏层30个神经元,输出层10个神经元。
利用随机梯度下降,迭代30次,随机下降的数据集为10个,学习速率为3.0。针对测试集的结果如下:

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
复制代码Epoch 0: 9080 / 10000
Epoch 1: 9185 / 10000
Epoch 2: 9327 / 10000
Epoch 3: 9348 / 10000
Epoch 4: 9386 / 10000
Epoch 5: 9399 / 10000
Epoch 6: 9391 / 10000
Epoch 7: 9446 / 10000
Epoch 8: 9427 / 10000
Epoch 9: 9478 / 10000
Epoch 10: 9467 / 10000
Epoch 11: 9457 / 10000
Epoch 12: 9453 / 10000
Epoch 13: 9440 / 10000
Epoch 14: 9452 / 10000
Epoch 15: 9482 / 10000
Epoch 16: 9470 / 10000
Epoch 17: 9483 / 10000
Epoch 18: 9488 / 10000
Epoch 19: 9484 / 10000
Epoch 20: 9476 / 10000
Epoch 21: 9496 / 10000
Epoch 22: 9469 / 10000
Epoch 23: 9503 / 10000
Epoch 24: 9495 / 10000
Epoch 25: 9499 / 10000
Epoch 26: 9510 / 10000
Epoch 27: 9495 / 10000
Epoch 28: 9487 / 10000
Epoch 29: 9478 / 10000

可以看到随着训练次数的增加,模型的准确率在不断提高。

随机梯度下降更新biases和weights

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码    def update_mini_batch(self, mini_batch, eta):
"""
梯度下降更新weights和biases, 用到backpropagation反向传播。
:param mini_batch:
:param eta:
:return:
"""
nabla_b = [np.zeros(b.shape) for b in self.biases]
nabla_w = [np.zeros(w.shape) for w in self.weights]

for x, y in mini_batch:
delta_nabla_b, delta_nabla_w = self.backprop(x, y)
nabla_b = [nb + dnb for nb, dnb in zip(nabla_b, delta_nabla_b)]
nabla_w = [nw + dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]

self.weights = [w - (eta / len(mini_batch)) * nw
for w, nw in zip(self.weights, nabla_w)]
self.biases = [b - (eta / len(mini_batch)) * nb
for b, nb in zip(self.biases, nabla_b)]

首先初始化与biases和weights相同大小的矩阵。
对于每一个nimi_batch的x(像素矩阵, 784), y(预测矩阵, 10),利用反向传播算法计算

delta_nabla_b, delta_nabla_w = self.backprop(x, y)得到每一次的梯度,相加得到mini_batch的梯度,进行权重和偏置项更新。

反向传播算法

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
复制代码    def backprop(self, x, y):
"""
:param x:
:param y:
:return: (nabla_b, nabla_w): gradient for 损失函数,类似于biaes, weight。
"""
nabla_b = [np.zeros(b.shape) for b in self.biases]
nabla_w = [np.zeros(w.shape) for w in self.weights]
# feedforward
activation = x
activations = [x] # 存储所有激活值
zs = [] # 存储所有的z向量
for b, w in zip(self.biases, self.weights):
z = np.dot(w, activation) + b
zs.append(z)
activation = sigmoid(z)
activations.append(activation)

# backward pass
delta = self.cost_derivative(activations[-1], y) * \
sigmoid_prime(zs[-1])
nabla_b[-1] = delta
nabla_w[-1] = np.dot(delta, activations[-2].transpose())

for l in range(2, self.num_layers):
z = zs[-l]
sp = sigmoid_prime(z)
delta = np.dot(self.weights[-l + 1].transpose(), delta) * sp
nabla_b[-l] = delta
nabla_w[-l] = np.dot(delta, activations[-l - 1].transpose())
return (nabla_b, nabla_w)

def cost_derivative(self, output_activations, y):
"""
:param output_activations:
:param y:
:return: 给定输出激发。
"""
return (output_activations - y)

上面就是最重要也就是最复杂的反向传播算法。该算法mini_bitch中的向量为参数,
首先也是初始化与biases和weights相同大小的矩阵,并存储所有的激活值。

1
2
3
4
5
复制代码 for b, w in zip(self.biases, self.weights):
z = np.dot(w, activation) + b
zs.append(z)
activation = sigmoid(z)
activations.append(activation)

上述函数对biases和weights进行运算,通过sigmoid函数得到激活值。
接着求出最后一层的参数。

1
2
3
4
5
6
复制代码        for l in range(2, self.num_layers):
z = zs[-l]
sp = sigmoid_prime(z)
delta = np.dot(self.weights[-l + 1].transpose(), delta) * sp
nabla_b[-l] = delta
nabla_w[-l] = np.dot(delta, activations[-l - 1].transpose())

该函数从倒数第二层开始,迭代分别求出每一层梯度值,返回更新梯度。

至此算法的全部代码完成。
完整代码请查看:

github: code

总结:

  • python3
  • 神经网络入门
  • 随机梯度下降
  • 反向传播算法

todo:

利用这个思想去看kaggle上的手写数字识别的题目。

本文转载自: 掘金

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

后端进阶书籍推荐(持续更新)

发表于 2018-04-07

经常收到一些同学的咨询,对技术很有热情,但是不知道要看什么书。大学毕业之后我看了不少书,涉及的面比较广,基本上相当于再次读了一次大学。

多读书始终是好的,于是,抽时间整理了一些个人觉得比较好的书单,百分之八十我读过,还有些是朋友推荐的,不仅限于提升技术硬实力,还包括提升自身软实力的书籍。后续我觉得好的书籍,会持续更新~

世上没有一劳永逸,请努力坚持到底。

  1. Java

《大型网站技术架构:核心原理与案例分析》
《构建高性能Web站点(修订版)》
《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)》
《Java并发编程实战》
《Spring实战(第4版)》
《Java性能优化权威指南》
《Effective Java中文版(第2版)》
《重构 改善既有代码的设计》
《程序员修炼之道——从小工到专家》
《Lucene实战(第2版)》
《代码大全》

2 C/C++

《征服C指针》
《C++ Primer中文版(第5版)》
《深度探索C++对象模型》
《STL 源码剖析》
《C++ 沉思录》
《Effective C++:改善程序与设计的55个具体做法(第三版)》

3 网络

《图解TCP/IP:第5版》
《UNIX 环境高级编程(第2版)》
《UNIX网络编程 卷1》
《Linux多线程服务端编程:使用muduo C++网络库》
《TCP/IP 详解(卷1:协议)》

4 计算机基础

《计算机组成原理 (第2版)》
《汇编语言(第3版)》
《深入理解计算机系统(原书第3版)》

5 操作系统

《鸟哥的Linux私房菜 基础学习篇(第三版)》
《深入理解Linux内核(第三版)》

6 数据存储

《大话存储【终极版】》
《MySQL技术内幕:InnoDB存储引擎(第2版)》
《Hadoop权威指南(第3版)》

7 软件工程

《人月神话》
《人件》

8 哲学

《论语别裁》
《原本大学微言》
《金刚经说什么》

9 管理

《金字塔原理》
《第五项修炼:学习型组织的艺术与实践》
《从优秀到卓越》

10 成功/励志

《大师亚当斯》

打赏 欢迎关注人生设计师的微信公众账号
公众号ID:longjiazuoA

本文转载自: 掘金

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

使用 PHP 来做 Vuejs 的 SSR 服务端渲染

发表于 2018-04-03

对于客户端应用来说,服务端渲染是一个热门话题。然而不幸的是,这并不是一件容易的事,尤其是对于不用 Node.js 环境开发的人来说。

我发布了两个库让 PHP 从服务端渲染成为可能.spatie/server-side-rendering 和 spatie/laravel-server-side-rendering适配
laravel 应用。

让我们一起来仔细研究一些服务端渲染的概念,权衡优缺点,然后遵循第一法则用 PHP 建立一个服务端渲染。

什么是服务端渲染

一个单页应用(通常也叫做 SPA )是一个客户端渲染的 App 。这是一个仅在浏览器端运行的应用。如果你正在使用框架,比如 React, Vue.js 或者 AngularJS ,客户端将从头开始渲染你的 App 。

浏览器的工作

在 SPA 被启动并准备使用之前,浏览器需要经过几个步骤。

  • 下载 JavaScript 脚本
  • 解析 JavaScript 脚本
  • 运行 JavaScript 脚本
  • 取回数据(可选,但普遍)
  • 在原本的空容器渲染应用 (首次有意义的渲染)
  • 准备完成! (可以交互啦)

用户不会看到任何有意义的内容,直到浏览器完全渲染 App(需要花费一点时间)。这会造成一个明显的延迟,直到 首次有意义的渲染 完成,从而影响了用户体验。

这就是为什么服务端渲染(一般被称作 SSR )登场的原因。SSR 在服务器预渲染初始应用状态。这里是浏览器在使用服务端渲染后需要经过的步骤:

  • 渲染来自服务端的 HTML (首次有意义的渲染)
  • 下载 JavaScript 脚本
  • 解析 JavaScript 脚本
  • 运行 JavaScript 脚本
  • 取回数据
  • 使已存在的 HTML 页面可交互
  • 准备完成! (可以交互啦)

由于服务器提供了 HTML 的预渲染块,因此用户无需等到一切完成后才能看到有意义的内容。注意,虽然 交互时间 仍然处于最后,但可感知的表现得到了巨大的提升。

服务端渲染的优点

服务端渲染的主要优点是可以提升用户体验。并且,如果你的网站需要应对不能执行 JavaScript 的老旧爬虫,SSR 将是必须的,这样,爬虫才能索引服务端渲染过后的页面,而不是一个空荡荡的文档。

服务端如何渲染?

记住服务端渲染并非微不足道,这一点很重要。当你的 Web 应用同时运行在浏览器和服务器,而你的 Web 应用依赖 DOM 访问,那么你需要确保这些调用不会在服务端触发,因为没有 DOM API 可用。

基础设施复杂性

假设你决定了服务端渲染你的应用端程序,你如果正在阅读这篇文章,很大可能正在使用 PHP 构建应用的大部分(功能)。但是,服务端渲染的 SPA 需要运行在 Node.js 环境,所以将需要维护第二个程序。

你需要构建两个应用程序之间的桥梁,以便它们进行通信和共享数据:需要一个 API。构建无状态 API 相比于构建有状态是比较 困难 的。你需要熟悉一些新概念,例如基于 JWT 或 OAUTH 的验证,CORS,REST ,添加这些到现有应用中是很重要的。

有得必有所失,我们已经建立了 SSR 以增加 Web 应用的用户体验,但 SSR 是有成本的。

服务器端渲染权衡取舍

服务器上多了一个额外的操作。一个是服务器增加了负载压力,第二个是页面响应时间也会稍微加长。 不过因为现在服务器返回了有效内容,在用户看来,第二个问题的影响不大。

大部分时候你会使用 Node.js 来渲染你的 SPA 代码。如果你的后端代码不是使用 Javascript 编写的话,新加入 Node.js 堆栈将使你的程序架构变得复杂。

为了简化基础架构的复杂度, 我们需要找到一个方法,使已有的 PHP 环境作为服务端来渲染客户端应用。

在 PHP 中渲染 JavaScript

在服务器端渲染 SPA 需要集齐以下三样东西:

  • 一个可以执行 JavaScript 的引擎
  • 一个可以在服务器上渲染应用的脚本
  • 一个可以在客户端渲染和运行应用的脚本

SSR scripts 101

下面的例子使用了 Vue.js。你如果习惯使用其它的框架(例如 React),不必担心,它们的核心思想都是类似的,一切看起来都是那么相似。

简单起见,我们使用经典的 “ Hello World ” 例子。

下面是程序的代码(没有 SSR):

1
2
3
4
5
6
7
8
9
10
gradle复制代码// app.js
import Vue from 'vue'

new Vue({
template: `
<div>Hello, world!</div>
`,

el: '#app'
})

这短代码实例化了一个 Vue 组件,并且在一个容器(id 值为 app 的 空 div)渲染。

如果在服务端运行这点脚本,会抛出错误,因为没有 DOM 可访问,而 Vue 却尝试在一个不存在的元素里渲染应用。

重构这段脚本,使其 可以 在服务端运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
javascript复制代码// app.js
import Vue from 'vue'

export default () => new Vue({
template: `
<div>Hello, world!</div>
`
})

// entry-client.js
import createApp from './app'

const app = createApp()

app.$mount('#app')

我们将之前的代码分成两部分。app.js 作为创建应用实例的工厂,而第二部分,即 entry-client.js,会运行在浏览器,它使用工厂创建了应用实例,并且挂载在 DOM。

现在我们可以创建一个没有 DOM 依赖性的应用程序,可以为服务端编写第二个脚本。

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码// entry-server.js
import createApp from './app'
import renderToString from 'vue-server-renderer/basic'

const app = createApp()

renderToString(app, (err, html) => {
if (err) {
throw new Error(err)
}
// Dispatch the HTML string to the client...
})

我们引入了相同的应用工厂,但我们使用服务端渲染的方式来渲染纯 HTML 字符串,它将包含应用初始状态的展示。

我们已经具备三个关键因素中的两个:服务端脚本和客户端脚本。现在,让我们在 PHP 上运行它吧!

执行 JavaScript

在 PHP 运行 JavaScript,想到的第一个选择是 V8Js。V8Js 是嵌入在 PHP 扩展的 V8 引擎,它允许我们执行 JavaScript。

使用 V8Js 执行脚本非常直接。我们可以用 PHP 中的输出缓冲和 JavaScript 中的 print 来捕获结果。

1
2
3
4
5
6
7
8
9
10
php复制代码$v8 = new V8Js();

ob_start();

// $script 包含了我们想执行的脚本内容

$v8->executeString($script);

echo ob_get_contents();
print('<div>Hello, world!</div>')

这种方法的缺点是需要第三方 PHP 扩展,而扩展可能很难或者不能在你的系统上安装,所以如果有其他(不需要安装扩展的)方法,它会更好的选择。

这个不一样的方法就是使用 Node.js 运行 JavaScript。我们可以开启一个 Node 进程,它负责运行脚本并且捕获输出。
Symfony 的 Process 组件就是我们想要的。

1
2
3
4
5
6
7
8
9
awk复制代码use Symfony\Component\Process\Process;

// $nodePath 是可执行的 Node.js 的路径
// $scriptPath 是想要执行的 JavaScript 脚本的路径

new Process([$nodePath, $scriptPath]);

echo $process->mustRun()->getOutput();
console.log('<div>Hello, world!</div>')

注意,(打印)在 Node 中是调用 console.log 而不是 print 。

让我们一起来实现它吧!

spatie/server-side-rendering 包的其中一个关键理念是 引擎 接口。引擎就是上述 JavaScript 执行的一个抽象概念。

1
2
3
4
5
6
7
8
9
10
actionscript复制代码namespace Spatie\Ssr;

/**
* 创建引擎接口。
*/
interface Engine
{
public function run(string $script): string;
public function getDispatchHandler(): string;
}

run 方法预期一个脚本的输入 (脚本 内容,不是一条路径),并且返回执行结果。 getDispatchHandler 允许引擎声明它预期脚本如何展示发布。例如 V8 中的print 方法,或是 Node 中的 console.log 。

V8Js 引擎实现起来并不是很花俏。它更类似于我们上述理念的验证,带有一些附加的错误处理机制。

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
php复制代码namespace Spatie\Ssr\Engines;

use V8Js;
use V8JsException;
use Spatie\Ssr\Engine;
use Spatie\Ssr\Exceptions\EngineError;

/**
* 创建一个 V8 类来实现引擎接口类 Engine 。
*/
class V8 implements Engine。
{
/** @var \V8Js */
protected $v8;

public function __construct(V8Js $v8)
{
$this->v8 = $v8;
}

/**
* 打开缓冲区。
* 返回缓冲区存储v8的脚本处理结果。
*/
public function run(string $script): string
{
try {
ob_start();

$this->v8->executeString($script);

return ob_get_contents();
} catch (V8JsException $exception) {
throw EngineError::withException($exception);
} finally {
ob_end_clean();
}
}

public function getDispatchHandler(): string
{
return 'print';
}
}

注意这里我们将 V8JsException 重新抛出作为我们的 EngineError。 这样我们就可以在任何的引擎视线中捕捉相同的异常。

Node 引擎会更加复杂一点。不像 V8Js,Node 需要 文件 去执行,而不是脚本内容。在执行一个服务端脚本前,它需要被保存到一个临时的路径。

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
php复制代码namespace Spatie\Ssr\Engines;

use Spatie\Ssr\Engine;
use Spatie\Ssr\Exceptions\EngineError;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;

/**
* 创建一个 Node 类来实现引擎接口类 Engine 。
*/
class Node implements Engine
{
/** @var string */
protected $nodePath;

/** @var string */
protected $tempPath;

public function __construct(string $nodePath, string $tempPath)
{
$this->nodePath = $nodePath;
$this->tempPath = $tempPath;
}

public function run(string $script): string
{
// 生成一个随机的、独一无二的临时文件路径。
$tempFilePath = $this->createTempFilePath();

// 在临时文件中写进脚本内容。
file_put_contents($tempFilePath, $script);

// 创建进程执行临时文件。
$process = new Process([$this->nodePath, $tempFilePath]);

try {
return substr($process->mustRun()->getOutput(), 0, -1);
} catch (ProcessFailedException $exception) {
throw EngineError::withException($exception);
} finally {
unlink($tempFilePath);
}
}

public function getDispatchHandler(): string
{
return 'console.log';
}

protected function createTempFilePath(): string
{
return $this->tempPath.'/'.md5(time()).'.js';
}
}

除了临时路径步骤之外,实现方法看起来也是相当直截了当。

我们已经创建好了 Engine 接口,接下来需要编写渲染的类。以下的渲染类来自于 spatie/server-side-rendering 扩展包,是一个最基本的渲染类的结构。

渲染类唯一的依赖是 Engine 接口的实现:

1
2
3
4
5
6
7
php复制代码class Renderer
{
public function __construct(Engine $engine)
{
$this->engine = $engine;
}
}

渲染方法 render 里将会处理渲染部分的逻辑,想要执行一个 JavaScript 脚本文件,需要以下两个元素:

  • 我们的应用脚本文件;
  • 一个用来获取解析产生的 HTML 的分发方法;

一个简单的 render 如下:

1
2
3
4
5
6
7
8
9
10
11
12
php复制代码class Renderer
{
public function render(string $entry): string
{
$serverScript = implode(';', [
"var dispatch = {$this->engine->getDispatchHandler()}",
file_get_contents($entry),
]);

return $this->engine->run($serverScript);
}
}

此方法接受 entry-server.js 文件路径作为参数。

我们需要将解析前的 HTML 从脚本中分发到 PHP 环境中。dispatch 方法返回 Engine 类里的 getDispatchHandler 方法,dispatch 需要在服务器脚本加载前运行。

还记得我们的服务器端入口脚本吗?接下来我们在此脚本中调用我们的 dispatch 方法:

1
2
3
4
5
6
7
8
9
10
coffeescript复制代码// entry-server.js
import app from './app'
import renderToString from 'vue-server-renderer/basic'

renderToString(app, (err, html) => {
if (err) {
throw new Error(err)
}
dispatch(html)
})

Vue 的应用脚本无需特殊处理,只需要使用 file_get_contents 方法读取文件即可。

我们已经成功创建了一个 PHP 的 SSR 。spatie/server-side-rendering 中的完整渲染器 Renderer 跟我们实现有点不一样,他们拥有更高的容错能力,和更加丰富的功能如有一套 PHP 和 JavaScript 共享数据的机制。如果你感兴趣的话,建议你阅读下源码 server-side-rendering 代码库 。

三思而后行

我们弄清楚了服务器端渲染的利和弊,知道 SSR 会增加应用程序架构和基础结构的复杂度。如果服务器端渲染不能为你的业务提供任何价值,那么你可能不应该首先考虑他。

如果你 确实 想开始使用服务器端渲染,请先阅读应用程序的架构。大多数 JavaScript 框架都有关于 SSR 的深入指南。Vue.js 甚至有一个专门的 SSR 文档网站,解释了诸如数据获取和管理用于服务器端渲染的应用程序方面的坑。

如果可能,请使用经过实战检验的解决方案

有许多经过实战检验的解决方案,能提供很好的 SSR 开发体验。比如,如果你在构建 React 应用,可以使用 Next.js,或者你更青睐于 Vue 则可用 Nuxt.js,这些都是很引人注目的项目。

还不够?尝试 PHP 服务端渲染

你仅能以有限的资源来管理基础架构上的复杂性。你想将服务端渲染作为大型 PHP 应用中的一部分。你不想构建和维护无状态的 API。 如果这些原因和你的情况吻合,那么使用 PHP 进行服务端渲染将会是个不错方案。

我已经发布两个库来支持 PHP 的服务端 JavaScript 渲染: spatie/server-side-rendering 和专为 Laravel 应用打造的 spatie/laravel-server-side-rendering 。Laravel 定制版在 Laravel 应用中近乎 0 配置即可投入使用,通用版需要根据运行环境做一些设置调整。当然,详细内容可以参考软件包自述文件。

如果你仅是想体验,从 spatie/laravel-server-side-rendering-examples 检出项目并参考指南进行安装。

如果你考虑服务端渲染,我希望这类软件包可以帮到你,并期待通过 Github 做进一步问题交流和反馈!

更多现代化 PHP 知识,请前往 Laravel / PHP 知识社区

本文转载自: 掘金

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

【Java EE】从零开始写项目【总结】 从零开发项目概述

发表于 2018-03-31

从零开发项目概述

最近这一直在复习数据结构和算法,也就是前面发出去的排序算法八大基础排序总结,Java实现单向链表,栈和队列就是这么简单,十道简单算法题等等…

被虐得不要不要的,即使是非常简单有时候绕半天,想不明白。

本来这篇博文是想留到我最后Java基础复习完再写出来的,鉴于公众号有的同学想看看邮箱登陆的案例,想看看Java开发后台是怎么的,于是我就提前写了这篇博文

这个项目是我自己手写的第一个SSM项目(毕竟学了当然得搞点事情,对吧),是上年写的小东西。当然了,现在回过头去看这个项目是有很多的不足的,仅供参考。用的也不是新技术,只是把我之前当时所学的一些东西做一个总结…如果想学新知识的同学看到这里就可以留步啦~

所以说,你可能会看到比较旧的技术,写得不好的地方都会有~如果是写过项目的同学而言,这个仅仅是一个Demo。对于没写过项目的同学可以适当的参考一下,因为用的是非常简单的技术,都是耳熟能详的技术,不会有什么难懂的地方~

PS:希望各位大佬不要攻击它,我没有做任何的安全措施。即使留了评论的功能,XSS攻击防范也没做,请手下留情啊~

地址:

  • 预览:www.zhongfucheng.top/
  • GitHub:github.com/ZhongFuChen…

编写记录:

从零开始写项目第一篇【搭建环境】

从零开始写项目第二篇【登陆注册、聊天、收藏夹模块】

从零开始写项目第三篇【在线聊天和个人收藏夹】

从零开始写项目第四篇【搭建Linux环境】

从零开始写项目第五篇【评论功能、备忘录】

从零开始写项目终极【维护网站、修复Bug】

一、功能介绍

巴巴拉拉地说了一大堆东西,我们来看一下这个项目有什么功能~

这是首页,会根据当地给出三天的天气预报:

很简单的注册页面,通过邮箱来进行注册~

2018年3月30日15:51:23,好吧,写这篇博文的时候也出错了…..

org.springframework.mail.MailAuthenticationException: Authentication failed

我怀疑是授权发送邮箱的密码错了(过时),于是我去更新了一下,果然就行了~

此时点击注册,就会要邮箱校验了~

去自己的邮箱查看一下邮件:

点击校验它即可完成注册(如果不校验的话,那么是不允许登陆的):

该项目用Shiro做了权限认证的,如果没在登陆的状态下点击以下的菜单都会跳转到登陆页面~:


网站说明:

这个网站的功能并不复杂,开发的功能都是为了方便使用而已。

  • 一、个人收藏夹:将常用的网站添加进去,值得注意的地方就是:url(网址)最好是从浏览器复制下来,这是最方便也是最安全的做法
  • 二、个人备忘录:添加个人备忘录,设置好时间、在邮件发送出去之前都能够修改时间

发送邮件好像会有点延迟~~~~


接下来是个人收藏夹了,添加一些常用的网址进去,并给它命名,当输入命名部分的字符时,即会提示:

用键盘上下键选中按回车即可跳转到对应的页面了~

二、技术介绍

网站用到的技术:

  • 一、全站页面布局:前端是使用Bootstrap来进行布局的、背景是使用了GitHub的一个开源项目:particles-js。导航条来源于一个国外网站:http://toolofna.com/
  • 二、首页:天气预报功能来源于高德地图API和和风天气API组合而成,能够查看当前IP地址所在地的近三日天气
  • 三、个人收藏夹:使用了Elasticsearch全文搜索引擎工具。
  • 四、在线聊天:使用了GoEasy的服务推送和jquery.barrager.js构建弹幕。目前该功能并未完善,敬请期待。
  • 五、注册和登陆:使用了BootstrapValidation进行表单校验、JavaMail发送邮件的功能、Gif动态验证码
  • 六、个人备忘录:使用了Quartz任务调度工具来定时发送邮件

总概要:

    1. Maven构建项目
    1. 使用Mysql数据库
    1. Tomcat作用应用服务器
    1. Dao层采用Mybatis,Controller层采用SpringMVC,Spring对Mybatis和SpringMVC进行整合和事务管理
    1. Shiro权限管理框架控制登陆以及对个人收藏夹、在线聊天功能进行认证的管理
    1. 使用FreeMarker来渲染页面和配置发送邮箱的模版
    1. 使用Quartz和Spring整合,完成个人备忘录的功能。
    1. 使用Nginx作为代理服务器,对静态资源处理,代理转发

可以看到的是,网站的功能并不多,就几个小模块,我就创建了三张表而已:

三、谈谈编写过程

其实所有的初学者都一样:怎么使用Java编写一个网站?数据库设计感觉好难,设计错了就很麻烦了。Web前端好烦啊,自己写的页面不好看。这个功能感觉好难实现哦,应该是挺高深的技术吧。我的基础还不够扎实,等我学好了再写吧,等等等..就一直有想写网站的念头,但一直没开始动手。

上面的问题我都有想过,不过我还是写了这么一个小东西….

当时写的练手项目都是视频教程给出的题目,比如我之前发过的:移动商城项目总结和纳税服务系统总结,jar包或者maven的坐标都是有给出来的,项目的前台页面和数据库标也大都是设计好的,我只需要补补填填写Java代码。而这一次,前端页面,数据库设计,Maven坐标等等全由自己包办

从开始到编写结束大概花了我一个月的时间,期间要去上课呀,回家呀,一些琐事呀等等才拖了那么久。如果自己从来没写过项目的同学建议自己折腾一下搞一个,期间能学到很多很多东西的。也不要怕自己写得不好(只要像我一样不要脸,不也将这么一个小东西发到公众号上来,发到其他的博客平台了么)

那么我自己折腾这么一个小玩意能学到什么了呢?

3.1搭建环境收获

  • 在搭建环境的时候,不要觉得最新的版本就是牛逼,我要用最新的,不做过时的男人!我当时就是撘环境是用Spring5.0,Mysql驱动包6.x,jdk1.8,能用最新就用最新的,觉得自己贼牛逼。然后撘完项目就启不动了~~~
  • 其实没必要用最新的,最新的一般都会有bug,没bug的也不一定兼容其他的版本。要知道,Spring可不单单有Spring的东西,还依赖很多其他的jar包的。同样Mysql用最新的驱动包,逆向工程时也不好使(我在SegementFault已经见过几次跟我一样遇到的坑)
  • 一般最新的东西资料都挺少的,除了官方文档,出了什么问题都只能自己解决了。较老的版本就不一样了,很多人都会遇到的问题,解决方案也会相当多~

当时我在图书馆撘了一个晚上,得出的教训:

3.2登陆与注册收获

登陆与注册这个功能只要学过JavaWeb基础的同学,肯定做过。反正我是做了很多很多次的了,学Servlet做一次,学JSP做一次,学AJAX做一次,学xxx系统做一次,学XXX商城做一次。反正每次都会有新的东西补充上去

一开始我本来想做的是可以使用微信登陆,也可以使用账户(邮箱)登陆,也可以使用短信登陆的。

后来发现微信登陆要企业认证,做不了。短信登陆要钱,不想给。

所以最后就只有用邮箱登陆了~当然了,该有的基本功能是有的:

  • 加密密码
  • 前台校验和后台校验参数
  • 忘记密码,记住我功能
  • 验证码

由于之前是简单学过Shiro的,当时觉得Shiro做权限很方便,于是自己也想体验一把~实际上用起来并没有我当时学的时候那么简单!

在Shiro这一块也耽误了不少时间,因为我当时想的是使用AJAX来提交,如果正确则跳转到首页,如果密码(参数..)等错误就返回JSON给用户一个友好的提示。可是,Shiro的表单认证起默认返回的是一个页面,在这里就卡住了挺久的(有的时候没bug,有的时候又有bug),百思不得其解…最后查阅了很多资料才将FormAuthenticationFilter改写好,Shiro很多东西都是要自己重写来进行控制的,当时理解后画的流程图:

Shiro的“记住我”功能本来以为是很好用的,实则也挺鸡肋的..当时我做的记录:

反正Shiro给我的感觉就是很多东西都要自己来重写,控制,而如果是小范围使用我觉得自己写url控制会方便。也可能是我的学习姿势不对~

在登陆注册模块期间也自己去找了不少资料。

  • 数据库设计上也是参考了网上和自己做了些许修改
  • JavaMail我之前是没接触过的
  • BootStrapValidation也是看中了就用,
  • 折腾Shiro几天就更不用说了。

所以说,未必要使用自己熟悉的东西,只要想实现一些普通、你见过的功能,总会有方案可以参考。

3.3个人收藏夹收获

这个自动补全功能是我一开始想要做自己网站的时候想做的。因为我习惯了使用键盘去找文件,去找东西。

比如,在windows下,我下了一个rolan,将常用的软件丢进去,输入一些关键字即提示我,然后我通过键盘上下选择就可以打开软件了,十分方便

在编写之前,我学过Lucene的相关知识,知道Lucene是全文搜索索引的始祖(没做过相关案例)【参考博文:Lucene就是这么简单】,后来知道Solr和Elasticsearch在企业上是用得挺火的,而Elasticsearch又是各种火热,各种吹(哈哈哈,无贬义,是挺好用的)。

于是我就花了几天从零开始学Elasticsearch,当时参考的博主给出的是2.3.3版本,我也觉得没什么,就使用了。那是最新是5.x~就有了后来的博文:【Elasticsearch就是这么简单】

Elasticsearch遇到很多的问题都得去翻英文的,所以可以访问外网也挺重要的~

我英语也不咋地,就慢慢看看,弄个Chrome插件翻译呗,事情总得解决,也就这么成长的~

3.4搭建Linux环境收获

在之前只会Linux一些非常简单的命令,ls cd vi这些,用得少就忘记了,于是又得重新去回顾一下Linux(这个学期也开了Linux的课,我打算过一阵再总结Linux的常用命令和相关要点,敬请期待~)

花了点点钱买了台云服务器(学生有优惠就10块一个月),我也建议是在校的学生也可以买来自己玩玩,熟悉一下Linux命令,还有很多软件都在Linux下才更好地支持(例如:Elasticsearch,Nginx),尤其Elasticsearch在windows下装它简直麻烦!

到了Linux阶段,我也去学了下Nginx了。在没学Nginx时我就经常听说过这个HTTP服务器了,但一直不知道它是干嘛用的。它的最常见作用就两个:

  • 处理静态资源
  • 动态资源转发到Web服务器(Tomcat)

学学Nginx的配置文件相关知识,是如何配置的,配置有什么用~就可以使用了

3.5评论功能和备忘录收获

评论功能在数据库设计上也有好几种:

  • 将回复和评论都统一当成是评论,能够使用@XXX来当做是回复,类似与IOS知乎的评论【此做法是最简单的】
  • 将回复和评论分开,要么单表就多增加一个父属性,要么就分两张表【稍微复杂,一个评论可以对应多个回复】
  • 实现评论与回复“盖楼”的方式,这种就是网易的评论【此种是最复杂的】

我做的只是一个留言的功能,就直接使用第一种了~

对于备忘录就得用到Quartz时间调度框架,之前接触过,但用的使用还是出现了Bug

  • 在Junit测试Quartz没有反应
  • Spring不能注入对象进Quartz的Job类中

如果业务简单的话,下次也想玩玩Spring自带的任务调度~

四、最后

上面简要地介绍了我的小项目的功能和编写过程,更详细的业务得去看我下面的博文和源码了~也希望大家能够有所收获,自己动手做一个小项目~

现在看来很多没有写成规范(返回JSON、JavaScript模块化)等等,但毕竟是自己学编程的成长经历,看回以前的代码总会有这种感觉。我短时间内也不做重构了,学Java基础去~

后面的博文几乎都是Java基础或算法(少量)了,希望大家多多支持哈,在校的日子我会尽量保持日更的~

如果文章有错的地方欢迎指正,大家互相交流。习惯在微信看技术文章,想要获取更多的Java资源的同学,可以关注微信公众号:Java3y

本文转载自: 掘金

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

还在用 Swagger2 生成 Restful API 文档

发表于 2018-03-29

本文介绍一个非常好用的自动化生成 Restful API 文档的工具——Api2Doc 它基于 SpringBoot ,原理类似于 Swagger2,但比 Swagger2 要简单好用。

此项目已经放到 github 中,需要源码的朋友请点击 这里

目录

  • 项目背景
  • Api2Doc 简介
  • 引入 Api2Doc 依赖
  • 启用 Api2Doc 服务
  • 给 Controller 类上添加文档注解
  • @Api2Doc 注解详述
  • @ApiComment 注解详述
  • @ApiError 注解详述
  • 给文档菜单项排序
  • 补充自定义文档
  • 定制文档的欢迎页
  • 定制文档的标题及图标
  • 关闭 Api2Doc 服务

项目背景

在互联网/移动互联网软件的研发过程中,大多数研发团队前后台分工是非常明确的, 后台工程师负责服务端系统的开发,一般是提供 HTTP/HTTPS 的 Restful API 接口, 前端工程师则负责 Android、iOS、H5页面的开发,需要调用 Restful API 接口。

这就需要有一套 Restful API 文档,以帮助两方在 API 接口进行沟通,并达成一致意见。 一般情况下,编写文档的工作都会落在后台工程师身上,毕竟 API 是他们提供的嘛。

但问题是,编写 Restful API 文档是一件既繁琐、又费时、还对提高技术能力没啥帮助的苦差事, 尤其在是快速迭代、需求频繁修改的项目中,改了代码还要同步改文档, 哪点改错了或改漏了都可能产生前后端实现的不一致,导致联调时发现 BUG, 这个锅最终还是要后台工程师来背(宝宝心里苦啊…)。

因此,业界就出现了一些根据代码自动生成 Restful API 文档的开源项目, 与 Spring Boot 结合比较好的是 Swagger2,Swagger2 通过读取 Controller 代码中的注解信息,来自动生成 API 文档,可以节省大量的手工编写文档的工作量。

本项目作者之前也是用的 Swagger2,但发现 Swagger2 也有好多地方用得不爽:

第一,Swagger2 的注解非常臃肿,我们看下这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
less复制代码
@RestController
@RequestMapping(value = "/user3")
public class UserController2Swagger2 {

@ApiOperation(value = "获取指定id用户详细信息",
notes = "根据user的id来获取用户详细信息",
httpMethod = "GET")
@ApiImplicitParams({
@ApiImplicitParam(name = "userName", value = "用户名",
paramType = "query", required = true, dataType = "String"),
@ApiImplicitParam(name = "password", value = "用户密码",
paramType = "query", required = true, dataType = "String")
})
@RequestMapping(name = "用户注册", value = "/regist",
method = RequestMethod.GET)
public UserInfo regist(@RequestParam("userName") String userName,
@RequestParam("password") String password) {
return new UserInfo();
}
}

@ApiOperation、@ApiImplicitParam 都是 Swagger2 提供的注解,用于定义 API 信息。 其实,API 方法本身就包含了很多信息,如HTTP Method、参数名、参数类型等等, 像 @ApiImplicitParam 中除了 value 属性有用外,其它都是重复描述。

第二,Swagger2 的页面排版不太友好,它是一个垂直排列的方式,不利于信息的展示。 并且看 API 详细信息还要一个个展开,中间还夹杂着测试的功能,反正作为文档是不易于阅读; 至于作为测试工具嘛…,现在专业的测试工具也有很多,测试人员好像也不选它。

第三,Swagger2 还有好多细节没做好,比如看这个图:

swgger2-1.png

红框中的 API 其实对应的是同一个方法,之所以有这么多,只是因为写这个方法 时没有指定 method:

1
2
3
4
5
6
7
8
9
10
less复制代码@RestController
@RequestMapping(value = "/user2")
public class UserController2Swagger2 {

@RequestMapping(value = "/do_something")
public void doSomethingRequiredLogon() {
}

// 其它方法,这里省略...
}

(当没指定 method 时,Spring Boot 会默认让这个接口支持所有的 method)

因此,考虑到与其长长久久忍受 Swagger2 的各种不爽,不如花些时间做一个 更好用的“自动化文档系统”,于是就诞生了本项目: Api2Doc 。

Api2Doc 简介

Api2Doc 专注于 Restful API 文档的自动生成,它的原理与 Swagger2 是类似的, 都是通过反射,分析 Controller 中的信息生成文档,但它要比 Swagger2 好很多。

最大的不同是: Api2Doc 比 Swagger2 要少写很多代码。

举个例子,使用 Swagger2 的代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
less复制代码
@RestController
@RequestMapping(value = "/user")
public class UserController {

@ApiOperation(value = "添加用户", httpMethod = "POST",
notes = "向用户组中添加用户,可以指定用户的类型")
@ApiImplicitParams({
@ApiImplicitParam(name = "group", value = "用户组名",
paramType = "query", required = true, dataType = "String"),
@ApiImplicitParam(name = "name", value = "用户名",
paramType = "query", required = true, dataType = "String"),
@ApiImplicitParam(name = "type", value = "用户类型",
paramType = "query", required = true, dataType = "String")
})
@RequestMapping(value = "/addUser", method = RequestMethod.POST)
public User addUser(String group, String name, String type) {
return null; // TODO: 还未实现。
}
}

我们看下使用 Api2Doc 注解修饰后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
less复制代码@Api2Doc(id = "users")
@ApiComment(seeClass = User.class)
@RestController
@RequestMapping(value = "/api2doc/demo2")
public class UserController2 {

@ApiComment("向用户组中添加用户,可以指定用户的类型")
@RequestMapping(name = "添加用户",
value = "/user", method = RequestMethod.POST)
public User addUser(String group, String name, String type) {
return null; // TODO: 还未实现。
}

// 其它方法,这里省略...
}

看,Api2Doc 仅需要在方法上加上 @Api2Doc @ApiComment 注解等极少数代码, 但它生成的文档可一点不含糊,如下图所示:

api2doc-2-1.png api2doc-2-2.png

有的朋友可能会觉得很奇怪:文档页面上的说明、示例值等内容,在代码中没有写啊, 这些是哪来的呢?

这里涉及到 Api2Doc 的核心设计理念,就是:它尽可能通过智能分析,自动收集 生成文档所需的信息,从而让用户少写代码。

说得有点抽象哈,下面我们来正面回答这个问题,请大家注意这个类上有一个注解:

1
python复制代码@ApiComment(seeClass = User.class)

它意思是: 在 API 方法上遇到没写说明信息时,请参照 User 类中的定义的说明信息。

下面是 User 类的代码:

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
reasonml复制代码public class User {

@ApiComment(value = "用户id", sample = "123")
private Long id;

@ApiComment(value = "用户名", sample = "terran4j")
private String name;

@ApiComment(value = "账号密码", sample = "sdfi23skvs")
private String password;

@ApiComment(value = "用户所在的组", sample = "研发组")
private String group;

@ApiComment(value = "用户类型", sample = "admin")
private UserType type;

@ApiComment(value = "是否已删除", sample = "true")
@RestPackIgnore
private Boolean deleted;

@ApiComment(value = "创建时间\n也是注册时间。")
private Date createTime;

// 省略 getter / setter 方法。
}

大家看明白了没? API 方法中的参数,如果与 User 类的属性同名的话,就用类 属性的 @ApiComment 说明信息自动填充。

其实这也符合实际的业务逻辑。因为在大部分项目中,有的字段会在多个实体类、 多个 API 方法中用到,完全没有必要重复编写其说明信息,只要有一个地方定义好了, 然后其它地方参照就行了。

当然,这只是 Api2Doc 比 Swagger2 好用的特性之一,还有不少比 Swagger2 好用的地方。

下面我们就来全面讲解它的用法,希望可以帮助开发者们从文档编写的苦海中解脱出来。

引入 Api2Doc 依赖

如果是 maven ,请在 pom.xml 中添加依赖,如下所示:

1
2
3
4
5
dust复制代码        <dependency>
<groupId>com.github.terran4j</groupId>
<artifactId>terran4j-commons-api2doc</artifactId>
<version>${api2doc.version}</version>
</dependency>

如果是 gradle,请在 build.gradle 中添加依赖,如下所示:

1
bash复制代码compile "com.github.terran4j:terran4j-commons-api2doc:${api2doc.version}"

${api2doc.version} 最新稳定版,请参考 这里

启用 Api2Doc 服务

本教程的示例代码在 src/test/java 目录的 com.terran4j.demo.api2doc 中, 您也可以从 这里 获取到。

首先,我们需要在有 @SpringBootApplication 注解的类上,添加 @EnableApi2Doc 注解,以启用 Api2Doc 服务,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typescript复制代码package com.terran4j.demo.api2doc;

import com.terran4j.commons.api2doc.config.EnableApi2Doc;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// 文档访问地址: http://localhost:8080/api2doc/home.html
@EnableApi2Doc
@SpringBootApplication
public class Api2DocDemoApp {

public static void main(String[] args) {
SpringApplication.run(Api2DocDemoApp.class, args);
}

}

给 Controller 类上添加文档注解

然后我们在 RestController 类添加 @Api2Doc 注解,在需要有文档说明的地方 添加 @ApiComment 注解即可,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码package com.terran4j.demo.api2doc;

import com.terran4j.commons.api2doc.annotations.Api2Doc;
import com.terran4j.commons.api2doc.annotations.ApiComment;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

@Api2Doc(id = "demo1", name = "用户接口1")
@ApiComment(seeClass = User.class)
@RestController
@RequestMapping(value = "/api2doc/demo1")
public class UserController1 {

@ApiComment("添加一个新的用户。")
@RequestMapping(name = "新增用户",
value = "/user", method = RequestMethod.POST)
public User addUser(String group, String name,
@ApiComment("用户类型") UserType type) {
return null; // TODO: 还未实现。
}
}

这个方法的返回类型 User 类的定义为:

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
reasonml复制代码public class User {

@ApiComment(value = "用户id", sample = "123")
private Long id;

@ApiComment(value = "用户名", sample = "terran4j")
private String name;

@ApiComment(value = "账号密码", sample = "sdfi23skvs")
private String password;

@ApiComment(value = "用户所在的组", sample = "研发组")
private String group;

@ApiComment(value = "用户类型", sample = "admin")
private UserType type;

@ApiComment(value = "是否已删除", sample = "true")
@RestPackIgnore
private Boolean deleted;

@ApiComment(value = "创建时间\n也是注册时间。")
private Date createTime;

// 省略 getter / setter 方法。
}

以及 type 属性的类型,也就是 UserType 类的定义为:

1
2
3
4
5
6
7
8
9
10
11
12
less复制代码package com.terran4j.demo.api2doc;

import com.terran4j.commons.api2doc.annotations.ApiComment;

public enum UserType {

@ApiComment("管理员")
admin,

@ApiComment("普通用户")
user
}

编写好代码后,我们运行 main 函数,访问 Api2Doc 的主页面:

1
awk复制代码http://localhost:8080/api2doc/home.html

文档页面如下:

api2doc-3-1.png api2doc-3-2.png

说明 Api2Doc 服务起作用了,就是这么简单!

@Api2Doc 注解详述

Api2Doc 一共有 3 个注解:@Api2Doc、@ApiComment 及 @ApiError 。

@Api2Doc 用于对文档的生成进行控制。

@Api2Doc 修饰在类上,表示这个类会参与到文档生成过程中,Api2Doc 服务 会扫描 Spring 容器中所有的 Controller 类,只有类上有 @Api2Doc 的类, 才会被生成文档,一个类对应于文档页面左侧的一级菜单项,@Api2Doc 的 name 属性则表示这个菜单项的名称。

@Api2Doc 也可以修饰在方法,不过在方法上的 @Api2Doc 通常是可以省略, Api2Doc 服务会扫描这个类的所有带有 @RequestMapping 的方法, 每个这样的方法对应文档页面的左侧的二级菜单项, 菜单项的名称取 @RequestMapping 的 name 属性,当然您仍然可以在方法上用 @Api2Doc 的 name 属性进行重定义。

@ApiComment 注解详述

@ApiComment 用于对 API 进行说明,它可以修饰在很多地方:

  • 修饰在类上,表示对这组 API 接口进行说明;
  • 修饰在方法上,表示对这个 API 接口进行说明;
  • 修饰在参数上,表示对这个 API 接口的请求参数进行说明;
  • 修饰在返回类型的属性上,表示对这个 API 接口的返回字段进行说明;
  • 修饰在枚举项上,表示对枚举项进行说明;

如果相同名称、相同意义的属性或参数字段,其说明已经在别的地方定义过了, 可以用 @ApiComment 的 seeClass 属性表示采用指定类的同名字段上的说明信息, 所以如这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
less复制代码@Api2Doc(id = "demo1", name = "用户接口1")
@ApiComment(seeClass = User.class)
@RestController
@RequestMapping(value = "/api2doc/demo1")
public class UserController1 {

@ApiComment("添加一个新的用户。")
@RequestMapping(name = "新增用户",
value = "/user", method = RequestMethod.POST)
public User addUser(String group, String name, UserType type) {
return null; // TODO: 还未实现。
}
}

虽然 group, name ,type 三个参数没有用 @ApiComment 进行说明, 但由于这个类上有 @ApiComment(seeClass = User.class) , 因此只要 User 类中有 group, name ,type 字段并且有 @ApiComment 的说明就行了。

@ApiError 注解详述

@ApiError 用于定义错误码,有的 API 方法在执行业务逻辑时会产生错误, 出错后会在返回报文包含错误码,以方便客户端根据错误码作进一步的处理, 因此也需要在 API 文档上体现错误码的说明。

如下代码演示了 @ApiError 的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
less复制代码@Api2Doc(id = "demo", name = "用户接口", order = 0)
@ApiComment(seeClass = User.class)
@RestController
@RequestMapping(value = "/src/test/resources/demo")
public class UserController {

@Api2Doc(order = 50)
@ApiComment("根据用户id,删除指定的用户")
@ApiError(value = "user.not.found", comment = "此用户不存在!")
@ApiError(value = "admin.cant.delete", comment = "不允许删除管理员用户!")
@RequestMapping(name = "删除指定用户",
value = "/user/{id}", method = RequestMethod.DELETE)
public void delete(@PathVariable("id") Long id) {
}
}

@ApiError 的 value 属性表示错误码,comment 表示错误码的说明。

错误码信息会显示在文档的最后面,效果如下所示:

api2doc-7.png

给文档菜单项排序

我们可以用 @Api2Doc 中的 order 属性给菜单项排序,order 的值越小, 该菜单项就越排在前面,比如对于这段代码:

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
kotlin复制代码package com.terran4j.demo.api2doc;

import com.terran4j.commons.api2doc.annotations.Api2Doc;
import com.terran4j.commons.api2doc.annotations.ApiComment;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@Api2Doc(id = "demo2", name = "用户接口2", order = 1)
@ApiComment(seeClass = User.class)
@RestController
@RequestMapping(value = "/api2doc/demo2")
public class UserController2 {

@Api2Doc(order = 10)
@ApiComment("添加一个新的用户。")
@RequestMapping(name = "新增用户",
value = "/user", method = RequestMethod.POST)
public User addUser(
@ApiComment("用户组名称") String group,
@ApiComment("用户名称") String name,
@ApiComment("用户类型") UserType type) {
return null; // TODO: 还未实现。
}

@Api2Doc(order = 20)
@ApiComment("根据用户id,查询此用户的信息")
@RequestMapping(name = "查询单个用户",
value = "/user/{id}", method = RequestMethod.GET)
public User getUser(@PathVariable("id") Long id) {
return null; // TODO: 还未实现。
}

@Api2Doc(order = 30)
@ApiComment("查询所有用户,按注册时间进行排序。")
@RequestMapping(name = "查询用户列表",
value = "/users", method = RequestMethod.GET)
public List<User> getUsers() {
return null; // TODO: 还未实现。
}

@Api2Doc(order = 40)
@ApiComment("根据指定的组名称,查询该组中的所有用户信息。")
@RequestMapping(name = "查询用户组",
value = "/group/{group}", method = RequestMethod.GET)
public UserGroup getGroup(@PathVariable("group") String group) {
return null; // TODO: 还未实现。
}
}

显示的结果为:

api2doc-3.png

在类上的 @Api2Doc 同样可以给一级菜单排序,规则是一样的,这里就不演示了。

补充自定义文档

有时候光有自动生成的 API 文档似乎还不太完美,或许我们想补充点别的什么东西, 比如: 对项目的背景介绍、技术架构说明之类,那这个要怎么弄呢?

Api2Doc 允许用 md 语法手工编写文档,并集成到自动生成的 API 文档之中,方法如下:

首先,要在类上的 @Api2Doc 定义 id 属性,比如对下面这个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
less复制代码package com.terran4j.demo.api2doc;

import com.terran4j.commons.api2doc.annotations.Api2Doc;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Api2Doc(id = "demo3", name = "用户接口3")
@RestController
@RequestMapping(value = "/api2doc/demo3")
public class UserController3 {

@Api2Doc(order = 10)
@RequestMapping(name = "接口1", value = "/m1")
public void m1() {
}

@Api2Doc(order = 20)
@RequestMapping(name = "接口2", value = "/m2")
public void m2() {
}
}

@Api2Doc(id = “demo3”, name = “用户接口3”) 表示:对应的一级菜单“用户接口3” 的 id 为 demo3。

然后,我们在 src/main/resources 中创建目录 api2doc/demo3, 前面的 api2doc 是固定的,后面的 demo3 表示这个目录中的文档是添加到 id 为 demo3 的一级文档菜单下。

然后我们在 api2doc/demo3 目录中编写 md 格式的文档,如下图所示:

api2doc-4.png

文件名的格式为 ${order}-${文档名称}.md,即 - 号前面的数字表示这个文档的排序, 与 @Api2Doc 中的 order 属性是一样的,而 - 号后面是文档名称,也就是二级菜单的名称。

因此,最后文档的显示效果为:

api2doc-5.png

看,手工编写的补充文档与自动生成的 API 文档,通过 order 进行排序组合在一起, 看起来毫无违和感。

定制文档的欢迎页

每次访问文档页面 http://localhost:8080/api2doc/home.html 时, 中间的内容是非常简单的一句:

1
复制代码欢迎使用 Api2Doc !

这似乎有点不太好,不过没关系,我们可以编写自己的欢迎页。

方法很简单,在 src/main/resources 目录的 api2doc 目录下,创建一个名为 welcome.md 的文件(这个名称是固定的),然后用 md 语法编写内容就可以。

配置文档的标题及图标

可以在 application.yml 中配置文档的标题及图标,如下所示:

1
2
3
awk复制代码api2doc:
title: Api2Doc示例项目——接口文档
icon: https://spring.io/img/homepage/icon-spring-framework.svg

图标为一个全路径 URL,或本站点相对路径 URL 都行。

配置后的显示效果为:

api2doc-6.png

关闭 Api2Doc 服务

您在 application.yml 中配置 api2doc.enabled 属性,以开启或关闭 Api2Doc 服务,如:

1
2
3
4
5
6
7
8
9
10
11
12
yaml复制代码# 本地环境
api2doc:
title: Api2Doc示例项目——接口文档
icon: https://spring.io/img/homepage/icon-spring-framework.svg

---
# 线上环境
spring:
profiles: online

api2doc:
enabled: false

api2doc.enabled 为 false 表示关闭 Api2Doc 服务,不写或为 true 表示启用。

由于 Api2Doc 服务没有访问权限校验,建议您在受信任的网络环境(如公司内网) 中才启用 Api2Doc 服务。

本文转载自: 掘金

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

有赞权限系统

发表于 2018-03-26

文 | 毛成光、马纯洁 on 零售

有赞作为一个商家服务公司,通过产品和服务,帮助互联网时代的生意人成功。在新零售的浪潮下,有赞零售为商家提供不同规模的门店和网店经营解决方案,帮助零售商家们快速进入新零售时代。

与传统网上商城场景不同,零售面对着全新的业务场景和难题,一家运转成熟的新零售店铺,通常需要包括老板、店长、客服、收银员、核销员、仓管、财务等十余个不同能力的角色分工、搭配。摆在零售商家们眼前的一大难题是,如何优雅的管理各个员工,自由分配角色,无痛又润滑地解决员工角色管理问题。

在充分分析零售行业业务场景,员工角色管理方案的不断探索讨论后,权限系统 SAM(Security Access Manager)应运而生,SAM 是有赞零售在员工角色权限管理道路上探索的里程碑,支持着零售 PC、App 和 Pad 产品的权限业务,任何一家使用了有赞零售的零售店都可以通过 SAM 权限系统提供的服务来灵活的给店里员工灵活分配角色,责任到人,以此提高店铺运转效率;支撑零售业务的同时,抽象出了一套权限管理框架,对其他业务线产品(微商城)进行同样支持。

在介绍 SAM 系统之前,首先以几个案例来理解权限系统的概念和设计。

计算机世界中的许多事物是现实世界的一个阴影,现实中所见的许多模式/概念在计算机世界里都能找到。曾记否,QQ 里隐身对她可见,怕她看不见,下线又上线,却依旧被视而不见;曾记否,亲密无间的恋人们,分手后变成了最熟悉的陌生人,悲痛伤心之余,微信、电话、 QQ 拉黑。这些案例,都是计算机权限系统对现实世界的一个映射,你对女神隐身可见,实际上是赋予了她可以看到你的隐身状态(真实状态)的权限,当然你也赋予了人家伤害你的权限;恋人们把对方拉到了黑名单用户组,这样一来,他们就看不见相互动态,成为最熟悉的陌生人;从此,从你的全世界路过。

RBAC

上面例子,我们可以抽象出这样的模式:“ Who 对 What(Which)进行 How 的操作” 。例如,恋人们的例子,在你拉黑对方后,在朋友圈中你(Who)将看不到(How)对方的消息(What)。这是一个经典的 RBAC(基于角色的权限访问控制)权限模型。RBAC 认为权限授权实际上是 Who、What、How 的问题。在 RBAC 模型中,Who、What、How 构成了访问权限三元组,也就是“Who(权限的拥用者或主体)对 What(Which)(权限针对的对象或资源)进行 How(具体的权限)的操作”。

RBAC 模型引入了“角色”的概念。所谓“角色”就是一个或一群用户在系统中可执行操作的集合,它是一个用户的集合,又是一个授权许可的集合。通过将角色指派给用户,为角色赋予权限的方式,使用户和权限通过角色间接相联系。RBAC 基本模型如图所示:

在 RBAC 中,用户与角色之间、角色与权限之间都是多对多的关系。会话是一个用户对多个角色的映射,此时的用户权限可以为激活角色权限的并集。RBAC 对资源授权管理过程分为两个部分,首先实现访问权限与角色相关联,然后再实现角色与用户相关联,从而实现了用户与访问权限的逻辑分离。

权限系统 SAM

SAM 权限系统模型设计

RBAC 模型不同于强制存取控制以及自由选定存取控制直接赋予使用者权限,是将权限赋予角色。在 RBAC 中,权限与角色相关联,用户通过成为适当角色成员而得到这些角色的权限,角色可依新的需求和系统的合并而赋予新的权限,而权限也可根据需要而从某角色中回收。RBAC 相对于传统访问控制更为中性且更具灵活性的存取控制技术。从一家零售店铺员工角色管理角度看,设置角色是为了完成各种工作而创造,员工则根据它的责任和资格来被指派相应的角色,员工应该可以很容易地从一个角色被指派到另一个角色。因此,零售选择了基于
RBAC 模型来实现权限系统解决商家们管理员工角色问题。

依据 RBAC 模型思想,SAM 权限系统业务模型设计为员工管理和权限管理两部分,员工管理主要指管理员工以及为员工指派角色,权限管理主要指管理菜单、页面、按钮、API 等资源,通过定义最基本的业务功能点作为权限点,实现管理角色对资源主体的请求,构成“用户-角色-权限-资源”的授权模型。

下面是 SAM 权限系统模型中的一些通用语言:

  • 员工:角色的载体,权限的实行者
  • 角色:角色是权限集进一步映射。业务系统可动态管理角色,各业务为方便用户使用可提供给默认角色列表,满足不同的员工权限
  • 权限点:全局唯一的用来表示某一个功能点对应的权限的状态
  • 功能点:逻辑上定义的用来描述系统资源的最小基本单位,每一个功能点都对应唯一一个权限点
  • 功能集(权限集):即功能点的集合,有一组功能点按照特定格式进行组合
  • API:请求系统资源的通道和动作,拥有功能集属性
  • 菜单:将系统资源组织后展示给请求者的入口,拥有功能集属性
  • 页面:被当做一种特殊的菜单,拥有 URL 属性
  • 按钮:页面中更细粒度的资源入口,被当作一种特殊的菜单

SAM权限系统模型的实现

在传统的 RBAC 模型中,通常通过一张关系表来保存角色与权限集的对应关系,实现权限与角色相关联。可以预见的是,随着零售业务的不断发展会积累下不计其数的功能点,导致关联表的数据极难维护和使用。SAM 权限系统利用进制转换的策略解决了这个问题 ,同时提高了存储效率以及权限判定效率。一个基本类型为 Long 的十进制数字,它也可以看做是由 64 位 0 或 1 组成的二进制。在 SAM 系统模型设计中,每一个功能点定义为一个权限点,该权限点由 idx 和 pos 两个属性确保是全局唯一的权限点。idx
表示第几个 Long 型空间,pos 表示 Long 型对应的二进制数中所处的位置,64 位长度即可代表 64 个不同能功能点。当 64 位满时无法再容放更多的功能点,这时 idx 属性会自增,重新申请一个 Long 型空间。如此一个 64 位的 Long 数字,通过 0 或 1 的组合,即可表示最多对 64 个不同的功能点所拥有权限的状态描述。

例如:权限集{1}表示拥有 idx=0, pos=0 对应功能点的权限,权限集{-1,1}表示拥有idx=0,pos∈[0,1,2,..,63]与 idx=1,pos=0 对应功能点的权限。

SAM 权限系统将资源与所代表的功能点的关联关系通过进制的方式管理起来,采用计算机进制的思想,抽象出功能集换算公式来完成资源与二进制之间的映射,以及角色与二进制的映射。

权限集换算公式:{(idx0,pos0),(idx0,pos1)…(idxN,posM)} => {Long0,Long1…LongN}

SAM 权限系统同样通过进制思想实现“ Who 对 What 进行了 How 的操作”,角色请求某个资源(菜单/API)时,通过权限校验计算公式——进制按位“与”运算操作的思想(见下)得出该角色是否拥有访问资源的权限。采用进制来实现运算,权限判定的效率会变得更加的高效。例如,一个仓管在点击一个商品库存菜单时,背后的权限校验计算公式,其实是将角色的权限集与资源的权限集进行按位与计算,任意一对序号为 idx 的 Long 算得不为 0,即两集合有公共的功能集,认为该角色拥有对资源访问的权限。

权限校验计算公式:{Long0,Long1…LongN} & {Long0,Long1…LongM}

SAM 权限系统模型的实现遵循 RBAC 模型中的最小权限原则,责任分离原则和数据抽象原则三大原则,通过最小权限原则可以将角色配置成其完成任务所需要的最小的功能集;有了责任分离原则可以通过调用相互独立互斥的角色来共同完成敏感的任务而体现,比如要求一个仓管和商品管理员共同参与一个商品。数据抽象则可以通过权限的抽象来体现,如仓管操作商品发货,库存管理等抽象权限,而不用操作系统提供的典型的读、写、执行权限。

SAM 权限系统架构

零售通过 PC、App 和 Pad 来满足不同商家的终端需求,因此 SAM 权限系统需要满足零售不同客户端权限业务场景,同时也要支持微商城产品权限业务。SAM 权限系统采用微服务的方式对外提供服务,采用分布式分层架构实现,主要包括客户端和服务端两部分,客户端以轻量的方式嵌入在业务系统,提供给不同业务系统实现角色访问资源的控制;服务端通过提供 Dubbo 服务,Nova 服务跟客户端进行交互。服务端主要对员工,菜单,角色,API,功能点进行数据管理。SAM 作为基础服务,每天的请求量巨大,通过
Redis 缓存来解决性能问题,选用 Druid 作为数据库连接池,管理着数据库的连接以及释放。同时,通过对接天网监控平台来观察系统运行状态,提高系统的稳定性。

有赞零售系统基于 SAM 实现的角色对于资源的访问控制主要是 API 校验和菜单渲染,任何一家零售店登入有赞零售系统后,点击页面中的某一个菜单或者页面元素(按钮,链接…),都会进行菜单渲染以及 API 接口的校验。由于两部分调用量巨大,同时不同的客户端请求量不同,防止相互之间干扰,因此将菜单渲染,API 校验等能力在不同的客户端中各自实现。

菜单渲染

SAM 通过客户端的方式进行接入,菜单渲染在客户端一侧进行。目前 SAM 已经提供了 php/node js 两套客户端,供 web 层进行接入和渲染。菜单渲染的过程可以分为三点:

一、结点定位

按照系统功能的划分,菜单通常以一棵树的形式进行展现。以零售 PC 后台为例,所有在页面中展示的元素,都认为是一种菜单,这样的菜单元素包括:菜单、页面、按钮。在后台访问时,用户停留的菜单通常是页面,页面有一个全局唯一的属性:URL,往上:可以通过父菜单找到根结点,往下,页面下可能包含一些子菜单——按钮。因此 SAM 只需要根据当前请求的 URL,即可在后台菜单树中定位到唯一的页面菜单,同时获得该菜单的结点路径以及拥有的按钮。

二、权限计算

我们已经获得了用户的角色权限和完整的菜单树,根据每个菜单结点的权限集,可以计算出当前用户对结点的访问权。根据计算结果,客户端对菜单可以进行区分渲染,比如:用户通过拼 URL 访问一个无权限页面时会提示非法,无权限访问的菜单和按钮会自动置灰不可点击。

三、属性传递

默认菜单不具备 URL 属性。菜单的 URL 属性通过子菜单的 URL 传递生成,SAM 会选择第一个有权限的子菜单的 URL 作为父结点的属性,并逐级传递到一级菜单。

API 权限校验

零售系统中除了菜单外,API 是另一种被请求的资源类型。API 校验是除了菜单渲染外另一道权限控制的保障。通过卡门( API 网关)的 API 请求转发到具体业务系统时,嵌入在业务系统中的 SAM API 校验客户端会首先通过上面的权限校验计算公式对该角色是否具有权限访问这个 API 进行判定,若权限校验通过则执行后面业务逻辑。

具体流程如下图所示

API 权限校验的伪代码实现:

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
复制代码`#权限不通过错误码提示信息`
AUTHPERM_ERROR(231000401,"您没有权限执行该操作!")
# 织入点
@Before("@annotation(com.youzan.sam.common.Auth)")
# 切面处理方法
def handle(JoinPoint pjp):
   # 可以启动时或者运行时控制该开关是否对API进行权限校验
   if(!enable):
        return
   # 权限校验结果包装对象
   def pass=checkPermission()
   # 权限校验执行成功
   if (pass.isSuccess()):
      # 权限校验通过
      if(pass.getData().get("isSuccess")):
        return
      # 权限校验不通过
      else:
        throw new BusinessException(AUTHPERM_ERROR.getCode,AUTHPERM_ERROR.getMessage());
   # 权限校验执行失败
   else:
      throw BusinessException(pass.getCode(), pass.getMessage())

# 权限校验方法
def checkPermission():
   # 判断是否需要走权限校验,对于某些内部调用可以直接跳过
   {...}
   # 获取卡门(API网关)隐式参数,运用了dubbo的隐式传参的能力
   def kdt_id=RpcContext.getContext().getAttachment(Constants.KDT_ID_KEY)
   def admin_id=RpcContext.getContext().getAttachment(Constants.ADMIN_ID_KEY)
   def service = RpcContext.getContext().getAttachment(Constants.SERVICE_KEY)
   def method = RpcContext.getContext().getAttachment(Constants.METHOD_KEY)
   def version = RpcContext.getContext().getAttachment(Constants.VERSION_KEY)
   # 上述参数的校验
   {...}
   # 通过StaffPermServiceProxy获取角色的权限集
   def staffPerm=StaffPermServiceProxy.getStaffPerms(adminId, kdtId)
   # 通过APIPermServiceProxy获取API的权限集
   def apiPerm=APIPermServiceProxy.getServicePerms(service, version, method)
   # 运用权限校验计算公式判定该角色是否可以访问此API
   {...}
   # 返回结果
   return pass

API 权限校验流程可以总结如下:

  1. 业务方在对应需要权限校验的 API 上标注 @Auth 注解,Spring 框架会在初始化创建业务 bean 的时候,扫描该 bean 是否有 @Auth 注解标注的方法,对于有 @Auth 注解标注的,会创建代理类,然后会将该权限切面织入到代理类中;
  2. 业务调用有 @Auth 注解标注的方法时,会执行该权限校验切面逻辑,首先检查权限校验开关,判断是否需要权限校验,该开关可以在运行时动态设置;
  3. 如果需要,再调用 AuthService 的权限校验方法,AuthService 会根据店铺 ID 与用户 ID 从 SAM 服务端获取员工角色权限信息,根据卡门(API 网关)隐式参数中 service,method,version 去 SAM 服务端获取对应 API 权限(相对于在对应 API 上直接标注权限点,这种方式更加的灵活,而且可以随着业务 API 版本的升级,进行很方便的升级,同时结合卡门( API 网关)可以对 API 进行分流,不同的商家可以对应不同 API
    的权限校验。
  4. 在获取到角色权限集和 API 权限集后,基于上面的角色与权限校验逻辑进行权限校验。校验通过,则正式发起 API 请求。校验不过,则提示无权限。

SAM权限系统抽象模型

产品在分析完需求后,将需求交由开发去完成。SAM 权限系统支撑着零售业务的同时,也支撑着微商城业务。 零售各个模块就有不同的产品支撑着,为了更好的满足服务商家的需求,以及方便产品们的分析。SAM 权限系统可以抽象成如下模型,商家和产品可以从各自不同的视角,去对接 SAM 权限系统。例如,下图所示商家想要一个运营的角色需要有新建商品,以及查看订单的能力,同时需要一个收银员只有查看订单的能力。产品从自己的设计角度分析,对应的就是商品管理,订单管理的模块,对应的模块下有对应的商品,订单菜单,最后将角色的权限体现在页面元素和
API 上,例如新建商品的按钮,以及查看订单的按钮会呈现不同的渲染样式;按钮触发对应的是与后端交互的不同 API,不同的角色具有 API 的不同执行能力。

未来展望

自定义角色

在了解商家需求后,零售提供了 8 大默认角色来支持单店版的员工角色问题。零售业务错综复杂,默认角色很多时候并不能对付所有场景,现在一些自定义角色已经在某些零售店使用。未来自定义角色将全线支持各个商家,定制任意权限的任意角色。

多角色

有些零售商家为了缩减人力成本,一个员工常常担任多个角色,因此需要提供一个员工多角色的能力。零售业务已经在使用多角色的能力。

零售中台的支持

零售中台是有赞零售的一个旗舰型产品,旨在为商家提供一个覆盖线上多渠道线下多门店的全渠道解决方案,并利用数据化运营思路帮助商家拉新获客、提高复购。其业务形态非常复杂,涉及到多种角色和权限的组合,而且每个商家可能存在一些个性化需求,如何提供灵活的适配能力是 SAM 系统的一个挑战。

自定义菜单

过去后台功能的发布上线,往往是由发布系统控制,发布则功能即刻上线,一旦发现故障立时回滚。SAM 通过菜单管理,可以实时控制线上的任意菜单、页面和按钮的渲染状态,从容地上下线页面和功能。

技术改造

SAM 作为零售以及其他业务的公共基础组件,需要打造成一个高可用,高性能,易扩展,可伸缩且安全的系统。随着业务的不断接入,通过对系统的不断改造来支撑业务的不断发展。

结语

权限系统目前归属有赞零售技术团队,对外开放权限接入和员工服务化能力。目前团队业务发展迅速,HC 持续开放中,期待更多有志之士加入搞事情。( 点击阅读原文,或关注公众号回复「有赞零售技术团队」,获取「有赞零售技术团队」信息)

本文转载自: 掘金

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

浅谈MySQL的B树索引与索引优化 为什么索引无法全部装入内

发表于 2018-03-26

MySQL的MyISAM、InnoDB引擎默认均使用B+树索引(查询时都显示为“BTREE”),本文讨论两个问题:

  • 为什么MySQL等主流数据库选择B+树的索引结构?
  • 如何基于索引结构,理解常见的MySQL索引优化思路?

为什么索引无法全部装入内存

索引结构的选择基于这样一个性质:大数据量时,索引无法全部装入内存。

为什么索引无法全部装入内存?假设使用树结构组织索引,简单估算一下:

  • 假设单个索引节点12B,1000w个数据行,unique索引,则叶子节点共占约100MB,整棵树最多200MB。
  • 假设一行数据占用200B,则数据共占约2G。

假设索引存储在内存中。也就是说,每在物理盘上保存2G的数据,就要占用200MB的内存,索引:数据的占用比约为1/10。1/10的占用比算不算大呢?物理盘比内存廉价的多,以一台内存16G硬盘1T的服务器为例,如果要存满1T的硬盘,至少需要100G的内存,远大于16G。

考虑到一个表上可能有多个索引、联合索引、数据行占用更小等情况,实际的占用比通常大于1/10,某些时候能达到1/3。在基于索引的存储架构中,索引:数据的占用比过高,因此,索引无法全部装入内存。

其他结构的问题

由于无法装入内存,则必然依赖磁盘(或SSD)存储。而内存的读写速度是磁盘的成千上万倍(与具体实现有关),因此,核心问题是“如何减少磁盘读写次数”。

首先不考虑页表机制,假设每次读、写都直接穿透到磁盘,那么:

  • 线性结构:读/写平均O(n)次
  • 二叉搜索树(BST):读/写平均O(log2(n))次;如果树不平衡,则最差读/写O(n)次
  • 自平衡二叉搜索树(AVL):在BST的基础上加入了自平衡算法,读/写最大O(log2(n))次
  • 红黑树(RBT):另一种自平衡的查找树,读/写最大O(log2(n))次

BST、AVL、RBT很好的将读写次数从O(n)优化到O(log2(n));其中,AVL和RBT都比BST多了自平衡的功能,将读写次数降到最大O(log2(n))。

假设使用自增主键,则主键本身是有序的,树结构的读写次数能够优化到树高,树高越低读写次数越少;自平衡保证了树结构的稳定。如果想进一步优化,可以引入B树和B+树。

B树解决了什么问题

很多文章将B树误称为B-(减)树,这可能是对其英文名“B-Tree”的误解(更有甚者,将B树称为二叉树或二叉搜索树)。特别是与B+树一起讲的时候。想当然的认为有B+(加)树就有B-(减)树,实际上B+树的英文名是“B+-Tree”。

如果抛开维护操作,那么B树就像一棵“m叉搜索树”(m是子树的最大个数),时间复杂度为O(logm(n))。然而,B树设计了一种高效简单的维护操作,使B树的深度维持在约log(ceil(m/2))(n)~logm(n)之间,大大降低树高。

image.png

再次强调:

不要纠结于时间复杂度,与单纯的算法不同,磁盘IO次数才是更大的影响因素。读者可以推导看看,B树与AVL的时间复杂度是相同的,但由于B树的层数少,磁盘IO次数少,实践中B树的性能要优于AVL等二叉树。

同二叉搜索树类似,每个节点存储了多个key和子树,子树与key按顺序排列。

页表的目的是扩展内存+加速磁盘读写。一个页(Page)通常4K(等于磁盘数据块block的大小,见inode与block的分析),从磁盘读写的角度出发,操作系统每次以页为单位将内容从磁盘加载到内存(以摊分寻道成本),修改页后,再择期将该页写回磁盘。考虑到页表的良好性质,可以使每个节点的大小约等于一个页(使m非常大),这每次加载的一个页就能完整覆盖一个节点,以便选择下一层子树;对子树同理。对于页表来说,AVL(或RBT)相当于1个key+2个子树的B树,由于逻辑上相邻的节点,物理上通常不相邻,因此,读入一个4k页,页面内绝大部分空间都将是无效数据。

假设key、子树节点指针均占用4B,则B树节点最大m * (4 + 4) = 8m B;页面大小4KB。则m = 4 * 1024 / 8 = 512,一个512叉的B树,1000w的数据,深度最大 log(512/2)(10^7) = 3.02 ~= 4。对比二叉树如AVL的深度为log(2)(10^7) = 23.25 ~= 24,相差了5倍以上。震惊!B树索引深度竟然如此!

另外,B树对局部性原理非常友好。如果key比较小(比如上面4B的自增key),则除了页表的加成,缓存还能进一步预读加速。美滋滋~

B+树解决了什么问题

B树的剩余问题

然而,如果要实际应用到数据库的索引中,B树还有一些问题:

  1. 未定位数据行
  2. 无法处理范围查询

问题1

数据表的记录有多个字段,仅仅定位到主键是不够的,还需要定位到数据行。有3个方案解决:

  1. 直接将key对应的数据行(可能对应多行)存储在节点中。
  2. 数据行单独存储;节点中增加一个字段,定位key对应数据行的位置。
  3. 修改key与子树的判断逻辑,使子树大于等于上一key小于下一key,最终所有访问都将落于叶子节点;叶子节点中直接存储数据行或数据行的位置。

方案1中,数据行通常非常大,存储数据行将减少页面中的子树个数,m减小树高增大。假设数据行占用200B,可忽略组织B树的指针,则新的m = 4 * 1024 / 200 = 20.48 ~= 21,深度最大 log(21/2)(10^7) ~= 7。增加了一倍以上的IO,不考虑。

方案2中,节点增加了一个字段。假设是4B的指针,则新的m = 4 * 1024 / 12 = 341.33 ~= 341,深度最大 log(341/2)(10^7) = 3.14 ~= 4。与3差别不大,可以考虑。

方案3的节点m与深度不变,但时间复杂度变为稳定的O(logm(n))。考虑。

问题2

实际业务中,范围查询的频率非常高,B树只能定位到一个索引位置(可能对应多行),很难处理范围查询。给出2种方案:

  1. 不改动:查询的时候先查到左界,再查到右界,然后DFS(或BFS)遍历左界、右界之间的节点。
  2. 在“问题1-方案3”的基础上,由于所有数据行都存储在叶子节点,B树的叶子节点本身也是有序的,可以增加一个指针,指向当前叶子节点按主键顺序的下一叶子节点;查询时先查到左界,再查到右界,然后从左界到有界线性遍历。

乍一看感觉方案1比方案2好——时间复杂度和常数项都一样,方案1还不需要改动。但是别忘了局部性原理,不管节点中存储的是数据行还是数据行位置,方案2的好处在于,叶子节点连续存储,对页表和缓存友好。而方案1则面临节点逻辑相邻、物理分离的缺点。

引出B+树

综上,问题1的方案2与问题2的方案1可整合为一种方案(基于B树的索引),问题1的方案3与问题2的方案2可整合为一种(基于B+树的索引)。实际上,数据库、文件系统有些采用了B树,有些采用B+树。

由于某些猴子暂未明白的原因,包括MySQL在内的主流数据库多选择了B+树。即:

image.png

主要变动如上所述:

  • 修改key与子树的组织逻辑,将索引访问都落到叶子节点
  • 按顺序将叶子节点串起来(方便范围查询)

B树和B+树的增、删、查过程

B树的增删过程暂时可参考从B树、B+树、B*树谈到R 树的“6、B树的插入、删除操作”小节,B+树的增删同理。此处暂不赘述。

Mysql索引优化

根据B+树的性质,很容易理解各种常见的MySQL索引优化思路。

暂不考虑不同引擎之间的区别。

优先使用自增key作为主键

前面的分析中,假设用4B的自增key作为索引,则m可达到512,层高仅有3。使用自增的key有两个好处:

  1. 自增key一般为int等整数型,key比较紧凑,这样m可以非常大,而且索引占用空间小。最极端的例子,如果使用50B的varchar(包括长度),那么m = 4 * 1024 / 54m = 75.85 ~= 76,深度最大 log(76/2)(10^7) = 4.43 ~= 5,再加上cache缺失、字符串比较的成本,时间成本增加较大。同时,key由4B增长到50B,整棵索引树的空间占用增长也是极为恐怖的(如果二级索引使用主键定位数据行,则空间增长更加严重)。
  2. 自增的性质使得新数据行的插入请求必然落到索引树的最右侧,发生节点分裂的频率较低,理想情况下,索引树可以达到“满”的状态。索引树满,一方面层高更低,一方面删除节点时发生节点合并的频率也较低。

优化经历:

猴子曾使用varchar(100)的列做过主键,存储containerId,过了3、4天100G的数据库就满了,DBA小姐姐邮件里委婉表示了对我的鄙视。。。之后增加了自增列作为主键,containerId作为unique的二级索引,时间、空间优化效果相当显著。

最左前缀匹配

索引可以简单如一个列(a),也可以复杂如多个列(a, b, c, d),即联合索引。如果是联合索引,那么key也由多个列组成,同时,索引只能用于查找key是否存在(相等),遇到范围查询(>、<、between、like左匹配)等就不能进一步匹配了,后续退化为线性查找。因此,列的排列顺序决定了可命中索引的列数。

如有索引(a, b, c, d),查询条件a = 1 and b = 2 and c > 3 and d = 4,则会在每个节点依次命中a、b、c,无法命中d。也就是最左前缀匹配原则。

=、in自动优化顺序

不需要考虑=、in等的顺序,mysql会自动优化这些条件的顺序,以匹配尽可能多的索引列。

如有索引(a, b, c, d),查询条件c > 3 and b = 2 and a = 1 and d < 4与a = 1 and c > 3 and b = 2 and d < 4等顺序都是可以的,MySQL会自动优化为a = 1 and b = 2 and c > 3 and d < 4,依次命中a、b、c。

索引列不能参与计算

有索引列参与计算的查询条件对索引不友好(甚至无法使用索引),如from_unixtime(create_time) = '2014-05-29'。

原因很简单,如何在节点中查找到对应key?如果线性扫描,则每次都需要重新计算,成本太高;如果二分查找,则需要针对from_unixtime方法确定大小关系。

因此,索引列不能参与计算。上述from_unixtime(create_time) = '2014-05-29'语句应该写成create_time = unix_timestamp('2014-05-29')。

能扩展就不要新建索引

如果已有索引(a),想建立索引(a, b),尽量选择修改索引(a)为索引(a, b)。

新建索引的成本很容易理解。而基于索引(a)修改为索引(a, b)的话,MySQL可以直接在索引a的B+树上,经过分裂、合并等修改为索引(a, b)。

不需要建立前缀有包含关系的索引

如果已有索引(a, b),则不需要再建立索引(a),但是如果有必要,则仍然需考虑建立索引(b)。

选择区分度高的列作索引

很容易理解。如,用性别作索引,那么索引仅能将1000w行数据划分为两部分(如500w男,500w女),索引几乎无效。

区分度的公式是count(distinct <col>) / count(*),表示字段不重复的比例,比例越大区分度越好。唯一键的区分度是1,而一些状态、性别字段可能在大数据面前的区分度趋近于0。

这个值很难确定,一般需要join的字段要求是0.1以上,即平均1条扫描10条记录。


参考:

  • 从B树、B+树、B*树谈到R 树
  • MySQL索引原理及慢查询优化

本文链接:浅谈MySQL的B树索引与索引优化

作者:猴子007

出处:monkeysayhi.github.io

本文基于知识共享署名-相同方式共享 4.0国际许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名及链接。

本文转载自: 掘金

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

1…895896897…956

开发者博客

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