1 | package main |
1 | package main |
1 | package main |
1 | package main |
1 | package main |
注意:
如果程序有多个生产者消费者,千万不能使用
1 | Producer.Shutdown() |
这会导致与borker的连接关闭,引发很多问题
]]>先进先出
的数据结构。在介绍分布式事务的时候我们介绍过MQ(消息队列),只是简单的提了一下,这篇文章会从MQ的基本概念、RocketMQ的概念,使用RocketMQ等几个方面介绍RocketMQ。其应用场景主要包含以下三个方面
系统的耦合性越高,容错性就越低。
以电商应用为例,用户创建订单后,如果耦合调用库存系统、物流系统、支付系统,任何一个子系统出了故障或者因为升级等原因暂时不可用,都会造成下单操作异常,影响用户使用体验。
使用消息队列解耦合,系统的耦合性就会提高了。比如物流系统发生故障,需要几分钟才能来修复,在这段时间内,物流系统要处理的数据被缓存到消息队列中,用户的下单操作正常完成。当物流系统回复后,补充处理存在消息队列中的订单消息即可,终端系统感知不到物流系统发生过几分钟故障。
应用系统如果遇到系统请求流量的瞬间猛增,有可能会将系统压垮。有了消息队列可以将大量请求缓存起来,分散到很长一段时间处理,这样可以大大提到系统的稳定性和用户体验。
一般情况,为了保证系统的稳定性,如果系统负载超过阈值,就会阻止用户请求,这会影响用户体验,而如果使用消息队列将请求缓存起来,等待系统处理完毕后通知用户下单完毕,这样总不能下单体验要好。
出于经济考量目的:业务系统正常时段的QPS如果是1000,流量最高峰是10000,为了应对流量高峰配置高性能的服务器显然不划算,这时可以使用消息队列对峰值流量削峰。
对与A系统,其他系统都要A系统的服务,在不使用MQ时,如果要新增一个系统来拿到A系统的信息,这时候就要改A系统的代码,让他能和E系统通信。如果其中的一个系统不想要A系统了,这时候还要删除与它相关的代码。
而引入MQ之后,A系统将自己的产生的数据发送到MQ中,新系统想要数据就直接从MQ中消费,原来的系统如果不需要这些数据了,就可以不接受。
缺点包含以下几点:
1 | public enum SendStatus { |
下表概括了三者的特点和主要区别
发送方式 | 发送TPS(性能测试指标) | 发送结果反馈 | 可靠性 |
---|---|---|---|
同步发送 | 快 | 有 | 不丢失 |
异步发送 | 快 | 有 | 不丢失 |
单向发送 | 最快 | 没有 | 可能会丢失 |
普通消息是我们在业务开发中用到的最多的消息类型,生产者需要关注消息发送成功即可,消费者消费到消息即可,不需要保证消息的顺序,所以消息可以大规模并发地发送和消费,吞吐量很高,适合大部分场景。不能够保证顺序。
顺序消息分为分区顺序消息和全局顺序消息。
全局顺序消息比较容易理解,也就是哪条消息先进入,哪条消息就会先被消费,符合我们的FIFO。
很多时候全局消息的实现代价很大,所以就出现了分区顺序消息。
我们通过对消息的key,进行hash,相同hash的消息会被分配到同一个分区里面,当然如果要做全局顺序消息,我们的分区只需要-一个即可,所以全局顺序消息的代价是比较大的。
主要用来订单超时库存归还。
延迟的机制是在服务端实现的,也就是Broker收到了消息,但是经过一段时间以后才发送服务器按照1-N定义了如下级别:
1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
若要发送定时消息,在应用层初始化Message消息对象之后,调用Message.setDelayTimeLevel(int level)方法来设置延迟级别,按照序列取相应的延迟级别,例如level=2,则延迟为5s。
1 | msg.setDelayLevel(2); |
实现原理:
DelayTimeLevel
,那么该消息会被丢到ScheduleMessageService.SCHEDULE_ TOPIC
这个Topic里面DelayTimeLevel
选择对应的queue SCHEDULE TOPIC_ XXX
的每个DelayTimeLevelQueue
,有定时任务去刷新,是否有待投递的消息消息队列RocketMQ版提供的分布式事务消息适用于所有对数据最终一致性有 强需求的场景。
事务消息:消息队列RocketMQ版提供类似X或Open XA的分布式事务功能,通过消息队列RocketMQ版事务消息能达到分布式事务的最终一致。
半事务消息:暂不能投递的消息,发送方已经成功地将消息发送到了消息队列RocketMQ版服务端,但是服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,处于该种状态下的消息即半事务消息。
消息回查:由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,消息队列RocketMQ版服务端通过扫描发现某条消息长期处于“半事务消息”时,需要主动向消息生产者询问该消息的最终状态(Commit或是Rollback),该询问过程即消息回查。
消息队列RocketMQ版分布式事务消息不仅可以实现应用之间的解耦,又能保证数据的最终一致性。 同时,传统的大事务可以被拆分为小事务,不仅能提升效率,还不会因为某一个关联应用的不可用导致整体回滚,从而最大限度保证核心系统的可用性。在极端情况下,如果关联的某一个应用始终无法处理成功,也只需对当前应用进行补偿或数据订正处理,而无需对整体业务进行回滚。
使用docker-compose的方式进行快速启动
conf/broker.conf
文件brokerIP1
,将value值更改为当前服务器的ip
地址docker-compose up -d
启动关于消息回查,有三个常见的属性设置,他们都在Broker加载的配置文件中配置,如下
transactionTimeout=20
,指定TM在20秒内应该最终确认状态给TC,否者引发消息回查,默认为60秒transactionCheckMax=5
,指定最多回查5次,操过后将丢弃消息并记录错误日志,默认15次transactionCheckInterval=10
,指定设置的多次消息回查时间间隔为10秒,默认60秒两阶段提交又称2PC,2PC是一个非常经典的中心化的原子提交协议。
这里所说的中心化是指协议中有两类节点: 一个是中心化协调者节点(coordinator) 和N个参与者节点(partcipant)。
两个阶段:第一阶段:投票阶段和第二阶段:提交/执行阶段。
举例订单服务A,需要调用支付服务B去支付,支付成功则处理购物订单为待发货状态,否则就需要将购物订单处 理为失败状态。
对于订单服务来说,首先是要调用库存服务、开始执行扣减库存的事务,调用通知服务、通知服务将信息写入数据库、之后在订单服务本地执行新建订单、插入商品等一系列操作。如果在此之前包括现在没有出问题,那么久确认扣减库存commit、确认发送消息,业务结束。如果在此之前出现了问题,那么就要通知之前的服务进行rollback。业务结束。
这里通知服务只是一个通知卖家或买家的服务,通知货物是否买/卖了。
无论是在第一阶段的过程中,还是在第二阶段,所有的参与者(通知服务、库存服务)资源和协调者(订单服务)资源都是被锁住的,只有当所有节点准备完 毕,事务协调者才会通知进行全局提交。 参与者进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大。
如果是通知服务挂了或者超时了,那么就会导致库存服务的资源库存被锁住,只有在通知服务rollback之后库存服务才能释放资源。
由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。 尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(虽然协调者挂掉, 可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)。
一个订单支付之后,我们需要一下的步骤:
好,业务场景有了,现在我们要更进一步,实现一个TCC分布式事务的效果。上述这几个步骤,要么一起成功, 要么一起失败,必须是一个整体性的事务。
举个例子,现在订单的状态都修改为“已支付”了,结果库存服务扣减库存失败。那个商品的库存原来是100件,现在卖掉了2件,本来应该是98件了。结果呢? 由于库存服务操作数据库异常,导致库存数量还是100。这不是在坑人么,当然不能允许这种情况发生了!
对于所有的我们都应该加一个中间状态,判断是否确认还是取消。
1 | type orderService struct{ |
以出售接口为例:在model中加一个freeze冻结字段,表示有多少库存的东西被冻结了,那么在获取库存详细的时候就应该是获得的数量-冻结的数量。
在出售时应该就变为了try sell,不在是直接减库存了,给冻结库存加上出售的数量。
另外还需要confirm sell,给他确认扣减,这时候吧冻结的减掉,然后把真正的库存数量减冻结数量。
还有一个cancel sell ,出现问题之后我要取消冻结的库存,那么回滚冻结的数量freeze-=sellNum
.
跨服务调用库存微服务进行库存扣减
TCC 底层的实各个服务实现比较简单,在业务逻辑中的confirm和cancel是很复杂的。在什么情况下进行confirm,cancel都是问题。
总结一下, 你要玩TCC分布式事务的话: .
本地消息表这个方案最初是eBay提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。
订单服务将自己的业务完成之后,将信息发送到消息队列中去,库存服务和通知服务从队列中拿任务完成,如果消息消费完成了就确认删除,如果没有就重试。只要不确认都会在消息队列中。
虽然当前数据没有一致,但最终一定会一致。
隐患:
先记录再发送消息,发送失败了
已经接收到订单消息
时网络出问题超时了,这时候订单服务就会收到超时,订单就会回滚,但是库存和通知服务已经开始执行了。这时候要增加一个本地消息表来记录消息的生产和消费,这样才能保证消息不会丢失。(当消费时,去查询数据库里是否有记录这个MQ信息,如果没有,则可能存在上面2的情况,MQ收到消息但是回复的时候订单服务没有收到回退了,此时就不进行操作直接回复ack丢弃消息)
这种情况下,本地数据库操作与存储消息日志处于同一事务中,本地数据库操作与记录消息日志操作具备原子性。
如何保证将消息发送给消息队列呢? 经过第一步消息已经写到消息日志表中,可以启动独立的线程,定时对消息日志表中的消息进行扫描并发送至消息中间件,在消息中间件反馈发送成功后删除该消息日志,否则等待定时任务下一周期重试。
如何保证消费者一-定能消费 到消息呢? 这里可以使用MQ的ack (即消息确认)机制,消费者监听MQ,如果消费者接收到消息并且业务处理完成后向MQ发送ack (即消息确认),此时说明消费者正常消费消息完成,MQ将不再向消费者推送消息,否则消费者会不断重试向消费者来发送消息。 通知服务接收到“通知给用户”消息,开始通知用户,通知用户成功后消息中间件回应ack,否则消息中间件将重复投递此消息。由于消息会重复投递,积分服务的“增加积分”功能需要实现幂等性。
最大努力通知型( Best-effort delivery)是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果。典型的使用场景:如银行通知、商户通知等。最大努力通知型的实现方案,一般符合以下特点:
不可靠消息:业务活动主动方,在完成业务处理之后,向业务活动的被动方发送消息,直到通知N次后不再通知,允许消息丢失(不可靠消息)。 定期校对:业务活动的被动方,根据定时策略,向业务活动主动方查询(主动方提供查询接口),恢复丢失的业务消息。
以下这个例子,用户在支付之后,支付宝要尽自己最大的努力来通知到商家某某人已经支付成功了,但是对方的服务可能会挂掉或者这个对方这个接口不存在,这个时候,就要努力尝试通知商城
。对于通知的时间也是有讲究的,不能每一次都是1秒中通知一次,可以刚开始1秒钟尝试通知一次,之后2秒、5秒…但是也不能一直尝试通知,要有一定的上限。
商城不能直接从支付宝系统MQ中直接拿消息,而是要通过支付宝提供的服务来拿到消息。
通过使用基于RocketMQ的可靠消息实现最终一致性的分布式方案。
调用的过程如下图所示:
举例流程场景:
备注
half消息不能被消费。
这里使用MQ来保证订阅方收到的消息一定是可靠的。
MQ的回查是在一段时间后没有进行这个事务没有进行commit/rollback就会查询事务的消息状态。
但是这样做依旧还是有小瑕疵
订单服务发送一个half消息,开始执行本地事务,如果成功就commit,失败就rollback。库存服务一直在监听是否又库存扣减的消息,进行扣减库存。
本质上解决了可靠消息,消费者应该保证消息一定会被消费。这就要求我们的库存服务一定要可靠,一定要执行成功。这个服务一般可以保证可靠。
但是由于是库存服务,如果没有库存了,扣减失败怎么办?
第一种方案就是在上图中加锁,保证在并发情况下库存正常:
注意,在2步骤中,如果库存服务只有1个消费者,这时候开启事务后就可以不加锁(事务update时会自动加行锁),直接数据库使用SQL语句(库存表为 inventory
,库存字段为 stocks
, 购买数量为 10
,商品id为 1
)
1 | update inventory set stocks = stocks - 10 where gooods_id = 1 and stocks > 10 |
执行后查看影响行数是否为1判断是否执行成功,失败则认为库存不足返回。成功则扣减库存,返回成功,订单服务就可以正常创建。开始3后面正常的流程了。
]]>在微服务系统中,一个服务想要完成一个功能,往往都会涉及到需要调用其他几个服务共同成功,才算成功。例如新建订单,过程中可能就需要调用库存服务减少库存,调用积分服务增加用户积分等等,其中就会涉及到各个不同微服务之间的各自数据落盘。对于金额这方面,我们必须要保证数据的一致性,如果期间有一个服务出错,必须全部回滚。这在仅仅只有本地事务中是无法做到的,下面介绍数据不一致产生的原因和分布式事务的相关理论。
没有发送出去?
发送了,没有收到,导致以为出错了
扣完库存之后,新建订单出错了,而库存的事务以及提交了。库存就白被扣了
先建订单成功了,之后调用库存服务失败了
库存已经被扣减了,但是用户一直没有支付。
对于事务性问题,采用分布式的事务解决。
对于业务下单不支付,采用超时机制进行,超时之后将库存归还。
一组sq|语句操作单元,组内所有SQL语句完成一个业务, 如果整组成功:意味着全部SQL都实现;如 果其中任何一个失败,意味着整个操作都失败。失败,意味着整个过程都是没有意义的。应该是数据库回 到操作前的初始状态。这种特性,就叫“事务”。
功能不可再分,要么全部成功,要么全部失败
上述过程中: a. 是初始状态、b是中间状态、c是最终状态,a和c是我们期待的状态,但是2这种状态却不是我们期待出现的状态。
那么反驳的声音来了:
要么转账操作全部成功,要么全部失败,这是原子性。从例子上看全部成功,那么一致性就是原子性的一部分咯,为什么还要单独说一致性和原子性? 你说的不对。在未提交读的隔离级别下是事务内部操作是可见的,明显违背了一致性,怎么解释? 好吧,需要注意的是: 原子性和一致性的的侧重点不同:原子性关注状态,要么全部成功,要么全部失败,不存在部分成功的状态。而一致性关注数据的可见性,中间状态的数据对外部不可见,只有最初状态和最终状态的数据对外可见。
一致性是要保证操作前和操作后数据或者数据结构的一致性,而我提到的事务的一致性是关注数据的中间状态,也就是一致性需要监视中间状态的数据, 如果有变化,即刻回滚。
如果不考虑隔离性,事务存在3种并发访问数据问题,也就是事务里面的脏读、不可重复读、虚 度/幻读
分布式事务顾名思义就是要在分布式系统中实现事务,它其实是由多个本地事务组合而成。
对于分布式事务而言几乎满足不了ACID,其实对于单机事务而言大部分情况下也没有满足ACID,不然怎么会有mysql四种隔离级别呢?所以更别说分布在不同数据库或者不同应用上的分布式事务了。
cap理论是分布式系统的理论基石
“all nodes see the same data at the same time ”,即更新操作成功并返回客户端后,所有节点在同一时间的数据 完全一致,这就是分布式的一致性。
一致性的问题在并发系统中不可避免, 对于客户端来说,一致性指的是并发访问时更新过的数据如何获取的问题。从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。
一个是写数据库
一个是读数据库
一个服务将记录写入到写数据库
,另一个服务从读数据库
来读取刚刚的记录,这要保证,我只要写入进去了就一定可以读到。这里可以采用我写到数据库中,之后同步完成了,再响应给写的服务,这样可以保证我的读数据库
中已经包含了之前写的数据了。
可用性指“Reads and writes always succeed”,即服务一直可用, 而且是正常响应时间。
好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。
在前面的一致性中,我们要等到同步完成之后才响应,但是如果响应的时间很长(几秒),这时候,我要求必须要我写入了就可以从读数据库中获得。
这时候会出现一个问题就是,我在同步的过程中肯定要对这条数据枷锁,但是如果加锁了,就不能保证数据的一致性,你就访问不到了,这就与一致性相违背了。
一致性和可用性是互斥的。
即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。
分区容错性要求能够使应用虽然是一个分布式系统, 而看上去却好像是在一个可以运转正常的整体。
比如现在的分布式系统中有某一个或者几个机器宕掉了,其他剩下的机器还能够正常运转满足系统需求,对于用户而言并没有什么体验上的影响。
如果是一个分布式系统,一定要满足:分区容错性。
CAP三个特性只能满足其中两个,那么取舍的策略就共有三种:
CA:单机的数据库
CP:要保证一致性和分区容错性,这要等同步完成之后才能使用,在网络等有问题的时候不对外提供服务就行。NoSQL数据库,MongoDB、HBase、Redis
AP:保证可用性。CoachDB、Cassandra、DynamoDB
BASE是Basically Available (基本可用)、Soft state (软状态)和Eventually consistent (最终一致性) 三个短语的缩写。
BASE理论是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于CAP定理逐步演化而来的。
BASE理论的核心思想是:即使无法做到强一致性, 但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。
接下来看一下BASE中的三要素:
基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性一注意, 这绝不等价于系统不可用。比如:
软状态指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性。即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
最终一致性强调的是所有的数据副本,在经过一段时间的同步之后,最终都能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
总的来说,BASE理论面向的是大型高可用可扩展的分布式系统和传统的事物ACID特性是相反的,它完全不同于ACID的强一致性模型。而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。但同时,在实际的分布式场景中,不同业务单元和组件对数据一致性的要求是不同的,因此在具体的分布式系统架构设计过程中,ACID特性和BASE理论往往又会结合在一起。
一句话: CAP就是告诉你:想要满足C、A、P就是做梦, BASE才是你最终的归宿
]]>Elasticsearch 中文本分析Analysis是把全文本转换成一系列的单词(term/token)的过程,也叫分词。文本分析是使用分析器 Analyzer 来实现的,Elasticsearch内置了分析器,用户也可以按照自己的需求自定义分析器。
为了提高搜索准确性,除了在数据写入时转换词条,匹配 Query 语句时候也需要用相同的分析器对查询语句进行分析。
Analyzer 由三部分组成:Character Filters、Tokenizer、Token Filters
Character Filters字符过滤器接收原始文本text的字符流,可以对原始文本增加、删除字段或者对字符做转换。一个Analyzer 分析器可以有 0-n 个按顺序执行的字符过滤器。
Tokenizer 分词器接收Character Filters输出的字符流,将字符流分解成的那个的单词,并且输出单词流。例如空格分词器会将文本按照空格分解,将 “Quick brown fox!” 转换成 [Quick, brown, fox!]。分词器也负责记录每个单词的顺序和该单词在原始文本中的起始和结束偏移 offsets 。
一个Analyzer 分析器有且只有 1个分词器。
Token Filter单词过滤器接收分词器 Tokenizer 输出的单词流,可以对单词流中的单词做添加、移除或者转换操作,例如 lowercase token filter会将单词全部转换成小写,stop token filter会移除 the、and 这种通用单词, synonym token filter会往单词流中添加单词的同义词。
Token filters不允许改变单词在原文档的位置以及起始、结束偏移量。
一个Analyzer 分析器可以有 0-n 个按顺序执行的单词过滤器。
Standard Analyzer - 默认分词器,按词切分,小写处理
Simple Analyzer - 按照非字母切分(符号被过滤),小写处理
Stop Analyzer - 小写处理,停用词过滤(the ,a,is)
Whitespace Analyzer - 按照空格切分,不转小写
Keyword Analyzer - 不分词,直接将输入当做输出
Patter Analyzer - 正则表达式,默认 \W+
Language - 提供了 30 多种常见语言的分词器
例子:The 2 QUICK Brown-Foxes jumped over the lazy dog’s bone.
1 | #standard |
输出:
[the,2,quick,brown,foxes,a,jumped,over,the,lazy,dog’s,bone]
1 | #simpe |
输出:
[the,quick,brown,foxes,jumped,over,the,lazy,dog,s,bone]
1 | GET _analyze |
输出:
[quick,brown,foxes,jumped,over,lazy,dog,s,bone]
1 | #stop |
输出:
[The,2,QUICK,Brown-Foxes,jumped,over,the,lazy,dog’s,bone.]
1 | #keyword |
输出:
[The 2 QUICK Brown-Foxes jumped over the lazy dog’s bone.]
1 | GET _analyze |
输出:
[the,2,quick,brown,foxes,jumped,over,the,lazy,dog,s,bone]
支持语言:arabic, armenian, basque, bengali, bulgarian, catalan, czech, dutch, english, finnish, french, galician, german, hindi, hungarian, indonesian, irish, italian, latvian, lithuanian, norwegian, portuguese, romanian, russian, sorani, spanish, swedish, turkish.
1 | #english |
输出:
[2,quick,brown,fox,jump,over,the,lazy,dog,bone]
中文分词要比英文分词难,英文都以空格分隔,中文理解通常需要上下文理解才能有正确的理解,比如 [苹果,不大好吃]和
[苹果,不大,好吃],这两句意思就不一样。
IK Analyzer - 对中文分词友好,支持远程词典热更新,有ik_smart 、ik_max_word 两种分析器
pinyin Analyzer - 可以对中文进行拼音分析,搜索时使用拼音即可搜索出来对应中文
ICU Analyzer - 提供了 Unicode 的支持,更好的支持亚洲语言
hanLP Analyzer - 基于NLP的中文分析器
在搜索时,Elasticsearch 通过依次检查以下参数来确定要使用的分析器:
analyzer
参数。请参阅为查询指定搜索分析器。(搜索时指定)search_analyzer
映射参数。请参阅为字段指定搜索分析器。(创建字段时指定)analysis.analyzer.default_search
索引设置 。请参阅为索引指定默认搜索分析器。(创建index时指定)analyzer
映射参数。请参阅为字段指定分析器。(创建字段时,指定了 analyzer 但是没有指定 search_analyzer,则直接使用 analyzer)如果没有指定这些参数, 则使用standard
分析器。
注意:使用match查询时才可以指定搜索时使用的分析器,如果没有分析器则默认使用standard分析器。而term无法指定分析器,就是直接使用Keyword Analyzer
特殊:如果字段设置为keyword并且没有为该字段或者该index设置其他分析器,则用match搜索此字段时,默认使用字段的 analyzer 也就是Keywork
1 | PUT analy-test |
注意:如果指定了字段创建时用的分析器,如果搜索时使用match并且没有指定分析器,则会使用该字段创建时使用的分析器
1 | PUT analy-test |
1 | # 虽然match会分词,但是使用指定的分词器keyword,无法分词必须精确匹配,所以搜索无结果 |
1 | PUT analy-test |
单词是语言中重要的基本元素。一个单词可以代表一个信息单元,有着指代名称、功能、动作、性质等作用。在语言的进化史中,不断有新的单词涌现,也有许多单词随着时代的变迁而边缘化直至消失。根据统计,《汉语词典》中包含的汉语单词数目在37万左右,《牛津英语词典》中的词汇约有17万。
理解单词对于分析语言结构和语义具有重要的作用。因此,在机器阅读理解算法中,模型通常需要首先对语句和文本进行单词分拆和解析。
分词(tokenization)的任务是将文本以单词为基本单元进行划分。由于许多词语存在词型的重叠,以及组合词的运用,解决歧义性是分词任务中的一个挑战。不同的分拆方式可能表示完全不同的语义。如在以下例子中,两种分拆方式代表的语义都有可能:
1 | 南京市|长江|大桥 |
1.将复杂问题转化为数学问题
在 机器学习的文章 中讲过,机器学习之所以看上去可以解决很多复杂的问题,是因为它把这些问题都转化为了数学问题。
而 NLP 也是相同的思路,文本都是一些「非结构化数据」,需要先将这些数据转化为「结构化数据」,结构化数据就可以转化为数学问题了,而分词就是转化的第一步。
2.词是一个比较合适的粒度
词是表达完整含义的最小单位。
字的粒度太小,无法表达完整含义,比如”鼠“可以是”老鼠“,也可以是”鼠标“。
而句子的粒度太大,承载的信息量多,很难复用。比如”传统方法要分词,一个重要原因是传统方法对远距离依赖的建模能力较弱。”
英文有天然的空格作为分隔符,但是中文没有。所以如何切分是一个难点,再加上中文里一词多意的情况非常多,导致很容易出现歧义。下文中难点部分会详细说明。
英文单词存在丰富的变形变换。为了应对这些复杂的变换,英文NLP相比中文存在一些独特的处理步骤,称为词形还原(Lemmatization)和词干提取(Stemming)。中文则不需要
词性还原:does,done,doing,did 需要通过词性还原恢复成 do。
词干提取:cities,children,teeth 这些词,需要转换为 city,child,tooth”这些基本形态
例如「中国科学技术大学」就有很多种分法:
粒度越大,表达的意思就越准确,但是也会导致召回比较少。所以中文需要不同的场景和要求选择不同的粒度。这个在英文中是没有的。
目前中文分词没有统一的标准,也没有公认的规范。不同的公司和组织各有各的方法和规则。
例如「兵乓球拍卖完了」就有2种分词方式表达了2种不同的含义:
信息爆炸的时代,三天两头就会冒出来一堆新词,如何快速的识别出这些新词是一大难点。比如当年「蓝瘦香菇」大火,就需要快速识别。
分词的方法大致分为 3 类:
给予词典匹配的分词方式
优点:速度快、成本低
缺点:适应性不强,不同领域效果差异大
基本思想是基于词典匹配,将待分词的中文文本根据一定规则切分和调整,然后跟词典中的词语进行匹配,匹配成功则按照词典的词分词,匹配失败通过调整或者重新选择,如此反复循环即可。代表方法有基于正向最大匹配和基于逆向最大匹配及双向匹配法。
基于统计的分词方法
优点:适应性较强
缺点:成本较高,速度较慢
这类目前常用的是算法是 ** HMM、CRF、SVM、深度学习 ** 等算法,比如stanford、Hanlp分词工具是基于CRF算法。以CRF为例,基本思路是对汉字进行标注训练,不仅考虑了词语出现的频率,还考虑上下文,具备较好的学习能力,因此其对歧义词和未登录词的识别都具有良好的效果。
基于深度学习
优点:准确率高、适应性强
缺点:成本高,速度慢
例如有人员尝试使用双向LSTM+CRF实现分词器,其本质上是序列标注,所以有通用性,命名实体识别等都可以使用该模型,据报道其分词器字符准确率可高达97.5%。
常见的分词器都是使用机器学习算法和词典相结合,一方面能够提高分词准确率,另一方面能够改善领域适应性。
下面排名根据 GitHub 上的 star 数排名:
分词就是将句子、段落、文章这种长文本,分解为以字词为单位的数据结构,方便后续的处理分析工作。
分词的原因:
中英文分词的3个典型区别:
中文分词的3大难点
3个典型的分词方式:
下载地址
下载的版本一定要和es的版本保持一致
将目录文件夹改名为ik
1 | cd /data/elasticsearch/plugins |
docker restart xxxx
ik_smart 和 ik_max_word
1 | GET _analyze |
1 | mkdir -p elasticsearch ik custom |
1 | vim elasticsearch ik custom/extra_stopword.dic |
1 | vim /data/elasticsearch/plugins/ik/config/IKAnalyzer.cfg.xml |
重启容器即可
]]>Mapping 类似于数据库中的表结构定义 schema
,它有以下几个作用:
在 ES 早期版本,一个索引下是可以有多个 Type 的,从 7.0 开始,一个索引只有一个 Type,也可以说一个 Type 有一个 Mapping 定义。
在了解了什么是 Mapping 之后,接下来对 Mapping 的设置做下介绍:
1 | PUT users |
在创建一个索引的时候,可以对 dynamic
进行设置,可以设成 false
、true
或者 strict
。
比如一个新的文档,这个文档包含一个字段,当 Dynamic 设置为 true
时,这个文档可以被索引进 ES,这个字段也可以被索引,也就是这个字段可以被搜索,Mapping 也同时被更新;当 dynamic 被设置为 false
时候,存在新增字段的数据写入,该数据可以被索引,但是新增字段被丢弃;当设置成 strict
模式时候,数据写入直接出错。
另外还有 index
参数,用来控制当前字段是否被索引,默认为 true
,如果设为 false
,则该字段不可被搜索。
参数 index_options
用于控制倒排索引记录的内容,有如下 4 种配置:
doc id
doc id
和 term frequencies
doc id
、term frequencies
和 term position
doc id
、term frequencies
、term position
和 character offects
另外,text
类型默认配置为 positions
,其他类型默认为 doc
,记录内容越多,占用存储空间越大。
null_value
主要是当字段遇到 null
值时的处理策略,默认为 NULL
,即空值,此时 ES 会默认忽略该值,可以通过设定该值设定字段的默认值,另外只有 KeyWord 类型支持设定 null_value
。
copy_to
作用是将该字段的值复制到目标字段,实现类似 _all
的作用,它不会出现在 _source
中,只用来搜索。
ES 字段类型类似于 MySQL 中的字段类型,ES 字段类型主要有:核心类型、复杂类型、地理类型以及特殊类型,具体的数据类型如下图所示:
从图中可以看出核心类型可以划分为字符串类型、数字类型、日期类型、布尔类型、基于 BASE64 的二进制类型、范围类型。
其中,在 ES 7.x 有两种字符串类型:text
和 keyword
,在 ES 5.x 之后 string
类型已经不再支持了。
text
类型适用于需要被全文检索的字段,例如新闻正文、邮件内容等比较长的文字,text
类型会被 Lucene 分词器(Analyzer)处理为一个个词项,并使用 Lucene 倒排索引存储,text 字段不能被用于排序,如果需要使用该类型的字段只需要在定义映射时指定 JSON 中对应字段的 type
为 text
。
keyword
适合简短、结构化字符串,例如主机名、姓名、商品名称等,可以用于过滤、排序、聚合检索,也可以用于精确查询。
数字类型分为 long、integer、short、byte、double、float、half_float、scaled_float
。
数字类型的字段在满足需求的前提下应当尽量选择范围较小的数据类型,字段长度越短,搜索效率越高,对于浮点数,可以优先考虑使用 scaled_float
类型,该类型可以通过缩放因子来精确浮点数,例如 12.34 可以转换为 1234 来存储。
在 ES 中日期可以为以下形式:
即使是格式化的日期字符串,ES 底层依然采用的是时间戳的形式存储。
JSON 文档中同样存在布尔类型,不过 JSON 字符串类型也可以被 ES 转换为布尔类型存储,前提是字符串的取值为 true
或者 false
,布尔类型常用于检索中的过滤条件。
二进制类型 binary
接受 BASE64 编码的字符串,默认 store
属性为 false
,并且不可以被搜索。
范围类型可以用来表达一个数据的区间,可以分为5种:integer_range、float_range、long_range、double_range
以及 date_range
。
复合类型主要有对象类型(object)和嵌套类型(nested):
JSON 字符串允许嵌套对象,一个文档可以嵌套多个、多层对象。可以通过对象类型来存储二级文档,不过由于 Lucene 并没有内部对象的概念,ES 会将原 JSON 文档扁平化,例如文档:
1 | { |
实际上 ES 会将其转换为以下格式,并通过 Lucene 存储,即使 name
是 object
类型:
1 | { |
嵌套类型可以看成是一个特殊的对象类型,可以让对象数组独立检索,例如文档:
1 | { |
username
字段是一个 JSON 数组,并且每个数组对象都是一个 JSON 对象。如果将 username
设置为对象类型,那么 ES 会将其转换为:
1 | { |
可以看出转换后的 JSON 文档中 first
和 last
的关联丢失了,如果尝试搜索 first
为 wu
,last
为 xy
的文档,那么成功会检索出上述文档,但是 wu
和 xy
在原 JSON 文档中并不属于同一个 JSON 对象,应当是不匹配的,即检索不出任何结果。
嵌套类型就是为了解决这种问题的,嵌套类型将数组中的每个 JSON 对象作为独立的隐藏文档来存储,每个嵌套的对象都能够独立地被搜索,所以上述案例中虽然表面上只有 1 个文档,但实际上是存储了 4 个文档。
地理类型字段分为两种:经纬度类型和地理区域类型:
经纬度类型字段(geo_point)可以存储经纬度相关信息,通过地理类型的字段,可以用来实现诸如查找在指定地理区域内相关的文档、根据距离排序、根据地理位置修改评分规则等需求。
经纬度类型可以表达一个点,而 geo_shape
类型可以表达一块地理区域,区域的形状可以是任意多边形,也可以是点、线、面、多点、多线、多面等几何类型。
特殊类型包括 IP 类型、过滤器类型、Join 类型、别名类型等,在这里简单介绍下 IP 类型和 Join 类型,其他特殊类型可以查看官方文档。
IP 类型的字段可以用来存储 IPv4 或者 IPv6 地址,如果需要存储 IP 类型的字段,需要手动定义映射:
1 | { |
Join 类型是 ES 6.x 引入的类型,以取代淘汰的 _parent
元字段,用来实现文档的一对一、一对多的关系,主要用来做父子查询。
Join 类型的 Mapping 如下:
1 | PUT my_index |
其中,my_join_field
为 Join 类型字段的名称;relations
指定关系:question
是 answer
的父类。
例如定义一个 ID 为 1 的父文档:
1 | PUT my_join_index/1?refresh |
接下来定义一个子文档,该文档指定了父文档 ID 为 1:
1 | PUT my_join_index/_doc/2?routing=1&refresh |
Dynamic Mapping 机制使我们不需要手动定义 Mapping,ES 会自动根据文档信息来判断字段合适的类型,但是有时候也会推算的不对,比如地理位置信息有可能会判断为 Text
,当类型如果设置不对时,会导致一些功能无法正常工作,比如 Range 查询。
ES 类型的自动识别是基于 JSON 的格式,如果输入的是 JSON 是字符串且格式为日期格式,ES 会自动设置成 Date
类型;当输入的字符串是数字的时候,ES 默认会当成字符串来处理,可以通过设置来转换成合适的类型;如果输入的是 Text
字段的时候,ES 会自动增加 keyword
子字段,还有一些自动识别如下图所示:
下面我们通过一个例子是看看是怎么类型自动识别的,输入如下请求,创建索引:
1 | PUT /mapping_test/_doc/1 |
然后使用 GET /mapping_test/_mapping
查看,结果如下图所示:
可以从结果中看出,ES 会根据文档信息自动推算出合适的类型。
如果是新增加的字段,根据 Dynamic 的设置分为以下三种状况:
true
时,一旦有新增字段的文档写入,Mapping 也同时被更新。false
时,索引的 Mapping 是不会被更新的,新增字段的数据无法被索引,也就是无法被搜索,但是信息会出现在 _source
中。strict
时,文档写入会失败。另外一种是字段已经存在,这种情况下,ES 是不允许修改字段的类型的,因为 ES 是根据 Lucene 实现的倒排索引,一旦生成后就不允许修改,如果希望改变字段类型,必须使用 Reindex API 重建索引。
不能修改的原因是如果修改了字段的数据类型,会导致已被索引的无法被搜索,但是如果是增加新的字段,就不会有这样的影响。
text会被Lucene 倒排索引存储,而keyword不会,只会原封不动的存储(所以查询keyword时需要完全一样,因为keyword是原封不动的存储)
会将字符串既用text存储,又在字段内使用keyword存储
1 |
|
这样则可以对字段进行完全准确的查询(用match或者用term都可以准确查询,这里的match不会进行分词)
1 | GET /user/_search |
1 |
|
查看所有索引
1 | GET /_cat/indices |
查看指定index的基本信息(不会查询具体的文档数据)
1 | GET /account |
在account下保存id为1的数据,这里id是必须的
1 | PUT /account/_doc/1 |
同一个请求发送多次,下面的信息会产生变化
1 | "_version": 11, |
关于_version和_seq_no的区别和作用请参考官方文档
不带id则id在插入数据后自动生成
1 | POST _doc |
或者
1 | POST /user/_doc/1 |
如果post带id就和put一样的操作了,put是不允许不带id的
没有就创建,有就报错
1 | POST /user/_create/1 |
只能用POST方法
1 | POST /account/_ /1 |
此时会发现,已有的数据的name字段没有了,只有age字段。说明这是一个完全覆盖的操作,每次都会影响到_version
此时我们需要使用
1 | POST /account/_update/1 |
此时如果会更新指定字段,字段不存在则新增该字段。对该字段操作成功,则_version
+1,如果无改变,则_version
保持不变
删除指定id数据
1 | DELETE /account/_doc/1 |
删除索引
1 | DELETE /account/_doc |
1 | GET /user/_doc/1 |
只返回source的值
1 | GET /user/_source/1 |
Elasticsearch有2种查询方式
URI带有查询条件(轻量查询)
查询能力有限,不是所有的查询都可以使用此方式
请求体中带有查询条件(复杂查询)
查询条件以JSON格式表现,作为查询请求的请求体,适合复杂的查询
请求参数位于_search端点之后,参数之间使用&分割,例如:
1 | GET /_search?pretty&q=title:azure&explain=true&from=1&size=10&sort=title:asc |
获取所有index里的document
1 | GET /_search |
获取指定index里的document
1 | GET /account/_search |
其中一个失败都不会回退,不影响同一批次的其他操作
1 | POST /_bulk |
获取指定index里的指定具体document,方便对比
1 | GET /_mget |
1 |
|
如果数据量过大的话,分页对es会有性能影响。这时候用scroll分页.
模糊匹配,需要指定字段名,但是输入会进行分词,比如”hello world”会进行拆分为hello和world,然后匹配,如果字段中包含hello或者world,或者都包含的结果都会被查询出来,也就是说match是一一个部分匹配的模糊查询。查询条件相对来说比较宽松。
1 |
|
会对输入做分词,但是需要结果中也包含所有的分词,而且顺序要求一样。以”hello world”为例,要求结果中必须包含hello和world,而且还要求他们是连着的,顺序也是固定的,hello that word不满足,world hello也不满足条件。
1 | GET /user/_search |
multi match 查询提供了一个简便的方法用来对多个字段执行相同的查询,即对指定的多个字段进行match查询
1 |
|
和match类似,但是match需要指定字段名,query_string是在所有字段中搜索,范围更广泛
default_field
不写默认为*
,代表所有字段中搜索
1 | # 搜索所有字段,并且同时包含 Madison 和 Street (字段A包含Street,字段B包含Madison 也算符合条件) |
获取所有index里的document
1 | GET /_search |
获取指定index里的document
1 | GET /account/_search |
这种查询和match在有些时候是等价的,比如我们查询单个的词hello,那么会和match查询结果一样,但是如果查询”hello world”,结果就相差很大,因为这个输入不会进行分词(也不会把查询内容小写),就是说查询的时候,是查询字段分词结果中是否有”hello world”的字样,而不是查询字段中包含”hello world”的字样,elasticsearch会对字段内容进行分词,”hello world”会被分成hello和world,不存在”hello world”,因此这里的查询结果会为空。这也是term查询和match的区别。
1 |
|
1 | GET /user/_search |
1 |
|
编辑距离
1 |
|
Elasticsearch bool 查询对应Lucene BooleanQuery,格式如下
1 | GET /user/_search |
其中
1 | must:必须匹配,查询上下文,加分 |
bool查询采用了一种匹配越多越好的方法,因此每个匹配的must或should子句的分数将被加在一起,以提供每个文档的最终得分
1 | GET /user/_search |
1 |
|
1 | docker run -d --name kibana -e ELASTICSEARCH_HOSTS="http://192.168.137.200:9200" -p 5601:5601 kibana:7.10.1 |
mysql | Elasticsearch |
---|---|
database | |
table | index(7.x开始type为固定值_doc) |
row | document |
column | field |
schema | mapping |
sql | DSL(Descriptor Structure Laguage) |
倒排索引就是es会将document进行单词小写再分词后存入,然后记录每个单词出现在哪个_id
里以及对应的位置。当用户搜索时,就会将用户的查询内容也进行分词处理,返回对应查询的结果与对应匹配分数。(所以es不是精确查询,而是类似百度引擎的模糊准确搜索)
注意: 使用term查询时,es会原样使用,不会进行单词全部小写、分词处理。
有2个含义: 动词(insert),名词(表)
Elasticsearch将它的数据存储到一个或者多个索引(index)中,索引就像数据库(7.x就是表),可以向索引写入文档或者从索引中读取文档。
文档(document)是Elasticsearch中的主要实体。所以,所有使用Elasticsearch最终都会归结到文档的搜索上,从客户端看,文档就是一个JSON对象,文档由字段构成,每个字段包含字段名以及一个或多个字段值。文档之间可能有各自不同的字段集合,文档没有固定的模式或强制的结构。
Elasticsearch中每个文档都有与之对应的类型(type)定义,允许在一个索引存储多种文档类型,并提供不同映射。
类型就像一个数据库表
映射做的就是,存储分析链所需的信息。
主要就是设置一些参数,根据这些参数来做过滤还是分割词条。
]]>之前一直是使用坚果云搭配vscode来写笔记和进行同步,越到后面就感觉越麻烦,发现这种有以下几个问题:
所以后面体验了几款云笔记软件,印象笔记、网易云笔记、为知笔记、Notion、以及语雀之类的,基本都用过,不是不花钱不让用就是有各种限制。我希望可以满足以下几个要求:
找来找去,貌似也只有私有化部署才能满足我的要求了,在网上找了一圈,发现为知笔记支持私有化部署,部署端跟着官网同步更新,官网维护,而且为知笔记的各种客户端都能支持私有化登陆,体验了一下UI也符合个人审美。除了私有化部署只能支持5个注册用户以及不能使用收藏服务,其他功能全部都可以使用,实在良心。更多私有化的详细说明可以查看官方文档
为知笔记官方提供了服务端docker镜像,方便我们可以快速搭建使用。
现在基于Ubuntu环境(IP: 192.168.100.2)进行讲解。更多环境部署方式可以查看官方文档了解更多
直接执行docker命令启动即可(请确保已经安装了docker,不会安装docker可以查看 Kubernetes 1.22集群安装教程(基于Ubuntu) 里如何安装docker)
1 | docker run --name wiz -it \ |
例如:
1 | docker run --name wiz -it \ |
参数说明:
/usr/local/wiz_data
文件夹里保存的是所有笔记数据,在任何地方,只要有这份数据,就可以恢复之前笔记记录,为后面的备份做准备。
8080
提供为知笔记服务端口,网页访问和客户端访问都是使用这个端口
3306
暴露出MySQL数据库端口,方便远程连接上笔记的数据库
这些都是根据自己需要自行配置,后面会以这些配置作为例子讲解
数据库密码在为知笔记容器里的 /wiz/app/entrypoint.sh 文件里可以获取到哦
访问 http://192.168.100.2:8080 查看网页端能否正常访问。
初始化默认的账号为 admin@wiz.cn 密码为 123456
上面有说过,/usr/local/wiz_data
文件夹里保存的是所有笔记数据。所以只要保存这个文件夹里所有的数据即可。思路是先将这个文件夹进行打包,然后再进行压缩,压缩后为了安全起见,再将压缩包上传到网盘。
这里会搭配Rclone将压缩包传到阿里网盘进行云盘端的备份,Rclone
挂载阿里云盘可以查看 通过webdav协议挂载阿里云盘到本地
这里附上我的脚本参考,我使用的压缩是lz4,自行百度安装。或者使用bz2以及gz的压缩方式都可以。
backup_aliyun.sh
1 |
|
还可以搭配linux的crontab进行自动备份
1 | 0 3,15 * * * /root/backup_aliyun.sh |
当在一个全新的环境,想要恢复之前的笔记数据,可以参考如下恢复步骤:
同样我用的解压命令也是lz4
恢复脚本参考 recover_aliyun.sh
:
1 |
|
然后就可以启动为知笔记容器了:
1 | docker run --name wiz -it \ |
由于是私有化笔记部署,很有可能不是部署在云服务器上,如果是部署在家里,则需要考虑如何能让外部访问到,这样才能分享文章或者手机端都可以直接访问到我们的笔记。关于内部服务如何能让外部访问到,则可以考虑ddns或者内网穿透的方式,这两种方式的区别、限制可以参考 简单说说几种常用的网络访问方法
我使用的是钉钉的内网穿透, 子域名名称使用的是 wiz-test
,端口是8080
(对应为知笔记容器启动时的端口)。然后我是直接用容器启动的内网穿透
1 | docker run -d --net=host -e SUB_DOMAIN=wiz-test -e PORT=8080 --name pierced guaosi/pierced |
现在可以使用为知笔记官方的桌面端或者手机端,将登陆切换为私有部署登陆,连接地址填写内网穿透的域名和端口即可:
点击 登陆 可以正常登陆进入则代表内网穿透成功,可以正常分享文章给朋友们直接访问啦。
]]>这篇文章会简单说说常见的网络通信访问。
首先,我们需要了解一些概念,什么是二层,什么是跨网段以及什么是内网,对应云厂商的相关概念又是怎么样的。(不会讲的太详细,只会讲个粗略,否则一篇根本写不完)
根据OSI七层模型可以知道,二层是数据链路层,现实中的物理设备就是二层交换机。处于同一个二层的意思就是说2台设备,通过两根网线连接在同一台交换机下,同一台交换机下的设备是处于同一个网段或者同一个局域网中(比如192.168.1.0网段),他们之间互相访问,数据包只需要经过交换机,对应上交换机的arp对应表,即可找到对方互相通信。
你可能会想,家里的同一台路由器下的设备也是处于同一个局域网,同一个网段,他们之间也能互相通信,并没有用到什么交换机啊。这其实是家用路由器已经整合了交换机的功能。
那是不是交换机设备就没什么作用了呢?纯二层交换机在校园网还有企业用的比较多,例如一个普通的家用路由器只有4lan口,但是我有10个设备都想用网线进行连接,那怎么办?
当不在同一个二层或者说不在同一个局域网内时,也就是说不在一个网段内,此时通信就需要借助硬件路由器。比如上面的旧路由器(192.168.1.0网段)接了新路由器的wan口(192.168.2.0网段),此时各自路由器下的设备想互相访问到对方,就属于跨网段通信,由路由器帮我们实现通信(RIP,OSPF之类的相关协议),如果想访问外网,也就是公网,也同样是需要路由器帮我们进行通信(BGP之类的相关协议)。
举个例子,一台设备想要访问百度,此时他需要发送http数据包(假设三次握手已经成功),首先在本设备随机开启一个端口,用来接收数据或者发送数据,直到四次挥手结束才关闭该端口,数据包根据本机路由表,发现外网地址不能直达,数据包就传送给网关,也就是路由器。路由器发现是目的ip是公网,先在路由器上记录数据包的源ip跟端口(nat表),再在路由器上随机开启一个端口,用来接收或者发送数据。再将数据包发送给运营商,由运营商负责数据包在公网上的传输,最后将结果返回给路由器对应的端口。路由器再将数据包返回给原来记录的设备。
上面说的都是对应个人家里的网络情况,如果是目前公有云厂商,那又是如何对应的呢?
现在我们手头上有A这样一台设备(认为是服务器或者电脑都行),然后有B这样一台服务器,上面的8080端口的web服务。我们想通过举例B服务器在不同的网络环境中,A电脑能进行访问,来给出不同的通讯方案以及其中一些限制因素。
A跟B在同一个二层,也就是同一个局域网,这种最简单了,ip加端口直接就能访问到。
例如新旧路由器,旧路由器的lan接新路由器的wan口。实际上这种情况,借助路由器也是可以直接,一样能通过ip加端口访问,只要路由可达即可。
B在公网环境也就是说B有公网IP可以直接被访问到,A从内网出来到公网进行访问,由路由器转发,运营商转发处理即可,对于A来说一样可以直接使用IP加端口进行访问。
这种情况最为复杂也是最麻烦的。比如A在公司,B在家里,此时两边网络不互通,无法直接进行访问,要如何处理?
比较简单的办法就是B所在环境开一个VPN,能让A直接接入B所在网络,这样就可以直接通过B的内网ip加端口直接进行访问。如果不能创建vpn,那又要如何进行处理?
最常用的就是2个办法,一个是ddns,另一个就是内网穿透。
讲解ddns时,先回顾一下, A在内网环境B在公网环境 这种情况中。用公网ip加端口就可以直接访问到云厂商的实例,那我们家里是否也能这样办到?答案是能的。但是有个前提条件,就是你要家里真有专属公网IP。如何知道自己是否真有专属的公网IP?一般如果是接正规宽带提供商(电信联通移动),并且是拨号上网,就会有个人的公网ip。这还不够,如果是光猫拨号,需要保证对光猫有足够的控制权,如果是路由器拨号,需要保证对路由器有足够的控制权,因为需要上去开端口映射或者dmz。
这样,当路由器设置好端口映射规则后,通过访问路由器的公网ip加端口,访问就会被路由器对应规则转发到对应的内网机器上以及对应的端口。这样就可以实现外部环境访问到内网的服务。
我们再了解一下域名。当我们访问一个域名,实际上会通过DNS(域名解析系统)获取到这个域名实际对应的公网IP,然后再访问该公网IP。
既然通过域名可以查到公网IP,那我们可以给路由器的公网IP绑定一个域名,这样我们在外部环境直接访问域名,就可以访问到我们的路由器了。但是有一点,因为是拨号上网,宽带运营商是不会给我们固定的IP,也就是每一次重新拨号IP都会发生变化,可是域名对应的IP地址还是旧的,那要怎么处理呢?这时候就需要使用DDNS(动态域名解析系统),路由器上需要有一个专门的程序,每分钟查询自己的公网IP是多少,然后通过域名提供商开放的API接口,来更新域名对应的最新公网IP,这样达到能全自动更新,而我们依旧只需要知道域名即可。(域名可以使用阿里云的,ddns相关api在网上也有对应案例)
这种方式相当于直接访问到内网的机器里进行交互。所以此时的传输速度,也就是上行带宽取决于家庭带宽的上行带宽限制。
当对路由器没有控制权或者运营商不给公网IP时,也就是ddns这种方案行不通时,那就只能使用内网穿透了。内网穿透的原理是将当前机器跟提供内网穿透服务的服务端建立长连接,注册上需要转发到本机的端口。注册成功后内网穿透服务提供商会提供一个域名以及端口,供任何地方可以直接访问。当访问内网穿透服务提供商会提供的域名以及端口后,请求被内网穿透服务提供商中转后,被转发到原先机器和端口上。
这种方式需要一层内网穿透服务提供商的中转,所以此时的传输速度不仅仅取决于家庭带宽的上行带宽限制,更是取决于内网穿透服务提供商提供了多大的带宽(免费的一般就给1M的带宽)。
临时使用的话,可以使用钉钉的内网穿透服务,免费而且带宽大,就是不太稳定,个人不商用应该是足够使用的。
钉钉内网穿透说明文档: https://open.dingtalk.com/document/resourcedownload/http-intranet-penetration
钉钉内网穿透项目:https://github.com/open-dingtalk/pierced
注意:这个项目很久不维护了,而且有一个bug,就是使用时间越长,内存占用越大,所以最好能写个脚本定时进行重启。
为了方便使用,我也用docker做了个封装
1 | docker run -d --net=host -e SUB_DOMAIN=子域名名称 -e PORT=转发到本机的端口 --name pierced guaosi/pierced |
注意,一定要使用host模式,否则只会在容器内转发请求,到不了宿主机上
例如
1 | docker run -d --net=host -e SUB_DOMAIN=guaosi-test -e PORT=8080 --name pierced guaosi/pierced |
WebDAV 基于 HTTP 协议的通信协议,在GET、POST、HEAD等几个HTTP标准方法以外添加了一些新的方法,使应用程序可对Web Server直接读写,并支持写文件锁定(Locking)及解锁(Unlock),还可以支持文件的版本控制。因为基于HTTP,在广域网上共享文件有天然的优势,移动端文件管理APP也大多支持WebDAV协议。使用HTTPS还能保安全性。Apache和Nginx支持WebDAV,可作为WebDAV文件共享服务器软件。也可以使用专门的WebDAV软件部署。
可以认为通过webdav,我们可以直接将云盘挂载到本地,不需要使用服务商提供的专门软件就能进行管理使用。目前国内支持的最好的就是坚果云了,自身就支持webdav,但是坚果云免费的限制流量。阿里云盘明面上不支持,但是通过搭建 webdav-aliyundriver 项目,来代理转发请求,实现阿里云盘支持webdav协议。
本篇将会讲解如下
webdav-aliyundriver
Raidrive
挂载阿里云盘Rclone
挂载阿里云盘阿里云盘自身是不支持webdav协议的,所以我们需要一个中转,接收webdav协议,然后再将webdav协议转换为HTTP请求发送给阿里云盘,以此来模拟阿里云盘支持webdav。而这个中转,就是 webdav-aliyundriver
。更多的介绍使用和搭建方式可以参考项目的readme
我们通过在ubuntu20
(ip:192.168.137.200)上使用docker
快速启动一个webdav-aliyundriver
1 | docker run -d --name=webdav-aliyundriver --restart=always -p 9090:8080 -v /etc/localtime:/etc/localtime -v /etc/aliyun-driver/:/etc/aliyun-driver/ -e TZ="Asia/Shanghai" -e ALIYUNDRIVE_REFRESH_TOKEN="下面有获取方式" -e ALIYUNDRIVE_AUTH_PASSWORD="admin" -e JAVA_OPTS="-Xmx1g" zx5253/webdav-aliyundriver |
注意:我将宿主机端口映射为9090,也就是后面的client想要连接该server,端口修改为9090
1 | --aliyundrive.refresh-token |
容器正常跑起来后,意味着webdav server
已经成功搭建了。连接webdav server
的信息如下(上面启动容器时的参数决定的)
ip | 端口 | 账号 | 密码 |
---|---|---|---|
192.168.137.200 | 9090 | admin | admin |
请确认要连接 webdav server的客户端所在主机能正常与 192.168.137.200 通信
现在我们可以先到阿里云盘的网页或者客户端上,在顶级目录下创建一个名为webdav_test
的文件夹,作为接下来的讲解案例使用
Raidrive
挂载阿里云盘webdav-aliyundriver
对Windows原生自带的添加网络设备兼容不够好,存在问题,所以需要安装第三方,也就是Raidrive
作为客户端。下载地址:https://cloud.189.cn/t/fiy2immmIbIn(访问码:ypc2)
Raidrive
安装完毕后,点击Add
,然后如下配置
这里的path如果不填写,则默认就是挂载阿里云盘的顶级目录。如果填写/webdav_test
,则是挂载我们刚刚创建的webdav_test
目录。点击OK后,如果挂成功,我的电脑里就会发现已经挂载了一块网盘,打开就可以看到阿里云盘的里保存的文件。
这样就可以不使用阿里云盘的客户端,直接对云盘里的文件进行操作了,也可以直接使用本地播放器在线播放里面的电影。
Rclone
挂载阿里云盘Rclone的步骤比Raidrive
略显复杂,它需要先配置连接信息,然后通过该配置信息再对网盘执行相关操作。
我们直接使用官网的安装脚本进行安装,更多安装方式可以查看 官网说明
1 | curl https://rclone.org/install.sh | sudo bash |
安装完成后,配置一个专门连接webdav-server
的配置,详细步骤如下:
输入 rclone config
,开始进行配置(注意看注释说明)
1 | root@guaosi:~# rclone config |
输入命令
1 | rclone ls aliyunwebdav:/ |
查看是否会列出网盘下的文件列表,会则表示配置无误了。
1 | # 将本地的 /home/file 文件上传到阿里云盘的 webdav_test 目录下 |
1 | # 将阿里云盘的 webdav_test 目录加的file文件下载到本地 /home/test/ 目录下 |
1 | # 展示顶级目录下的所有文件 |
上面只是在发送命令来与webdav server
进行交互,如果我们想像在windows
上一样像挂载一块盘进来,可以使用cp
,mv
之类的命令直接进行操作,那应该如何设置呢?
实际上很简单,我们通过rclone
自带命令就可以实现盘的挂载
1 | # 创建目录来给阿里云盘挂载 |
上面命令会将阿里云盘的webdav_test
(也可以挂载顶级目录,也就是 /
)目录挂载到本地的 /usr/local/webdav_test
目录下。
另外,该命令会阻塞命令窗口,所以另外启动一个窗口进入/usr/local/webdav_test
查看是否能看到云盘里的文件。此外,如果我们关闭了先前执行挂载命令的窗口,那么挂载就会自动解挂退出,并且如果我们想开机时自动进行云盘挂载,也需要重新手动跑命令,不够方便。所以,我们可以借助Linux的systemd
将命令服务化来进行管理。
创建system的service
1 | vim /etc/systemd/system/rclone.service |
内容如下
1 | [Unit] |
保存退出后,启动服务
1 | systemctl start rclone |
设置开机自启
1 | systemctl enable rclone |
其它操作命令
1 |
|
好啦,这样就可以安心的跟小姐姐们玩耍啦~
]]> 之前有写过 Kubernetes 18.04集群安装教程(基于Centos7) 。现在补上基于Ubuntu20
的环境进行安装。由于Ubuntu
使用的Linux
内核一直都是最新的,并且因为工作原因k8s
安装得多了,有了很多简单设置的方式,所以K8s
的目前安装过程就会比Centos
的简单了很多
操作系统: Ubuntu20
(如果还不会安装Ubuntu20的话可以看这个)
个人学习使用k8s
的话一般推荐2种方案, 一种是all in one
,另一种就是1个master
跟1个node
。(自己玩没必要搞多个master跟多个node,安装过程都是重复性的工作)
all in one
的本质就是一个master
,然后将master
上的污点删除,允许pod可以调度到master
。而node
跟master
安装过程也相差无几,只差在master
需要执行k8s
的初始化操作,而node
只需要加入k8s
的集群即可。本文两种方式都会讲解到。
节点 | 操作系统 | cpu | 内存 | 磁盘 | IP |
---|---|---|---|---|---|
k8s-master | Ubuntu20 | 2核2线程 | 8G | 60G | 192.168.137.200 |
节点 | 操作系统 | cpu | 内存 | 磁盘 | IP |
---|---|---|---|---|---|
k8s-master | Ubuntu20 | 2核1线程 | 4G | 30G | 192.168.137.200 |
k8s-node | Ubuntu20 | 2核1线程 | 4G | 30G | 192.168.137.210 |
以下操作到安装Kubernetes
前,master
跟node
都要执行。如果是all in one
,那就只要master
执行即可。
1 | # master上执行 |
网络IP的配置地址文件为/etc/netplan/50-cloud-init.yaml
master
修改为1
2
3
4
5
6
7
8
9
10network:
ethernets:
ens33:
dhcp4: no
addresses: [192.168.137.200/24]
optional: true
gateway4: 192.168.137.2
nameservers:
addresses: [223.5.5.5,223.6.6.6]
version: 2
node
修改为1
2
3
4
5
6
7
8
9
10network:
ethernets:
ens33:
dhcp4: no
addresses: [192.168.137.210/24]
optional: true
gateway4: 192.168.137.2
nameservers:
addresses: [223.5.5.5,223.6.6.6]
version: 2
完成后应用配置1
netplan apply
查看修改是否生效1
ip addr
hosts
所在位置为 /etc/hosts
1 | 192.168.1.200 k8s-master |
卸载旧版本(如果有的话)
1 | apt-get remove docker docker-engine docker.io containerd runc |
更新索引
1 | apt-get update |
安装 apt
依赖包,用于通过HTTPS
来获取仓库
1 | apt-get install \ |
添加 Docker
的官方 GPG
密钥
1 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - |
设置docker
稳定版源仓库
1 | add-apt-repository \ |
更新 apt
包索引
1 | apt-get update |
安装 Docker Engine-Community
和containerd
1 | apt-get install docker-ce docker-ce-cli containerd.io |
查看docker版本是否安装成功
1 | docker version |
使用的是阿里云的免费加速进行配置,根据文档内容进行修改
修改daemon.json
文件
1 | vim /etc/docker/daemon.json |
加入
1 | { |
然后重启docker
1 | systemctl restart docker |
一样的master
跟node
都要执行,如果只需要master
执行的,标题会有提示。如果是all in one
,那就只要master
执行即可。
暂时禁止:
1 | swapoff -a |
永久禁止:
1 | vi /etc/fstab #注释掉swap一行 |
1 | cat <<EOF | tee /etc/sysctl.d/k8s.conf |
生效
1 | sysctl --system |
添加 kubernetes
的阿里云 GPG
密钥
1 | curl -s https://mirrors.aliyun.com/kubernetes/apt/doc/apt-key.gpg | sudo apt-key add - |
设置kubernetes
阿里云的源仓库
1 | tee /etc/apt/sources.list.d/kubernetes.list <<EOF |
更新 apt
包索引
1 | apt-get update |
安装kubeadm kubeadm kubectl
1 | apt-get install -y kubelet kubeadm kubectl |
阻止 apt
自动更新
1 | apt-mark hold kubelet kubeadm kubectl |
设置开机启动以及现在立即启动 kubelet
1 | systemctl enable kubelet.service |
导出kubeadm
的默认配置文件到本地
1 | kubeadm config print init-defaults > kubeadm-config.yaml |
修改kubeadm-config.yaml
,需要修改跟新增的项如下所示(看注释项)
1 | apiVersion: kubeadm.k8s.io/v1beta3 |
node
可以直接通过scp
命令拷贝 master
的kubeadm-config.yaml
过来使用,也可以通过 kubeadm config print init-defaults > kubeadm-config.yaml
重新导出一份到本地修改,看个人喜好
1 | kubeadm config images pull --config kubeadm-config.yaml |
1 | kubeadm init --config=kubeadm-config.yaml | tee kubeadm-init.log |
完毕后,控制台输出的日志会告诉我们继续执行什么指令以及node
节点如何加入
执行日志中的指令
1 | mkdir -p $HOME/.kube |
切换到node
,执行
1 | # master初始化完成后给的加入命令 |
因为初始化的日志很有用,里面包含了子节点如何加入master
的,所以我们把这些文件保存起来
1 | mkdir -p /usr/local/install-k8s/core |
现在我们执行kubectl
相关指令已经会有了正常响应,但是此时节点处于NotReady
的状态,这是因为我们还没有为Kubernetes
指定它的网络模式。我们使用flannel
来作为它的网络模式,这样就可以让不同节点上的容器跨主机通信。Overlay
网络这块以后会再出文章讲解,目前先进行部署。
1 | # 创建文件夹 |
1 | # 查看命名空间为kube-system的pod情况 |
如果你是all in one
,或者你想master也参与pod的调度,那么可以执行如下指令
1 | # 删除污点,允许调度 |
1 | wget https://raw.githubusercontent.com/kubernetes/dashboard/v2.0.0-beta4/aio/deploy/recommended.yaml |
修改 recommended.yaml
里的 Service
,新增一个nodePort
方便我们外部进行访问
1 |
|
1 | kubectl apply -f recommended.yaml |
1 | # 在default命名空间下创建admin账户 |
然后通过 http://master的ip:30002
进行访问。输入之前获取的token,即可正常进入。(这里无论dashboard
在master
还是node
上都可以访问到,因为有kube-proxy
会帮我们进行转发)
如果过程kubeadm init
中失败了,不让再一次kubeadm init
,那么可以这样做
1 |
|
然后再重新执行kubeadm init
进行初始化
1 | docker login --username=xxx registry.cn-shenzhen.aliyuncs.com |
xxx是你的阿里云登录名,例如我的是 guaosi@vip.qq.com
1 | # 创建 Secret,命名为 regcred: |
1 | apiVersion: v1 |
国外以及云原生的环境下,用ubuntu
的比较多,而且目前centos
也处于商业状态,所以掌握以及使用ubuntu
作为服务环境还是很有必要的。
操作系统: win10
所需文件:
VMware16虚拟机
下载地址: https://cloud.189.cn/web/share?code=ZjUF3yMJvaIr(访问码:uo2y)
Ubuntu-20.04.2-live-server-amd64镜像文件
下载地址: https://cloud.189.cn/web/share?code=7bqEneaIrQJ3(访问码:p0nm)
将给的VMware16下载好后打开,一路下一步,最后会自动填写许可证,完成退出重启电脑即可。
基于正常使用的话,配置一个2核4G内存60G磁盘容量
的虚拟机即可。当然,这个看个人需求。以下是操作过程:
自定义(高级)
后点击下一步16.2
后点击下一步稍后安装操作系统
后点击下一步Linux
后版本选择Ubuntu64
下一步位置
可以自定义选择喜欢的,然后点下一步2
后下一步4096
后下一步使用网络地址转换(NAT)
后下一步SCSI
控制器选择LSI Logic
后下一步SCSI
后下一步创建新虚拟磁盘
后下一步60
,选择将虚拟磁盘存储为单个文件
后下一步编辑虚拟机设置
CD/DVD(SATA)
,选择 使用ISO映像文件
,点击 浏览
找到下载好的 Ubuntu-20.04.2-live-server-amd64.iso
镜像文件后点击确定。开启此虚拟机
开始进行Ubuntu
的安装English
回车continue without updating
回车Done
回车Done
回车proxy
代理,选择Done
回车http://mirrors.aliyun.com/ubuntu/
,方便我们进行以及apt
安装或安装软件,修改完毕后选择Done
回车Done
回车Done
回车,然后选择continue
回车Done
回车Install OpenSSH server
后选择Done
回车Done
回车Reboot Now
后回车,等待系统重启1 | sudo passwd root |
修改sshd的配置文件
1 | sudo vim /etc/ssh/sshd_config |
搜索 PermitRootLogin
,将 #PermitRootLogin prohibit-password
修改为 PermitRootLogin yes
保存退出
重启ssh,使配置生效
1 | sudo service ssh restart |
然后就可以直接用root
账号来进行ssh
登陆了
系统安装成功网络IP的配置地址文件为/etc/netplan/50-cloud-init.yaml
,可以看一下里面的内容
1 | network: |
我们将dhcp获取ip方式修改为手动设置静态ip(做这步前请先确认目前ubuntu自身的ip信息,不要改成其他网段跟网关了,否则无法通信)
1 | network: |
完成后应用配置1
netplan apply
查看修改是否生效1
ip addr
当我们推送代码到Gitlab
时,Gitlab
会主动地通知Jenkins
对应的任务,它会通过设置的源码管理
去拉取Gitlab
上对应地址的代码,然后执行我们预先设置好的构建的脚本(脚本是我们在之前做的构建镜像以及推送到阿里云)。接着再通过我们上面Publish over SSH
配置好的信息登陆到Kubernetes
的部署节点,最后执行我们后面将会放置在上面的部署脚本
进行部署,更新pod
。
我们进入到部署节点创建我们的部署脚本。/root/account/deploy/k8s/k8s-deploy.sh
1 | kubectl scale --replicas=0 deployment/svc-account -n go-micro && kubectl scale --replicas=3 deployment/svc-account - |
想要触发pod更新,需要部署文件的某些指定位置有被修改过才会触发。生产环境中,我们是通过更改镜像的tag也就是版本号镜像更新,比较简单。这个系列中,我们通过缩小增大pod的副本数量,来达到触发更新pod。
Jenkins
->新建任务,创建一个Jenkins
任务Gitlab
链接以及勾选丢弃旧的构建
。Git
选项,填写Repositories
信息,并创建Gitlab
账号密码凭据。Build when a change is pushed to GitLab. GitLab webhook URL: http://192.168.1.220:8080/project/go-micro
填入相关信息
我们拿到上图中,我们可以拿到webhook
的通知URL
以及密钥
,我们回到gitlab
的account
仓库中进行配置
/var/jenkins_home/workspace/构建任务名
。1 | cd deploy |
执行我们之前就已经写好的构建和推送脚本
1 | cd /root/account/deploy/k8s |
最后保存退出
保存之后,我们立即更改account
中部分代码,比如打印输出的字符串进行修改。然后推送到Gitlab
仓库,先观察jenkins
任务中的控制台输出
是否返回SUCCESS
。再观察此时请求后的标准输出
是否已经发生了变化,来验证我们重新整个CI/CD
流程是否成功~
我们先来回顾一下本系列的CI/CD 流程图
所以我们现在就差最后一个Jenkins
工具,就可以做我们想要的持续集成,交付,部署的功能了。
本文参考该文后,验证成功后总结得出。
1 | mkdir -p /usr/local/data/jenkins/data |
我们继续使用Docker
进行安装
jenkins/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22version: '3.7'
services:
jenkins:
user: root
container_name: jenkins
image: 'jenkins/jenkins:2.242'
restart: always
networks:
- cicd
environment:
- TZ=Asia/Shanghai
ports:
- '8080:8080'
- '50000:50000'
volumes:
- /usr/local/data/jenkins/data:/var/jenkins_home
- /var/run/docker.sock:/var/run/docker.sock
- /bin/docker:/bin/docker # linux使用
networks:
cicd:
external:
name: cicd
注意哦,docker.sock跟docker表示在Jenkins里可以直接使用外部的docker命令了
1 | cd jenkins |
完全启动时间大约3分钟
我们查询Gitlab
在Docker
容器里的ip
地址1
docker inspect gitlab --format "{{.NetworkSettings.Networks.cicd.IPAddress}}"
等待3分钟后,我们进入Jenkins
容器内1
docker exec -it jenkins /bin/bash
1 | apt-get update |
1 | apt install vim |
我们在Jenkins
容器里做一个跟gitlab
的域名映射。(IP
是我们上面查询的噢~)1
172.21.0.3 git.guaosi.com
1 | # 下载 |
1 | cd /var/jenkins_home/updates |
1 | docker restart jenkins |
等待三分钟后,我们输入http://192.168.1.220:8080
。我们将会看到
我们进入容器中,然后执行1
cat /var/jenkins_home/secrets/initialAdminPassword
将获得到的密码复制后输入。
我们解锁后,不要跳过,直接将所有插件进行的安装。
等完全安装完毕后进入到主界面。
进入Jenkins
-> Plugin Manager
页面安装以下插件(或者直接进入 http://localhost:8080/pluginManager/available):
我们进入系统管理
-全局工具配置
,在Go
那一栏选择Go
安装,因为我们上面已经在Jenkins
容器里安装好go了,所以我们这里配置目录就好.
配置如下:
我们进入系统管理
-系统配置
进行大体的配置
我们使用上一章中在自建Gitlab
中创建了仓库并且上传了代码的账户进行登录,然后根据下图获取access_token
然后我们回到jenkins
的系统配置
。
到Gitlab
栏,勾选上Enable authentication for '/project' end-point
,然后点击Credentials
旁边的添加
-Jenkins
。然后按下图步骤添加
然后配置好,测试一下连通性。
找到Publish over SSH
配置,如下图所示(方便演示我是直接账户密码登录的,可以自行密钥登陆或者免密认证登录):
我们打算把gitlab
跟jenkins
都通过docker
的方式,安装在同一个虚拟机里。虚拟机IP:192.168.1.220
。
我们跟之前一样,创建一个专属网络1
docker network create cicd --driver bridge
把虚拟机的ssh端口从22改为222(因为GIT底层也是22端口)
1 | vim ssh/sshd_config |
1 | mkdir -p /usr/local/data/gitlab/etc |
gitlab/docker-compose.yml
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
47version: '3'
services:
gitlab:
image: 'gitlab/gitlab-ce:13.1.1-ce.0'
restart: always
hostname: 'git.guaosi.com'
networks:
- cicd
container_name: gitlab
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'http://git.guaosi.com'
gitlab_rails['time_zone'] = 'Asia/Shanghai'
gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "smtp.exmail.qq.com"
gitlab_rails['smtp_tls'] = true
gitlab_rails['smtp_port'] = 465
gitlab_rails['smtp_user_name'] = "xxx@qq.com"
gitlab_rails['smtp_password'] = "xxx"
gitlab_rails['smtp_domain'] = "smtp.qq.com"
gitlab_rails['smtp_authentication'] = "login"
gitlab_rails['smtp_enable_starttls_auto'] = true
gitlab_rails['gitlab_email_from'] = 'guaosi'
gitlab_rails['backup_keep_time'] = 14515200
logging['logrotate_frequency'] = "weekly"
logging['logrotate_rotate'] = 52
logging['logrotate_compress'] = "compress"
logging['logrotate_method'] = "copytruncate"
logging['logrotate_delaycompress'] = "delaycompress"
gitlab_rails['gitlab_shell_ssh_port'] = 22
# Add any other gitlab.rb configuration options
ports:
- '443:443'
- '22:22'
- '80:80'
- '9090:9090'
volumes:
- '/usr/local/data/gitlab/etc:/etc/gitlab'
- '/usr/local/data/gitlab/log:/var/log/gitlab'
- '/usr/local/data/gitlab/data:/var/opt/gitlab'
- '/root/.ssh:/root/.ssh'
networks:
cicd:
external:
name: cicd
1 | cd gitlab |
完全启动时间大约5分钟
我们在Mac
上更改hosts
1
192.168.1.220 git.guaosi.com
我们在Mac
上登陆 http://git.guaosi.com,第一次会让我们初始化`root`用户的密码,设置成功后进入`gitlab`界面。
我们进入 http://git.guaosi.com/admin/application_settings/network
然后在 Outbound requests
中勾上
Allow requests to the local network from web hooks and services
以及
Allow requests to the local network from system hooks
然后点击保存,退出当前账号,重新注册一个自己喜欢的账号。
我们进入 http://git.guaosi.com/profile/keys
将我们Mac
的公钥填入,然后保存即可。现在我们就可以直接通过Git
命令与Gitlab
进行交互了。
我们可以创建一个projects
,然后将我们本地的account
文件夹用git
初始化后上传到我们自建的gitlab
中。
id_key的生成以及git指令这块相信大家应该都很清楚了,所以我就说一下大体流程而已。如果还不懂Git的小伙伴们,只能自行百度学习一下了噢~
如果是直接
git clone
我github
里代码的小伙伴,里面account
文件夹下是有.git
信息的,记得删除后再初始化git
噢~
对应的相关代码和部署文件,已经传至github,欢迎star。
持续交付、集成、部署:https://github.com/guaosi/go-cicd
]]>上一章中,我们通过Docker Compose
已经创建了etcd
、account
、apigw
、Traefik
多个镜像对应的容器。为了不被干扰,并且我们改用Kubetnetes
作为容器编排,我们需要删除之前创建的容器。
1 | docker rm -f apigw apigw2 account1 account2 account3 go_micro_traefik_proxy_1 etcd1 |
我们将Kubernetes
搭建在了同网段的Windows
下的Vmware
虚拟机中,使用的是桥接模式
。也就是我的Mac
可以直接与虚拟机通讯。还不会搭建Kubernetes
的小伙伴可以先看 Kubernetes 18.04集群安装教程(基于Centos7) 这篇文章进行搭建
网络配置与 1.2 环境的准备与安装 里的相同。
由于配置有限,所以我只搭建了一个Master
节点,没有搭建Node
节点,并且通过污点,设置master
允许pod
创建
1 | kubectl taint node k8s-master node-role.kubernetes.io/master- |
以下操作都是在Kubetnetes
的Master
节点做的
如果你使用的是公开公共仓库镜像,即不需要登陆,就可以下载的镜像,则可以跳过私有仓库
这个步骤。
我的镜像已经开放了公有权限,可以直接下载,不需要进行验证。如果你想使用自己的镜像,请按照下面进行操作。
1 | docker login --username=<your-name> registry.cn-shenzhen.aliyuncs.com |
1 | # 创建 Secret,命名为 regcred: |
举个栗子1
2
3kubectl create secret docker-registry regcred --docker-server=registry.cn-shenzhen.aliyuncs.com --docker-username=guaosi@vip.qq.com --docker-password=a123654 --docker-email=guaosi@vip.qq.com -n go-micro
# 注意: -n 后面是我想这个密钥归属于哪个namespace,即哪个namespace可以使用
1 | apiVersion: v1 |
创建名字为go-micro
的namespace
,我们把需要的pod
、deploy
、svc
、secret
等等都放在这个namespace
下方便管理。
deploy/k8s/k8s-namespace.yml
1
2
3
4
5apiVersion: v1
kind: Namespace
metadata:
name: go-micro
namespace: go-micro
执行1
kubectl create -f deploy/k8s/k8s-namespace.yml
deploy/k8s/k8s-pod-rbac.yml
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
35apiVersion: v1
kind: ServiceAccount
metadata:
namespace: go-micro
name: micro-services
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: micro-registry
namespace: go-micro
rules:
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- patch
- watch
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: micro-registry
namespace: go-micro
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: micro-registry # 要被绑定的ClusterRole的name
subjects:
- kind: ServiceAccount
name: micro-services # 要被绑定的serviceAccount的name
namespace: go-micro
执行1
kubectl create -f deploy/k8s/k8s-pod-rbac.yml
account/deploy/k8s/k8s-pod-account.yml
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
26apiVersion: apps/v1
kind: Deployment
metadata:
namespace: go-micro
name: svc-account
spec:
replicas: 1
selector:
matchLabels:
app: svc-account
template:
metadata:
labels:
app: svc-account
spec:
containers:
- name: svc-account
command: [
"/account",
"--registry=kubernetes",
]
image: registry.cn-shenzhen.aliyuncs.com/go_micro/account:v1.0
imagePullPolicy: Always
serviceAccountName: micro-services
执行1
kubectl create -f account/deploy/k8s/k8s-pod-account.yml
关于
account
的负载均衡,只需要扩容deploy
的replicas
即可 kubectl scale –replicas=3 deployment/svc-account -n go-micro
account/deploy/k8s/k8s-svc-account.yml
1
2
3
4
5
6
7
8
9
10
11
12
13apiVersion: v1
kind: Service # 网络服务
metadata:
name: svc-account
namespace: go-micro # 都设置在一个命名空间下,相同网络
labels:
app: svc-account
spec:
ports:
- port: 8080 # 必须填写,否则报错
protocol: TCP
selector:
app: svc-account
执行1
kubectl create -f account/deploy/k8s/k8s-svc-account.yml
apigateway/deploy/k8s/k8s-pod-apigw.yml
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
30apiVersion: apps/v1
kind: Deployment
metadata:
namespace: go-micro
name: svc-apigw
spec:
replicas: 1
selector:
matchLabels:
app: svc-apigw
template:
metadata:
labels:
app: svc-apigw
spec:
containers:
- name: svc-apigw
command: [
"/apigw",
"--p=8091",
"--registry=kubernetes",
]
image: registry.cn-shenzhen.aliyuncs.com/go_micro/apigw:v1.0
imagePullPolicy: Always
ports:
- containerPort: 8091
name: apigw-port
serviceAccountName: micro-services
执行1
kubectl create -f apigateway/deploy/k8s/k8s-pod-apigw.yml
关于
apigateway
的负载均衡,只需要扩容deploy
的replicas
即可 kubectl scale –replicas=3 deployment/svc-apigw -n go-micro
apigateway/deploy/k8s/k8s-svc-apigw.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15apiVersion: v1
kind: Service # 网络服务
metadata:
name: svc-apigw
namespace: go-micro # 都设置在一个命名空间下,相同网络
labels:
app: svc-apigw
spec:
type: NodePort
ports:
- port: 8091 # cluster模式访问的端口
nodePort: 30088 #设置 nodeport 端口 30000-32767 此时
# targetPort:访问容器内部的端口,与containerPort值相同。 当没有设置targetPort时,此时targetPort的值与port相同
selector:
app: svc-apigw
执行1
kubectl create -f apigateway/deploy/k8s/k8s-svc-apigw.yml
1 | > curl -X POST -d "username=guaosi&password=guaosi" http://192.168.1.200 |
下面继续使用Traefik
来进行反向代理了。
由于Kubernetes
的Ingress
只能支持四层的IP:Port
转发,所以我们需要使用Traefik
来代替Ingress
。
该部分参考 Kubernetes 部署 Ingress 控制器 Traefik v2.2 后运行成功后,总结得出。
在
Traefik v2.0
版本后,开始使用CRD
(Custom Resource Definition)来完成路由配置等,所以需要提前创建CRD
资源。
traefik/k8s/k8s-crd-traefik.yaml
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## IngressRoute
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
namespace: go-micro
name: ingressroutes.traefik.containo.us
spec:
scope: Namespaced
group: traefik.containo.us
version: v1alpha1
names:
kind: IngressRoute
plural: ingressroutes
singular: ingressroute
## IngressRouteTCP
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
namespace: go-micro
name: ingressroutetcps.traefik.containo.us
spec:
scope: Namespaced
group: traefik.containo.us
version: v1alpha1
names:
kind: IngressRouteTCP
plural: ingressroutetcps
singular: ingressroutetcp
## Middleware
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
namespace: go-micro
name: middlewares.traefik.containo.us
spec:
scope: Namespaced
group: traefik.containo.us
version: v1alpha1
names:
kind: Middleware
plural: middlewares
singular: middleware
## TLSOption
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
namespace: go-micro
name: tlsoptions.traefik.containo.us
spec:
scope: Namespaced
group: traefik.containo.us
version: v1alpha1
names:
kind: TLSOption
plural: tlsoptions
singular: tlsoption
## TraefikService
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
namespace: go-micro
name: traefikservices.traefik.containo.us
spec:
scope: Namespaced
group: traefik.containo.us
version: v1alpha1
names:
kind: TraefikService
plural: traefikservices
singular: traefikservice
## TLSStore
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
namespace: go-micro
name: tlsstores.traefik.containo.us
spec:
group: traefik.containo.us
version: v1alpha1
names:
kind: TLSStore
plural: tlsstores
singular: tlsstore
scope: Namespaced
## IngressRouteUDP
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
namespace: go-micro
name: ingressrouteudps.traefik.containo.us
spec:
group: traefik.containo.us
version: v1alpha1
names:
kind: IngressRouteUDP
plural: ingressrouteudps
singular: ingressrouteudp
scope: Namespaced
执行1
kubectl create -f traefik/k8s/k8s-crd-traefik.yaml
traefik/k8s/k8s-rbac-traefik.yaml
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
apiVersion: v1
kind: ServiceAccount
metadata:
namespace: go-micro
name: traefik-ingress-services
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
namespace: go-micro
name: traefik-ingress-controller
rules:
- apiGroups: [""]
resources: ["services","endpoints","secrets"]
verbs: ["get","list","watch"]
- apiGroups: ["extensions"]
resources: ["ingresses"]
verbs: ["get","list","watch"]
- apiGroups: ["extensions"]
resources: ["ingresses/status"]
verbs: ["update"]
- apiGroups: ["traefik.containo.us"]
resources: ["middlewares","ingressroutes","ingressroutetcps","tlsoptions","ingressrouteudps","traefikservices","tlsstores"]
verbs: ["get","list","watch"]
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1beta1
metadata:
namespace: go-micro
name: traefik-ingress-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: traefik-ingress-controller
subjects:
- kind: ServiceAccount
name: traefik-ingress-services
namespace: go-micro
执行1
kubectl create -f traefik/k8s/k8s-rbac-traefik.yml
下面配置中可以通过配置kubernetesCRD
与kubernetesIngress
两项参数,让Traefik
支持CRD
与Ingress
两种路由方式。traefik/k8s/k8s-config-traefik.yml
1 | kind: ConfigMap |
执行1
kubectl create -f traefik/k8s/k8s-config-traefik.yml
下面将用DaemonSet
方式部署Traefik
,便于在多服务器间扩展,用hostport
方式绑定服务器80
、443
端口,方便流量通过物理机进入Kubernetes
内部。
traefik/k8s/k8s-pod-traefik.yml
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
86apiVersion: v1
kind: Service # 这个svc主要用来跟rule转发规则里转到至的端口相对应
metadata:
namespace: go-micro
name: traefik
spec:
ports:
- name: web
port: 80
- name: websecure
port: 443
- name: admin
port: 8080
selector:
app: traefik
apiVersion: apps/v1
kind: DaemonSet # DaemonSet保证在每个Node上都运行一个Pod,如果 新增一个Node,这个Pod也会运行在新增的Node上,如果删除这个DadmonSet,就会清除它所创建的Pod。
metadata:
name: traefik-ingress-controller
namespace: go-micro
labels:
app: traefik
spec:
selector:
matchLabels:
app: traefik
template:
metadata:
name: traefik
labels:
app: traefik
spec:
serviceAccountName: traefik-ingress-services
terminationGracePeriodSeconds: 1
hostNetwork: true ## 将容器端口绑定所在服务器端口
containers:
- image: traefik:v2.2.1
name: traefik-ingress-lb
ports:
- name: web
containerPort: 80
- name: websecure
containerPort: 443
- name: admin
containerPort: 8080 ## Traefik Dashboard 端口
resources:
limits:
cpu: 2000m
memory: 1024Mi
requests:
cpu: 1000m
memory: 1024Mi
securityContext:
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
args:
- --configfile=/config/traefik.yaml
volumeMounts:
- mountPath: "/config"
name: "config"
readinessProbe:
httpGet:
path: /ping
port: 8080
failureThreshold: 3
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /ping
port: 8080
failureThreshold: 3
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
volumes:
- name: config
configMap:
name: traefik-config
执行1
kubectl create -f traefik/k8s/k8s-pod-traefik.yml
DaemonSet
保证在每个Node
都运行一个Pod
,如果 新增一个Node
,这个Pod
也会运行在新增的Node
上,如果删除这个DadmonSet
,就会清除它所创建的Pod
。
如果想指定只能在哪些node
上创建traefik
,则需要提前指定Label
,这样当程序部署时会自动调度到设置Label
的节点上。
1 | # 格式:kubectl label nodes [节点名] [key=value] |
同时,需要修改 traefik/k8s/k8s-pod-traefik.yml
1
2
3
4
5
6
7
8
9volumes:
- name: config
configMap:
name: traefik-config
# 以上是已经存在的设置,下面为要添加的设置
tolerations: # 设置容忍所有污点,防止节点被设置污点
- operator: "Exists"
nodeSelector: # 设置node筛选器,在特定label的节点上启动
IngressProxy: "true"
我懒就没做了。。。
Traefik 应用已经部署完成,但是想让外部访问Kubernetes
内部服务,还需要配置路由规则,上面部署Traefik
时开启了Traefik Dashboard
,这是Traefik
提供的视图看板,所以,首先配置基于HTTP
的Traefik Dashboard
路由规则,使外部能够访问 Traefik Dashboard
。这里使用CRD
进行演示。
想使用Ingress或者加上HTTPS认证,请参考这篇文章
使用 CRD 方式创建路由规则可言参考 Traefik 文档 Kubernetes IngressRoute
traefik/k8s/k8s-crd-router-traefik.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: traefik-dashboard-route
namespace: go-micro
spec:
entryPoints:
- web
routes:
- match: Host(`traefik.guaosi.com`)
kind: Rule
services:
- name: traefik # svc的名称
port: 8080 # cluster 访问
- match: Host(`apigw.guaosi.com`)
kind: Rule
services:
- name: svc-apigw # svc的名称
port: 8091 # cluster 访问
执行1
kubectl create -f traefik/k8s/k8s-crd-router-traefik.yml
客户端想通过域名访问服务,必须要进行DNS
解析,由于这里没有DNS
服务器进行域名解析,所以修改hosts
文件将Traefik
、apigw
所在节点服务器的IP
和自定义Host
绑定,。打开电脑的Hosts
配置文件,往其加入下面配置:
1 | 192.168.1.200 traefik.guaosi.com |
打开浏览器输入地址:http://traefik.guaosi.com ,即可打开Traefik Dashboard
1 | > curl -X POST -d "username=guaosi&password=guaosi" http://apigw.guaosi.com/account/register |
最终的代码和部署文件,已经传至github,欢迎star。
]]>上一章中,我们已经创建了etcd
、account
、apigw
这三个镜像对应的容器。在Docker Compose
中,我们想重新创建新的容器,不被之前的容器所干扰,并且把它们放在一个专属的网络中去,所以我们需要删除之前创建的容器。
1 | docker rm -f etcd1 account apigw |
然后我们创建一个名为gomicro
的bridge
类型专属的网络,将Docker Compose
创建的容器的网络都放在gomicro
这个网络下。1
docker network create gomicro --driver bridge
上一章和之前,我们都是通过IP:Port
的形式来进行服务的访问。当时实际环境中,我们是需要通过域名来进行访问的,所以,我们还需要一款反向代理、负载均衡软件来帮助我们,这里我们使用的是——Traefik
Traefik
是一个为了让部署微服务更加便捷而诞生的现代HTTP反向代理、负载均衡工具。 它支持多种后台 (Docker
, Swarm
, Kubernetes
, Marathon
, Mesos
, Consul
, Etcd
, Zookeeper
, BoltDB
, Rest API
, file
…) 来自动化、动态的应用它的配置文件设置。
它跟Nginx最大的不同有两点:
Docker Compose
, Swarm
, Kubernetes
不同容器编排工具traefik
),然而Nginx只能通过手动更改配置文件来增删节点。最重要的是它是用Go写的呀~
更多相关内容可以通过Traefik的官方文档进行了解使用
我们先来编写一下traefik
的配置文件traefik/traefik.toml
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
34defaultEntryPoints = ["http"]
[global]
checkNewVersion = false
sendAnonymousUsage = false
[log]
level = "WARN"
format = "common"
[api]
dashboard = true
insecure = true
[ping]
[accessLog]
[providers]
[providers.docker]
watch = true
exposedByDefault = false
endpoint = "unix:///var/run/docker.sock"
swarmMode = false
useBindPortIP = false
network = "traefik"
[providers.file]
watch = true
directory = "/etc/traefik/config"
debugLogGeneratedTemplate = true
[entryPoints]
[entryPoints.http]
address = ":80"
traefik/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19version: '3'
services:
proxy:
image: traefik:v2.2
command: --api.insecure=true --providers.docker
networks:
- web
ports:
- "80:80"
- "8080:8080"
volumes:
- run/docker.sock: run/docker.sock
- ./traefik.toml: traefik/traefik.toml
networks:
web:
external:
name: gomicro
然后我们执行1
docker-compose up -d
此时traefik已经启动了,我们访问http://127.0.0.1:8080
查看一下它提供的dashboard
etcd/docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17version: '2'
services:
etcd1:
image: quay.io/coreos/etcd:v3.3.8
container_name: etcd1
command: etcd -name etcd1 --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://0.0.0.0:2379
ports:
- "2379:2379"
- "2380:2380"
networks:
- web
networks:
web:
external:
name: gomicro
然后我们启动etcd
1
docker-compose up -d
account/deploy/docker-compose.yml
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
32version: '2'
services:
# account service
account1:
image: registry.cn-shenzhen.aliyuncs.com/go_micro/account:v1.0
container_name: account1
networks:
- web
environment:
- PARAMS=--registry etcd --registry_address etcd1:2379
account2:
image: registry.cn-shenzhen.aliyuncs.com/go_micro/account:v1.0
container_name: account2
networks:
- web
environment:
- PARAMS=--registry etcd --registry_address etcd1:2379
account3:
image: registry.cn-shenzhen.aliyuncs.com/go_micro/account:v1.0
container_name: account3
networks:
- web
environment:
- PARAMS=--registry etcd --registry_address etcd1:2379
networks:
web:
external:
name: gomicro
这里,我们特意创建了3个account
服务实现高可用。
因为docker-compose启动时会根据当前的所在目录名取名,这样目录名称下执行docker-compose
会被认为冲突 所以需要特定名称1
docker-compose -p go_micro_account up -d
apigw/deploy/docker-compose.yml
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
39version: '2'
services:
apigw:
image: registry.cn-shenzhen.aliyuncs.com/go_micro/apigw:v1.0
container_name: apigw
networks:
- web
ports:
- "8091:8091"
environment:
- PARAMS=-p 8091 --registry etcd --registry_address etcd1:2379
labels:
- "traefik.enable=true"
- "traefik.http.routers.apigw.rule=Host(`apigw.guaosi.com`)"
- "traefik.http.services.apigw.loadbalancer.server.port=8091"
apigw2:
image: registry.cn-shenzhen.aliyuncs.com/go_micro/apigw:v1.0
container_name: apigw2
networks:
- web
ports:
- "8092:8092"
environment:
- PARAMS=-p 8092 --registry etcd --registry_address etcd1:2379
labels:
- "traefik.enable=true"
- "traefik.http.routers.apigw2.rule=Host(`apigw.guaosi.com`)"
- "traefik.http.services.apigw2.loadbalancer.server.port=8092"
networks:
web:
external:
name: gomicro
这里,我们特意创建了2个apigw
服务实现高可用。
1 | docker-compose -p go_micro_apigw up -d |
此时我们再进入traefik
的dashboard,我们可以发现网关信息已经注册到Traefik
上了
我们直接更改hosts
文件,将apigw.guaosi.com
指向本地1
apigw.guaosi.com 127.0.0.1
我们发送请求进行验证1
2
3> curl -X POST -d "username=guaosi&password=guaosi" http://apigw.guaosi.com/account/register
{"code":0,"message":""}
我们可以通过查看相同服务不同容器的日志,以及停止容器来验证服务的高可用与负载均衡调度。
最终的代码和部署文件,已经传至github,欢迎star。
]]>这一章中,我们来测试一下上一章制作的镜像是否可以正常使用。
还记得吗?在2.3 服务编写时我们说过,go-micro
默认查找的地址是127.0.0.1
,但是etcd
、account
、apigw
都是在不同容器中运行的,所以需要我们启动时手动指定etcd
容器所在的地址。
我们执行1
2
3> docker inspect etcd1 --format "{{.NetworkSettings.IPAddress}}"
172.17.0.2
这样我们就能拿到etcd在docker容器中的地址。
docker容器运行时不指定网络命名空间,则默认使用default,default之间的容器在同一个网段中,所以可以互相通讯。
我们接着启动account
服务1
docker run -e PARAMS="--registry etcd --registry_address 172.17.0.2:2379" --name="account" -d registry.cn-shenzhen.aliyuncs.com/go_micro/account:v1.0
通过 -e 传递环境变量到PARAMS中,这跟我们上一章编写的Dockerfile文件有关
我们继续启动apigw
服务1
docker run -e PARAMS="-p 8091 --registry etcd --registry_address 172.17.0.2:2379" -p 8091:8091 --name="apigw" -d registry.cn-shenzhen.aliyuncs.com/go_micro/apigw:v1.0
我们最后发送请求进行测试1
2
3> curl -X POST -d "username=guaosi&password=guaosi" http://127.0.0.1:8091/account/register
{"code":0,"message":""}
最终的代码和部署文件,已经传至github,欢迎star。
]]>我们根据2.3 服务编写 与 2.4 网关编写所建立好的目录,编写对应的Dockerfile文件
account/deploy/Dockerfile
1
2
3
4
5
6
7
8
9
10
11FROM centos:7
ADD bin/account /
RUN chmod 777 /account
# 通过先设置一个环境变量,然后在容器运行时传入环境变量具体的值,达到外部指定参数运行的效果
ENV PARAMS=""
ENTRYPOINT ["sh","-c","/account $PARAMS"]
apigateway/deploy/Dockerfile
1
2
3
4
5
6
7
8
9
10
11FROM centos:7
ADD bin/apigw /
RUN chmod 777 /apigw
# 通过先设置一个环境变量,然后在容器运行时传入环境变量具体的值,达到外部指定参数运行的效果
ENV PARAMS=""
ENTRYPOINT ["sh","-c","/apigw $PARAMS"]
编写好Dockerfile
后,我们先不急着立刻使用docker build
来创建镜像,因为我们需要经常重新制作镜像以及上传到我们私人的镜像仓库,所以我们接下面编写shell
脚本
企业的私人仓库可以自己构建Harbor
,这个演示我就直接使用阿里云的免费容器镜像服务作为私人仓库
请自行开通容器镜像服务与创建好
命名空间哦
我们在阿里云上将仓库设置为私有,然后我们在本地进行docker仓库的认证登陆1
2
3docker login --username=guaosi@vip.qq.com registry.cn-shenzhen.aliyuncs.com
# guaosi@vip.qq.com 是阿里云的登录名 请自行修改后,回车后输入密码,回车通过认证
account/deploy/docker_build.sh
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
DOCKER_IMAGE_HOST="registry.cn-shenzhen.aliyuncs.com"
DOCKER_IMAGE_NAMESPACE="go_micro"
DOCKER_IMAGE_HUB="account"
IMAGE_TAG="v1.0"
WORK_PATH=$(dirname $0)
# 当前位置跳到脚本位置
cd ./${WORK_PATH}
# 取到脚本目录
WORK_PATH=$(pwd)
mkdir bin
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.io,direct
# 跨平台 Mac编译Linux 需要交叉编译
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ${WORK_PATH}/bin/ ${WORK_PATH}/../
go build -o ${WORK_PATH}/bin/ ${WORK_PATH}/../
echo -e "\033[32m编译完成: \033[0m ${WORK_PATH}/bin/"
# 容器制作
docker build -t ${DOCKER_IMAGE_HOST}/${DOCKER_IMAGE_NAMESPACE}/${DOCKER_IMAGE_HUB}:${IMAGE_TAG} -f ./Dockerfile .
echo -e "\033[32m镜像打包完成,请推送: \033[0m ${DOCKER_IMAGE_HOST}/${DOCKER_IMAGE_NAMESPACE}/${DOCKER_IMAGE_HUB}:${IMAGE_TAG}\n"
# 删除原二进制文件以及所在目录
rm -rf bin
echo -e "\033[32m残留二进制文件清理成功"
DOCKER_IMAGE_NAMESPACE
请自行修改为自己的阿里云容器镜像服务的命名空间
然后我们执行如下命令,进行account
的镜像制作1
chmod a+x account/deploy/docker_build.sh && account/deploy/docker_build.sh
account/deploy/docker_push.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
DOCKER_IMAGE_HOST="registry.cn-shenzhen.aliyuncs.com"
DOCKER_IMAGE_NAMESPACE="go_micro"
DOCKER_IMAGE_HUB="account"
IMAGE_TAG="v1.0"
# 推送
docker push ${DOCKER_IMAGE_HOST}/${DOCKER_IMAGE_NAMESPACE}/${DOCKER_IMAGE_HUB}:${IMAGE_TAG}
echo -e "\033[32m镜像推送完成: \033[0m ${DOCKER_IMAGE_HOST}/${DOCKER_IMAGE_NAMESPACE}/${DOCKER_IMAGE_HUB}:${IMAGE_TAG} \n"
DOCKER_IMAGE_NAMESPACE
请自行修改为自己的阿里云容器镜像服务的命名空间
然后我们执行如下命令,进行account
的镜像推送1
chmod a+x account/deploy/docker_push.sh && account/deploy/docker_push.sh
apigateway/deploy/docker_build.sh
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
DOCKER_IMAGE_HOST="registry.cn-shenzhen.aliyuncs.com"
DOCKER_IMAGE_NAMESPACE="go_micro"
DOCKER_IMAGE_HUB="apigw"
IMAGE_TAG="v1.0"
WORK_PATH=$(dirname $0)
# 当前位置跳到脚本位置
cd ./${WORK_PATH}
# 取到脚本目录
WORK_PATH=$(pwd)
mkdir bin
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.io,direct
# 跨平台 Mac编译Linux 需要交叉编译
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ${WORK_PATH}/bin/ ${WORK_PATH}/../
go build -o ${WORK_PATH}/bin/ ${WORK_PATH}/../
echo -e "\033[32m编译完成: \033[0m ${WORK_PATH}/bin/"
# 容器制作
docker build -t ${DOCKER_IMAGE_HOST}/${DOCKER_IMAGE_NAMESPACE}/${DOCKER_IMAGE_HUB}:${IMAGE_TAG} -f ./Dockerfile .
echo -e "\033[32m镜像打包完成,请推送: \033[0m ${DOCKER_IMAGE_HOST}/${DOCKER_IMAGE_NAMESPACE}/${DOCKER_IMAGE_HUB}:${IMAGE_TAG}\n"
# 删除原二进制文件以及所在目录
rm -rf bin
echo -e "\033[32m残留二进制文件清理成功"
DOCKER_IMAGE_NAMESPACE
请自行修改为自己的阿里云容器镜像服务的命名空间
然后我们执行如下命令,进行apigw
的镜像制作1
chmod a+x account/deploy/docker_build.sh && account/deploy/docker_build.sh
apigateway/deploy/docker_push.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
DOCKER_IMAGE_HOST="registry.cn-shenzhen.aliyuncs.com"
DOCKER_IMAGE_NAMESPACE="go_micro"
DOCKER_IMAGE_HUB="apigw"
IMAGE_TAG="v1.0"
# 推送
docker push ${DOCKER_IMAGE_HOST}/${DOCKER_IMAGE_NAMESPACE}/${DOCKER_IMAGE_HUB}:${IMAGE_TAG}
echo -e "\033[32m镜像推送完成: \033[0m ${DOCKER_IMAGE_HOST}/${DOCKER_IMAGE_NAMESPACE}/${DOCKER_IMAGE_HUB}:${IMAGE_TAG} \n"
DOCKER_IMAGE_NAMESPACE
请自行修改为自己的阿里云容器镜像服务的命名空间
然后我们执行如下命令,进行apigw
的镜像推送1
chmod a+x account/deploy/docker_push.sh && account/deploy/docker_push.sh
最终的代码和部署文件,已经传至github,欢迎star。
]]>上一章中,我们已经编写好了account
服务。这一章我们来完成apigateway
,也就是网关的编写。
我们将会使用gin
框架,来对外提供HTTP
服务与路由
。一个url
请求进入后,gin
框架的路由解析后找到对应的handler
方法,然后该handler
中会调用对应的服务
,获得最终的结果,返回给用户。
对外HTTP,对内RPC
我们来看一下apigateway
的最终目录结构
1 | ├─apigateway |
文件夹大家根据结构自行创建~
好,那我们现在开始编写相关文件。
最终的代码和部署文件,已经传至github,欢迎star。
https://github.com/guaosi/go-micro-build
account
的proto
相关文件已经在之前的 GO微服务系列-2.1 proto文件的编写与生成 已经编写并且成功好了,我们复制到proto
文件夹中即可。
serviceclient/init.go
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
40package serviceclient
import (
"apigw/handler"
proto "apigw/proto/account"
"github.com/micro/cli/v2"
"github.com/micro/go-micro/v2"
// 这里使用 kubernetes 是为了之后可以通过命令行指定注册中心用 kubernetes
_ "github.com/micro/go-plugins/registry/kubernetes/v2"
)
var Port string
func RegisterService() {
// 连接服务注册中心
service := micro.NewService(
micro.Flags(
&cli.StringFlag{
Name: "p",
Usage: "port",
},
),
)
// 解析命令行参数
// 我们希望可以使用 -p 参数来手动指定我们HTTP服务对外提供服务时的端口
service.Init(
micro.Action(func(c *cli.Context) error {
Port = c.String("p")
if len(Port) == 0 {
Port = "8091"
}
return nil
},
),
)
// 复用服务注册的客户端
cli := service.Client()
// 获取在服务注册中心上 micro.service.account 的客户端
handler.AccountServiceClient = proto.NewAccountService("micro.service.account", cli)
}
handler/account.go
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
36package handler
import (
proto "apigw/proto/account"
"context"
"github.com/gin-gonic/gin"
"log"
"net/http"
)
var (
AccountServiceClient proto.AccountService
)
func RegisterHandler(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
// 通过AccountService服务的client,调用 AccountRegister 方法
res, err := AccountServiceClient.AccountRegister(context.TODO(), &proto.ReqAccountRegister{
Username: username,
Password: password,
})
if err != nil {
log.Print(err.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"code": -2,
"message": "server error",
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": res.Code,
"message": res.Message,
})
return
}
router/router.go
1
2
3
4
5
6
7
8
9
10
11
12package router
import (
"apigw/handler"
"github.com/gin-gonic/gin"
)
func NewRouter() *gin.Engine {
route := gin.Default()
route.POST("/account/register", handler.RegisterHandler)
return route
}
apigw.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17package main
import (
"apigw/router"
"apigw/serviceclient"
"log"
)
func init() {
serviceclient.RegisterService()
}
func main() {
r := router.NewRouter()
if err := r.Run("0.0.0.0:" + serviceclient.Port); err != nil {
log.Print(err.Error())
}
}
编写完毕后,我们运行直接指定以8091
作为端口启动http
服务,并且服务发现和注册切换为etcd
1
go run . -p 8091 --registry etcd
记得要启动我们上一章编写的account服务哦~
开启成功后,我们发送curl请求,进行验证1
2
3> curl -X POST -d "username=guaosi&password=guaosi" http://127.0.0.1:8091/account/register
{"code":0,"message":""}
在 1.1 涉及的组件、框架介绍 中已经大致介绍了一下我们微服务的整个请求流程。我们这章就开始编写一个最简单的服务——account
,作为服务A
注意:这个系列的重心在整个微服务的创建以及部署上,所以会尽可能地简化业务逻辑代码。
我们来看一下account
的最终目录结构
1 | ├─account |
文件夹大家根据结构自行创建~
好,那我们现在开始编写相关文件。
最终的代码和部署文件,已经传至github,欢迎star。
https://github.com/guaosi/go-micro-build
account
的proto
相关文件已经在之前的 GO微服务系列-2.1 proto文件的编写与生成 已经编写并且成功好了,我们复制到proto
文件夹中即可。
handler/account.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22package handler
import (
"account/proto"
"context"
"fmt"
)
type AccountService struct {
}
func (a *AccountService) AccountRegister(c context.Context, req *proto.ReqAccountRegister, res *proto.ResAccountRegister) error {
fmt.Println("hit here")
if req.Username == "guaosi" && req.Password == "guaosi" {
res.Code = 0
res.Message = ""
return nil
}
res.Code = -1
res.Message = "账号或者密码不正确"
return nil
}
我们做一个小小的限制,只有当请求时Username
和Password
都为guaosi
时才返回正确,否则返回账号或者密码不正确
的提示。当被请求时,我们都会在标准输出中打印hit here
,以便我们高可用时可以进行验证。
accountservice.go
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
28package main
import (
"account/handler"
"account/proto"
"github.com/micro/go-micro/v2"
// 这里使用 kubernetes 是为了之后可以通过命令行指定注册中心用 kubernetes
_ "github.com/micro/go-plugins/registry/kubernetes/v2"
"log"
)
func main() {
service := micro.NewService(
micro.Name("micro.service.account"),
)
// 初始化相关操作
service.Init()
// 注册实现了服务的handler
if err := proto.RegisterAccountServiceHandler(service.Server(), new(handler.AccountService)); err != nil {
log.Print(err.Error())
return
}
// 运行server
if err := service.Run(); err != nil {
log.Print(err.Error())
return
}
}
编写完毕后,我们运行1
go run .
如果提示以下相关内容,则表明此时我们使用mdns来服务发现,服务开启成功~1
2
32020-07-11 18:35:27 file=v2@v2.9.0/service.go:200 level=info Starting [service] micro.service.account
2020-07-11 18:35:27 file=grpc/grpc.go:864 level=info Server [grpc] Listening on [::]:57088
2020-07-11 18:35:27 file=grpc/grpc.go:697 level=info Registry [mdns] Registering node: micro.service.account-d3fbac56-26ab-4916-ae2d-0a56ee396300
然后我们接着将服务发现,服务注册切换为使用etcd
。
我们直接使用docker
来安装etcd
1
2
3
4
5
6
7
8
9
10
11
12
# etcd单一节点
docker run -d \
-p 2379:2379 \
-p 2380:2380 \
--name etcd1 \
quay.io/coreos/etcd:v3.3.8 \
/usr/local/bin/etcd \
--name s1 \
--listen-client-urls http://0.0.0.0:2379 \
--advertise-client-urls http://0.0.0.0:2379
如果想在etcd
容器中使用cli
1
2
3
4
5
6
7
8# 进入容器
docker exec -it etcd1 sh
# 设置docker中环境变量
export ETCDCTL_API=3
# 切换至etcdctl目录
cd /bin
然后我们可以顺便看一下在etcd
中注册的服务的信息1
etcdctl get /micro/registry/ --prefix
请先确保你的etcd跟你的服务在一个网络下,因为go-micro
默认查找组件的地址为127.0.0.1
1
go run . --registry=etcd
如果不在同一个网络下,或者端口不是2379
,那个可以使用--registry_address
来手动指定(比如在192.168.1.1
下,并且端口为23790
)1
go run . --registry=etcd --registry_address=192.168.1.1:23790
前面已经说过Micro
和go-micro的区别了
。micro
是使用go-micro
框架编写的运行时工具集,它最主要的作用是辅助微服务开发。比如想自己马上构建一个web
环境或者api
环境进行调试,或者查看服务的健康状态,注册信息之类的。
而我们开发微服务时,使用的框架是go-micro
。它可以很好地帮助我们创建服务、服务注册与发现、负载均衡、扩展网关功能等等。
我们来简单了解一下go-micro
框架中的核心组件
我们来简单看一下右边的代码:
1 | func main() { |
代码最后是service.Run()
实际上是开启server
服务,供其他服务的client
进行调用。
我们现在来说一下左边核心组件各自的功能:
其中,Broker
和Transport
都是通讯组件,区别就是一个是异步
,另一个是同步
。所以我们在业务中使用Transport
组件会比较频繁,因为业务需要关心调用结果。
Codec
是数据编码组件,它可以自动将请求及其参数,转换为需要的格式。例如,当我们需要调用其他服务时,Transport
默认的是使用grpc2
,grpc2
使用的通讯格式是Protobuf
,所以Codec
会帮我们将数据转为Protobuf
格式进行发送。
Registry
是服务注册组件,它既可以帮我们把我们的服务注册到服务中心,又可以在服务中心中获取已经注册的服务列表,供我们进行调用。
Selector
客户端均衡器配合服务注册组件。当从服务中心中获取已经注册的服务列表时,由于相同的一个服务可能是高可用的架构,所以需要一个均衡调度器,根据不同的均衡权重算法,来帮我们选择一个合适的节点进行调用。
下面我们来通过核心组件调用关系
来加深一下理解
当执行service.Run()
时,Registry
服务注册组件会将我们之前设置的micro.Name
,当前服务所在的IP
与端口
信息注册到服务中心,供其他服务的Client
进行调用。
Client
是当我们需要其他服务时使用的。它会先通过Registry
服务注册组件,从注册中心获取到服务以及其对应的地址列表,然后Selector
客户端均衡器会根据算法选择一个合适的地址。确定之后,再通过Broker
或者是Transport
通讯组件,在Codec
数据编码组件的编码过后,进行发送。
服务端收到的请求,经过Codec
数据编码组件的解码后,计算得出结果。如果是Transport
,则再通过通讯组件、数据编码组件的编码后进行发送。调用方获得获得结果。
由于go-micro
的扩展性支持Registry
、Transport
、Broker
、Server
都可以是使用不同协议或不同工具的。所以我们有必要来了解一下默认都是使用哪一些协议或工具。
我提前使用下一章编写好的的account
服务注册通过etcd
的注册方式到服务中心后,通过micro
工具查看注册信息
1 | > micro --registry=etcd get service micro.service.account |
通过protocol=grpc,registry=etcd,server=grpc,transport=grpc,broker=http
我们可以看出:
grpc
server
默认是grpc
transport
默认是grpc
broker
默认是http
registry
实际上默认是mdns
,我通过参数指定的方式让它使用的是etcd
(因为后面使用的基本都是etcd)。这篇文章也只是大致简单的介绍了一下go-micro
框架。如果对micro
有兴趣的小伙伴去Micro中国以及micro中文资源进行更加深入、系统的学习和使用。
上一篇文章我们已经安装过Proto
但是还是不能生成go
与go-micro
版本的对应的文件。
所以我们还需要安装protoc-gen-go
和protoc-gen-micro
这2个插件
1 | go get -u github.com/golang/protobuf/protoc-gen-go |
1 | # 创建文件夹 |
然后我们编写如下内容:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16syntax="proto3";
package proto;
option go_package=".;proto";
service AccountService{
rpc AccountRegister(ReqAccountRegister) returns(ResAccountRegister){}
}
message ReqAccountRegister{
string username=1;
string password=2;
}
message ResAccountRegister{
int32 code=1;
string message=2;
}
创建一个request
跟一个response
结构体。其中,request
需要传递username
(用户名)跟password
(密码)。当请求成功后,收到的response
中包含code
(内部状态码)跟message
(信息)
1 | # protoc --go_out=转换输出为go文件的目录 --micro_out=转换输出为micro微服务文件的目录 原始proto文件所在位置 |
生成成功后,我们1
2
3> ls goprotobuf/build
account.pb.go account.pb.micro.go
account.pb.go
是定义ReqAccountRegister
与ResAccountRegister
结构体。
account.pb.go
定义了AccountService
接口。
组件我想的是尽量都是使用容器,这样方便运行环境的统一。所以像Gitlab
、Jenkins
、Traefik
等等,这些组件我会在后面等用到了直接使用容器构建。
除了以上那些,像Go
运行环境,Micro
、ProtoBuf
、Kubernetes
还是需要我们手动进行安装的。
这个没啥好说的,可以去GO语言中文网
进行下载安装,我使用的是1.14.1
版本。记得配置好系统变量。同时,请设置好go mod
代理.
不会设置请看 https://goproxy.io/zh/
Micro
是使用go-micro
框架编写的运行时工具集,它最主要的作用是辅助微服务开发。比如想自己马上构建一个web
环境或者api
环境进行调试,或者查看服务的健康状态,注册信息之类的。
感兴趣的可以安装(这个系列用到的不是很经常):
1 | go get github.com/micro/micro/v2 |
可以去了解一下它的cli文档
go-micro
的transport
跟server
默认通讯协议是grpc
,grpc
使用的通讯格式是Protobuf
,所以我们需要安装Protobuf
环境,以便我们编写生成对应的Protobuf
文件。
如果跟我一样是Mac
环境并且安装了brew
,那么可以直接使用下面指令进行安装1
brew install protobuf
其他环境的小伙伴,可以先去下载protobuf中最新的发布包,解压放到bin
目录下即可。举个例子,如果是Ubuntu,可以按照如下步骤操作:1
2
3
4# 下载安装包
$ wget https://github.com/protocolbuffers/protobuf/releases/download/v3.12.1/protoc-3.12.1-linux-x86_64.zip
# 解压到 /usr/local 目录下
$ sudo 7z x protoc-3.12.1-linux-x86_64.zip -o/usr/local
如果不想安装在 /usr/local
目录下,可以解压到其他的其他,并把解压路径下的 bin
目录 加入到环境变量即可。
如果能正常显示版本,则表示安装成功。1
2$ protoc --version
libprotoc 3.12.1
最好是可以知道Protobuf的语法以及如何转换为go
大家可以根据自己不同的操作系统,来决定如何安装Docker
。例如,Mac
可以安装Docker Desktop For Mac
,windows
用户我建议是虚拟机安装Ceentos
系统或Ubuntu
系统后,再在里面安装Docker
来使用,而不是使用Docker Desktop For Windows
。
大家可以根据菜鸟教程
里的Docker教程里,根据自己不同的操作系统进行安装,并且记得设置好镜像加速器
噢~
自行参考菜鸟教程中的Docker Compose进行安装即可。
我之前的文章已经完整的写过如何安装Kubetnetes
:
Kubernetes 18.04集群安装教程(基于Centos7)
注意,我是使用Windows下的VMware构建Kubernetes集群环境以及Gitlab和Jenkins环境
我的本机运行环境是Mac
,Kubetnetes
跟Gitlab和Jenkins环境都是安装在Windows
的Vmware
虚拟机里,虚拟机使用的是桥接模式。Mac
和虚拟机在同一个网段下,可以正常通讯。还不是很清楚Vmware
跟Docker
网络模式区别的,可以看一看Vmware和Docker的网络模式讲解
节点 | cpu | 内存 | 磁盘 | IP |
---|---|---|---|---|
Mac | 6核12线 | 16G | 50G | 192.168.1.10 |
K8s | 2核2线 | 4G | 30G | 192.168.1.200 |
Jenkins | 2核2线 | 8G | 30G | 192.168.1.220 |
Gitlab | 2核2线 | 8G | 30G | 192.168.1.220 |
]]>Jenkins跟Gitlab我放在同一个虚拟机里噢~
首先来看一下整体的流程图
在这张图中:
traefik
,它的特色是可以动态发现,完美支持docker
,kubernetes
。go-micro
框架,由于go-micro
的web和路由功能并不能很好地满足现实需求,所以的对外提供HTTP服务的部分使用的是gin
框架。go-micro
框架编写微服务go-micro
框架,该框架默认使用的是grpc
通讯,grpc
通讯的通讯格式是Protobuf
mdns
,我们更换为使用etcd
做服务注册中心。当使用Kubernetes
部署时,Kubernetes
将Service
的名称当做域名注册到kube-dns
中,通过Service
的名称就可以访问其提供的服务docker-compose
,最终使用Kubernetes
在这张图中:
gitlab
jenkins
jenkins
需要安装的插件有:Localization Chinese(Simplified)
、Publish Over SSH
、Gitlab
、Golang
技术 | 使用 | 版本 |
---|---|---|
语言 | Golang | 1.14.1 |
Web框架(网关) | Gin | v1.6.3 |
通讯格式 | Protobuf | v3.12.1 |
微服务框架 | Go-micro | v2.9.0 |
反向代理 | Traefik | v2.2.1 |
服务注册中心 | Etcd/Kubernetes | v3.3.8/v1.16.5 |
容器 | Docker | v19.03.8 |
编排工具 | Docker-Compose/Kubernetes | v1.25.5/v1.16.5 |
代码仓库 | Gitlab | v13.1.1-ce.0 |
持续集成 | Jenkins | v2.242 |
对应的相关代码和部署文件,已经传至github,欢迎star。
微服务与部署:https://github.com/guaosi/go-micro-build
持续交付、集成、部署:https://github.com/guaosi/go-cicd
]]>我觉得微服务是什么?什么是微服务这种问题都老生常谈了,随便百度的回答都是各式各样的,我也没什么好介绍的了。开门见山,我就直接说我想做成什么样的。
gin
框架结合go-micro
作为微服务的网关,后期加入链路追踪
、熔断与降级
、监控
等扩展功能。这个系列开发、部署各占一半,所以需要你能满足以下要求:
对应的相关代码和部署文件,已经传至github,欢迎star。
微服务与部署:https://github.com/guaosi/go-micro-build
持续交付、集成、部署:https://github.com/guaosi/go-cicd
由于涉及的点很多,一篇文章是肯定写不完的,所以分了几个模块,几个章节进行介绍。(hexo的有序列表有子节点会有问题,所以就只能这样凑合看吧,排班不是很舒服)
1.环境构建
2.微服务的编写
2.1 proto文件的编写与生成
2.2 简单介绍了解go-micro
2.3 服务编写
2.4 网关编写
3.基于容器的构建运行
3.1 Dockerfile编写与镜像制作
3.2 直接Docker容器构建执行
3.3 Docker Compose下的构建执行
3.4 Kubernetes下的构建执行
4.持续交付、集成、部署(CI/CD)
4.1 Gitlab的搭建与设置
4.2 Jenkins的搭建、插件的安装与配置
4.3 Jenkins构建部署Kubernetes
5.网关功能扩展(TODO)
5.1 链路追踪-jaeger
5.2 熔断与降级-hystrix
5.3 监控-prometheus
Kubernetes在前一篇 Kubernetes 18.04集群安装教程(基于Centos7) 已经介绍过了,这里不做过多的描述,重点讲述 Mac OS 下如何使用Kubernetes。
其实非常简单,因为我们使用的是 Docker For Mac 。下载下来安装即可。
以版本为 2.3.0.3 为例
打开 Docker For Mac
,点击 Preferences
偏好设置进入控制面板。
点击 Docker Engine
,配置如下1
2
3
4
5
6
7{
"registry-mirrors": [
"镜像加速服务器地址"
],
"debug": true,
"experimental": false
}
镜像加速服务器推荐使用阿里云,可以去阿里云的容器服务里免费申请噢~
由于Kubernetes的镜像是谷歌源被墙,所以需要提前自行准备一个代理。
这里用的代理工具是Clash
工具,Clash
默认的HTTP
代理端口是7890
,Socks5
代理端口是7891
。
回到Docker For Mac
控制面板,依次点击Resources
->PROXIES
我们开启Manual proxy configuration
然后我们将Web Server (HTTP)
跟Secure Web Server (HTTPS)
都配置为http://127.0.0.1:7890
即可
Docker For Mac
控制面板,点击Kubernetes
然后我们勾上 Enable Kubernetes
(启动Kubernetes) 以及 Show system containers (advanced)
(docker容器里可以看到Kubernetes的容器),然后点击 Apply & Restart
即可。
Docker For Mac
一直显示 Kubernetes is starting
,并且网络速率没有下载的迹象,那么请退出 Docker For Mac
(点击 Quit Docker Desktop
)。然后再重新打开 Docker For Mac
即可Kubernetes
后,那么之前使用Docker
以及Docker-Compose
启动的容器都无法继续启动,只能使用Pod创建的容器。关闭Kubernetes
后,重新退出再开启Docker For Mac
,这样才能恢复容器启动。就是容器跟Kubernetes
只能选一个。kubernetes,简称K8s,是用8代替8个字符“ubernete”而成的缩写。是一个开源的,用于管理云平台中多个主机上的容器化的应用,Kubernetes的目标是让部署容器化的应用简单并且高效(powerful),Kubernetes提供了应用部署,规划,更新,维护的一种机制。
在开始之前,先简单介绍一下环境以及配置。这里使用的是vmware创建的虚拟机。
节点 | 操作系统 | cpu | 内存 | 磁盘 | IP |
---|---|---|---|---|---|
k8s-master | centos7 | 2核2线 | 4G | 50G | 192.168.1.200 |
kubernetes的服务器必须是2核4G以上配置才可以安装的
当然,如果你想是一个Kubernetes集群,则node节点给是相同的配置即可,例如:
节点 | 操作系统 | cpu | 内存 | 磁盘 | IP |
---|---|---|---|---|---|
k8s-node01 | centos7 | 2核2线 | 4G | 50G | 192.168.1.211 |
k8s-node02 | centos7 | 2核2线 | 4G | 50G | 192.168.1.212 |
Centos7的镜像用的是阿里源下载的。使用的是CentOS-7-x86_64-Minimal-2003镜像。
Centos7系统的安装过程就不给出了,自行下一步下一步即可
这里要说明一点,Kubernetes需要的资源较多,如果配置跟不上或者只是学习体验,只有一个master,没有node节点是可以正常使用的。master默认是不允许被pod调度,只要设置污点为允许即可。这个在文章后面会讲如何设置。
以下操作对3个节点都是相同的噢~
1 | BOOTPROTO=static |
1 | BOOTPROTO=static |
1 | BOOTPROTO=static |
1 | # master上执行 |
1 | 192.168.1.200 k8s-master |
1 | yum install -y conntrack ntpdate ntp ipvsadm ipset jq iptables curl sysstat libseccomp wget vim net-tools git lrzsz |
1 | systemctl stop firewalld && systemctl disable firewalld |
1 | swapoff -a && sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab |
1 | cat > kubernetes.conf <<EOF |
1 | # 设置系统时区为 中国/上海 |
1 | systemctl stop postfix && systemctl disable postfix |
1 | mkdir /var/log/journal |
CentOS 7.x 系统自带的 3.10.x 内核存在一些 Bugs,导致运行的 Docker、Kubernetes 不稳定,例如rpm -Uvh http://www.elrepo.org/elrepo-release-7.0-3.el7.elrepo.noarch.rpm
1 | rpm -Uvh http://www.elrepo.org/elrepo-release-7.0-3.el7.elrepo.noarch.rpm |
注意,内核会随着时间升级的,我写这篇文章的时候,内核版本是 4.4.202 ,这并不意味着你做的时候还是这个内核版本,如果变动了,请自行修改上面的命令即可。
以下操作对3个节点依旧都是相同的噢~
1 | modprobe br_netfilter |
1 | yum install -y yum-utils device-mapper-persistent-data lvm2 |
daemon.json文件里也可以配置你的镜像加速器地址噢~
使用该工具来快速部署kubernets1
2
3
4
5
6
7
8
9
10
11
12
13
14cat > /etc/yum.repos.d/kubernetes.repo <<EOF
[kubernetes]
name=Kubernetes
baseurl=http://mirrors.aliyun.com/kubernetes/yum/repos/kubernetes-el7-x86_64
enabled=1
gpgcheck=0
repo_gpgcheck=0
gpgkey=http://mirrors.aliyun.com/kubernetes/yum/doc/yum-key.gpg
http://mirrors.aliyun.com/kubernetes/yum/doc/rpm-package-key.gpg
EOF
yum install -y kubelet-1.18.4 kubeadm-1.18.4 kubectl-1.18.4
systemctl enable kubelet.service
注意,kubelet、kubeadm、kubectl也是会随着时间升级的,我写的时候是18.04是最新的,如果到时候执行报错了,请自行安装最新版本即可。
只要操作master节点即可噢~
1 | kubeadm config print init-defaults > kubeadm-config.yaml |
因为Kubernetes所需要的初始化必备镜像都是从谷歌官方拉取的,不会走docker的加速镜像服务器。由于谷歌被墙,所以我们需要自行下载必备镜像,怎么做呢?
首先列出使用的镜像以及版本号
1 | kubeadm config images list --config kubeadm-config.yaml |
接着,我们通过国内的第三方镜像仓库下载完毕后再更改镜像名称与谷歌的镜像名称一致
即可
我们编写一个shell脚本
1 |
|
当然这里用什么版本,是由Kubeadm的版本节点的。通过上方的列出使用的镜像以及版本号
我们可以很清楚的知道要下什么版本,下哪些的镜像了。
然后我们执行脚本,开始下载镜像(注意哦,这个下载镜像,2个node节点也要做的)1
2
3
4
5# 给予执行权限
chmod +x docker-download.sh
# 初始化,并且将标准输出同时写入至kubeadm-init.log文件
kubeadm init --config=kubeadm-config.yaml | tee kubeadm-init.log
完毕后,控制台输出的日志会告诉我们继续执行什么指令以及node节点如何加入
执行日志中的指令
1 | mkdir -p $HOME/.kube |
因为初始化的日志很有用,里面包含了子节点如何加入master
的,所以我们把这些文件保存起来1
2
3
4mkdir -p /usr/local/install-k8s/core
# 将重要文件移入core
mv kubeadm-init.log kubeadm-config.yaml /usr/local/install-k8s/core
只要操作master节点即可噢~
现在我们执行kubectl
相关指令已经会有了正常响应,但是此时节点处于NotReady
的状态,这是因为我们还没有为Kubernetes指定它的网络模式。我们使用flannel
来作为它的网络模式,这样就可以让不同节点上的容器跨主机通信。如果对这块感兴趣,可以自行搜索flannel
的网络实现。
现在我们开始安装flannel
1 | # 创建文件夹 |
1 | # 查看命名空间为kube-system的pod情况 |
根据kubeadm-init.log
日志文件内容或者安装的时候的标准输出,在node节点执行指令1
2kubeadm join 192.168.1.200:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:7c2677754a3b09da10d5ffa6a7d6348ad63219cd69d2f5c3a27642d4b95ff15b
即可将node节点加入master
如果你只有一个master没有node节点,或者你想master也参与pod的调度,那么可以执行如下指令
1 | # 允许 |
1 | # 获取资源清单 |
1 | # 创建admin账户 |
然后我们输入1
kubectl get pod -n kubernetes-dashboard -o wide
查看此时dashboard所在的服务器节点。通过 http://所在服务器IP:nodePort
进行访问。输入之前获取的token,即可正常进入。
1 | docker login --username=xxx registry.cn-shenzhen.aliyuncs.com |
xxx是你的阿里云登录名,例如我的是 guaosi@vip.qq.com
1 | # 创建 Secret,命名为 regcred: |
1 | apiVersion: v1 |
首先,先上一下公司测试环境的简单网络结构
简单说一下,测试环境的上游服务是放在阿里云服务器的,通过阿里云的VPN网关,发起HTTP请求到公司内部测试环境的下游服务。然后下游服务处理后,再返回对应的相关数据。
之前一直都没什么网络问题。今天突然发现上游服务请求某一接口一直报服务超时,但是其他接口又是正常的。问题探究之路从这里展开~
首先,先简单猜想一下会有哪些原因造成这种情况的?
这种猜想比较容易验证。由于上游服务请求少有服务不止一个接口,但是只有其中一个接口一直是报请求超时,其他接口都正常,可以排除网络丢包的问题。
这种猜想也非常好验证,首先检查一下下游服务的相关进程,相关端口是否正常开启,检查下游服务的日志信息,请求记录。发现下游服务的请求记录中有上游服务请求超时的接口,并且正常响应,程序没有报异常。
为了进一步验证,使用tcmpdump
进行抓包,再将抓取的数据导入Wireshark
进行tcp流追踪,最后确认请求数据正常接收以及响应。
如今只剩下最后一个猜想,数据丢包问题。会不会是被阿里云的VPN网关主动丢弃?因为有一个想不透的点,为什么只有这个接口出现了问题,并且问题是请求超时,其他的接口都可以正常响应?这样一想,数据丢包的可能性非常的话。于是查阅了阿里云VPN网关文档,查看是否有相关限制。然后看到MTU注意事项,其中有一段注意事项
1 | 您必须配置本地VPN网关,将其使用的MTU限制在1400字节之内,建议MTU设置为1400字节。 |
当然,如果你对MTU很陌生,可以查看这篇文章,补充一些关于数据帧方面的知识。
既然阿里云VPN网关有这个限制,不妨先设置看看,是否可以解决问题。于是在下游服务器的出口网卡上设置MTU为1300(安全起见)
以Centos为例:
临时设定1
ifconfig eth0 mtu 1300 up
永久设定1
2
3
4vi /etc/sysconfig/network-scripts/ifcfg-eth0
#增加如下内容
MTU="9000"
然后重启网卡生效1
service network restart
然后回到上游服务查看相关日志,发现接口已经正常响应,服务正常了。
原因很明显,就是VPN网关的MTU与下游服务器的网卡MTU不一致。默认的MTU都是1500字节,当数据包超过这个大小时,就会被分包。当然,分包这个工作是由网卡替我们完成的。
阿里云的VPN网关规定大小为1400字节,返回的数据帧不超过1400字节时,正常转发放行。如果超过,就会被丢弃。就算假设数据帧总大小为1800字节,下游服务器此时MTU还是1500字节,在网络层拆成小于1500(考虑帧也有大小)字节和大于300字节的数据包。此时1500字节的帧被阿里云的VPN网关丢弃,300字节的数据帧转发到上游服务器,上游服务器到网络层进行数据包的重组,发现该数据包无法重组,就进行丢弃。这也就是为什么有的接口正常,但是有的接口超时的原因了
之前没有出现这个问题,是由于因为数据量不够,导致数据帧的大小还没达到1400字节,所以一直没出现。
数据报(传输层)>数据包(网络层)>数据帧(数据链路层)
不要忽略基础知识,网络知识的重要性,有时候依靠这些知识,才能更好地分析问题,解决问题。
]]>如图为主备模式的简单架构模型,主要是利用HaProxy
去做的主备切换,当主节点挂掉时,HaProxy
会自动进行切换,把备份节点升级为主节点
HAProxy 是一款提供高可用性、负载均衡以及基于TCP(第四层)和HTTP(第七层)应用的代理软件, 简单了解一下它的相关配置
1 | #监听集群 |
Shovel架构模型:绿色部分就代表了两个不同地域的MQ节点,假设用户在A地方下一个订单,然后订单投递到了MQ,第一个地方的MQ节点为了避免压力过大、负载过高可以设置一个阈值,如果负载过高将订单信息转到另一个地方的MQ,分摊服务压力。同时也可以进行两个或多个中心的数据同步。
好处:在使用了shovel插件后,模型变成了近端同步确认,远端异步确认的方式,大大提高了订单确认速度,并且还能保证可靠性
Shovel集群的拓扑图如下所示 ,一个订单进来之后,里面有两个队列,如果正常队列压力过大,会将订单路由到backup队列,这个队列和另一个地域的MQ(某个交换机)产生了Shovel联系,就会把数据复制到远端MQ中心,在远端会有相应的队列进行消费
rabbitmq-plugins enable amqp_client
rabbitmq-plugins enable rabbitmq_shovel
touch /etc/rabbitmq/rabbitmq.config
事实上这个配置会相对复杂一些,实现双活已经有更好的方式,所以远程模式了解即可。
黄色的就是应用服务器,里面包含了RabbitMQ节点,节点里面有个Mirror queue
,这三个镜像队列数据是要同步的。
外部发送一条消息,落到一台服务器上,这台服务器将数据进行同步,同步到另外两个节点上。
利用HA-proxy
做负载均衡,然后KeepAlived
做多个HA-proxy的高可用切换
federation
插件,可以实现持续的可靠的AMQP数据通信,多活模式在实际配置与应用上非常的简单。上层就是应用层,然后经过LBS负载均衡,两套RabbitMQ集群,可能是两套镜像队列,两套集群通过federation插件进行数据的复制和流转。
当然federation插件不是建立在集群上的,而是建立到单个节点上,比如左边node3可以和右边任意一台建立这种多活机制,然后,自己这边的集群如果是采用镜像队列那么也会去进行同步,所以这种性能也是非常好的。
HAProxy是一款提供高可用性、负载均衡以及基于TCP和HTTP应用的代理软件,HAProxy是完全免费的、借助HAProxy可以快速并且可靠的提供基于TCP和HTTP应用的代理解决方案。
HAProxy适用于那些负载较大的web站点,这些站点通常又需要会话保持或七层处理。
HAProxy可以支持数以万计的并发连接,并且HAProxy的运行模式使得它可以很简单安全的整合进架构中,同时可以保护web服务器不被暴露到网络上。
Haproxy借助于OS上几种常见的技术来实现性能的最大化:
Keepalived,它是一个高性能的服务器高可用或热备解决方案,Keepalived主要来防止服务器单点故障的发生问题,可以通过其与Nginx、Haproxy等反向代理的负载均衡服务器配合实现web服务端的高可用。Keepalived以VRRP协议为实现基础,用VRRP协议来实现高可用性(HA).VRRP(Virtual Router Redundancy Protocol)协议是用于实现路由器冗余的协议,VRRP协议将两台或多台路由器设备虚拟成一个设备,对外提供虚拟路由器IP(一个或多个)。
KeepAlived服务的三个重要功能:
Keepalived高可用服务对之间的故障切换转移,是通过VRRP(Virtual Router Redundancy Protocol,虚拟路由器冗余协议)来实现的。在Keepalived服务正常工作时,主Master节点会不断地向备节点发送(多播的方式)心跳消息,用以告诉备Backup节点自己还活看,当主Master节点发生故障时,就无法发送心跳消息,备节点也就因此无法继续检测到来自主Master节点的心跳了,于是调用自身的接管程序,接管主Master节点的IP资源及服务.而当主Master节点恢复时,备Backup节点又会释放主节点故障时自身接管的IP资源及服务,恢复到原来的备用角色(nopreempt 开启恢复抢占)
服务器IP | hostname | 端口 | 管控台地址 |
---|---|---|---|
192.168.0.201 | controller1 | 8100 | http://192.168.0.201:8100/rabbitmq-status |
192.168.0.202 | controller2 | 8100 | http://192.168.0.202:8100/rabbitmq-status |
192.168.0.211 | rabbitmq master | 5672 | http://192.168.0.211:15672 |
192.168.0.212 | rabbitmq slave | 5672 | http://192.168.0.212:15672 |
192.168.0.213 | rabbitmq slave | 5672 | http://192.168.0.213:15672 |
设置VIP为: 192.168.0.200
关闭防火墙,SELinux。设置好主机名称
1 | # 关闭防火墙 |
停止RabbitMQ服务
1 | rabbitmqctl stop |
选择211、212、213任意一个节点为Master(这里选择211为Master),也就是说我们需要把76的Cookie文件同步到212、213节点上去。
进入/var/lib/rabbitmq目录下,把/var/lib/rabbitmq/.erlang.cookie文件的权限修改为777,原来是400;然后把.erlang.cookie文件copy到各个节点下;最后把所有cookie文件权限还原为400即可。
1 | # 修改权限 |
1 | rabbitmq-server -detached |
1 | rabbitmqctl stop_app |
修改集群名称,随便在某一个节点执行,全部都有效(默认为第一个node名称)
1 | rabbitmqctl set_cluster_name rabbitmq_cluster |
最后在集群的任意一个节点执行命令:查看集群状态
1 | # 查询集群状态 |
同时此时查看管控台,也会都是集群展示
设置镜像队列策略(在任意一个节点上执行)
1 | rabbitmqctl set_policy ha-all "^" '{"ha-mode":"all"}' |
将所有队列设置为镜像队列,即队列会被复制到各个节点,各个节点状态一致,RabbitMQ高可用集群就已经搭建好了,我们可以重启服务,查看其队列是否在从节点同步。
此时发送一条信息的rabbitmq的任意一个节点,其他两个节点都会进行同步。同时,此时如果消费任意一个节点的任意一个消息,其他节点上也会消失。
HAProxy是一款提供高可用性、负载均衡以及基于TCP和HTTP应用的代理软件,HAProxy是完全免费的、借助HAProxy可以快速并且可靠的提供基于TCP和HTTP应用的代理解决方案。
HAProxy适用于那些负载较大的web站点,这些站点通常又需要会话保持或七层处理。
HAProxy可以支持数以万计的并发连接,并且HAProxy的运行模式使得它可以很简单安全的整合进架构中,同时可以保护web服务器不被暴露到网络上。
在控制节点都进行安装
1 | //下载依赖包 |
1 | vim /etc/haproxy/haproxy.cfg |
1 | /usr/local/haproxy/sbin/haproxy -f /etc/haproxy/haproxy.cfg |
查看haproxy监控: http://192.168.0.201:8100/rabbitmq-status
1 | killall haproxy |
Keepalived,它是一个高性能的服务器高可用或热备解决方案,Keepalived主要来防止服务器单点故障的发生问题,可以通过其与Nginx、Haproxy等反向代理的负载均衡服务器配合实现web服务端的高可用。Keepalived以VRRP协议为实现基础,用VRRP协议来实现高可用性(HA).VRRP(Virtual Router Redundancy Protocol)协议是用于实现路由器冗余的协议,VRRP协议将两台或多台路由器设备虚拟成一个设备,对外提供虚拟路由器IP(一个或多个)。
1 | //安装所需软件包 |
添加文件位置为/etc/keepalived/haproxy_check.sh
(主,从两个节点文件内容一致即可)
1 |
|
赋予执行权限
1 | chmod +x /etc/keepalived/haproxy_check.sh |
vim /etc/keepalived/keepalived.conf
1 | ! Configuration File for keepalived |
vim /etc/keepalived/keepalived.conf
1 | ! Configuration File for keepalived |
当我们启动俩个haproxy节点以后,我们可以启动keepalived服务程序
1 | //启动两台机器的keepalived |
此时只要连上VIP即可,如果主节点挂了,从节点会立刻设置VIP
不用配置
/usr/lib/rabbitmq/lib/rabbitmq_server-3.6.5/ebin/rabbit.app
tcp_listerners :设置rabbimq的监听端口,默认为[5672]。
disk_free_limit :磁盘低水位线,若磁盘容量低于指定值则停止接收数据,默认值为{mem_relative, 1.0},即与内存相关联1:1,也可定制为多少byte.
vm_memory_high_watermark :设置内存低水位线,若低于该水位线,则开启流控机制,默认值是0.4,即内存总量的40%。
hipe_compile :将部分rabbimq代码用High Performance Erlang compiler编译,可提升性能,该参数是实验性,若出现erlang vm segfaults,应关掉。
force_fine_statistics :该参数属于rabbimq_management,若为true则进行精细化的统计,但会影响性能
Haproxy和KeepAlived在同一个节点上(称为高可用负载均衡代理节点),当主节点故障时,VIP漂浮到备用节点,由备用节点变成主节点。KeepAlived的作用是检测高可用负载均衡代理节点是否挂了,挂了会影响Haproxy。Haproxy的作用是观察下游服务(rabbitmq)集群中是否有机器挂了,挂了则不将消息转发到该节点。
RabbitMQ镜像队列集群的恢复的解决方案和应用场景:
前提:比如两个节点A和B组成一个镜像队列
A先停,B后停
方案1: 该场景下B是Master,只要先启动B,再启动A即可。或者先启动A,再30秒内启动B即可恢复镜像队列。
A、B同时停机
方案:该场景可能是由于机房掉电等原因造成的,只需在30秒之内连续启动A和B即可恢复镜像。
A先停,B后停,且A无法恢复
方案:改场景是1场景的加强版,因为B是Master,所以等B起来以后,在B节点上调用控制台命令:rabbitmqctl forget_cluster_node A
解除与A的Cluster关系,再将新的Slave节点加入B即可重新恢复镜像队列
A先停,B后停,且B无法恢复
方案:该场景是场景3的加强版,比较难处理,原因是因为Master节点无法恢复,早在3.1.x时代之前没有什么好的解决方案,但是现在已经有解决方案了,在3.4.2以后的版本。 因为B是主节点,所以直接启动A是不行的,当A无法启动的时候,也就没办法在A节点上调用之前的rabbitmqctl forget_cluster_node B
命令了。新版本中,forget_cluster_node
支持--offline
参数。
这就意味着允许rabbitmqctl在理想节点上执行该命令,迫使RabbitMQ在未启动Slave节点中选择一个节点作为Master。当在A节点执行 rabbitmqctl forget_cluster_node --offline B
时,RabbitMQ会mock一个节点代表A,执行forget_cluster_node
命令将B剔除cluster,然后A就可以正常启动了(此时将A作为Master),最后将新的Slave节点加入A即可重新恢复镜像队列
A先停、B后停,且A、B均无法恢复,但是能得到A或B的磁盘文件
方案:这种场景更加难处理,只能通过恢复数据的方式去尝试恢复,将A或B的数据库文件默认在$RABBIT_HOME/var/lib/
目录中,把它拷贝到新节点(比如C、D)的对应目录下,再将新节点(比如C、D)的hostname改成A或B的hostname,如果是A节点(Slave)的磁盘文件,则按照场景4处理即可,如果是B节点(Master)的磁盘文件,则按照场景3处理,最后将新的Slave加入到新节点后完成恢复。
A先停、B后停,且A、B均无法恢复,且得不到A或B的磁盘文件
方案:洗洗睡吧。
]]>单机即在一台机器上部署一个redis节点,主要会存在以下问题:
如果发生机器故障,例如磁盘损坏,主板损坏等,未能在短时间内修复好,客户端将无法连接redis。
当然如果仅仅是redis节点挂掉了,可以进行问题排查然后重启,姑且不考虑这段时间对外服务的可用性,那还是可以接受的。
而发生机器故障,基本是无济于事。除非把redis迁移到另一台机器上,并且还要考虑数据同步的问题。
假如一台机器是16G内存,redis使用了12G内存,而其他应用还需要使用内存,假设我们总共需要60G内存要如何去做呢,是否有必要购买64G内存的机器?
redis官方数据显示可以达到10w的QPS,如果业务需要100w的QPS怎么去做呢?
关于容量瓶颈和QPS瓶颈是redis分布式需要解决的问题,而机器故障就是高可用的问题了。
如图所示左边是Master节点,右边是slave节点,即主节点和从节点。从节点也是可以对外提供服务的,主节点是有数据的,从节点可以通过复制操作将主节点的数据同步过来,并且随着主节点数据不断写入,从节点数据也会做同步的更新。
整体起到的就是数据备份的效果。
除了一主一从模型之外,redis还提供了一主多从的模型,也就是一个master可以有多个slave,也就相当于有了多份的数据副本。
这样可以做一个更加高可用的选择,例如一个master和一个slave挂掉了,还能有其他的slave数据备份。
除了作为数据备份,主从模型还能做另外一个功能,就是读写分离。
让master节点负责提供写服务,而将数据读取的压力进行分流和负载,分摊给所有的从节点。
如图,想让6380节点成为6379的从节点,只需要执行 slaveof
命令即可,此复制命令是异步进行的,redis会自动进行后续数据复制的操作。
注:一般生产环境不允许主从节点都在一台机器上,因为没有任何的价值。
注意,执行这个命令成功后,从节点会把自身原有数据情空,完全与主节点数据相同
如果6380节点不希望成为6379的从节点,可以执行 slave of on one
命令,取消后6380节点的数据不会被清除
,只是说后续6379节点新写入的数据不会再同步到该节点了。
注意:如果取消复制后想slave一个新的主节点,新的主节点在同步给slave节点数据时,会先将从节点的数据全部清除
1 | # 配置主节点的IP和端口号 |
方式 | 命令 | 配置 |
---|---|---|
优点 | 无需重启 | 统一配置 |
缺点 | 不便于管理 | 需要重启 |
1 | redis > info replication #可以查询当前节点是否为主节点 |
redis每次启动的时候都会有一个随机的ID,作为一个标识,这个ID就是runid
,当然重启之后值就改变了。
查看runid:redis-cli -p 6379 info | grep run
假如端口为6380的redis去复制6379,知道runid
后,在6380上做一个标识,如果runid
改变了,说明主可能重启了或者发生了其它变化,这时候就可以做一个全量复制把数据同步过来。或者第一次启动时根本不知道6379的runid
,也会进行全量复制
偏移量:数据写入量的字节
比如主执行set hello world,就会有一个偏移量,然后从同步数据,也会记录一个偏移量
当两个偏移量达到一致时候,实际上数据就是完全同步的状态。
启动主从redis,并在写入命令执行前后查看主偏移量:redis-cli -p 6379 info replication | grep master_repl
从节点会向主节点做一个上报,把从节点的状态同步给主节点,这样主节点就知道了从节点的偏移量
生产环境我们一般不关心这个值,有时候做监控的时候会比对一下这两个值的差,如果差的太多,说明主从是有问题的。
全量复制主节点会将RDB文件也就是当前状态去同步给slave,在此期间主新写入的命令会单独记录起来,然后当RDB文件加载完毕之后,会通过偏移量对比将这个期间产生的写入值同步给slave,这样就能达到数据完全同步的效果
psync
,是做同步的命令,它可以完成全量复制和部分复制的功能,当启动slave节点时,它会发送psync
命令给主节点,需要传递两个参数,runid
和offset
(偏移量),也就是从向主传递主节点的runid以及自己的偏移量,对于第一次复制而言,就直接传递?和 -1,当然这个参数是由slave内部传的。bgsave
生成RDB文件,并且在此期间新产生的写入命令会被记录到repl_back_buffer
(复制缓冲区)实际上全量复制的开销是非常大的,主要体现在如下方面
全量复制除了上述开销之外,还会有个问题:
假如master和slave网络发生了抖动,那一段时间内这些数据就会丢失,对于slave来说这段时间master更新的数据是不知道的。最简单的方式就是再做一次全量复制,从而获取到最新的数据,在redis2.8之前是这么做的。
部分复制,redis2.8之后提供。如果发生类似抖动时候,可以有一种机制将这种损失降低到最低,如何实现的?
通过部分复制(增量复制)有效的降低了全量复制的开销。
1 | # 开启无磁盘化复制 |
如果在复制期间,rdb复制时间超过60秒,内存缓冲区持续消耗超过64MB,或者一次性超过256MB,那么将会停止复制(失败)
配置项:client-output-buffer-limit slave 256MB 64MB 60
redis所有数据保存在内存中,对数据的更新将异步地保存到磁盘上。
将数据持久化到硬盘中,这样redis重启数据也不会丢失。当需要恢复数据时,就可以从硬盘中读取数据到内存中,然后进行数据恢复。
快照 –> 某个时刻的完整数据备份(MySQL Dump、Redis RDB)
写日志 –> 记录数据的变化信息(MySQL Binlog、Hbase Hlog、Redis AOF)
RDB方式其实是通过一条命令将当前redis内存数据完整的生成一个快照
然后写入到RDB文件中,RDB文件有自身的一些格式,是采用二进制形式来保存的,当需要对redis数据进行恢复,比如redis做了一次重启,那么就可以将该文件重新载入到redis中,将某个时刻的redis数据备份恢复到redis当中。
在主从复制中RDB还充当了复制的媒介作用。
主要三种方式包括:save(同步)、bgsave(异步)、自动
在客户端执行save命令,redis就会帮我们生成一个RDB文件。
执行这个命令有个问题就是它是一个同步命令,如果save命令执行比较慢,其他所有命令都需要排队,也就是说数据量大的时候可能造成redis服务器阻塞。
文件策略是当存在老的RDB文件时,执行命令会先生成一个临时文件,当命令执行完毕后将老的文件替换掉(复杂度O(n))。
首先客户端执行 bgsave
命令,它不会像save命令一样去同步生成RDB,而是会在后台单独开启一个线程去执行。
即使用了linux的fork()
函数生成了主进程的一个子进程,由redis子进程去生成RDB文件,当RDB文件生成完毕后会通知主进程文件已经生成成功了。
注意:fast说明子进程是执行非常快的(大多数情况),但是极少数情况下fork执行慢的话依然会阻塞redis
其文件策略和复杂度与save命令相同,对于客户端而言执行完第一和第二步之后服务器就可以正常响应客户端了。
命令 | save | bgsave |
---|---|---|
IO类型 | 同步 | 异步 |
阻塞 | 是 | 是(阻塞发生在fork) |
复杂度 | O(n) | O(n) |
优点 | 不会消耗额外内存 | 不阻塞客户端命令 |
缺点 | 阻塞客户端命令 | 需要fork,消耗内存 |
通过配置实现,达到某些条件情况下自动生成RDB文件。
如图所示的配置代表,如果60s改变了1万次数据,300s改变了10次数据,900s内改变了一条数据,三个条件任意满足一条即会自动生成RDB文件,当然它的自动生成实际是执行了bgsave
命令。
那么自动生成是否真的很好呢,显然不是。因为这样我们无法控制它生成RDB的频率。或者说它生成RDB文件频率有可能太高了。
比如60s有1万条改变,对于写入量非常大的应用也是非常容易达到这样的条件,或者达到前两条,那么就会频繁的去生成RDB。
这里姑且不讨论RDB文件生成可能带来的一些问题,姑且认为一个内存中的数据写一个快照到硬盘当中,如果数据非常大的话,或者说很频繁去做这样的操作,肯定会对硬盘造成一定的压力。而且生成的规则也不太好控制,因为写入量我们无法控制。
除了save的三个配置外,redis提供的配置还包括
dbfilename:
配置生成的文件名
dir:
包括rdb/aof和日志文件存放在哪,默认是当前目录
stop-writes-on-bgsave-error:
如果bgsave发生错误是否停止写入
rdbcompression:
rdb文件是否采用压缩格式
rdbchecksum:
是否对rdb文件的校验和进行检验
1 | save 900 1 |
1 | dbfilename dump-${port}.db |
有时候没有执行save和bgsave,也没有配置自动生成策略,为什么还会生成RDB文件?这就要考虑到全量复制,也就是主从之间要进行复制的时候,主会自动生成RDB文件
Redis还提供了debug reload
来进行debug级别的重启,也就是不需要将内存(数据)进行清空的重启,这时候这个机制仍然会触发RDB文件的生成
我们在执行shutdown时候,它会有一个参数叫shutdownsave会执行RDB文件的生成
RDB生成过程就是内存中的Redis数据dump到硬盘当中,形成一个RDB文件。
因为需要将所有数据进行dump,所以是比较耗时的,写比较消耗CPU资源,fork消耗内存。如果数据量很大,写入硬盘也会消耗IO性能。
无论是定时执行save/bgsave命令,还是使用自动生成策略,都存在数据丢失的可能性
创建:基于日志的原理,客户端执行每条写入命令都会追加到AOF文件中,当然存入命令时肯定是使用AOF特定的格式
恢复:当redis发生宕机之后,就可以使用AOF将redis中的数据进行完整的恢复,而且恢复基本是实时的。因为有记录到每个写入命令,当然根据不同AOF的策略也会决定写入的频率
1 | appendonly yes # (默认no,关闭)表示是否开启AOF持久化: |
三种策略: always、everysec、no
在记录写入命令时,通常会先存放在缓冲区中,可以提高写入的效率,缓冲区再根据生成策略刷新到AOF文件中。always:
每条命令都会fsync到硬盘中,这样redis的写入数据就不会丢失
everysec:
每秒都会刷新缓冲区到硬盘中(默认值)
在高写入量时候会适当保护到硬盘,但是如果redis出现故障,有可能会丢失1秒的数据
no:
根据当前操作系统的规则决定什么时候刷新到硬盘中,不需要我们来考虑
通常采用默认配置:everysec
,第一种如果redis写入量非常大,硬盘压力也会非常大。
命令 | always | everysec | no |
---|---|---|---|
优点 | 不丢失数据 | 每秒一次fsync 丢1秒数据 | 不用管 |
缺点 | IO开销较大,一般的stat盘只有几百TPS | 丢1秒数据 | 不可控 |
AOF存在的问题是随着时间逐步推移,AOF文件也会逐渐变大,那就会出现很多问题,比如如果使用AOF文件来进行恢复会非常慢。
如果文件无限制的增大,无论是对硬盘的管理还是写入命令的速度都会有一定影响,所以redis提供了AOF重写的方式来解决这个问题。
其实AOF重写就是将一些过期的、没有用的、重复的以及一些可优化的命令都可以进行化简,化简成一个很小的AOF文件。
从而达到如下目的:减少硬盘占用量、加速恢复速度
比如对一个key执行了一亿次incr key
操作,那么对于AOF文件来说就有一亿次的incr,可以想像文件量是非常大的,会占用很多的磁盘空间,如果做了AOF文件重写的话,实际上就是一条命令set key 一亿
。
对于文件恢复来说也是一样的,如果写了一亿次的incr到文件当中,当redis需要使用AOF来进行恢复的时候,需要执行一亿次命令实际上是没有任何意义的,而且速度会非常慢,所以AOF文件重写就是用来解决上面这两个问题。
客户端发送此命令到redis,redis返回OK结果,并异步执行命令,会fork出一个子进程来完成AOF的重写。
注意这里的AOF重写实际上就是将redis内存当中的数据进行一次回溯,回溯成AOF文件,而不是真的去将AOF文件抽象成去做重写,然后抽象成一个新的AOF文件。(而是从redis内存中进行重写)
第一个配置就是说当AOF文件多大时进行重写,第二个就是说下一次增长率是多少进行重写。
比如达到100M进行重写,并且文件增长率为100%,即200M,400M后会再次进行重写。
配置名 | 含义 |
---|---|
auto-aof-rewrite-min-size | AOF文件重写需要的尺寸 |
auto-aof-rewrite-percentage | AOF文件增长率 |
AOF统计项
统计名 | 含义 |
---|---|
aof_current_size | AOF当前尺寸(单位:字节) |
aof_base_size | AOF上次启动和重写的尺寸(单位:字节) |
自动触发时机:当达到以下两个条件才可以进行自动重写
1.当前尺寸 > 重写需要的最小尺寸
aof_current_size > auto-aof-rewrite-min-size
2.当前尺寸 - 上次重写尺寸 / 上次重写尺寸 > 增长率aof_current_size - aof_base_size/aof_base_size > auto-aof-rewrite-percentage
bgrewriteaof
命令,由redis主(父)进程执行fork
出一个子进程aof_buf
当中,然后去写入到旧的AOF文件当中aof_rewrite_buf
当中 appendonly:
要使用AOF功能需要设置为yes,默认为no
appendfilename:
AOF文件名
appendfsync:
AOF生成策略
dir:
保存RDB、AOF文件以及日志文件的目录
no-appendfsync-on-rewrite:
在AOF重写的时候,是否要做正常的append操作,yes代表不做这个操作
auto-aof-rewrite-percentage:
AOF文件增长率
auto-aof-rewrite-min-size:
AOF文件重写需要的尺寸
1 | appendonly yes |
命令 | RDB | AOF |
---|---|---|
启动优先级 | 低 | 高 |
体积 | 小 | 大 |
恢复速度 | 快 | 慢 |
数据重要性 | 丢数据 | 根据策略决定 |
轻重 | 重 | 轻 |
fork 操作是一个同步操作,若执行较慢会阻塞 redis 主线程
执行时间与内存量相关:内存越大,耗时越长;虚拟机较慢,真机较快
查看 fork 执行时间,可做监控
info : latest_fork_usec 上一次执行fork的微秒数
优先使用物理机或者高效支持fork操作的虚拟化技术
控制 Redis 实际最大可用内存:maxmemory
合理配置 Linux 内存分配策略 vm.overcommit_memory = 1
默认这个值为 0,表示当发现没有足够内存做内存分配的时候,就不去分配。在内存比较低的时候,会发生fork 阻塞。设置为 1 表示认为机器有足够内存,来做内存分配。
降低 fork 频率:例如放宽 AOF 重写自动触发时机,不必要的全量复制
CPU 开销
内存
开销:fork内存开销,copy-on-write,子进程会共享父进程的物理内存页,当父进程执行写请求的时候会创建一个副本,此时会消耗内存。即父进程在大量写入的时候,子进程开销会比较大,创建副本。
优化1:防止单机多部署的时候发生大量的重写
优化2:
1 | echo never > /sys/kernel/mm/transparent_hugepage/enabled |
硬盘
no-appendfsync-on-rewrite = yes
Redis在执行 fsync 的时候,redis 为了保证 AOF 文件安全性,会校验上次 fsync 的时间是否大于2秒。若超过2秒,会发生阻塞。
通过Redis日志进行定位。出行这行,即发生阻塞:
1 | Asynchronous AOF fsync is taking too long (disk is busy?) |
通过 info persistence
命令进行查看:每发生一次,aof_delayed_fsync
会增 1 。aof_deloayed_fsync:0
,说明 aof 并没有发生阻塞。
通过 df -h
和 du -sh
统计整体磁盘情况和查看单独目录点用情况。
如图所示为客户端请求到Redis的完整生命周期:发送命令、排队、执行命令、返回结果
slowlog-max-len
slowlog-log-slower-than
slowlog get [n]:
获取慢查询队列(n为可选参数,指定慢查询条数)slowlog len:
获取慢查询队列长度slowlog reset:
清空慢查询队列slowlog-log-slower-than
不要设置过大,默认10ms,通常设置1msslowlog-max-len
不要设置过小,通常设置1000左右对比: 1次网络命令通信模型 vs 批量网络命令通信模型
执行redis命令时间通常是非常快的,而网络则存在很多不稳定因素。另外redis虽然提供了mget、mset和hmget、hmset等这样的命令,但是如果我们需要同时执行get和hget命令需要怎么做呢,其实这就是流水线帮助我们实现的功能。
流水线就是将一批命令进行一个打包,而在服务端进行批量的计算,然后按顺序将结果返回给客户端,使用流水线可以大大节省网络的开销。
两点说明:
1.redis的命令时间是微秒级别
2.pipeline每次条数要控制(网络)
命令 | N个命令操作 | 1个pipline(n个命令) |
---|---|---|
时间 | n次网络+n次命令 | 1次网络+n次命令 |
数据量 | 1条命令 | n条命令 |
分析一个极端例子,假设客户端和服务端相距1300公里,粗略计算命令传输时间为13毫秒,而命令执行时间只有微秒级,所以如果想要做批量操作而没有使用pipeline这样的功能,那redis的使用效率就不会很高。
假设需要执行hset命令1万次,key也有1万个,这样的操作是无法使用hmset来完成的,因为hmset是只针对一个key。
我们先使用for循环进行批量命令操作,最终执行时间为50s,显然对于这样的时间是没办法接受的。
我们换成使用pipeline实现,每次执行100个命令,执行100次pipeline操作,最终时间只需要0.7秒,速度大大提升。
与原生M操作对比,M操作是一个原子操作,只需要执行和计算一次,而pipeline是将命令进行打包,传送到redis时,则会拆分成子命令,结果会按顺序返回。
角色主要有发布者(publisher)、订阅者(subscriber)、频道(channel)
发布者会发布消息到频道上,订阅者通过订阅频道来获取消息。
类似一些新闻APP(微信公众号),只要订阅了某些频道,这些频道有新消息发布订阅者就能收到消息。
模型如图所示(类似生产者与消费者模型),对于订阅者而言其实也是个客户端,订阅者是可以订阅多个频道的,有个问题就是假如发布者已经发布了一条消息到频道中,但是一个新的订阅者是收不到之前已经发送过的消息的,使用时一定要注意这种场景,就是说redis并没有提供消息堆积的功能,所以无法获取历史消息。
发布消息:publish channel message
1 | 127.0.0.1:6379 > publish sohu:tv "hello world" |
订阅频道:subscribe [channel]
# 一个或多个
返回信息:订阅了哪个频道,收到消息详细信息
1 | 127.0.0.1:6379 > subscribe sohu:tv |
取消订阅:unsubscribe [channel]
# 一个或多个
1 | 127.0.0.1:6379 > unsubscribe sohu:tv |
其他API,例如第一个命令根据pattern去匹配订阅,如: v*,匹配v开头的频道
1 | psubscribe [pattern...] #订阅模式 |
发布订阅是频道发送一条消息,订阅该频道的订阅者都能收到消息。而消息队列是抢模式,最终只有一个订阅者能抢到消息进行消费。
当然redis本身没有提供消息队列这样的功能,我们可以使用list实现,使用阻塞去拉取…
具体根据场景选择相应的模式,就是要搞清楚你的消费者是都需要收到还是只有一个收到。
每个字符串对应的有其ASCII码,将ASCII码转换成对应的二进制,二进制每个数字代表位,即bit
bitmap
就是可以用来对位进行操作。
例如设置了一个键值后,我们可以通过key获取到对应的value,同时我们也可以获取每一位二进制数。
1 | 127.0.0.1:6379> set hello big |
设置位图:setbit key offset value
#给位图指定索引设置值
1 | 127.0.0.1:6379> setbit unique:users:2016-04-05 0 1 |
最终效果,设置对应的位为对应的值,其他都用0补充
如果对刚才的user执行setbit key 50 1
,那么从19位到49位全部补0。这个过程本身会比较慢,所以在执行 setbit
命令时最好不要在很短的位图上做很大的偏移量。
获取位图:getbit key offset
1 | 127.0.0.1:6379> getbit unique:users:2016-04-05 8 |
范围获取:bitcount key [start end]
#获取位图指定范围(start到end,单位为字节,如果不指定就是获取全部)位值为1的个数
注意start和end指定的是字节,1个字节代表8位
如0 0代表第一个字节,即0-7位,1 2代表第二个字节到第三个字节,即8-23位。
1 | 127.0.0.1:6379> bitcount unique:users:2016-04-05 |
多位图操作:bitop op destkey key[key...]
# 做多个Bitmap的and(交集)、or(并集)、not(非)、xor(异或) 操作并将结果保存在destkey中
op代表操作符,比如and、or,返回值为操作后的值字节长度
1 | # 求两个位图的并集 |
获取索引:bitpos key targetBit [start] [end]
# 计算位图指定范围(start到end,单位为字节,如果不指定就是获取全部)第一个偏移量对应的值等于targetBit的位置
1 | 127.0.0.1:6379> bitpos unique:users:2016-04-04 1 |
使用位图实现的思路就是,每个用户的ID占一位,比如用户的ID为10000,那我们就将key的第1万位设置为1。
当然这里内存只是个预估值,如果每天都要进行统计,使用位图还是能节省非常大的内存开销的。
数据类型 | 每个userid占用空间 | 需要存储的用户量 | 全部内存量 |
---|---|---|---|
set | 32位(假设userid用的是整型,实际很多网站用的是长整型) | 50,000,000 | 32位*50,000,000=200MB |
Bitmap | 1位 | 100,000,000 | 1位*100,000,000=12.5MB |
随着时间的迁移
数据类型 | 一天 | 一个月 | 一年 |
---|---|---|---|
set | 200MB | 6G | 72G |
Bitmap | 12.5M | 375M | 4.5G |
那么是不是说在做这样的功能的时候 set
就完全不如 bitmap
好呢。其实是没有完美绝对的事情。
假设只有100万独立用户呢,可以看到使用set
会更节省内存,所以使用bitmap
也不是绝对的好
数据类型 | 每个userid占用空间 | 需要存储的用户量 | 全部内存量 |
---|---|---|---|
set | 32位(假设userid用的是整型,实际很多网站用的是长整型) | 1,000,000 | 32位*1,000,000=4MB |
Bitmap | 1位 | 100,000,000 | 1位*100,000,000=12.5MB |
实际上位图的id不一定要和userid一样,两者之前可以有一定的偏移量。只要有规则就好,这样可以避免过多的空间浪费。
pfadd key element [element ...]
#向hyperloglog添加元素pfcount key [key ...]
#计算hyperloglog的独立总数pfmerge destkey sourcekey [sourcekey ...]
#合并多个hyperloglogAPI使用示例一
1 | 127.0.0.1:6379> pfadd 2017_03_06:unique:ids "uuid-1" "uuid-2" "uuid-3" "uuid-4" |
API使用示例二
1 | 127.0.0.1:6379> pfadd 2016_03_06:unique:ids "uuid-1" "uuid-2" "uuid-3" "uuid-4" |
使用shell脚本实现百万用户添加,分1千次添加,每次加入1千条数据
1 |
|
内存消耗情况如图,相对于使用bitmap或者set来说,消耗内存是非常的小。
时间 | 内存消耗 |
---|---|
1天 | 15KB |
1个月 | 450KB |
1年 | 15KB*365=5MB |
还是那句话,没有绝对完美的事情,在使用HyperLogLog
前考虑以下两点
例如我们之前插入的百万数据,真实查询结果是有偏差的,并且再次插入新的值然后查询结果又会不一样。
1 | 127.0.0.1:6379> pfcount 2016_05_01:unique:ids |
redis3.2新特性,用来计算地理位置信息相关的功能
GEO(地理信息定位):存储经纬度,计算两地距离、范围计算等
首先给出5个城市的经纬度
城市 | 经度 | 纬度 | 简称 |
---|---|---|---|
北京 | 116.28 | 39.55 | beijing |
天津 | 117.12 | 39.08 | tianjin |
石家庄 | 114.29 | 38.02 | shijiazhuang |
唐山 | 118.01 | 39.38 | tangshan |
保定 | 115.29 | 38.51 | baoding |
添加地理位置:geoadd key lng lat member[lng lat member...]
# 增加地理位置信息
1 | 127.0.0.1:6379> geoadd cities:locations 116.28 39.55 beijing |
获取地理位置:geopos key memeber[member...]
# 获取地理位置信息
1 | 127.0.0.1:6379> geopos cities:locations tianjin |
计算距离:geodist key member1 member2 [unit]
# 获取两个地理位置的距离 # unit: m(米)、km(千米)、mi(英尺)、ft(尺)
1 | 127.0.0.1:6379> geodist cities:locations tianjin beijing km |
获取周边:georadius
1 | georadius key longitude radiusm|km|ft|mi [withcoord] [withdist] [withhash] [COUNT count] [asc|desc] [store key][storedist key] |
示例:计算在北京150km以内的城市
1 | cities:locations beijing 150 km georadiusbymember |
keys *
#遍历所有的key
1 | 127.0.0.1:6379 > set hello world |
keys [pattern]
#遍历所有的key,指定模式(通配符)进行筛选
1 | 127.0.0.1:6379 > mset hello world hehe haha php good phe his |
keys
命令一般不在生产环境中使用,因为生产环境数据庞大,而redis又是单线程机制,keys命令是o(n)复杂度,执行会很慢,且容易阻塞其他命令。keys *
怎么用:热备从节点(在从节点上使用)、scan(使用scan命令)
dbsize
#计算key的总数
该命令可以随便使用,因为redis内置了计数器,会实时更新keys总数,而不需要遍历所有数据。
1 | 127.0.0.1:6379 > mset k1 v1 k2 v2 k3 v3 k4 v4 |
exists key
#检查key是否存在
返回值:存在返回1,不存在返回0
1 | 127.0.0.1:6379 > set a b |
del key [key...]
#删除指定key-value,可删除多个
返回值:删除成功返回1,key不存在返回0
1 | 127.0.0.1:6379 > set a b |
expire key seconds
#key在seconds秒后过期
ttl key
#查看key剩余的过期时间,-1代表没有过期时间,-2代表key不存在
persist key
#去掉key的过期时间
1 | # 示例1 |
type key
#返回key的类型
返回值:string、hash、list、set、zset、none(key不存在)
1 | 127.0.0.1:6379 > set a b |
了解命令的时间复杂度对我们使用Redis的API是非常有帮助的,因为我们需要在合理的场景以及合理的数据规模下进行使用。
命令 | 时间复杂度 |
---|---|
keys | O(n) |
dbsize | O(1) |
del | O(1) |
exists | O(1) |
expire | O(1) |
type | O(1) |
Redis对外主要提供了5种数据结构,分别是string、hash、list、set、sorted set
并且对于每一种数据结构redis都提供了至少2种相应的内部编码实现以应对不同的使用场景。
在Redis源码内部有一个redisObject
这样的对象或者说结构体,redis使用redisObject
表示所有的key-value。
它里面有很多属性,比较重要的就是数据类型type和编码方式encoding,如图所示。
redis在一个瞬间只会执行一条命令,所有命令以串行方式进行排列,等待顺序执行。如图所示当第一个get命令执行完成之后才会执行第二个get命令,其实这就是redis单线程最简单的一个表现。
理解redis单线程非常重要,同时这也是redis设计的精髓所在。
redis会将所有数据放在内存中,而内存的响应速度是非常快的。所以redis之所以能达到如此高性能是依赖于内存的。
其实无论你做了很多优化或者说代码的优化实际它的本质是数据放在内存当中。
redis使用了epoll模型作为IO多路复用的实现,redis自身也实现了事件处理,将epoll的连接、读写、关闭转换为自身的事件,不在网络IO上浪费过多的时间。
这其实也是单线程的特性,很多时候如果多线程没有达到一个合理的使用时,它甚至比单线程还要慢。
fysnc file descriptor
和close file descriptor
操作时也会单独开一个线程,了解即可key:对于redis而言所有的key都是一个字符串
value:实际可以为字符串、数字、二进制、json串
字符串的value最大限制为512MB,其实已经足够大了,实际使用最好不要过大,建议在100k以内。
还有很多很多应用场景,只要你掌握了这样的API就可以结合你的应用场景进行思考,然后进行API的选用来实现你的功能。
1 | get key # 获取key对应的value |
1 | 127.0.0.1:6379 > set hello "world" |
1 | 127.0.0.1:6379 > get counter |
incr userid:pageview
(单线程 : 无竞争)
redis是天然适合做计数器的,因为是单线程的,所以并发执行incr的时候不会有竞争问题,无论并发量多大都不会记错数。
很多网站或者应用会使用incr或者decr这样的命令来做计数器,非常简单但是非常实用。
incr id
(原子操作)
分布式id生成器,即多个应用并发访问获取的id是自增且不重复的,仍然可以使用incr这样的命令来实现,当然实际的实现方案会比这个要复杂一些,但是最基础的一个实现的思路或者原理都是使用这样一个规则。
1 | set key value # 不管key是否存在,都进行设置 |
其实这些都是set命令及选项的组合,使用这种组合命令好处是将多个操作作为一个原子操作来执行,就不会存在并发竞争的问题,在实现一些场景比如分布式锁是非常有用的。
1 | 127.0.0.1:6379 > exists php |
1 | mget key1 key2 key3... # 批量获取key,原子操作 |
mget和mset是批量操作,时间复杂度都是o(n),是非常方便且能提高性能的命令,但是一定要节制去使用。
使用时需要注意的是对于大数据量的获取,最好将其拆分成多个mget操作,例如获取10万个key操作,每次获取1000个,执行100次mget操作。
1 | 127.0.0.1:6379 > mset hello world java best php good |
n次get命令 = n次网络时间 + n次命令时间
1次mget操作 = 1次网络时间 + n次命令时间
这里需要注意的是网络时间,因为客户端和服务端通常是在不同机器甚至是不同的机房、不同的地区,所以网络时间通常是一个很大的开销,而命令本身开销是非常小的,redis大部分命令执行速度都非常快,那么网络时间就显得非常珍贵。
所以使用mget这样的操作可以省去大量的网络时间。在很多场景下它的效率是非常高的,当然如果网络时间越长、获取key的个数越多效果越明显。
1 | getset key newvalue |
其中strlen
命令的时间复杂度也是o(1),redis字符串内部也会对字符串长度进行实时更新,不需要遍历字符串来计算完整的长度。
1 | 127.0.0.1:6379 > set hello world |
1 | 127.0.0.1:6379 > incr counter |
命令 | 含义 | 复杂度 |
---|---|---|
set key value | 设置key-value | O(1) |
get key | 获取key-value | O(1) |
del key | 删除key-value | O(1) |
setnx setxx | 根据key是否存在设置key-value | O(1) |
incr decr | 计数 | O(1) |
mget mset | 批量操作key-value | O(n) |
key仍然是一个字符串,value其实分为两个部分,field代表某个属性,value代表属性的值。在哈希结构中,可以添加一个新的属性和值,也可以修改或者删除某个属性,这与字符串有很大的不同。如果用字符串实现新增属性的话需要将整个value取出来做一个反序列化,然后添加属性后重新序列化存入Redis中。
实际上哈希是一个Mapmap的结构,也就是外层是一个key-value结构,对于value内部而言又是一个map结构。
对于内部的map结构而言,field不能相同,而value可以相同。
1 | hget key field # 获取hash key对应field的value |
1 | 127.0.0.1:6379 > hset user:1:info age 23 |
1 | hmget key field1 field2... fieldN # 批量获取hash key的一批field对应的值 |
1 | 127.0.0.1:6379 > hmset user:2:info age 30 name kaka page 50 |
小心使用 hgetall
命令,它会返回所有的field和value,如果你的hash key存入了很多属性比如一万条,使用该命令执行速度就会非常慢,牢记redis是单线程的。大多数情况下应该都不需要把所有的属性都取出来。
1 | hgetall key # 返回hash key对应所有的field和value |
1 | user:2:info hgetall |
1 | hsetnx key field value # 设置hash key对应field的value(如field已经存在,则失败) |
hincrby user:1:info pageview count
定义一个含有用户ID的key,然后在value中我们增加了pageview属性,用它来记录该用户的访问量。这种方式与字符串不同的是,字符串一个key只能存储访问量,如果还需要存储用户的其他属性,需要再单独定义相应的key,而使用哈希则可以存储用户的完整信息。
将用户的ID作为key,然后它的value是将所有的属性作为一个整体序列化的结果,比如是一个json串。如果需要获取就将value取出进行反序列化成相应的对象,如果需要重新写入,就将修改后的对象重新序列化写回给redis
用户ID和每个属性作为一个key,属性值作为value进行存储。这样更新某个属性值就非常方便,而且添加新的属性也不会影响原有的key,缺点是用户的信息不是一个整体,不便于管理。
就是我们介绍的使用hash的方式,将所有的属性和值作为hash的value进行存储,可以单独更新或者删除某个属性,并且也可以很方便的添加新的属性。
命令 | 优点 | 缺点 |
---|---|---|
string v1 | 编程简单 可能节约内存 | 1. 序列化开销大 2.设置属性要操作整个数据 |
string v2 | 直观 可以部分更新 | 1. 内存占用较大 2. Key较为分散 |
hash | 直观 节约空间 可以部分更新 | 1. 编程稍微控制 2. ttl不好控制 |
命令 | 复杂度 |
---|---|
hget hset hdel | O(1) |
hexists | O(1) |
hincyby | O(1) |
hgetall hvals hkeys | O(n) |
hmget hmset | O(n) |
key仍然是一个字符串,value是一个有序队列,且可以重复,左右两边插入弹出。
可以计算列表的长度-llen
,删除列表中指定的某个元素-lrem
,获取子列表-lrange
,按照索引获取列表值-lindex
1 | rpush key value1 value2 ...valueN # 从列表右边插入值(1-N个),O(1~N) |
命令演示效果如图
1 | lpop key # 从列表左侧弹出一个item |
lpop、rpop、lrem命令演示效果
ltrim命令演示效果
ltrim命令在做一些大的列表删除时是非常有用的,假设数据量是上百万,如果直接执行del key会将redis阻塞掉,我们可以使用ltrim每次裁剪10万分之一数据,最后达到一个删除的效果。
1 | lrange key start end(包含end) # 获取列表指定索引范围所有item,O(n) |
命令演示效果如图
1 | lset key index newValue # 设置列表指定索引|值为newValue,O(n) |
1 | 127.0.0.1:6379 > rpush mylist a b c |
实际上就是微博的时间轴功能,会将你关注的用户最新的微博按照从新到旧的顺序来排列,这和我们的列表是非常类似的,可以使用类似lpush、rpush、lpop、rpop这样的功能,还可以按照每10页做一个分页。
比如微博列表顺序,就是以关注用户的微博ID作为key,而用户的如微博内容、点赞数等等信息则可以另外存储到一个比如哈希结构里,通过微博ID就可以进行一个关联。
假设你关注的人更新了微博,就可以使用lpush
命令去更新list数据,使用lrange
命令获取最新的10条微博,然后通过hmget
命令去哈希中取出微博的具体内容。
执行这两个命令会有一个阻塞的过程,如果设置了非0的timeout,那么当列表为空时,命令不会立即返回结果,而是阻塞等待直到超时,或者当有新的值被插入时就会进行获取并返回。在实现类似消息队列功能时会非常有帮助。
1 | blpop key timeout |
key仍然是一个字符串,value就是一个集合,它可以将若干个字符串进行一个组合。集合的特点是无序,无重复元素,同时支持集合间操作,比如求并集、交集和差集。
1 | sadd key element... O(1~n) # 向集合key添加一个或多个element(如果element已经存在,添加失败) |
注意smembers
命令,它返回的结果是无序的,由于它会返回集合中所有的元素,需要小心使用。如果集合内元素过多,可以使用sscan
命令进行扫描。
1 | scard user:1:follow = 4 # 计算集合大小 |
1 | 127.0.0.1:6379 > sadd uesr:1:follow it news his sports |
可以将所有满足条件的用户存入到集合中,只需要存入一个用户唯一标识即可。然后使用spop
命令或者srandmember
进行随机元素弹出,弹出的用户就作为中奖用户。当活动结束后将集合进行删除。
如果用户进行了Like、赞、踩,我们就可以将该用户放入到这个微博或者新闻的赞、踩集合当中。需要展示赞、踩的用户列表时就可以进行取出,当然这个也可以使用其他数据类型来实现。
我们可以给用户添加标签,也可以给标签添加用户,看看关心这个标签的都有哪 些用户。实际上这两个操作应该是同事务下的,可以使用事务来结合使用,关于事务后续章节会介绍。
其实就是计算集合间的差集(sdiff)、交集(sinter)和并集(sunion),也可以把计算结果保存在指定key中。
1 | sdiff user:1:follow user:2:follow = music his # 差集 |
这个在微博或者其他一些社交网站是用的比较多的,比如展示用户间共同关注的好友,共同关注的兴趣等等都可以使用集合间API来进行实现。
key仍然是一个字符串,value就是一个集合,在有序集合中一个元素又包含了score和value,score用于排序可重复,而value不可以重复。
有序集合每个元素都带有分数,分数可以重复,以分数作为排序规则,有序集合中的成员在集合中的位置是有序的
有序集合可以实现很多集合实现不了的功能,同时它的API相对于集合来说时间复杂度是普遍偏高的,因为它需要体现一个有序的概念,所以并不是说集合就毫无用处了。
集合 | 有序集合 |
---|---|
无重复元素 | 无重复元素 |
无序 | 有序 |
element | element+score |
集合 | 有序集合 |
---|---|
可以有重复元素 | 无重复元素 |
有序 | 有序 |
element | element+score |
1 | zadd key score element(可以是多对) |
1 | 127.0.0.1:6379 > zadd player:rank 1000 ronaldo 900 messi 800 c-ronaldo 600 kaka |
1 | zrange key start end [withscores] # 返回指定索引范围内的升序元素[分值],O(log(N)+M) |
1 | 127.0.0.1:6379 > zadd player:rank 1000 ronaldo 900 messi 800 ronaldo 600 kaka |
排行榜功能在很多应用都是普遍存在的,例如音乐排行榜、电影排行榜、文章排行榜、热门视频等等。类似这种场景就可以使用有序集合来实现。
可以使用zadd
去添加元素和初始分数,然后使用zincrby
实现分数的更新,使用zrem
将一些元素删除榜外,使用zrangebyscore
获取一定范围分数的榜单等等。
那么这里最核心的就是分数具体代表什么,例如最新榜单可以使用timeStamp作为分数,销售量可以使用saleCount,关注量使用followCount。然后使用相关的API进行业务操作,也可以对多个集合进行汇总根据一定的规则作为类似综合排序的结果。
对于一些不太常用的命令或者不太典型的命令进行查缺补漏。
1 | zrevrank key element |
操作类型 | 命令 |
---|---|
基本操作 | zadd zrem zcard zincrby zscore |
范围操作 | zrange zrangebyscore zcount zremrangebyrank |
集合操作 | zunionstore zinterstore |
golang中比较好用的第三方开源redisclient有:
两个都是非常优秀的redisclient库,也是redis官网上推荐,我选择是的是go-redis,因为go-redis封装了redis的大部分命令,不用关心redis的命令的细节,直接调用相应接口就行;redigo是基于命令的,发送一个命令,然后在解析reply;所以相对而言,我觉得go-redis接口更友好。
1 | package main |
其实这个参数是比较难确定的,举个例子:
对于适合的PoolSize而言,我们需要考虑
REmote DIctionary Server
ANSI C
语言编写的开源数据库key-value
数据库VMware
主持Pivotal
赞助官网:https://redis.io/
中文官网:http://www.redis.cn/
redis的作者,叫Salvatore Sanfilippo
,来自意大利的西西里岛,2008年这个作者在做一个网站实时统计系统LLOOGG,就是用来统计用户访问网站的记录,最开始采用的是MySQL来实现,可能是由于MySQL的特性不太适用于这个场景,或者是当时机器不太好,导致他未能实现这个功能,所以他就开发了redis第一个版本来实现这个功能。
Redis的作者并不满足只将redis用于这一款产品,而是希望有更多的人来使用它,于是同一年将Redis开源发布,短短几年时间Redis就在国内外拥有了庞大的用户群体。直到今天,Redis仍然是一个非常优秀的存储服务系统。
假如说我们现在问Redis作者一个问题,有哪些公司在使用Redis,我想他可能会开玩笑的说,Who is not using Redis?言下之意难道还有公司不在使用Redis吗。当然这也只是一句玩笑,但是从侧面证明Redis的使用确实是非常广泛的。
如图是Redis内部使用的一个redisObject
对象的结构,redis使用redisObject
表示所有的key和value。
Redis数据类型有:string
(字符串)、list
(链表)、set
(无序集合)、sorted set
(有序集合)、hash
(散列类型)
来看一个典型的场景,一个用户访问一个App Server
,首先App Server
会从cache
中去获取,如果cache
中有需要的数据,就直接返回给App Server
,然后返回给用户。
如果cache
中没有会从数据源Storage
中去获取真实数据,为了下次方便在cache
中获取相同的数据,我们会将Storage
中的数据存到cache
中,然后最终将Storage
中的数据返回给用户。
实际当中cache可以是很多种类型,例如本地缓存、memcache等,这里我们就使用Redis来表示。
如图像微博的转发数和评论数,都是可以使用Redis来完成这样一个功能,redis有提供incr
这样的命令可以在单线程下非常高效的进行计数,而且不会有计数错误的问题。所以像很多视频网站都会使用redis来对视频的播放数来进行一个记录,这些计数对产品决策以及上层的决策是非常有帮助的。
消息队列系统在很多的公司已经成为项目中开发的一个标配,成熟的消息队列系统有很多,例如activemq、rabbitmq等。
而Redis也提供了像发布订阅、阻塞队列来实现类似的模型。在实现一些对消息队列功能不是很强要求的一些系统时,可以使用Redis来实现。
Redis也可以实现类似排行榜的功能,Redis提供了一个有序集合对完成排行榜这样的功能是非常有帮助的。
可以说Redis和社交网络是天然吻合的,很多媒体社交功能都可以使用redis实现,例如粉丝数、关注数、共同关注、时间轴列表等。
最典型的如布隆过滤器,可以使用Redis提供的位图功能来实现布隆过滤器这样的功能,在对于一些垃圾邮件过滤、实时系统的处理会非常有帮助。
官方给出的数据是10w OPS,也就是每秒可以实现10万次读写,虽然官方给出的数据通常会偏高一些,但是从实际使用来看达到万级别的OPS是基本没有问题的,当然这也和使用的方法以及具体的数据是有关的,这里我们不做具体讨论。
Redis之所以这么快有这么几点原因,数据是存在内存中,源代码使用C语言编写,代码短小精悍,使用的是线程模型是单线程。
这里使用单线程主要是因为内存读写速度非常快,使用单线程能达到很高的性能,很多实际开发当中,多线程往往会成为我们并发的瓶颈。
1 | 数据存在哪? => 内存 |
其实速度块最主要的原因就是内存。无论代码写的多好,或者使用什么线程模型,如果数据是存在一个比较慢的介质当中,那性能也不会很高。
相信学过计算机专业的同学对下面这张图一定不陌生,该图展示了计算机存储的各个介质,从上到下包括寄存器、一级缓存、二级缓存、内存、本地硬盘、远程硬盘,从上到下它的速度是由快至慢,容量由小到大,价格由高到低。
内存与硬盘对比
类型 | 每秒读写次数 | 随机读写延迟 | 访问带宽 |
---|---|---|---|
内存 | 千万级 | 80ns | 5GB |
SSD盘 | 35000 | 0.1-0.2ms | 100~300MB |
机械盘 | 100左右 | 10ms | 100MB左右 |
我们知道Redis是将数据保存在内存中,而内存的数据不具有持久化的特性,也就是说当机器发生断电时,是无法对内存的数据进行恢复的,为此Redis就提供了持久化的功能。
Redis所有数据保存在内存中,对数据的更新将异步的保存到磁盘上。
Redis提供了RDB
和AOF
两种方式对数据进行持久化。
Redis提供了5种主要的数据结构:String、Hash、List、Set、Sorted Set
除此之外,在Redis的后续版本以及迭代当中,也提供了一些其他的(衍生的)数据结构:
BitMaps:
位图(本质是字符串)
HyperLogLog:
超小内存唯一值计数(本质是字符串)
GEO:
地理信息定位(Redis3.2提供,本质是有序集合)
由于Redis受到了很多公司的支持,以及Redis提供了一个非常简单的基于TCP的通信协议,所以说非常多的编程语言都主动去支持Redis服务器。包括但不限于以下所有语言,所以这也是Redis非常受欢迎的原因之一
Redis除了提供5种主要数据结构以外,还提供了很多其他的功能,像发布订阅可以实现很多消息的功能,同时还支持Lua脚本,这样可以实现一些自定义命令,同时还支持简单的事务功能、最后Redis也支持pipeline来提高客户端的并发效率。
总之如果将Redis使用好的话,它会像一把瑞士军刀一样无所不能。
Redis的单机核心代码只有23000行,redis3.0提供了更丰富的集群分布式功能(代码大约4-5万行),如果想真正吃透Redis这个项目,都可以去阅读它的源代码,甚至有些企业可以去修改它的源代码来实现自己业务的定制化。
Redis的简单还体现在它不依赖外部库(like libevent),同时它是一个单线程模型,这意味着无论是服务端还是客户端的开发都会相对容易一些。
Redis提供了主从复制的功能,也就是说在Redis中有两种角色,主服务器和从服务器,主服务器的数据可以同步到从服务器中,这样可以为高可用以及分布式提供一个很好的基础。
本身Redis的单点或者说Redis的主从复制模型对于实现一个高可用的数据库来说是比较困难的甚至是几乎不可能的。
因此Redis在2.8版本当中提供了Redis-Sentinel
这样的功能来支持高可用。
其次对于一个数据库来说,分布式功能对于当下的企业也是非常重要的,所以Redis从3.0版本开始正式支持分布式,也就是Redis-Cluster
1 | 高可用 => Redis-Sentinel(v2.8) 支持高可用 |
官方下载地址:http://download.redis.io/releases/
或者执行命令:wget http://download.redis.io/releases/redis-3.0.7.tar.gz
推荐3版本使用3.0.7 最稳定
1 | # 安装依赖 |
事实上redis的作者并未对windows平台进行很好的支持,也就是说Redis官方并未提供windows的版本。
Redis的windows版本是由微软的开源团队来维护的,地址:https://github.com/MicrosoftArchive/redis/releases
从Releases或者Tags寻找对应的版本,下载zip包,然后解压缩。
使用命令行进入redis目录,执行./redis-server.exe
启动redis
在Redis解压缩目录的src目录下有一些可执行文件,对它们的说明如图
最简启动(使用默认配置启动):./redis-server
动态参数启动(指定端口启动):./redis-server --port 6380
配置文件启动:./redis-server ../redis/redis.conf
生产环境通常选择配置文件启动
单机多实例配置文件可以用端口区分开
直接执行redis-server
命令是在前端启动,前端启动好处是方便即时查看日志,缺点是当前终端无法继续其他操作。
线上一般采用后台启动方式,这样可以让redis日志打印到指定文件中。
后台启动建议修改redis.conf配置文件,将 daemonize no
修改为yes
然后使用配置文件启动: ./redis-server ../redis/redis.conf
执行命令:redis-cli -h ${ip} -p ${port} -a ${password}
如果没有设置密码,则 -a
参数可以省略;
如果端口为6379则 -p
参数可以省略;
如果连接本机,则 -h
参数可以省略;
执行命令:redis-cli -h ${ip} -p ${port} -a ${password} shutdown
其实就是在客户端连接命令后加上shutdown即可。
不推荐使用 kill -9 PID
命令进行关闭,该命令不会自动触发持久化操作,容易造成缓存数据丢失。
对Redis客户端返回值进行一个简单的说明,了解客户端的返回值对我们的后续开发以及日常运维也是非常有帮助的。
Redis有非常多的配置项,这里只介绍一些最基础的配置,更多的配置会伴随着Redis的更深入的学习进行介绍。比如AOF和RDB的配置、慢查询日志配置、内存管理配置等等。
是否是守护进程(no|yes),默认为no,通常设置为yes,保证redis进程能在后台启动并进行正常的日志打印。
配置示例:daemonize yes
Redis对外端口号,默认端口是6379,对于单机多实例通常需要进行配置
配置示例:port 6382
Redis工作目录,默认redis-server
启动时会在当前目录生成或读取dump.rdb
,也就是说如果在根目录下执行redis-server ${redis.conf}
的话,读取的是根目录下的dump.rdb
,为了使redis-server
可在任意目录下正常执行和读取数据,需要修改dir为绝对路径。
配置示例:dir "/usr/local/redis/data"
需要先在redis目录下执行 mkdir data
Redis系统日志,默认没有配置,配置该项会将redis的日志打印到指定文件下
配置示例:logfile "redis-6382.log"
当启动redis时会在redis目录下自动生成该文件
密码配置,redis在生产环境中通常都会设置密码以保证一定的安全性,在配置文件中搜索requirepass
,将 #requirepass foobared 打开注释,foobared修改为redis的密码即可
配置示例:requirepass 123456
主从配置项,用于从服务器配置对应主服务的IP和端口号。找到配置项slaveof
,取消注释,将后面的 masterip 和 masterport 修改为主服务器的IP和端口号,当前这台服务器即作为从服务器使用。
配置示例:slaveof 192.168.0.102 6379
这里提一下如果配置了主从服务器,主服务器配置了密码,那么从服务器就必须配置该项,否则无法进行正常复制。
找到表示主服务器的密码配置项#masterauth
,取消注释,将
配置示例:masterauth 123456
1 | # 查看所有配置(key - value各占一行),redis客户端下执行 |
如果想保障消息百分百投递成功,只做到前三步不一定能够保障。有些时候或者说有些极端情况,比如生产端在投递消息时可能就失败了,或者说生产端投递了消息,MQ也收到了,MQ在返回确认应答时,由于网络闪断导致生产端没有收到应答,此时这条消息就不知道投递成功了还是失败了,所以针对这些情况我们需要做一些补偿机制。
具体使用哪种要根据业务场景和并发量、数据量大小来决定
进行数据的入库
比如我们要发送一条订单消息,首先把业务数据也就是订单信息进行入库,然后生成一条消息,把消息也进行入库,这条消息应该包含消息状态属性,并设置初始值比如为0,表示消息创建成功正在发送中,这种方式缺陷在于我们要对数据库进行持久化两次。
首先要保证第一步消息都存储成功了,没有出现任何异常情况,然后生产端再进行消息发送。如果失败了就进行快速失败机制。
MQ把消息收到的结果应答(confirm)
给生产端
生产端有一个Confirm Listener
,去异步的监听Broker
回送的响应,从而判断消息是否投递成功,如果成功,去数据库查询该消息,并将消息状态更新为1,表示消息投递成功。
假设第二步OK了,在第三步回送响应时,网络突然出现了闪断,导致生产端的Listener就永远收不到这条消息的confirm应答了,也就是说这条消息的状态就一直为0了。
(Retry Send)
,也就是从第二步开始继续往下走针对这种情况如何去做补偿呢,可以有一个补偿系统去查询这些最终失败的消息,然后给出失败的原因,当然这些可能都需要人工去操作。
对于第一种方案,我们需要做两次数据库的持久化操作,在高并发场景下显然数据库存在着性能瓶颈。其实在我们的核心链路中只需要对业务进行入库就可以了,消息就没必要先入库了,我们可以做消息的延迟投递,做二次确认,回调检查。
当然这种方案不一定能保障百分百投递成功,但是基本上可以保障大概99.9%的消息是OK的,有些特别极端的情况只能是人工去做补偿了,或者使用定时任务去做都可以。
使用第二种方式主要目的是为了减少数据库操作,提高并发量。
Upstream Service
上游服务也就是生产端,Downstream service
下游服务也就是消费端,Callback service
就是回调服务。
(Second Send Delay Check)
,即延迟消息投递检查,这里需要设置一个延迟时间,比如5分钟之后进行投递。confirm
消息,也就是回送响应,但是这里响应不是正常的ACK,而是重新生成一条消息,投递到MQ中。Callback service
是一个单独的服务,其实它扮演了第一种方案的存储消息的DB角色,它通过MQ去监听下游服务发送的confirm
消息,如果Callback service
收到confirm
消息,那么就对消息做持久化存储,即将消息持久化到DB中。Callback service
还是去监听延迟消息所对应的队列,收到Check消息后去检查DB中是否存在消息,如果存在,则不需要做任何处理,如果不存在或者消费失败了,那么Callback service
就需要主动发起RPC通信给上游服务,告诉它延迟检查的这条消息我没有找到,你需要重新发送,生产端收到信息后就会重新查询业务消息然后将消息发送出去。这么做的目的是少做了一次DB的存储,在高并发场景下,最关心的不是消息100%投递成功,而是一定要保证性能,保证能抗得住这么大的并发量。所以能节省数据库的操作就尽量节省,可以异步的进行补偿。
其实在主流程里面是没有这个Callback service的,它属于一个补偿的服务,整个核心链路就是生产端入库业务消息,发送消息到MQ,消费端监听队列,消费消息。其他的步骤都是一个补偿机制。
第二种方案也是互联网大厂更为经典和主流的解决方案。
简单来说就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的。
我们可以借鉴数据库的乐观锁机制来举个例子
当消费者消费完消息时,在给生产端返回ack时由于网络中断,导致生产端未收到确认信息,该条消息会重新发送并被消费者消费,但实际上该消费者已成功消费了该条消息,这就是重复消费问题。
消费端实现幂等性,就意味着,我们的消息永远不会消费多次,即使我们收到了多条一样的消息
业界主流的幂等性操作:
整个思路就是首先我们需要根据消息生成一个全局唯一的ID,然后还需要加上一个指纹码。这个指纹码它并不一定是系统去生成的,而是一些外部的规则或者内部的业务规则去拼接,它的目的就是为了保障这次操作是绝对唯一的。
将ID + 指纹码拼接好的值作为数据库主键,就可以进行去重了。即在消费消息前呢,先去数据库查询这条消息的指纹码标识是否存在,没有就执行insert操作,如果有就代表已经被消费了,就不需要管了。
对于高并发下的数据库性能瓶颈,可以跟进ID进行分库分表策略,采用一些路由算法去进行分压分流。应该保证ID通过这种算法,消息即使投递多次都落到同一个数据库分片上,这样就由单台数据库幂等变成多库的幂等。
我们都知道redis是单线程的,并且性能也非常好,提供了很多原子性的命令。比如可以使用 setnx
命令。
在接收到消息后将消息ID作为key执行 setnx
命令,如果执行成功就表示没有处理过这条消息,可以进行消费了,执行失败表示消息已经被消费了。
使用 redis 的原子性去实现主要需要考虑两个点
关于不落库,定时同步的策略,目前主流方案有两种,第一种为双缓存模式,异步写入到缓存中,也可以异步写到数据库,但是最终会有一个回调函数检查,这样能保障最终一致性,不能保证100%的实时性。第二种是定时同步,比如databus同步。
生产端发送消息到Broker,然后Broker接收到了消息后,进行回送响应,生产端有一个Confirm Listener
,去监听应答,当然这个操作是异步进行的,生产端将消息发送出去就可以不用管了,让内部监听器去监听Broker给我们的响应。
ch.NotifyPublish(make(chan amqp.Confirmation))
<-confirmChan
,监听成功和失败的返回结果,根据具体的结果对消息进行重新发送、或记录日志等后续处理!1 | package main |
1 | package main |
先启动消费端,访问管控台:http://127.0.0.1:15672,检查Exchange和Queue是否设置OK,然后启动生产端,消息被消费端消费,生产端也成功监听到了ACK响应。
1 | # 消费端打印 |
什么时候会 confirm.Ack
为false呢,比如磁盘写满了,MQ出现了一些异常,或者Queue容量到达上限了等等
也有可能两个方法都不走,比如生产端发送消息就失败了,或者Broker端收到消息在返回ack时中途出现了网络闪断。
这种情况就需要定时任务去抓取中间状态的消息进行最大努力尝试次数的补偿重发,从而保障消息投递的可靠性。
Golang里面采用 AMQP 来连接 rabbitmq
, 使用之后发现这个库比较底层,只提供协议的封装。这个库用到生产环境不合适,包装了一层以提供更加稳定的功能, 代码地址
1 | package main |
Return Listener
用于处理一些不可路由的消息!
我们的消息生产者,通过指定一个Exchange 和Routingkey,把消息送达到某一个队列中去, 然后我们的消费者监听队列,进行消费处理操作!
但是在某些情况下,如果我们在发送消息的时候,当前的exchange不存在或者指定的路由key路由不到,这个时候如果我们需要监听这种不可达的消息,就要使用Return Listener
ch.NotifyReturn(make(chan amqp.Return))
,生产端去监听这些不可达的消息,做一些后续处理,比如说,记录下消息日志,或者及时去跟踪记录,有可能重新设置一下就好了Mandatory
:如果为true,则监听器会接收到路由不可达的消息,然后进行后续处理,如果为false,那么broker端自动删除该消息!1 | package main |
1 | package main |
先启动消费端,访问管控台:http://127.0.0.1:15672,检查Exchange和Queue是否设置OK,然后启动生产端。
由于生产端设置的是一个错误的路由key,所以消费端没有任何打印,而生产端打印了如下内容
1 | ---------handle return---------- |
如果我们将 Mandatory
属性设置为false,对于不可达的消息会被Broker直接删除,那么生产端就不会进行任何打印了。如果我们的路由key设置为正确的,那么消费端能够正确消费,生产端也不会进行任何打印。
RabbitMQ提供了一种qos
(服务质量保证)功能,即在非自动确认消息的前提下,如果一定数目的消息 (通过基于consume或者channel设置Qos的值) 未被确认前,不进行消费新的消息。
需要注意:
1.不能设置自动签收功能(autoAck = false)
2.如果消息没被确认,就不会到达消费端,目的就是给消费端减压
func (ch *Channel) Qos(prefetchCount, prefetchSize int, global bool)
prefetchCount:
一次最多能处理多少条消息,通常设置为1prefetchSize:
单条消息的大小限制,消费端通常设置为0,表示不做限制global:
是否将上面设置应用于channel,false代表consumer级别
prefetchSize
和global
这两项,rabbitmq没有实现,暂且不研究
prefetchCount
在 autoAck=false
的情况下生效,即在自动应答的情况下这个值是不生效的
func (d Delivery) Ack(multiple bool) error
手工ACK,调用这个方法就会主动回送给Broker一个应答,表示这条消息我处理完了,你可以给我下一条了。参数multiple
表示是否批量签收,由于我们是一次处理一条消息,所以设置为false
生产端就是正常的逻辑
1 | package main |
关闭autoACK,进行限流设置
1 | package main |
我们先注释掉手工ACK方法,然后启动消费端和生产端,此时消费端只打印了一条消息
1 | body: Hello RabbitMQ Send QOS message! |
这是因为我们设置了手工签收,并且设置了一次只处理一条消息,当我们没有回送ack应答时,Broker端就认为消费端还没有处理完这条消息,基于这种限流机制就不会给消费端发送新的消息了,所以消费端只打印了一条消息。
通过管控台也可以看到队列总共收到了5条消息,有一条消息没有ack。
将手工签收代码取消注释,再次运行消费端,此时就会打印5条消息的内容。
当我们设置 autoACK=false
时,就可以使用手工ACK方式了,那么其实手工方式包括了手工ACK与NACK。
当我们手工 ACK
时,会发送给Broker一个应答,代表消息成功处理了,Broker就可以回送响应给生产端了。NACK
则表示消息处理失败了,如果设置重回队列,Broker端就会将没有成功处理的消息重新发送。
消费端进行消费的时候,如果由于业务异常我们可以手工 NACK
并进行日志的记录,然后进行补偿!
方法:func (d Delivery) Nack(multiple, requeue bool) error
如果由于服务器宕机等严重问题,那我们就需要手工进行 ACK
保障消费端消费成功!
方法:func (d Delivery) Ack(multiple bool) error
对消息设置自定义属性以便进行区分
1 | package main |
关闭自动签收功能
1 | package main |
先启动消费端,然后启动生产端,消费端打印如下,显然第一条消息由于我们调用了NACK,并且设置了重回队列,所以会导致该条消息一直重复发送,消费端就会一直循环消费。
1 | num: 0 |
一般工作中不会设置重回队列这个属性,我们都是自己去做补偿或者投递到延迟队列里的,然后指定时间去处理即可。
Time To Live
的缩写,也就是生存时间1 | err = ch.Publish( |
1 | ttl := map[string]interface{}{ |
dead-letter-exchange
(dead message)
之后,它能被重新publish到另一个Exchange,这个Exchange就是DLX1 | Exchange: dlx.exchange |
arguments["x-dead-letter-exchange"]="dlx.exchange"
,这样消息在过期、requeue、 队列在达到最大长度时,消息就可以直接路由到死信队列!1 | package main |
1 | package main |
启动消费端,此时查看管控台,新增了两个Exchange,两个Queue。在test_dlx_queue
上我们设置了DLX,也就代表死信消息会发送到指定的Exchange上,最终其实会路由到dlx.queue
上。
此时关闭消费端,然后启动生产端,查看管控台队列的消息情况,test_dlx_queue
的值为1,而dlx_queue
的值为0。
10s后的队列结果如图,由于生产端发送消息时指定了消息的过期时间为10s,而此时没有消费端进行消费,消息便被路由到死信队列中。
实际环境我们还需要对死信队列进行一个监听和处理,当然具体的处理逻辑和业务相关,这里只是简单演示死信队列是否生效。
需要自己扩展安装延迟插件.
注意,RabbitMQ是无法手动指定清除哪一个消息的。只能等它触发,通过业务逻辑排除。
注意,消息是先进入交换机,路由,然后暂时放在别的地方,并没有进入队列。等时间到了才进入延迟队列。
插件下载地址
下载延迟插件
https://bintray.com/rabbitmq/community-plugins/rabbitmq_delayed_message_exchange/v3.6.x#files/
将下载的插件放入RabbitMQ的扩展目录
/usr/lib/rabbitmq/lib/rabbitmq_server-3.6.5/plugins
启用扩展
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
注意,官网下载的
rabbitmq_delayed_message_exchange-20171215-3.6.x.ez
有问题,无法使用。mq 3.6.5版本还是使用rabbitmq_delayed_message_exchange-0.0.1.ez
自己编写Dockerfile
rabbitmq_delayed_message_exchange-0.0.1.ez
1 | FROM rabbitmq:3.6.5-management |
1 | package main |
1 | package main |
启动消费端,此时查看管控台,延迟交换机以及延迟队列。
此时关闭消费端,然后启动生产端,查看管控台队列的消息情况,delay.queue
队列显示0条信息。等15s后,显示1条记录。
Exchange:交换机,接收消息,并根据路由键转发消息到绑定的队列
如图为官网提供的模型,蓝色框表示Send Message
,Client端把消息投递到Exchange上,通过RoutingKey路由关系将消息路由到指定的队列,绿色框代表Receive Message
,Client端和队列建立监听,然后去接收消息。红色框代表RabbitMQ Server
,黄色框表示RoutingKey
,即Exchange和Queue需要建立绑定关系。
一般有这四种:
Headers Exchange
,很少使用,是通过消息头进行路由的,通常我们都使用前三种。
Name:
交换机名称
Type:
交换机类型,direct、topic、 fanout、 headers
Durability:
是否需要持久化
Auto Delete:
当最后一个绑定到Exchange上的队列删除后,自动删除该Exchange
Internal:
当前Exchange是否用于RabbitMQ内部使用,默认为False
Arguments:
扩展参数,用于扩展AMQP协议定制化使用
交换机跟队列一定是先创建声明出来的,否则投递的消息会丢失。交换机可以不声明,此时默认使用系统自带的(AMQP default)
。在哪里声明创建都可以,可以在生产者,也可以在消费者。
交换机可以绑定多个routing key,以此来对应不同的队列。同时一个队列也可以被多个routing key绑定。(exchange->routing key->queue)
生产者只需要在意投递哪一个交换机的哪一个routing key即可。消费者只需要在意从哪个队列获取数据。
交换机以及队列的声明创建,只要曾经做过,就会一直存在(如果没有设置autoDelete
或者exclusive
为true)
交换机,routing和队列的绑定,只要曾经做过,绑定就会一直存在(如果没有设置队列的autoDelete
或者exclusive
为true)
一个交换机只能有一种交换机类型,就是最开始创建时设定的交换机类型。后期如果再次声明但是交换机类型不同,将会报错。
当有多个消费端时,当一个消费端拿出一个消息,另一个消费端是拿不到这个消息的。正常情况下可以保证不会被多消费。
直连方式,所有发送到 Direct Exchange
的消息被转发到RouteKey中指定的Queue
注意:Direct模式可以使用RabbitMQ自带的Exchange:default Exchange,所以不需要将Exchange进行任何绑定(binding)操作,消息传递时,RouteKey必须完全匹配
才会被队列接收,否则该消息会被抛弃。
看一下 Direct Exchange
的图解,其实意思就是说指定了RoutingKey的消息会被投递到绑定关系与该key值相同的队列上。
指定投递的Exchange和相应的RontingKey进行发送消息
1 | package main |
1 | package main |
先启动消费端,刷新管控台,在Exchange目录下可以看到我们声明的exchange以及type
点击该exchange可以看到和队列的绑定关系
然后启动生产端,此时消费端控制台进行了打印,共消费了两条消息,说明监听的两个队列都接收到了消息。
1 | Hello World RabbitMQ Direct Exchange Message ... |
如果修改值为:test.direct111,此时在启动生产端,消费端就收不到消息了,这就是直连的方式。
一个交换机可以有多个routing key。一个队列也可以被多个rouing key所绑定,一个routing也可以绑定多个队列。例如,当一个routing key绑定2个队列时,此时如果有生产者投递一个消息到该交换机的该routing key,此时2个队列都会存入这个消息。
路由的算法很简单 —— 交换机将会对binding key
和routing key
进行精确匹配,从而确定消息该分发到哪个队列。
下图能够很好的描述这个场景:
在这个场景中,我们可以看到direct
交换机 X和两个队列进行了绑定。第一个队列使用orange
作为binding key,第二个队列有两个绑定,一个使用black
作为binding key,另外一个使用green
。
这样以来,当消息发布到routing key为orange
的交换机时,就会被路由到队列Q1。routing key为black
或者green
的消息就会路由到Q2。其他的所有消息都将会被丢弃。
也称之为多个绑定(Multiple bindings)
多个队列使用相同的binding key是合法的。这个例子中,我们可以添加一个X和Q1之间的绑定,使用black
为binding key。这样一来,direct
交换机就和fanout
交换机的行为一样,会将消息广播到所有匹配的队列。带有routing key为black
的消息会同时发送到Q1和Q2。
所有发送到 Topic Exchange
的消息被转发到所有关心RouteKey中指定Topic的Queue上
Exchange将RouteKey和某Topic进行模糊匹配,此时队列需要绑定一个Topic
注意:可以使用通配符进行模糊匹配
1 | 符号 "#" 匹配一个或多个词 |
看一下 Topic Exchange
的图解,意思是说我们有4个队列,它们的绑定关系分别是usa.#
、#.news
、#.weather
、europe.#
对于第一个队列而言,它只关系以usa.
开头的相关消息。比如发送的第一条消息是usa.news
,那么这条消息会同时匹配上队列1和队列2,所以两个队列都能接收到,其他消息也是同样的规则,这里就不继续展开说了。
指定投递的Exchange和相应的RontingKey进行发送消息
1 | package main |
声明一个Topic Exchange
,声明队列,建立交换机和队列的绑定关系
1 | package main |
先启动消费端,同样可以在管控台可以看到我们新声明的exchange以及它的绑定队列,这里不再细说。然后启动生产端,此时消费端控制台进行了打印,共消费了三条消息,说明三条消息都和队列指定的Topic匹配上了,因为使用的是 #
匹配
1 | Hello World RabbitMQ Topic Exchange Message ... |
将消费端指定的RoutingKey进行修改:routingKey = "user.*";
然后重新启动消费端,注意此时该队列绑定了两个RoutingKey,那么生产者无论匹配到哪个都可以将消息投递到该队列中。我们在管控台将原来的路由规则进行解绑,如图所示,点击Unbind
按钮。
再次启动生产端,此时消费端控制台打印了两条消息,说明最后一条消息:user.delete.abc
没有匹配上,因为使用的是 *
匹配,这就是 Topic Exchange
的路由方式。
来看一下 Fanout Exchange
的图解,意思就是消息不走任何的路由规则,只有队列和交换机有绑定关系就能收到消息
不设置路由键直接发送消息到Fanout Exchange
1 | package main |
声明一个Fanout Exchange
,声明队列,建立交换机和队列的绑定关系,绑定关系不使用RoutingKey
1 | package main |
先启动消费端,然后启动生产端,消息被成功消费,这种就是Fanout Exchange
,它不走任何的路由规则,直接将消息路由到所有与它绑定的队列。
1 | Hello World RabbitMQ Fanout Exchange Message ... |
RabbitMQ是一个开源的消息代理和队列服务器,用来通过普通协议在完全不同的应用之间共享数据,RabbitMQ是使用Erlang语言来编写的,并且RabbitMQ是基于AMQP协议的。
官网:http://www.rabbitmq.com/
国内主要有滴滴、美团、头条、去哪儿、艺龙等互联网大厂都在使用RabblitMQ,主要原因如下:
最主要的原因就是因为RabbitMQ使用的开发语言是Erlang语言。Erlang语言最初在于交换机领域的架构模式,这样使得RabbitMQ在Broker之间进行数据交互的性能是非常优秀的。
RabbitMQ的作者在开发之前使用Erlang语言做了一个网络交换机的小程序,然后他发现Erlang语言有一个非常好的特点:Erlang有着和原生Socket一样的延迟。
基于这个特点,我们就有充分选择RabbitMQ的理由,因为在选择MQ的时候,有一个非常重要的考量指标,就是消息发送到MQ的延迟以及响应速度如何。任何一个架构的选型者或者设计者都会考虑到这一点。就是说这个MQ延迟怎么样,吞吐量怎么样,性能如何。
AMQP全称: Advanced Message Queuing Protocol
,即高级消息队列协议。是具有现代特征的二进制协议。是一个提供统一消息服务的应用层标准高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。
Publisher application
就是生产者应用服务,它把生产的消息发送到Server
端,这个Server可以认为是RabbitMQ节点,消息首先会经过指定的Virtual host
(虚拟主机),然后到达指定的Exchange
(交换机)。
Exchange
和Message Queue
(消息队列)是有一个绑定关系的,Exchange
会根据一定的规则转发消息到绑定的队列上。
Consumer application
就是消费者应用服务,它只需要去监听Message Queue
(消息队列),有消息就取出来消费就好了。
又称Broker,接受客户端的连接,实现AMQP实体服务
连接,应用程序与Broker的网络连接
网络信道,几乎所有的操作都在Channel中进行,Channel是进行消息读写的通道。客户端可建立多个Channel,每个Channel代表一个会话任务。
消息,服务器和应用程序之间传送的数据,由Properties和Body组成。Properties可以对消息进行修饰,比如消息的优先级、延迟等高级特性;Body则就是消息体内容。
虚拟主机(虚拟地址),用于迸行逻辑隔离,最上层的消息路由。一个Virtual Host里面可以有若干个Exchange和Queue,同一个VirtualHost里面不能有相同名称的Exchange或Queue
交换机,接收消息,根据路由键转发消息到绑定的队列
Exchange和Queue之间的虚拟连接,binding中可以包含routing key
一个路由规则,虚拟机可用它来确定如何路由一个特定消息
也称为Message Queue,消息队列,保存消息并将它们转发给消费者
左边的P代表Producer,消息生产者,右边的C代表Consumer,消息消费者。中间就是我们的RabbitMQ节点。生产者将消息投递到了X即交换机上,然后交换机把消息传递到了红色部分即消息队列。
生产者只需要关注将消息传递到指定的Exchange即可,而消费者也只需要监听指定的队列就可以了。Exchange会通过路由规则将消息路由到指定的一个或多个队列上供消费者进行消费。
官网地址:http://www.rabbitmq.com/
github地址:https://github.com/rabbitmq/rabbitmq-server/releases
初学建议采用RPM包安装方式,需要下载三个安装包,这里直接给出下载地址
rabbitmq下载地址:http://www.rabbitmq.com/releases/rabbitmq-server/
erlang下载地址:http://www.rabbitmq.com/releases/erlang/
socat下载地址:http://repo.iotti.biz/CentOS/7/x86_64/socat-1.7.3.2-5.el7.lux.x86_64.rpm
1 | # 在linux下直接执行命令下载 |
1 | # 安装基础依赖 |
1 | vim /usr/lib/rabbitmq/lib/rabbitmq_server-3.6.5/ebin/rabbit.app |
这是rabbitmq的核心配置文件,在这里可以查看到rabbitmq默认监听的端口号是5672。找到loopback_users
的配置项,去除guest
的尖括号和双引号,然后保存退出。
服务的启动:rabbitmq-server start &
服务的停止:rabbitmqctl stop
查看进程:lsof -i:5672
管理插件:rabbitmq-plugins enable rabbitmq-management
执行命令 rabbitmq-plugins enable rabbitmq_management
就可以启用管控台。
默认启用端口是15672,通过ip+端口进行访问如:http://127.0.0.1:15672/
最好先关闭下iptables规则:iptables -F && iptables -t nat -F
访问成功后需要输入用户名和密码进行登录,统一输入guest
即可。登录成功后界面如图所示
1 | version: '2' |
1 | # 关闭应用 |
1 | # 添加用户 |
1 | # 创建虚拟主机 |
1 | # 查看所有交换信息 |
1 | # 查看所有队列信息 |
1 | # 移除所有数据,要在rabbitmqctl stop_app之后使用 |
我们来实现一个最简单的消息生产与消费案例,构建生产者和消费者模型。也就是说生产者发送消息,投递到RabbitMQ中,然后消费者去监听队列,获取数据进行消费。
1 | package main |
1 | package main |
需要先声明创建队列才能投放生产消息以及消费消息(否则消息会丢失)。此时队列已经创建好了,就可以先启动生产者再启动消费者,同样可以完成消费。
这里有个问题就是在生产者投递消息时需要指定exchange
,但是我们指定的是空,为什么消息可以被正确投递到队列test001
中呢。
这是因为如果生产者在投递消息时不指定exchange
,那么会使用rabbitmq默认的exchange,可以通过管控台看到第一个就是,其他的都是rabbitmq内部的交换机。
这个AMPQ default
交换机的路由规则是按照指定的routingKey
去MQ中查找是否有相同名称的队列,如果有就将消息路由到该队列中,如果没有消息就发送失败。
Exchange和Exchange、Queue之间的连接关系
两个交换机之间也能建立连接关系,但是这么做的话调用节点就会长一点,比如消息发送到第一个Exchange,然后路由到第二个Exchange,然后通过第二个Exchange再发送到绑定的队列。
Binding中可以包含RoutingKey或者参数
Publishing
中的(Headers,Properties,Body)结构体组成1 | type Publishing struct { |
DeliveryMode:
送达模式,可以设置为持久化或者非持久化Headers:
自定义属性,可以自定义一些属性设置到headers中ContentType:
消息内容格式ContentEncoding:
消息字符集Priority:
优先级,值0-9,值越大优先级越高CorrelationId:
通常作为消息唯一ID,在做一些ACK、消息路由、幂等会使用到它ReplyTo:
做重回队列时,可以指定消息失败了返回到哪个队列Expiration:
消息过期时间,超过该时间没有被消费消息就会消失MessageId:
消息ID在发送消息时,设置一些消息的常用属性及自定义属性
1 | package main |
在消费端可以去获取到消息的自定义属性
1 | package main |
启动生产端,刷新管控台,查看队列test001
已经有了5条消息,过10秒钟后再次刷新队列消息变成了0条,说明消息过期属性生效了。
至于deliveryMode属性的验证,可以将消息的过期时间设置去掉,然后启动生产端,此时队列有了5条消息,关闭rabbitmq服务,重新启动查看管控台队列依然有5条消息,说明消息被成功持久化存储了。
此时启动消费端,5条消息被成功消费,并且打印了消息的自定义属性。
1 | Consumer Received a message: Hello World! |
Virtual Host
里面可以有若干个Exchange和QueueVirtual Host
里面不能有相同名称的Exchange或QueueVirtual host是一个逻辑的概念,主要用来划分具体的服务的,比如A服务可以把消息路由到 /a 的虚拟主机上,B服务可以把消息路由到 /b 虚拟主机上。
就类似于redis划分的16个DB,它也是一个逻辑的概念,比如给redis分配了16GB的内存,那对于DB0来说它可以将这些内存都占满,而不是说每个DB会被物理划分为1G内存。
ActiveMQ是Apache出品,最流行的,能力强劲的开源消息总线,并且它是一个完全支持JMS规范的消息中间件。其丰富的API、多种集群构建模式使得他成为业界老牌消息中间件,在中小型企业中应用广泛!
当然现在可能用的相对比较少了,因为ActiveMQ性能和其他的主流MQ相比是比较一般的,早期在传统行业为王的时代它是比较流行的,现如今对于一些高并发、大数据的应用场景随处可见,在MQ的选择上如果再使用ActiveMQ往往就比较力不从心了。
MQ衡量指标:服务性能、数据存储、集群架构
ActiveMQ它的性能不是特别的好,面对超大规模的并发时,就有可能出现各种各样的小问题,比如阻塞、消息堆积过多、产生一些延迟等等。
从数据存储来看ActiveMQ采用 KahaDB
这种存储方式作为默认的持久化方案。当然也可以选择使用高性能的Google的 LevelDB
这种基于内存的,或者说想要保证消息百分百可靠的话也可以选择一些关系型数据库如 MySQL
ActiveMQ流行了这么多年,其API包括相关的组件以及集成都是非常的完善的。所以说如果不是特别大的并发场景下,ActiveMQ也是比较不错的选择。其集群架构模式也非常不错。
ActiveMQ最简单的分为两种集群架构模式,一种是 Master-Slave
模式,即主备模式(左图)。它利用 zookeeper
进行两个节点之间的协调,也有可能是更多的节点,其中有一个是主节点,它是对外提供服务的,另外的节点启动但是不对外提供服务。当主节点挂掉时,利用 zookeeper
进行高可用的切换,把 slave
节点切换成主节点,继续对外提供服务。
还有一种集群模式是 NetWork
模式(右图),它其实本质上就是两组主备模式的集成,然后中间用网关进行连接配置,就可以实现分布式的集群了。
Kafka是LinkedIn开源的分布式发布订阅消息系统,目前归属于Apache顶级项目。Kafka主要特点是基于Pull的模式来处理消息消费,追求高吞吐量,一开始的目的就是用于日志收集和传输。0.8版本开始支持复制,不支持事务,对消息的重复、丢失、错误没有严格要求,适合产生大量数据的互联网服务的数据收集业务。
kafka最初设计时就是面向大数据方向的,主要用于日志收集,所以在使用kafka时,要注意业务是否允许出现消息重复、丢失、错误等问题,如果允许那使用kafka是性能最高的。它能够在廉价的服务器上也能支持单机每秒100k条数据以上的吞吐量。
kafka的高性能读写主要是借力于操作系统底层的提供的 PageCache
功能。而且kafka完全没有进行内存和磁盘的数据同步的烦恼,它仅仅使用内存的存储。只要你有足够的内存就能承载很大的数据。
kafka的集群模式也依赖于 zookeeper
,让zookeeper进行节点的协调和管理。每个kafka节点之间可以进行 replicate
(副本的复制),对于某个节点的数据会依次同步到集群的其他节点上,这样只要部署方案合理,即使某个节点挂掉,其他节点的数据也依然存在。
RocketMQ是阿里开源的消息中间件,目前也已经孵化为Apache顶级项目,它是纯Java开发,具有高吞吐量、高可用性、适合大规模分布式系统应用的特点。RocketMQ思路起源于Kafka,它对消息的可靠传输及事务性做了优化,目前在阿里集团被广泛应用于交易、充值、流计算、消息推送、日志流式处理、binglog分发等场景。
RocketMQ在早期2.X版本也是使用zookeeper做协调,在后续3.X版本中放弃了使用zookeeper,而是自行实现了一套 Name Server
去做集群之间的管理和协调工作。
RocketMQ的特点在于它能够保障消息的顺序性,提供了丰富的消息拉取和处理模式,支持订阅者进行水平扩展,实时消息订阅的机制,以及它能承载上亿级别的消息堆积能力。
它的集群架构也有很多种,比如 Master-Slave
模式,双Master模式、双主双从(2M2S)模式,多主多从模式。它的集群架构可选的方案是非常多的。而且RacketMQ刷盘策略也很多,比如说同步刷写、异步复制。它的存储方式借鉴了很多优秀的开源技术,比如zerocopy、linux的ext4文件系统等。
左边和右边分别是生产者集群和消费者集群, Name Server
就是自行实现的用于替代zookeeper的程序。中间就是两主两从服务集群,可以实现主从的自动切换,数据之间也可以采用 replicate
机制。
当然这些优秀的机制在Apache RocketMQ项目里面其实是不提供的,比如还有解决分布式事务使用MQ进行解耦,为什么没有呢?因为阿里的RocketMQ商业版是收费的,需要购买相关的产品才可以使用到这些功能。所以这也是使用RocketMQ的一个痛点。
RabbitMQ是使用Erlang语言开发的开源消息队列系统,基于AMQP协议来实现。AMQP的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。AMQP协议更多用在企业系统内,对数据一致性、稳定性和可靠性要求很高的场景,对性能和吞吐量的要求还在其次。
RabbitMQ它的性能虽然不及kafka,但是要比ActiveMQ高出很多,而且可以做一些性能优化。并其可靠性和安全性非常好,数据可以保证百分百不丢失。RabbitMQ集群可以构建很多组,实现异地双活架构。每一个节点存储方式可以采用内存(ram)或者磁盘(disk),所以是非常灵活的。
图中显示的是三个RabbitMQ节点作为一组集群,当然也可以有很多组。节点之间呢采用 Mirror Queue
(镜像队列)的方式,基于这种方式可以保证数据百分百不丢失。前端可以进行一个负载均衡,例如 HA-proxy
进行TCP级别的负载,配合 keepAlived
做一个高可用的配置。前端增加一个虚拟的VIP,通过访问VIP让请求路由到一个负载均衡组件,然后再往下路由到一个RabbitMQ节点。
这就是整个RabbitMQ的集群架构,它能够实现非常完善、非常高可用、并且性能也非常好,而且稳定性超强。它有各种各样的集群恢复的手段,比如节点挂点了、甚至是磁盘损坏了它也能去进行修复。这是RabbitMQ非常好的一个点。
]]>VRRP(Virtual Router Redundancy Protocol,虚拟路由器冗余协议)将可以承担网关功能的一组路由器加入到备份组中,形成一台虚拟路由器,这样主机的网关设置成虚拟网关,就能够实现冗余。
VRRP将局域网内的一组路由器划分在一起,称为一个备份组。备份组由一个Master路由器和多个Backup路由器组成,功能上相当于一台虚拟路由器。
VRRP备份组具有以下特点:
虚拟路由器具有IP地址,称为虚拟IP地址。局域网内的主机仅需要知道这个虚拟路由器的IP地址,并将其设置为缺省路由的下一跳地址。
网络内的主机通过这个虚拟路由器与外部网络进行通信。
备份组内的路由器根据优先级,选举出Master路由器,承担网关功能。其他路由器作为Backup路由器,当Master路由器发生故障时,取代Master继续履行网关职责,从而保证网络内的主机不间断地与外部网络进行通信。
当PC想发送报文到外网时,需要先经过网关,若Master无法正常工作,此时VIP会主动漂浮到Backup。但是由于之前使用的是Master作为网关,此时各个客户端(PC)里arp缓存表里记录的是Master的Mac地址,此时依旧会将报文发往Master。
所以需要在客户端(PC)与网关之间引入一个软件,来使用VMAC。VMAC来自行进行选择真实可用的网关Mac。
当Master故障时,会主动将VIP浮动到Backup上,此时Backup会发送广播包,通知所有客户端(gratuitous arp),更新此时VIP对应的Mac地址为Backup。
VIP作为子网卡漂浮在真实网卡上
KeepAlived就是对VRRP协议的具体实现,并且最先开始是为了LVS的DS以及RS做的KeepAlived。KeepAlived放在DS上,它不仅仅可以检测DS集群上的健康状态,更可以直接检测该DS后的RS集群的健康状态,这样有故障的RS就不会被DS转发到。
1 |
|
1 | cat /etc/keepalived/keepalived.conf |
2台Linux,一主一备
节点 | IP | 系统 |
---|---|---|
node1(Master) | 192.168.184.30 vip:192.168.184.100 | CentOS 7 |
node2(Backup) | 192.168.184.40 vip: 192.168.184.100 | CentOS 7 |
1 | ! Configuration File for keepalived |
1 | ! Configuration File for keepalived |
如果先启动Backup上的KeepAlived时,此时Backup会先查找该局域网内是否有Master节点,如果没有,此时它会认为Master可能已经掉线。会将VIP漂浮在自己的真实网卡上作为子网卡,将自己提升为Master,并且通知局域网内所有客户端更新mac(gratuitous arp)。
当Master启动KeepAlived后,会通知Backup,此时Backup会自行将等级从Master下降为Backup,此时由Master作为Master节点,并且通知局域网内所有客户端更新mac(gratuitous arp)。如果不想Master上线后下降Backup从Master下降为Backup,在配置文件中添加nopreempt
即可
节点 | IP | 系统 |
---|---|---|
DS(Master) | 192.168.184.100(VIP) 192.168.184.30(DIP) | CentOS 7 |
DS(Backup) | 192.168.184.100(VIP) 192.168.184.40(DIP) | CentOS 7 |
RS1 | 192.168.184.50(RIP) | CentOS 7 |
Rs2 | 192.168.184.60(RIP) | CentOS 7 |
安装服务
1 | yum -y install ipvsadm keepalived |
Master配置
1 | ! Configuration File for keepalived |
Backup配置
1 | ! Configuration File for keepalived |
注意,KeepAlived本身就是为了LVS制作的,所以在KeepAlived的配置文件里配置了LVS规则,就不用再使用ipvsadm编写规则了。
无论是Master或者Backup启动KeepAlived时,都会马上给本机加上ipvsadm的规则,只是Backup的真实网卡上没有子网卡VIP罢了
当监听到其中一台RS没有相应时,KeepAlived会操作ipvsadm,将该节点移除出规则。当节点恢复后,KeepAlived又会操作ipvsadm将该节点添加回ipvsadm规则里。
与lvs负载均衡-NAT与DR模型原理介绍
中的DR模式配置大体相同
1 |
|
LVS是Linux Virtual Server的缩写,从字⾯意思上翻译,LVS应该译为”Linux虚拟服务器”。通过LVS实现负载均衡集群的⽅案属于”软件⽅案”。它是作用在 第四层-传输层 上,也就是只在意 IP+端口。
Ipvs:工作在内核空间,实现集群服务的调度;借鉴了iptables的实现
Ipvsadm:工作在用户空间,负责为ipvs内核框架编写规则,定义谁是集群服务,谁是后端服务器。
类比Ipvs就是netfilter,Ipvsadm就是iptables
一般我们的应用,也就是放置在应用层的,都是用户空间。除了应用层,其他层都是在内核空间。
如果看不懂这张图,则表明不知道什么是iptables。建议可以点击这里简单了解一下Iptables,服用效果更好哦~
当客户端访问服务时,会访问VIP+端⼝,所以,客户端的请求报⽂会发往调度器,请求报⽂会先经过PREROUTING
链,然后进⾏路由判断,由于此刻报⽂的⽬标IP为VIP,而VIP对于调度器来说,就是本身的IP,所以报⽂会经过INPUT
链(②)。此刻,如果IPVS发现报⽂访问的VIP+端⼝与我们定义的LVS集群规则相符,ipvs则会根据定义好的规则与算法,将报⽂直接发往POSTROUTING
链,然后报⽂则会发出,最后到达后端的Real Server中。
简称 | 全称 | 解释 |
---|---|---|
DS | Director Server | 目标服务器,即负载均衡器 |
RS | Real Server | 真实服务器,即后端服务器 |
VIP | Vrtual IP Address | 直接面向用户的IP地址,通常为公网IP |
DIP | Director Server IP | 主要用于和内部主机通信的IP地址 |
RIP | Real Server IP | 后端真实服务器的IP地址 |
CIP | Client IP | 客户端IP |
类型 | 解释 |
---|---|
NAT | 修改目标IP地址为后端的Real Server的IP地址 |
DR | 修改目标MAC地址为后端的Real Server的MAC地址 |
TUNNEL | 较少使用,常用于异地容灾 |
NAT:Network Address Teanslation 网络地址转换
LVS NAT:修改目标IP地址为挑选出新的RS的IP地址。即请求进入负载均衡器时做DNAT
,响应出负载均衡器时做SNAT
Director Server会维护一个转换表,Dnat的时候记录VIP转为RIP(INPUT时),Snat的时候直接读取记录将RIP转为VIP(PREROUTING)
顺序:先创建集群服务,再添加节点
ipvs规则并不是永久有效的,重启后ipvs规则列表就会清空
参数 | 作用 |
---|---|
-C | 清空集群服务 |
-L | 查看IPVS规则 |
-Z | 计数器清空 |
-S | 使用ipvsadm -S 保存规则至磁盘 |
-R | 使用ipvsadm -R 从磁盘载入规则 |
参数 | 作用 |
---|---|
-A | 添加集群服务 |
-E | 修改集群服务 |
-D | 删除集群服务 |
-s | 指定调度算法,例如rr/wrr/lc/wlc等 |
-t | 指定协议为tcp |
-u | 指定协议为udp |
参数 | 向指定的集群 |
---|---|
-a | 向指定的集群服务添加RS(根据IP+端口区分集群) |
-r | 指明RS的IP地址,包含IP:PORT(不写默认使用集群设置的端口) |
-g | 指明LVS类型DR |
-i | 指明LVS类型TUN |
-m | 指明LVS类型为NAT |
-w | 指明RS的权重 |
-e | 修改指定的RS属性 |
-d | 删除RS |
-t | 指定协议为tcp |
-u | 指定协议为udp |
创建集群并且添加节点
1 | # 创建一个集群,集群名(规则)是1.1.1.1:3306,使用tcp协议,并且调度算法为轮询 |
基本指令
1 |
|
1 |
|
1 | # 安装扩展包 |
1 | vim /usr/share/nginx/html/index.html |
IP规划(皆为Centos7系统)
节点 | IP | 网卡模式 |
---|---|---|
DS | 192.168.147.100(VIP) 192.168.184.5(DIP) | VIP为仅主机 ,DIP为NAT |
RS1 | 192.168.184.10(RIP) | NAT |
RS2 | 192.168.184.20(RIP) | NAT |
RS3 | 192.168.184.30(RIP) | NAT |
都用nat模式,公用一个虚拟交换机
1 |
|
net.ipv4.ip_forward = 1
1
sysctl -p
RS需要将网关指向DS的DIP,这样RS发送出去的报文才会指定由DS发出。
在DS上curl测试 vip:80
能否正常负载均衡即可。
配置ipvsadm
1 | # 创建集群 |
重点:Linux里,一台机子可以有多个网卡。lo本地回环网卡只对本机内部可见,对外部隐藏。但是Linux里,如果数据报文访问的IP在本机上,虽然本机网卡不是这个网段,但是可以检测本机上是否有符合该IP的网卡(包括lo网卡也会检测)。如果有,本机一样会进行接收该数据报文。真实网卡会接收报文。环回地址(lo),子网卡(eth0:0)不会接收外部数据报文。
注意:因为VIP,DS和所有的RS都有绑定。当报文请求VIP时,底层发送ARP寻找MAC地址,DS跟RS都会有响应。此时需要将RS上的ARP响应关闭,让VIP单独响应。这样报文的目标MAC才肯定是DS的MAC,报文才能一定访问DS。
注意:VIP并不要跟DIP和RIP在同一个网段,这里只是方便调试,所以设置在同一个网段。
原因是因为就算arp_ignore 设置为1,此时只是不去查询其他网卡的IP设置以及其他网卡上的子网卡。但是流入的当前网卡以及当前网卡的子网卡如果符合,还是会进行响应的。所以RS要把VIP设置在lo环回网卡的别名上(lo是127.0.0.1不好修改)。
arp_ignore: 控制系统在接收到外部的arp请求时,是否要返回arp响应。
arp_announce: 控制系统在对外发送arp请求时,如何选择arp请求数据包的源IP地址。
值 | 含义 |
---|---|
0 | 响应任意网卡上接收到的对本机IP地址的ARP请求 |
1 | 只响应目的IP地址为接收网卡所配置IP地址的ARP请求 |
2 | 只响应目的IP地址为接收网卡所配置IP地址的ARP请求,并且ARP请求的源IP必须和接收网卡同网段 |
Arp_ignore 设置为1,只会检测真实网卡上,以及该真实网卡上的别名网卡配置的IP地址是否符合,不会再去查询本机的其他网卡以及其他网卡上的子网卡否符合。
值 | 含义 |
---|---|
0 | 允许使用任意网卡上的IP地址作为ARP请求的源IP |
1 | 尽量避免使用不属于该发送网卡子网的本地地址作为发送ARP请求的源IP |
2 | 忽略IP数据包的源IP地址,选择该发送网卡上最合适的本地地址作为arp请求的源IP地址 |
1 |
|
1 |
|
IP规划(皆为Centos7系统)
节点 | IP | 网卡模式 |
---|---|---|
DS | 192.168.184.10(VIP) 192.168.184.40(DIP) | NAT |
RS1 | 192.168.184.10(VIP) 192.168.184.50(DIP) | NAT |
RS2 | 192.168.184.10(VIP) 192.168.184.60(DIP) | NAT |
DS添加VIP以及强制路由
1 | # 子网卡添加VIP |
配置ipvsadm
1 | # 创建集群 |
RS添加VIP
1 | # 子网卡添加VIP.只能自己在一个网段,自己和自己通信。从而不对任何其他的非本ip的arp包做出响应 |
RS对ARP进行限制
1 |
|
注意,测试的时候不要在DS上用mysql客户端进行测试,并没有在内部进行3306转发
为什么DS上的VIP绑定在ens33这个真实网卡上而不是lo本地环回网卡?
因为DS的VIP需要接收客户端发来的请求报文,本地环回网卡不会接收外部请求的数据包,所以绑定在真实网卡上作为子网卡。
为什么RS上的VIP绑定在lo本地环回网卡而不是ens33这个真实网卡上?
因为当lvs负载均衡选择后,原先客户端发来的数据包里的目的mac已经被修改成指定RS的mac了(其实是真实网卡ens33的的mac),此时数据包可以直接从RS的真实网卡ens33流入。绑定在lo网卡上,就是为了防止RS响应原先应该DS接收客户端发来的请求报文。
为什么RS要设置arp_announce以及添加路由?
arp_announce设置为2和添加vip出口网卡为lo:0实际上都是为了做同一件事。设置为2,数据包传入RS时,会先查询路由规则,得知根据查询后的结果,流入或响应时应该使用lo网卡,流入时ens33将数据包交给lo网卡流入,响应时lo网卡将数据包交给ens33响应。此时源IP为VIP(不设置arp_announce为2,此时源IP则为RIP )。
1 | #!/bin/ |
1 | #!/bin/ |
防⽕墙可以⼤体分为主机防⽕墙和⽹络防⽕墙。
主机防⽕墙:针对于单个主机进⾏防护。
⽹络防⽕墙:往往处于⽹络⼊⼝或边缘,针对于⽹络⼊⼝进⾏防护,服务于防⽕墙背后的本地局域⽹。
⽹络防⽕墙和主机防⽕墙并不冲突,可以理解为,⽹络防⽕墙主外(集体), 主机防⽕墙主内(个⼈)。
从物理上讲,防⽕墙可以分为硬件防⽕墙和软件防⽕墙。
硬件防⽕墙:在硬件级别实现部分防⽕墙功能,另⼀部分功能基于软件实现,性能⾼,成本⾼。
软件防⽕墙:应⽤软件处理逻辑运⾏于通⽤硬件平台之上的防⽕墙,性能低,成本低。
一般我们的应用,也就是放置在应用层的,都是用户空间。除了应用层,其他层都是在内核空间
iptables其实不是真正的防⽕墙,我们可以把它理解成⼀个客户端代理,⽤户通过iptables这个代理,将⽤户的安全设定执⾏到对应的”安全框架”中,这个”安全框架”才是真正的防⽕墙,这个框架的名字叫netfilter
netfilter才是防⽕墙真正的安全框架(framework),netfilter位于内核空间。
iptables其实是⼀个命令⾏⼯具,位于⽤户空间,我们⽤这个⼯具操作真正的框架。
包过滤机制是netfilter,管理工具是iptables。netfilter作用在网络层,netfilter是iptables底层实现的原理,iptables只是一个cli调用工具。
INPUT:处理入站数据包
OUTPUT:处理出站数据包
FORWARD:处理转发数据包
PREROUTING:选择路由前处理数据包
POSTROUTING:选择路由后处理数据包
到本机某进程的报⽂:PREROUTING –> INPUT
由本机转发的报⽂:PREROUTING –> FORWARD –> POSTROUTING
由本机的某进程发出报⽂(通常为响应报⽂):OUTPUT –> POSTROUTING
我们把具有相同功能的规则的集合叫做”表”,所以说,不同功能的规则,我们可以放置在不同的表中进⾏管理,⽽iptables已经为我们定义了4种表,每种表对应了不同的功能,而我们定义的规则也都逃脱不了这4种功能的范围,所以,学习iptables之前,我们必须先搞明⽩每种表的作⽤。
iptables为我们提供了如下规则的分类,或者说,iptables为我们提供了如下”表”
filter表:负责过滤功能,防⽕墙;内核模块:iptables_filter
nat表:network address translation,⽹络地址转换功能;内核模块:iptable_nat
mangle表:拆解报⽂,做出修改,并重新封装 的功能;iptable_mangle
raw表:关闭nat表上启⽤的连接追踪机制;iptable_raw
也就是说,我们⾃定义的所有规则,都是这四种分类中的规则,或者说,所有规则都存在于这4张”表”中。
但是我们需要注意的是,某些”链”中注定不会包含”某类规则”
PREROUTING 的规则可以存在于:raw表,mangle表,nat表。
INPUT 的规则可以存在于:mangle表,filter表,(centos7中还有nat表,centos6中没有)。
FORWARD 的规则可以存在于:mangle表,filter表。
OUTPUT 的规则可以存在于:raw表mangle表,nat表,filter表。
POSTROUTING 的规则可以存在于:mangle表,nat表。
raw 表中的规则可以被哪些链使⽤:PREROUTING,OUTPUT
mangle 表中的规则可以被哪些链使⽤:PREROUTING,INPUT,FORWARD,OUTPUT,POSTROUTING
nat 表中的规则可以被哪些链使⽤:PREROUTING,OUTPUT,POSTROUTING(centos7中还有INPUT,centos6中没有)
filter 表中的规则可以被哪些链使⽤:INPUT,FORWARD,OUTPUT
处理动作在iptables中被称为target(这样说并不准确,我们暂且这样称呼),动作也可以分为基本动作和扩展动作。
此处列出⼀些常⽤的动作:
ACCEPT:允许数据包通过。
DROP:直接丢弃数据包,不给任何回应信息,这时候客户端会感觉⾃⼰的请求泥⽜⼊海了,过了超时时间才会有反应。
REJECT:拒绝数据包通过,必要时会给数据发送端⼀个响应的信息,客户端刚请求就会收到拒绝的信息。
SNAT:源地址转换,解决内⽹⽤户⽤同⼀个公⽹地址上⽹的问题。
MASQUERADE:是SNAT的⼀种特殊形式,适⽤于动态的、临时会变的ip上。
DNAT:⽬标地址转换。
REDIRECT:在本机做端⼝映射。
1 | # 查看本机防火墙配置信息 |
VMware中常用三种网络连接模式,分别是桥接模式
,主机模式
,NAT模式
桥接模式就是把宿主机的网卡与虚拟机的虚拟网卡通过虚拟网桥连接起来,将宿主机比作一个交换机,那么虚拟机还有宿主机全部连接在这一个交换机上,且他们属于同一网段,互相可以通信并不影响。(此时虚拟机拥有跟宿主机局域网内相同网段的IP)
当局域网内不仅有宿主机,还有主机A,主机B。虚拟机不仅仅可以在虚拟机内互通,也可以虚拟机与宿主机,更可以直接访问到主机A,主机B互通。支持访问外网资源。(可以把虚拟机想象成与宿主机对等的真实机器)
真正设置的时候,最好是使用虚拟网络编辑器
里桥接模式指定要桥接的网卡
, 来指定宿主机用的是哪个网卡上网,从而达到相同模拟,获取dhcp的能力.
相当于在虚拟机网卡和宿主机虚拟网卡之间,加了一个交换机。
相当于无物理网卡的NAT模式,只有宿主机内部和虚拟机互相通讯,保证安全。
docker使用的是Linux Container,是一种内核虚拟化技术,可以提供轻量级的虚拟化,以便隔离进程和资源。
就是使用namespance技术,来隔离网络,进程等资源。
如果namespace不同,就已经被隔离,没有讨论的意义。所以以下是在同一个namespace下
docker默认的网络模式。原理跟vmware
的NAT模式相同。安装docker时,会给宿主机创建一个docker0
网卡,该网卡会与一个虚拟交换机相连,当容器以Bridge模式创建启动时,会给容器创建一个虚拟网卡,该网卡分配的IP与宿主机的docker0
所在同一个局域网内(一般是172.16.0.0)。然后过程就和vmware
的NAT模式完全相同。
直接使用宿主机的当前网络,容器暴露的端口可以直接在宿主机中查到,可以当成就是在宿主机真实执行的程序。
与指定的容器公用一个网络,所以这2个容器不能有一样的端口暴露。
不会给容器创建网卡,此时容器无法与外部通讯。
MAC系统下,安装docker后不会创建docker0
网卡,同时host模式也不会生效。原因是
1 | Docker For Mac的实现和标准Docker规范有区别,Docker For Mac的Docker Daemon`是运行于虚拟机(xhyve)中的, 而不是像Linux上那样作为进程运行于宿主机. |
Windows下安装docker,实际上是使用了Hyper-V
虚拟机来安装了Linux系统,然后再在其中安装了docker。所以host模式也是无效,不会暴露当前windows系统中,而是暴露到了虚拟机Linux中。
压力测试时模拟实际应用的软硬件环境以及用户使用过程的系统负荷,长时间或超大负荷地运行测试软件,来测试被测系统的性能、可靠性、稳定性等。
以Web为例,关注项
参数 | 含义 |
---|---|
-n | 请求总数 |
-c | 并发请求个数 |
-t | 测试所进行的最大秒数 |
-v | 显示详细信息 |
-k | 启用Keeplived功能。一个HTTP会话中执行多个请求 |
-p | post-file 包含了POST数据的文件 |
-i | 执行HEAD请求,只获取头信息 |
-h | 显示帮助信息 |
-c跟-n的不同:如果-n的值是20000,代表一共会发送20000个请求,-c是5,则表示这20000个请求分4000次顺序发送,每次并发发送5个请求。
1 | # 一共1000个请求,分10次顺序发送,每次并发发100个 |
Linux每个进程,当需要访问外部时,都会打开一个套接字进行信息交互,默认是一个进程可以同时打开1024个套接字。当c过大,导致当前连接总数量超过套接字数量时,就会报错(socket:Too many open files)。可以通过查询 ulimit -n 查询默认套接字数量以及ulimit -n 65534 更改套接字限制大小。
]]>当同一台机器访问本服务器瞬间请求过多时,Linux内核会自动开启保护机制,拒绝访问。此时设置为0关闭该保护机制。echo 0 > /proc/sys/net/ipv4/tcp_syncookies
HTTPS主要作用是:
(1)对数据进行加密,并建立一个信息安全通道,来保证传输过程中的数据安全;
(2)对网站服务器进行真实身份认证。
我们经常会在Web的登录页面和购物结算界面等使用HTTPS通信。使用HTTPS通信时,不再用http://
,而是改用https://
。另外,当浏览器访问HTTPS通信有效的Web网站时,浏览器的地址栏内会出现一个带锁的标记。对HTTPS的显示方式会因浏览器的不同而有所改变。
讲解之前,我们需要对加密
以及数字证书
有一个大体的认识。
对称一般分为对称加密与非对称加密
当使用相同的秘钥不仅可以进行加密,也可以进行解密时,我们可以称之为对称加密。
当使用两个不同的秘钥(例如A,B)分别可以进行加密解密时,我们可以称之为非对称加密。这两个密钥,也可以认为是一种加密模型。
A、B是拥有一定数学关系的一组秘钥。私钥
自己使用,不对外公开。公钥
给大家使用,对外公开。公钥
进行加密,私钥
对公钥
加密的内容进行解密
数字证书是可信任组织颁发给特定对象的认证。为我们提供HTTPS证书制作的厂商,我们都可以认为他们是可信任组织。当域名申请https通过后,他们就会颁发HTTPS证书,供下载认证使用,该证书全球同步认证。
注意,如果是个人自己制作的HTTPS证书(比如用Linux直接内部生成),是不会全球同步认证的,所以浏览器访问个人制作的HTTPS站点,会有警告提示。
数字证书里一般包括如下几个内容:
对象公开密钥
对象公开密钥
也就是公钥,这个很重要,圈起来要考的!
图上我们可以得知:HTTPS除了端口使用443,多了一个SSL安全参数握手
的过程,其他过程与HTTP并没有什么区别。
我们按照图上三个流程进行分解讲解:
流程1:
客户端浏览器A,随机生成一个随机数1,浏览器支持的协议版本以及加密算法,发送到服务器B。此时客户端浏览器A和服务器B都有相同的随机数1。
流程2:
服务器B接收到请求后,随机生成一个随机数2,返回原先申请HTTPS颁发的数字证书以及确定下来的加密算法。返回给客户端浏览器A。此时客户端浏览器A和服务器B都有相同的随机数1,2。
流程3:
客户端浏览器A将服务器B返回的证书进行确认是否有效(如果是个人制作的,浏览器查询出来就是无效),然后生成随机数3,然后使用流程2的数字证书里的对象公开密钥(公钥)
进行加密随机数3,将加密后的随机数3再发给服务器B。
流程4:
图上只有三个流程,不过流程3结束之后服务器B进行的处理,才是最重要的步骤。
此时服务器B使用HTTPS颁发的私钥,将客户端浏览器A发来的加密随机数3进行解密。此时客户端浏览器A和服务器B都有相同的随机数1,2,3。现在双方都根据随机数1,2,3通过流程1,2上确认的加密算法,生成对称密钥
,此时生成的对称密钥客户端浏览器A和服务器B肯定相同。所以他们最后就使用这个对称密钥进行加密解密,来加密接下来的通信过程。
我们可以看出,整合SSL安全参数握手不仅仅使用了非对称加密,也是用了对称加密。并且双方是自行生成最后的对称加密密钥,并没有使用在数据报文中传输这个密钥,保证密钥的安全性。
]]>走迷宫是一个比较经典的算法问题,它既可以使用广度优先算法,也可以使用深度优先算法,这2种算法的本质是不一样的。
广度优先算法:周围每个点都走一下,周围的点走往后,前往下一个点继续周围的点走一下,以此类推。都是点到为止,不多走,这样可以保证达到终点时,路径一定是最短的。不需要使用到递归。
深度优先算法:从一个点,一直走到底,走到不能走。需使用递归实现。
本文使用广度优先算法实现查找迷宫最短路径。
使用文件内规定好的迷宫,0是通路,1是阻碍,迷宫从起点走到终点。并且在走完后,可以告知能否到达终点以及具体路径。
迷宫
1.首先,需要先定义探索的方向顺序,我们将其排序为先上后左再下最后右。
2.一开始,从一个点开始,那么,我的周围将会有4个点等着我去探索。我们先从上开始走一步,走完后需要先退回来再走左边而不是继续顺着再往下探索
,否则就变成了深度优先算法了。第一轮后,周围四个点将会被我们探索一遍。
3.周围四个点技术后,我们再前进一个点,继续按照2的步骤进行探索,以此类推。
4.这样,当我们碰到终点时,路径一定会是最短的。
1.首先,我们需要做一个队列,可以使用切片来模拟。这个队列用来存放可以探测的点,然后每次都从这个队列将点取出,进行4个方向的探测,每一个可以探测的点,都再存放到这个队列里。
2.我们将地图坐标认为与数组下标是一致的。从起点开始,周围探测一圈,发现只有(1,0)
可以探测,将(1,0)
放入队列
3.从队列中取出(1,0)
,根据上左下右的顺序,我们发现(2,0)
,(1,1)
可以探测,于是我们按照顺序,依次存入队列。
4.以此类推,当我们走到(1,2)
时,根据上左下右的顺序,我们发现(0,2)
,(1,1)
,(2,2)
可以探测,(1,1)
已经走过了,废弃掉。所以我们将(0,2)
,(2,2)
按照顺序,依次存入队列。
5.最后,当探测的点等于终点时,证明迷宫可以走出。所以,我们还需要另一张地图,专门用来记录走的路径以及第几步。
以上面的地图为例,最终我们会获得这样一个二维数组。
那应该如何找到走过的路径?从终点开始,往回走,比如此时终点是13,那么就找12的点在哪。然后把终点替换为12,再去找11在哪里,以此类推,最终找到0,就找到了整个迷宫所走的路径。
迷宫获取文件 maze.ini
1 | 6 5 |
maze.go
为了防止数组越界报错以及方便使用,我们将坐标点做成一个结构体point
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191package main
import (
"fmt"
"os"
)
//从配置文件中读取行,列,以及地图规则
func getMap(row, col *int) [][]int {
file, err := os.Open("./maze.ini")
if err != nil {
panic(err)
}
fmt.Fscanf(file, "%d %d", row, col)
maze := make([][]int, *row)
for i, _ := range maze {
maze[i] = make([]int, *col)
for j, _ := range maze[i] {
fmt.Fscanf(file, "%d", &maze[i][j])
}
}
return maze
}
//构建二维切片
func getTwoSlice(row, col int) [][]int {
two_slice := make([][]int, row)
for i, _ := range two_slice {
two_slice[i] = make([]int, col)
}
return two_slice
}
//定义坐标类型,不过坐标类型需要依照二维数组下标的规则
type point struct {
i, j int
}
//上 左 下 右 四个方向
var directions = [4]point{
{-1, 0},
{0, -1},
{1, 0},
{0, 1},
}
//计算点计算后的结果
func (this point) addPoint(dir point) point {
//故意不设置为引用传递,这样不需要重新创建一个point返回
this.i += dir.i
this.j += dir.j
return this
}
//判断该点是否越界以及返回该点的值为多少
func (this *point) normalGetValue(myMap [][]int) (int, bool) {
//先比行
if this.i < 0 || this.i >= len(myMap) {
return -1, false
}
//再比列
if this.j < 0 || this.j >= len(myMap[0]) {
return -1, false
}
//返回具体的值
return myMap[this.i][this.j], true
}
//打印地图
func printMap(myMap [][]int) {
fmt.Println("开始打印地图:")
for _, value := range myMap {
for _, val := range value {
fmt.Printf("%d\t", val)
}
fmt.Println()
}
}
//判断地图是否找到终点
func findEndPointAndPath(myMap [][]int, end point) ([]point, bool) {
var queue []point
end_value := myMap[end.i][end.j]
if end_value == 0 {
//证明没有到达重点
return queue, false
}
//如果找到了,先将终点存入切片
queue = append(queue, end)
for {
if end_value < 0 {
//如果比0小,证明已经全找完了。
break
}
//此时end_value的值是我们想要找的值
end_value--
for _, dir := range directions {
//继续寻找周围符合的点
next := end.addPoint(dir)
//再判断该点是否会越界
data, ok := next.normalGetValue(myMap)
if !ok || data != end_value {
continue
}
//通过证明是我们要找的点,存到切片中
queue = append(queue, next)
//更换end点
end = next
break
}
}
return queue, true
}
//开始执行,传入地图以及当前步数地图。开始位置以及终点位置
func walk(maze, steps [][]int, start, end point) [][]int {
//1. 先将起点放入队列
queue := []point{start}
for {
if len(queue) == 0 {
//如果队列取完了,证明已经结束了,退出程序
break
}
//没有走完,就取出队列第一个数,进行判断。
first := queue[0]
queue = queue[1:]
for _, dir := range directions {
//运算后,计算出下个点的坐标
next := first.addPoint(dir)
if next == end {
//证明这个运算后的点就是重点了
//此时在步数地图进行记录,然后退出
steps[next.i][next.j] = steps[first.i][first.j] + 1
break
}
//判断该点是否符合要求
//前提,都需要该点没有越界
//1.判断在maze地图中是否为1,1是墙就跳过
res, ok := next.normalGetValue(maze)
if !ok || res == 1 {
continue
}
//2.判断在steps地图中是否已经走过了,已经走过证明不通或者别的点会走,直接跳过
res, ok = next.normalGetValue(steps)
if !ok || res != 0 {
continue
}
//3.判断是不是起点,如果是起点就跳过
if next == start {
continue
}
//如果全部通过,证明可以走
//把这个点加入队列,可以继续探测
//并且将步数进行记录
queue = append(queue, next)
steps[next.i][next.j] = steps[first.i][first.j] + 1
}
}
//将步数地图返回
return steps
}
func main() {
var row, col int
maze := getMap(&row, &col)
printMap(maze)
steps := getTwoSlice(row, col)
start := point{0, 0}
end := point{5, 4}
res := walk(maze, steps, start, end)
printMap(res)
path, ok := findEndPointAndPath(steps, end)
if ok {
fmt.Println("最短路径坐标:")
for i := len(path) - 1; i >= 0; i-- {
fmt.Printf("%v ", path[i])
}
} else {
fmt.Println("对不起,没有找到通往终点的路径")
}
}
1 | 开始打印地图: |
我们知道golang有值传递与引用传递两种传递方式。
值类型: 基本数据类型int系列,float系列,bool,string,数组和结构体struct
引用类型: 指针,slice切片,map,管道chan,interface等
这两者的区别是,值传递相当于是复制了一份。而引用传递,是复制了相同的指针地址。如下图所示:
基础类型的比较简单易懂
1 | func test(a *int) { |
我们可以看到,当*a = b
时,此时是改变了指针a
所指向地址的内容,所以,main
函数的a
的值从10
变成了20
.同时,我们发现test
函数中a
的指向地址与main
函数的a相同,没有发生改变。
如果我们将b
的地址替换掉a
指向的地址,又会发生什么事情呢?1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19func test(a *int) {
var b = 20
*a = b
fmt.Println(a) //0xc00001c098
fmt.Println(&a) //0xc00001c0a0
a = &b
fmt.Println(a) //0xc00001c0b0
*a = 30
fmt.Println(*a) //30
}
func main() {
var a = 10
fmt.Println(&a) //0xc00001c098
test(&a)
fmt.Println(&a) //0xc00001c098
fmt.Println(a) //20
return
}
我们可以看到,test
函数中将a
的指向地址改成了b
的地址,此时对a
进行的任何操作,对main
函数的a
不会产生任何影响,这是因为test
中a
指向的地址与main
函数中a的地址已经不一样了。
改变指针所指向地址的内容,会对所有指向该地址的指针产生影响。但是,如果将指针所指向的地址发生改变,那就不同了。这对golang的其他数据类型同样适用。
结构体可以由我们自己觉得它的方法是值传递还是引用传递,如果是引用传递,对结构体内部成员的修改才会生效。但是,如果我们想直接将当前结构体的一个实例在方法里替换成另一个实例要怎么做呢?
1 | func (this *MyStruct1) test(there MyStruct1) { |
此时this
跟str1
都是指针类型,指向同一个地址。而我们是将指针指向的地址进行了更换,所以跟上面基础类型
的情况一样,对外部是不造成影响的。
与基础类型的一样,我们只要将指向地址的值进行修改替换,就可以达到目的。
1 | func (this *MyStruct1) test(there MyStruct1) { |
现在我们将this
指向地址的内容更改为了there
,此时外部就会收到了影响。
切片的比较复杂一点,原因是我们知道切片的数据结构其实是有指针的,切片通过这个指针的指向,来获得地址中对应的数值。当然,把切片认为是一个结构体,然后结构体里有一个指针,这样会更好理解。
1 | func test(a []int) { |
我们需要先了解,fmt.Printf("%p\n", a)
与fmt.Printf("%p\n", &a)
到底打印的是谁的地址。其实,fmt.Printf("%p\n", a)
打印的是切片a
中的指针地址
,而fmt.Printf("%p\n", &a)
打印的是a
自己本身的地址。
在test
函数中将a[0]
改变能对外部生效的原因是test
函数中的a
虽然与外部的a
不是同一个地址,但是切片内部的指针指向地址与外部是相同的。所以修改对外部有效。
append
函数不同,虽然append
改变的是切片内部指针指向的地址,但是因为test
中的a本身地址与main
函数中a
本身地址已经不同了,所以修改切片的内部指针对外部是无效的。
我们使用引用传递,将a本身地址传入,就可以解决这个问题。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18func test(a *[]int) {
fmt.Printf("%p\n", *a) //0xc0000161a0
fmt.Printf("%p\n", a) //0xc00000a060
*a = append(*a, 1)
fmt.Printf("%p\n", *a) //0xc000014090
fmt.Printf("%p\n", a) //0xc00000a060
}
func main() {
a := []int{2, 3, 4}
fmt.Println(a)
fmt.Printf("%p\n", a) //0xc0000161a0
fmt.Printf("%p\n", &a) //0xc00000a060
test(&a)
fmt.Println(a)
fmt.Printf("%p\n", a) //0xc000014090
fmt.Printf("%p\n", &a) //0xc00000a060
}
在test
函数中,因为a
是指针传递过来的,所以fmt.Printf("%p\n", a)
表示原地址(与main
中的a
的地址相同).而fmt.Printf("%p\n", *a)
则表示切片中的指针指向地址。
所以我们此时再使用append
函数,改变了切片中的指针指向地址。但是因为test
函数中a
的值与main
函数中的a
的地址是相同的,所以外部也会随之改变。
目前有这样两个需求:
1) 查询用户,显示用户的信息以及他写过的书籍。如果用户有书籍,则显示,最多显示2本。如果没有,则不显示书籍。
2) 显示用户的id号以及对应的书籍件数(只用SQL实现,不使用业务逻辑)。
本文中用到的user
模型,数据,控制器,路由之类的都已经在另一篇文章 手摸手教你让Laravel开发Api更得心应手 创建好了。
users
表中的数据
books
表中的数据
这个比较容易,只要在关联函数限制条数即可。
1 | php artisan make:model Models/Book |
编辑 app/Models/User.php
,添加关联函数1
2
3public function books(){
return $this->hasMany(Book::class,'user_id','id')->limit(2); //一对多,最多关联2条
}
在app/Http/Controllers/Api/UserController.php
里,随意添加一个测试函数1
2
3
4
5//关联查询限制条数
public function test(Request $request){
$users= User::with('books')->get();
return $users;
}
测试结果,符合要求,id为1的用户原来是3本书籍,现在只被取出2本。
一开始,我们会这样写SQL
语句
1 | select `u`.`id`,`u`.`name`,`u`.`num` from `users` as `u` left join (select `user_id`,count(*) as `num` from books group by `user_id`) as `b` on `u`.id = `b`.user_id |
最后显示如下,并不会将没有的显示为0
所以我们稍加修改,用上MySQL
的内置函数
1 | select distinct `u`.`id`,`u`.`name`,IFNULL( `b`.`num`, 0 ) AS num from `users` as `u` left join (select `user_id`,count(*) as `num` from books group by `user_id`) as `b` on `u`.id = `b`.user_id |
符合我们的需求。
写SQL很容易,那我们应该如何在框架中使用呢(不允许查完再用业务逻辑后获得答案)?同时我们再附加一个条件,只要id为1
,2
,3
,4
,5
的用户。
查询Laravel手册,参考查询构造器
的高级join语句
,我们会立刻想到下面这样编写1
2
3
4
5
6
7
8
9
10
11
12
13public function test3(){
//统计出所有内部员工的user_id
$user_ids = [1,2,3,4,5];
$users = User::selectRaw('u.id,IFNULL( b.number, 0 ) AS number')
->from('users as u')
->distinct()
->whereIn('id', $user_ids)
->leftJoin('books as b',function ($join) use($user_ids){
$join->selectRaw('user_id,count(*) as number')->whereIn('user_id', $user_ids)->groupBy('user_id')->on('u.id', '=', 'b.user_id');
})
->get();
return $users;
}
测试的时候我们发现报了错1
Unknown column 'b.number' in 'field list' (SQL: select distinct u.id,IFNULL( b.number, 0 ) AS number from `users` as `u` left join `books` as `b` on `user_id` in (1, 2, 3, 4, 5) and `u`.`id` = `b`.`user_id` where `id` in (1, 2, 3, 4, 5))
最后的SQL语句跟我们想象中的不太一样。
错误的原因是,我们其实是使用left join
连接了子查询,但是Laravel
的联表查询,例如join
,lefeJoin
,rightJoin
等,经过个人的测试,这些闭包并不能实现子查询的。所以最后获得的SQL语句是错误的。
Laravel官方文档的子查询并没有这方面详细的介绍,所以我们一起来了解一下其他地方查来的资料
toSql()
方法的作用是为了获取不带有binding
参数的SQL
例如:
1 | select * from `users` where `users`.`id` = ? |
getQuery()
方法的作用是为了获取binding
参数并代替toSql()
获得SQL
的问号,从而得到完整的SQL
例如:
1 | select * from `users` where `users`.`id` = 1 |
现在我们使用Query Builder
来修复一下之前的问题
1 | public function test2(){ |
最后的结果符合我们的需求
散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
google 公司的一个上机题:
有一个公司,当有新的员工来报道时,要求将该员工的信息加入(id,性别,年龄,住址..),当输入该员工 的 id 时,要求查找到该员工的所有信息.
要求:
1) 不使用数据库,尽量节省内存,速度越快越好=>哈希表(散列)
2) 添加时,保证按照雇员的 id 从低到高插入
1) 使用链表来实现哈希表, 该链表不带表头[即: 链表的第一个结点就存放雇员信息]
2) 思路分析并画出示意图
1 | package main |
有些程序员也把栈称为堆栈, 即栈和堆栈是同一个概念
1) 栈的英文为(stack)
2) 栈是一个先入后出(FILO-First In Last Out)的有序列表。
3) 栈(stack)是限制线性表中元素的插入和删除只能在线性表的同一端进行的一种特殊线性表。允许插入和删除的一端,为变化的一端,称为栈顶(Top),另一端为固定的一端,称为栈底(Bottom)。
4) 根据堆栈的定义可知,最先放入栈中元素在栈底,最后放入的元素在栈顶,而删除元素刚好相反,最后放入的元素最先删除,最先放入的元素最后删除
入栈
出栈
1) 子程序的调用:在跳往子程序前,会先将下个指令的地址存到堆栈中,直到子程序执行完后再 将地址取出,以回到原来的程序中。
2) 处理递归调用:和子程序的调用类似,只是除了储存下一个指令的地址外,也将参数、区域变 量等数据存入堆栈中。
3) 表达式的转换与求值。
4) 二叉树的遍历。
5) 图形的深度优先(depth 一 first)搜索法。
1 | package main |
1 | package main |
排序是将一组数据,依指定的顺序进行排列的过程, 常见的排序:
1)冒泡排序
2)选择排序
3)插入排序
4)快速排序
通过对待排序序列从后向前(从下标较大的元素开始) ,依次比较相邻元素的排序码,若发现逆序则交换,使排序码较小的元素逐渐从后部移向前部(从下标较大的单元移向下标较小的单元),就象水底下的气泡一样逐渐向上冒。
因为排序的过程中,各元素不断接近自己的位置,如果一趟比较下来没有进行过交换,就说明序列有序,因此要在排序过程中设置一个标志flag判断元素是否进行过交换。 从而减少不必要的比较。
1 | package main |
选择式排序也属于内部排序法,是从欲排序的数据中,按指定的规则选出某一元素,经过和其他元素重整,再依原则交换位置后达到排序的目的。
选择排序(select sorting)也是一种简单的排序方法。
它的基本思想是:第一次从 R[0]~R[n-1]中选 取最小值,与R[0]交换,第二次从 R[1]~R[n-1]中选取最小值,与R[1]交换,第三次从 R[2]~R[n-1]中选取最小值,与R[2]交换,…,第 i 次从 R[i-1]~R[n-1]中选取最小值,与 R[i-1]交换,…, 第 n-1 次从 R[n-2]~R[n-1]中选取最小值,与 R[n-2]交换,总共通过 n-1 次,得到一个按排序码从小到大排列的有序序列。
1 | package main |
插入式排序属于内部排序法,是对于欲排序的元素以插入的方式找寻该元素的适当位置,以达到排序的目的。
插入排序(Insertion Sorting)的基本思想是:把 n 个待排序的元素看成为一个有序表和一个无序表,开始时有序表中只包含一个元素,无序表中包含有 n-1 个元素,排序过程中每次从无序表中取出第一个 元素,把它的排序码依次与有序表元素的排序码进行比较,将它插入到有序表中的适当位置,使之成为新的有序表。
1 | package main |
快速排序(Quicksort)是对冒泡排序的一种改进。
通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列
以中点作为判断标准(与上面分析图找的判断标准不同,上面分析图找的是最右边)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
63package main
import (
"fmt"
)
//快速排序
//说明
//1. left 表示 数组左边的下标
//2. right 表示数组右边的下标
//3 array 表示要排序的数组
func QuickSort(left int, right int, array *[11]int) {
l := left
r := right
// pivot 是中轴, 支点
pivot := array[(left+right)/2]
//for 循环的目标是将比 pivot 小的数放到 左边 // 比 pivot 大的数放到 右边
//for 循环结束后无论pivot,在哪里。它的左边永远会比他小,它的右边永远比他大。
for l < r {
//从 pivot 的左边找到大于等于 pivot 的值
for array[l] < pivot {
l++
}
//从 pivot 的右边边找到小于等于 pivot 的值
for array[r] > pivot {
r--
}
// 当l==r时,证明这一轮已经全部找完了,退出循环,进行递归
if l == r {
l++
r--
break
}
//交换有可能是2个大小的值在两边进行交换,也有可能是我们的中间值会被交换。
//不过结果都会是中间件的左边都是会比它小的数,右边都是比它大的数
array[l], array[r] = array[r], array[l]
//后面两步是因为l或者r其中有一个指向了中间值,进行了交换。
//交换位置后,另一个值刚刚跟中间值已经比较过一次了,所以直接跳过
if array[l] == pivot {
r--
}
if array[r] == pivot {
l++
}
}
// 向左递归
if left < r {
QuickSort(left, r, array)
}
// 向右递归
if right > l {
QuickSort(l, right, array)
}
}
func main() {
arr := [11]int{-9, 78, 0, -900, 23, -567, 70, 123, 90, -23, -1200}
fmt.Println("初始", arr)
//调用快速排序
QuickSort(0, len(arr)-1, &arr)
fmt.Println("main..")
fmt.Println(arr)
}
可以先把递归代码注释,专门理解下面代码。这段代码的执行完毕后,设置的数,比它小的肯定都在它左边,比它大的都在它右边
1 | for l < r { |
考虑到快排的速度,所以同一用毫秒作为单位。随机从90万中取8万个数据,进行排序,比较时间差1
2
3
4
5
6
7
8var arr = [80000]int{}
for i := 0; i < len(arr); i++ {
arr[i] = rand.Intn(900000)
}
start := time.Now().UnixNano()
quickSort(0, len(arr)-1, &arr)
end := time.Now().UnixNano()
fmt.Printf("快速排序:%d 毫秒\n", (end-start)/1000/1000)
1 | 冒泡排序:9086 毫秒 |
可以看出,快排是真的很快。以上四种都是单线程排序,快排碾压的原因是其一直在递归,每次排序都是排2个(因为2个指针调换元素),所以会有这么惊人的速度。但是因为一直在使用递归函数,需要一直开辟新的空间,会耗费很大的cpu与内存。
]]>链表是有序的列表,但是它在内存中是存储如下:
单链表的示意图
说明:一般来说,为了比较好的对单链表进行增删改查的操作,我们都会给他设置一个头结点, 头结点的作用主要是用来标识链表头,本身这个结点不存放数据。
1 | package main |
1)单向链表,查找的方向只能是一个方向,而双向链表可以向前或者向后查找。
2)单向链表不能自我删除,需要靠辅助节点,而双向链表,则可以自我删除,所以前面我们单链表删除时节点,总是找到temp的下一个节点来删除的
1 | package main |
1 | package main |
设编号为 1,2,… n 的 n 个人围坐一圈,约定编号为 k(1<=k<=n)的人从 1开始报数,数到 m 的那个人出列,它的下一位又从 1 开始报数,数到 m 的那个人又出列,依次类推, 直到所有人出列为止,由此产生一个出队编号的序列。
用一个不带头结点的循环链表来处理 Josephu 问题:先构成一个有 n 个结点的单循环链表,然后由 k 结点起从 1 开始计数,计到 m 时,对应结点从链表中删除,然后再从被删除结点的下一个结点又 从 1 开始计数,直到最后一个结点从链表中删除算法结束。
1 | package main |
有这么一个需求,我想取出一个表(比如user
表)中,按照某一排序规则(比如按照时间倒叙),取出前100
条,进行分页,每页10
条。应该如何实现?自然而然可能会这样写下:
1 | $users = User::orderBy('id','desc')->limit(100)->paginate(10) |
最后打印结果可以发现,limit
并未生效,依旧是将所有结果进行分页。
本文中用到的user
模型,数据,控制器,路由之类的都已经在另一篇文章 手摸手教你让Laravel开发Api更得心应手 创建好了。
users
表中的数据
继续通过paginate
方法来分页以及行不通了。确幸Laravel
框架给我们提供了自定义分页类,我们通过使用自定义分页类来达到我们限制条数再分页的需求。
下面我们分别讲解数组手动分页
以及模型对象手动分页
通过id
来倒叙排序,并且取出前6条来分页,每页2条数据
1 | public function index1(Request $request){ |
符合我们的需求
比较适用于自建的数组想进行分页。
因为一开始就被转换为数组了,所以想要用模型中的方法是不可能了。
1.无法使用对应模型里的方法。
2.内置的Api资源无法正常使用。
当我们的UserResource.php
里的内容为这样时:
1 | class UserResource extends JsonResource |
此时在控制器中使用Api资源来返回结果1
return UserResource::collection($data);
报错,提示Trying to get property 'id' of non-object
。这是由于我们传入到内置分页类中的是数组而不是一开始的对象形式,所以提示找不到这个属性。我们只需要进行一些稍微的修改。
1 | public function toArray($request) |
此时返回结果正常,但是不会报错。但是还是有很多问题,比如,无法使用数据包裹
,条件关联
等等。因为传入的分页是数组而不是对象导致的。
所以说,数组来进行分页,只适用于自己构建的数据数组。
接着解决上面的痛点,一开始我们就将对象结果集转为了数组结果集(百度上千篇一律都是转成了数组结果集),所以让导致模型方法以及Api资源都不能很好地使用。
现在我们不转换为数组,直接用对象结果集来进行自定义分页。
先来看一个函数
1 | array_slice() |
这是上面数组能进行分页的关键,可以指定从第几个元素开始,显示几个元素。
那对象结果集是否有类似的方法可以调用?这样我们就可以做出对象结果集的分页了。答案是有的。文档中的集合方法slice()
拥有一样的功能。
1 | public function index(Request $request){ |
此时UserResource.php
文件中的内容为1
2
3
4
5
6
7
8
9
10
11
12
13class UserResource extends JsonResource
{
public function toArray($request)
{
return [
'id'=>$this->id,
'name' => $this->name,
'status' => UserEnum::getStatusName($this->status),
'created_at'=>(string)$this->created_at,
'updated_at'=>(string)$this->updated_at
];
}
}
没有任何错误,符合我们的要求
非自建数组,想使用模型的方法或者使用Api资源。
]]>队列是一个有序列表,可以用数组或是链表来实现。
遵循先入先出的原则。即:先存入队列的数据,要先取出。后存入的要后取出
队列本身是有序列表,若使用数组的结构来存储队列的数据,则队列数组的声明如下 其中maxSize是该队列的最大容量。
因为队列的输出、输入是分别从前后端来处理,因此需要两个交量front及rear分别记录队列前后端的下标,front 会随着数据输出而改变,而rear则是随着数据输入而改变。如图所示:
当我们将数据存入队列时称为” addqueue”, addqueue的处理需要有两个步骤:
1)将尾指针往后移: rear+1,front== rear [空]
2)若尾指针rear小于等于队列的最大下标MaxSize-1,则将数据存入rear所指的数组元素中,否则无法存入数据。rear == MaxSize-1[队列满]
1.创建一个数组arrary, 作为队列的一个字段
1 | AddQueue //加入数据到队列 |
1 | package main |
对前面的数组模拟队列的优化,充分利用数组,因此将数组看做是一个环形的。(通过取模的方式来实现)
提醒:
1) 尾索引的下一个尾头索引是表示队列满,即将队列容量空出一个作为约定,这个在判断队列满的时候需要注意(tail+1)%maxSize == head
[满]
2) tail == head [空]
1) 什么时候表示队列满 (tail + 1) % maxSize = hedd
2) tail == head
表示空
3) 初始化时, tail=0,head=0
4) 怎么统计该队列有多少个元素 (tail + maxSize - head ) % maxSize
1 | package main |
一直都是使用PHP的B/S
开发(不知道swoole的socket算不算),很想尝试一下C/S
开发是什么感觉。于是乎,就做了一个聊天室,这是一个网络编程几乎必做的项目。
果然,C/S
开发就是比B/S
麻烦的多,因为B/S
的协议都是已经定义好的,照着这个逻辑写就行。C/S
需要自己来制定协议,比如发送数据包之前,应该先制作一个数据包,专门记录将要发送数据包的长度。先行发送这个记录长度的数据包,然后再发送真正的数据包。
服务端也一样,先接收长度数据包后再接收真正的数据包,验证长度是否有误,是否有丢包。
当然,这些在B/S
里是完全不会让我们写的,因为这些底层已经全都封装好了,都不需要考虑丢包问题。
这或许是一件好事,不会让人操心这个,因为这东西确实很繁琐。但是也可能是一件坏事,因为我们会少了一些底层知识认知。
下面放一段用于读取数据与发送数据的代码
1 | package utils |
专门详解项目里每个文件的具体作用,篇幅又是很长,还是直接体验结果吧(README.md 有相关功能介绍)。
项目地址:
]]>当一个数组中大部分元素为0,或者为同一个值的数组时,可以使用稀疏数组来保存该数组。
1) 记录数组一共有几行几列,有多少个不同的值
2) 思想:把具有不同值的元素的行列及值记录在一个小规模的数组中,从而缩小程序的规模
1 | package main |
1 | package main |
过年期间家里装了2年免费的移动光纤(真香),可是移动装机默认光猫都是路由模式
,装机师傅以及移动人工客服都是拒绝给超级账户的。所以只能自己想办法解决。
1.工信部投诉,百试百灵
2.淘宝或者闲鱼花钱让别人破解(我查了貌似没有这个型号)
3.手摸手自己折腾自己搞
破解后的好处:
1.可以将光猫变成桥接模式
,然后用自己的路由器拨号,更稳定。
2.对光猫h2-3
有完全控制能力,可以使用全部功能。
破解后的界面图如下:
1.一台确认已经开启telnet
的电脑
2.一台正常可用的主人公h2-3光猫
3.知道光猫的普通账户的账号密码(在光猫后面贴着)
4.确认电脑与光猫在同一局域网内
5.Firefox
浏览器或者Chrome
浏览器
注意:如果想把光猫转成桥接模式,路由器来拨号的话,需要知道宽带密码
是多少。如果不知道,建议先打10086
人工客服下发重置密码的短信。
1.首先打开浏览器,在扩展中安装HTTP Header Live
插件(不知道怎么安装自行百度)。
2.浏览器打开1
http://192.168.1.1
看到光猫的登陆界面,使用普通账户进行登陆
3.打开HTTP Header Live
插件,在页面上随便点击一个页面,再次查看HTTP Header Live
插件,捕捉到了post
信息
我们需要使用它的header头来发送我们的消息,以此来修改光猫telnet
的账号密码
4.点击打开一个post
修改链接为1
http://192.168.1.1/boaform/set_telenet_enabled.cgi
内容为:1
mode_name=set_telenet_enabled&nonedata=0.3535281170047305&user_name=root&user_password=admin&telenet_enabled=1&default_flag=1
意思是将光猫的telnet账号密码分别设置为root
与admin
5.然后点击右下角的send
确认,会返回一个成功的页面,代表已经成功打开光猫的telnet
命令行
工具,输入1 | telnet 192.168.1.1 |
连接上后,在OpenWrt login
提示时输入账号root
,在Password
提示时输入密码admin
,然后回车。即可成功登陆进光猫的telnet
2.切换到配置文件目录1
cd /config/worka
然后我们查看一下文件1
ls -al
如果使用的是windows
的cmd
,那么可以使用1
dir
其中,backup_lastgood.xml
与lastgood.xml
是我们后面需要修改的文件
1.我们查看一下backup_lastgood.xml
里面的内容1
vim backup_lastgood.xml
然后搜索TeleAccountName
,就可以看到存放的账号以及密码
其中,TeleAccountName
和TeleAccountPassword
分别代表超级账户的账号密码。UserAccountName
和UserAccountPassword
分别代表普通账户的账号密码。
2.现在我们来分析一下,UserAccountName
的Value是user
,跟我们普通账户的账号相同。那密码UserAccountPassword
又是什么含义。比如,此时我的UserAccountPassword
的值为1
61,62,64,66,6e,68,00,00,00,......
我光猫背面的密码是1
abdfnh
此时不难发现,密码字母所对应的ASCII
数字,十六进制
转换后与UserAccountPassword
的值是相同的。
3.知道这个原理后,我们就可以将密码修改成我们喜欢的密码了。大家自行在查找字符所对应的ASCII
数字的十六进制
是多少即可,将其替换。
4.为了方便,我们将普通密码与超级密码设置成相同的,即把TeleAccountPassword
所对应的内容替换为UserAccountPassword
的内容。
5.lastgood.xml
与backup_lastgood.xml
内容基本是相同的,一样替换TeleAccountPassword
的内容即可。
输入1
reboot
重启光猫,2分钟后登陆1
192.168.1.1
输入超级账号1
CMCCAdmin
自己自定义的密码(比如我刚刚是与普通用户密码设置相同)1
abdfnh
登陆成功,代表破解成功。
网络
-宽带设置
,然后点击2_INTERNET_R_VID_400
,将连接模式从Route
改成Brige
,保存即可。
1.有可能修改成自己自定义密码后,自定义密码不会生效,此时有可能是光猫恢复成了万能密码,也就是1
aDm8H MdA
可以登录尝试看看。
2.如果你忘记了自己的宽带密码,拨打人工重置下发的话,光猫也会被移动远程重置信息。此时按照本教程重新再破解一次即可。
]]> 随着前后端完全分离,PHP
也基本告别了view
模板嵌套开发,转而专门写资源接口。Laravel
是PHP框架中最优雅的框架,国内也越来越多人告别ThinkPHP
选择了Laravel
。Laravel
框架本身对API
有支持,但是感觉再工作中还是需要再做一些处理。Lumen
用起来不顺手,有些包不能很好地支持。所以,将Laravel
框架进行一些配置处理,让其在开发API
时更得心应手。
内容划水过长,请谨慎打开
当然,你也可以点击这里,直接跳到成果~
1 | PHP > 7.1 |
1 | postman |
为了模拟AJAX请求,请将 header头
设置X-Requested-With
为 XMLHttpRequest
Laravel
只要>=5.5
皆可,这里采用文章编写时最新的5.7
版本1
composer create-project laravel/laravel Laravel --prefer-dist "5.7.*"
1 | CREATE TABLE `users` ( |
在项目的app
目录下可以看到,有一个User.php
的模型文件。因为Laravel
默认把模型文件放在app
目录下,如果数据表多的话,这里模型文件就会很多,不便于管理,所以我们先要将模型文件移动到其他文件夹内。
1) 在app
目录下新建Models
文件夹,然后将User.php
文件移动进来。
2) 修改User.php
的内容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
namespace App\Models; //这里从App改成了App\Models
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use Notifiable;
protected $table = 'users';
//去掉我创建的数据表没有的字段
protected $fillable = [
'name', 'password'
];
//去掉我创建的数据表没有的字段
protected $hidden = [
'password'
];
//将密码进行加密
public function setPasswordAttribute($value)
{
$this->attributes['password'] = bcrypt($value);
}
}
3) 因为有关于User的命名空间发生了改变,所以我们全局搜索App\User
,将其替换为App\Models\User
.我一共搜索到3个文件1
2
3
4app/Http/Controllers/Auth 目录下的 RegisterController.php
config 目录下的 services.php
config 目录下的 auth.php
database/factories 目录下的 UserFactory.php
因为是专门做API的,所以我们要把是API的控制器都放到app\Http\Controllers\Api
目录下。
使用命令行创建控制器1
php artisan make:controller Api/UserController
编写app/Http/Controllers/Api
目录下的UserController.php
文件1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class UserController extends Controller
{
//
public function index(){
return 'guaosi';
}
}
这里写了index函数,用来下面建立路由后的测试,查看是否可以正常访问。
在routes
目录下的api.php
是专门用来写Api接口的路由,所以我们打开它,填写以下内容,做一个测试.1
2
3
4
5
6
use Illuminate\Http\Request;
Route::namespace('Api')->prefix('v1')->group(function () {
Route::get('/users','UserController@index')->name('users.index');
});
因为我们Api控制器的命名空间是
App\Http\Controllers\Api
,而Laravel
默认只会在命名空间App\Http\Controllers
下查找控制器,所以需要我们给出namespace
。
同时,添加一个
prefix
是为了版本号,方便后期接口升级区分。
打开postman
,用get
方式请求你的域名/api/v1/users
,最后返回结果是1
guaosi
则成功
在创建用户之前,我们先创建验证器,来让我们服务器接收到的数据更安全.当然,我们也要把关于Api验证的放在一个专门的文件夹内。
先创建一个Request
的基类1
php artisan make:request Api/FormRequest
因为验证器默认的权限验证是false
,导致返回都是403
的权限不通过错误。这里我们没有用到权限认证,为了方便处理,我们默认将权限都是通过的状态。所以,每个文件都需要我们将false
改成true
。1
2
3
4
5
6public function authorize()
{
//false代表权限验证不通过,返回403错误
//true代表权限认证通过
return true;
}
所以我们修改app/Http/Requests/Api
目录下的 FormRequest.php
文件1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest as BaseFormRequest;
class FormRequest extends BaseFormRequest
{
public function authorize()
{
//false代表权限验证不通过,返回403错误
//true代表权限认证通过
return true;
}
}
这样这个命名空间下的验证器都会默认通过权限验证。当然,如果你需要权限验证,可以通过直接覆盖方法。
接着我们开始创建关于UserController
的专属验证器1
php artisan make:request Api/UserRequest
编辑app/Http/Requests/Api
目录下的 UserRequest.php
文件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
namespace App\Http\Requests\Api;
class UserRequest extends FormRequest
{
public function rules()
{
switch ($this->method()) {
case 'GET':
{
return [
'id' => ['required,exists:shop_user,id']
];
}
case 'POST':
{
return [
'name' => ['required', 'max:12', 'unique:users,name'],
'password' => ['required', 'max:16', 'min:6']
];
}
case 'PUT':
case 'PATCH':
case 'DELETE':
default:
{
return [
];
}
}
}
public function messages()
{
return [
'id.required'=>'用户ID必须填写',
'id.exists'=>'用户不存在',
'name.unique' => '用户名已经存在',
'name.required' => '用户名不能为空',
'name.max' => '用户名最大长度为12个字符',
'password.required' => '密码不能为空',
'password.max' => '密码长度不能超过16个字符',
'password.min' => '密码长度不能小于6个字符'
];
}
}
现在我们来编写创建用户接口,制作一些虚拟数据。(就不使用seeder来填充了)
打开UserController.php
1
2
3
4
5
6
7
8
9
10
11
12//用户注册
public function store(UserRequest $request){
User::create($request->all());
return '用户注册成功。。。';
//用户登录
public function login(Request $request){
$res=Auth::guard('web')->attempt(['name'=>$request->name,'password'=>$request->password]);
if($res){
return '用户登录成功...';
}
return '用户登录失败';
}
然后我们创建路由,编辑api.php
1
2Route::post('/users','UserController@store')->name('users.store');
Route::post('/login','UserController@login')->name('users.login');
打开postman
,用post
方式请求你的域名/api/v1/users
,在form-data
记得填写要创建的用户名和密码。
最后返回结果是1
用户创建成功。。。
则成功。
如果返回1
2
3
4
5
6
7
8
9
10
11{
"message": "The given data was invalid.",
"errors": {
"name": [
"用户名不能为空"
],
"password": [
"密码不能为空"
]
}
}
则证明验证失败。
然后验证是否可以正常登录。因为我们认证的字段是name
跟password
,而Laravel
默认认证的是email
跟password
。所以我们还要打开app/Http/Controllers/auth
目录下的 LoginController.php
,加入如下代码1
2
3
4 public function username()
{
return 'name';
}
打开postman
,用post
方式请求你的域名/api/v1/login
最后返回结果是1
用户登录成功...
则成功
为了测试使用,请自行通过接口创建10个用户。
给出整体控制器信息UserController.php
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
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\UserRequest;
use App\Models\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{
//返回用户列表
public function index(){
//3个用户为一页
$users = User::paginate(3);
return $users;
}
//返回单一用户信息
public function show(User $user){
return $user;
}
//用户注册
public function store(UserRequest $request){
User::create($request->all());
return '用户注册成功。。。';
}
//用户登录
public function login(Request $request){
$res=Auth::guard('web')->attempt(['name'=>$request->name,'password'=>$request->password]);
if($res){
return '用户登录成功...';
}
return '用户登录失败';
}
}
给出整体路由信息api.php
1
2
3
4
5
6
7
8
9<?php
use Illuminate\Http\Request;
Route::namespace('Api')->prefix('v1')->group(function () {
Route::get('/users','UserController@index')->name('users.index');
Route::get('/users/{user}','UserController@show')->name('users.show');
Route::post('/users','UserController@store')->name('users.store');
Route::post('/login','UserController@login')->name('users.login');
});
以上所有返回的结果,无论正确或者错误,都没有一个统一格式规范,对开发Api
不太友好的,需要我们进行一些修改,让Laravel框架可以更加友好地编写Api。
所有问题,跨域先行。跨域问题没有解决,一切处理都是纸老虎。这里我们使用medz做的cors扩展包
1 | composer medz/cors |
1 | php artisan vendor:publish --provider="Medz\Cors\Laravel\Providers\LaravelServiceProvider" --force |
打开config/cors.php
,在expose-headers
添加值Authorization
1
2
3
4
5return [
......
'expose-headers' => ['Authorization'],
......
];
这样跨域请求时,才能返回
header
头为Authorization
的内容,否则在刷新用户token
时不会返回刷新后的token
打开app/Http/Kernel.php
,增加一行1
2
3
4protected $routeMiddleware = [
...... //前面的中间件
'cors'=> \Medz\Cors\Laravel\Middleware\ShouldGroup::class,
];
打开routes/api.php
,在路由组中增加使用中间件1
2
3
4
5
6Route::namespace('Api')->prefix('v1')->middleware('cors')->group(function () {
Route::get('/users','UserController@index')->name('users.index');
Route::get('/users/{user}','UserController@show')->name('users.show');
Route::post('/users','UserController@store')->name('users.store');
Route::post('/login','UserController@login')->name('users.login');
});
接口主流返回json
格式,其中包含http状态码
,status请求状态
,data请求资源结果
等等。需要我们有一个API接口全局都能有统一的格式和对应的数据处理。参考于这里。
在 app/Api/Helpers
目录(不存在目录自己新建)下新建 ApiResponse.php
填入如下内容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
126
127
128
129
130
131
132
namespace App\Api\Helpers;
use Symfony\Component\HttpFoundation\Response as FoundationResponse;
use Response;
trait ApiResponse
{
/**
* @var int
*/
protected $statusCode = FoundationResponse::HTTP_OK;
/**
* @return mixed
*/
public function getStatusCode()
{
return $this->statusCode;
}
/**
* @param $statusCode
* @return $this
*/
public function setStatusCode($statusCode,$httpCode=null)
{
$httpCode = $httpCode ?? $statusCode;
$this->statusCode = $statusCode;
return $this;
}
/**
* @param $data
* @param array $header
* @return mixed
*/
public function respond($data, $header = [])
{
return Response::json($data,$this->getStatusCode(),$header);
}
/**
* @param $status
* @param array $data
* @param null $code
* @return mixed
*/
public function status($status, array $data, $code = null){
if ($code){
$this->setStatusCode($code);
}
$status = [
'status' => $status,
'code' => $this->statusCode
];
$data = array_merge($status,$data);
return $this->respond($data);
}
/**
* @param $message
* @param int $code
* @param string $status
* @return mixed
*/
/*
* 格式
* data:
* code:422
* message:xxx
* status:'error'
*/
public function failed($message, $code = FoundationResponse::HTTP_BAD_REQUEST,$status = 'error'){
return $this->setStatusCode($code)->message($message,$status);
}
/**
* @param $message
* @param string $status
* @return mixed
*/
public function message($message, $status = "success"){
return $this->status($status,[
'message' => $message
]);
}
/**
* @param string $message
* @return mixed
*/
public function internalError($message = "Internal Error!"){
return $this->failed($message,FoundationResponse::HTTP_INTERNAL_SERVER_ERROR);
}
/**
* @param string $message
* @return mixed
*/
public function created($message = "created")
{
return $this->setStatusCode(FoundationResponse::HTTP_CREATED)
->message($message);
}
/**
* @param $data
* @param string $status
* @return mixed
*/
public function success($data, $status = "success"){
return $this->status($status,compact('data'));
}
/**
* @param string $message
* @return mixed
*/
public function notFond($message = 'Not Fond!')
{
return $this->failed($message,Foundationresponse::HTTP_NOT_FOUND);
}
}
在 app/Http/Controller/Api
目录下新建一个Controller.php
作为Api
专门的基类
.
填入以下内容1
2
3
4
5
6
7
8
9
10
11
12
13
namespace App\Http\Controllers\Api;
use App\Api\Helpers\ApiResponse;
use App\Http\Controllers\Controller as BaseController;
class Controller extends BaseController
{
use ApiResponse;
// 其他通用的Api帮助函数
}
让Api的控制器继承这个基类即可。
打开UserController.php
文件,去掉命名空间use App\Http\Controllers\Controller
1
2
3
4
5
6
7
8
9
10namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\UserRequest;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{
......
}
得益于前面统一消息的封装,使用起来非常容易。
1.返回正确信息1
return $this->success('用户登录成功...');
2.返回正确资源信息1
return $this->success($user);
3.返回自定义http状态码的正确信息1
return $this->setStatusCode(201)->success('用户登录成功...');
4.返回错误信息1
return $this->failed('用户注册失败');
5.返回自定义http状态码的错误信息1
return $this->failed('用户登录失败',401);
6.返回自定义http状态码的错误信息,同时也想返回自己内部定义的错误码1
return $this->failed('用户登录失败',401,10001);
默认success返回的状态码是200,failed返回的状态码是400
我们将统一消息封装运用到UserController
中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
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\UserRequest;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class UserController extends Controller
{
//返回用户列表
public function index(){
//3个用户为一页
$users = User::paginate(3);
return $this->success($users);
}
//返回单一用户信息
public function show(User $user){
return $this->success($user);
}
//用户注册
public function store(UserRequest $request){
User::create($request->all());
return $this->setStatusCode(201)->success('用户注册成功');
}
//用户登录
public function login(Request $request){
$res=Auth::guard('web')->attempt(['name'=>$request->name,'password'=>$request->password]);
if($res){
return $this->setStatusCode(201)->success('用户登录成功...');
}
return $this->failed('用户登录失败',401);
}
}
http://你的域名/api/v1/users
http://你的域名/api/v1/users/1
http://你的域名/api/v1/login
http://你的域名/api/v1/login
在上面请求返回用户列表和返回单一用户时,返回的字段都是数据库里所有的字段,当然,不包含我们在User
模型中去除的password
字段。
此时,我们如果想控制返回的字段有哪些,可以使用select
或者使用User
模型中的hidden
数组来限制字段。
这2种办法虽然可以,但是扩展性太差。并且我想对status
返回的形式进行修改,比如0的时候显示正常,1显示冻结,此时就需要遍历数据进行修改了。此时,Laravel提供的API 资源
就可以很好地解决我们的问题。
当构建 API 时,你往往需要一个转换层来联结你的 Eloquent 模型和实际返回给用户的 JSON 响应。Laravel 的资源类能够让你以更直观简便的方式将模型和模型集合转化成 JSON。
也就是在C层输出V层时,中间再来一层来专门处理字段问题,我们可以称之为
ViewModel
层。
详细可以查看手册如何使用。
1 | php artisan make:resource Api/UserResource |
修改app/Http/Resources/Api
目录下的 UserResource.php
文件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
namespace App\Http\Resources\Api;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
switch ($this->status){
case -1:
$this->status = '已删除';
break;
case 0:
$this->status = '正常';
break;
case 1:
$this->status = '冻结';
break;
}
return [
'id'=>$this->id,
'name' => $this->name,
'status' => $this->status,
'created_at'=>(string)$this->created_at,
'updated_at'=>(string)$this->updated_at
];
}
}
返回单一用户(单一的资源)1
return $this->success(new UserResource($user));
返回用户列表(资源列表)1
2
3return UserResource::collection($users);
//这里不能用$this->success(UserResource::collection($users))
//否则不能返回分页标签信息
1 | //返回用户列表 |
返回单一用户(单一的资源)
返回用户列表(资源列表)
我们常常会使用数字来代表状态,比如用户表,我们使用 -1
代表已删除 0
代表正常 1
代表冻结。
1 | //有可能状态有很多,所以这边就直接用 或 来判断不取反了。 |
上面逻辑和编写没有什么问题。因为是现在看,可以很明白的知道-1
代表已删除,1
代表冻结。但是如果一个月后再来看这行代码,早已经忘记了 -1
跟 1
具体表示的含义。
UserResource.php
编写时,判断status
具体状态函数,我们是使用switch
语句。这样太不美观,而且地方用多了还容易冗余,每次编写都需要去查看每个数字代表的具体意思。-1
跟 1
具体表示的含义?这是因为单纯的数字没有解释说明的作用,变量以及函数这些具有解释说明的作用,可以让我们立刻知道具体含义。
提供一个函数,返回这个数字代表的具体含义。
而这些,都可以使用Enum枚举
可以解决。
PHP
和Laravel
框架本身是不支持Enum枚举
的,不过我们可以模拟枚举的功能
在 app/Models
下新建目录 Enum
,并在目录Enum
下新建 UserEnum.php
文件,填写以下内容1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace App\Models\Enum;
class UserEnum
{
// 状态类别
const INVALID = -1; //已删除
const NORMAL = 0; //正常
const FREEZE = 1; //冻结
public static function getStatusName($status){
switch ($status){
case self::INVALID:
return '已删除';
case self::NORMAL:
return '正常';
case self::FREEZE:
return '冻结';
default:
return '正常';
}
}
}
1.表示具体含义1
2
3
4
5
6//有可能状态有很多,所以这边就直接用 或 来判断不取反了。
if($user->status==UserEnum::INVALID||$user->status==UserEnum::FREEZE){
// 不允许用户登录逻辑
return
}
//用户正常登录逻辑
2.修改UserResource.php
1
2
3
4
5
6
7
8
9
10public function toArray($request)
{
return [
'id'=>$this->id,
'name' => $this->name,
'status' => UserEnum::getStatusName($this->status),
'created_at'=>(string$this->created_at,
'updated_at'=>(string)$this->updated_at
];
}
再请求单一用户和用户列表接口,返回结果和之前一样。
我们在UserController.php
文件中修改1
2
3
4
5//返回单一用户信息
public function show(User $user){
3/0;
return $this->success(new UserResource($user));
}
故意报个错,请求看看结果
我们再把设置成ajax
的header
头去掉
报错非常详细,并且把我们隐私设置都暴露出来了,这是由于我们.env
的APP_DEBUG
是true
状态。我们不希望这些信息被其他访问者看到。我们改为false
,再请求看看结果。
嗯。很好,不仅别人看不到了,连我们自己都看不到了
json
格式输出.env
文件默认是不加入git
上传线上的,我们希望可以当APP_DEBUG
为true
(本地)的时候可以继续显示详细的错误信息,false
(线上)的时候就显示简要json
信息,比如500。在 app/Api/Helpers
目录下新建 ExceptionReport.php
文件,填入以下内容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
namespace App\Api\Helpers;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;
class ExceptionReport
{
use ApiResponse;
/**
* @var Exception
*/
public $exception;
/**
* @var Request
*/
public $request;
/**
* @var
*/
protected $report;
/**
* ExceptionReport constructor.
* @param Request $request
* @param Exception $exception
*/
function __construct(Request $request, Exception $exception)
{
$this->request = $request;
$this->exception = $exception;
}
/**
* @var array
*/
//当抛出这些异常时,可以使用我们定义的错误信息与HTTP状态码
//可以把常见异常放在这里
public $doReport = [
AuthenticationException::class => ['未授权',401],
ModelNotFoundException::class => ['该模型未找到',404],
AuthorizationException::class => ['没有此权限',403],
ValidationException::class => [],
UnauthorizedHttpException::class=>['未登录或登录状态失效',422],
TokenInvalidException::class=>['token不正确',400],
NotFoundHttpException::class=>['没有找到该页面',404],
MethodNotAllowedHttpException::class=>['访问方式不正确',405],
QueryException::class=>['参数错误',401],
];
public function register($className,callable $callback){
$this->doReport[$className] = $callback;
}
/**
* @return bool
*/
public function shouldReturn(){
//只有请求包含是json或者ajax请求时才有效
// if (! ($this->request->wantsJson() || $this->request->ajax())){
//
// return false;
// }
foreach (array_keys($this->doReport) as $report){
if ($this->exception instanceof $report){
$this->report = $report;
return true;
}
}
return false;
}
/**
* @param Exception $e
* @return static
*/
public static function make(Exception $e){
return new static(\request(),$e);
}
/**
* @return mixed
*/
public function report(){
if ($this->exception instanceof ValidationException){
$error = array_first($this->exception->errors());
return $this->failed(array_first($error),$this->exception->status);
}
$message = $this->doReport[$this->report];
return $this->failed($message[0],$message[1]);
}
public function prodReport(){
return $this->failed('服务器错误','500');
}
}
修改 app/Exceptions
目录下的 Handler.php
文件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
namespace App\Exceptions;
use App\Api\Helpers\ExceptionReport;
use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
class Handler extends ExceptionHandler
{
public function render($request, Exception $exception)
{
//ajax请求我们才捕捉异常
if ($request->ajax()){
// 将方法拦截到自己的ExceptionReport
$reporter = ExceptionReport::make($exception);
if ($reporter->shouldReturn()){
return $reporter->report();
}
if(env('APP_DEBUG')){
//开发环境,则显示详细错误信息
return parent::render($request, $exception);
}else{
//线上环境,未知错误,则显示500
return $reporter->prodReport();
}
}
return parent::render($request, $exception);
}
}
继续打开设置AJAX
的header
头
1.关闭APP_DEBUG
,请求刚刚故意错误的接口。
2.开启APP_DEBUG
,请求刚刚故意错误的接口。
3.请求一个不存在的路由,查看返回结果。
其他的异常显示,自行测试啦~
在传统web中,我们一般是使用session
来判定一个用户的登陆状态。而在API
开发中,我们使用的是token
。jwt-token
是Laravel
开发API
用的比较多的。
JWT 全称 JSON Web Tokens ,是一种规范化的 token。可以理解为对 token 这一技术提出一套规范,是在 RFC 7519 中提出的。
jwt-auth
的详细介绍分析可以看JWT超详细分析这篇文章,具体使用可以看JWT完整使用详解 这篇文章。
1 | composer require tymon/jwt-auth 1.0.0-rc.3 |
如果是Laravel5.5
版本,则安装rc.1
。如果是Laravel5.6
版本,则安装rc.2
配置参考来自使用 Jwt-Auth 实现 API 用户认证以及无痛刷新访问令牌
1.添加服务提供商
打开 config
目录下的 app.php文件,添加下面代码1
2
3
4
5
6'providers' => [
...
Tymon\JWTAuth\Providers\LaravelServiceProvider::class,
]
2.发布配置文件1
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
此命令会在 config
目录下生成一个 jwt.php
配置文件,你可以在此进行自定义配置。
3.生成密钥1
php artisan jwt:secret
此命令会在你的 .env
文件中新增一行 JWT_SECRET=secret
。以此来作为加密时使用的秘钥。
4.配置 Auth guard
打开 config
目录下的 auth.php文件,修改为下面代码1
2
3
4
5
6
7
8
9
10
11'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
这样,我们就能让api的用户认证变成使用jwt
。
5.更改 Model
如果需要使用 jwt-auth
作为用户认证,我们需要对我们的 User
模型进行一点小小的改变,实现一个接口,变更后的 User
模型如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace App\Models;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
use Notifiable;
public function getJWTIdentifier()
{
return $this->getKey();
}
public function getJWTCustomClaims()
{
return [];
}
......
6.配置项详解config
目录下的jwt.php
文件配置详解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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203<?php
return [
/*
|--------------------------------------------------------------------------
| JWT Authentication Secret
|--------------------------------------------------------------------------
|
| 用于加密生成 token 的 secret
|
*/
'secret' => env('JWT_SECRET'),
/*
|--------------------------------------------------------------------------
| JWT Authentication Keys
|--------------------------------------------------------------------------
|
| 如果你在 .env 文件中定义了 JWT_SECRET 的随机字符串
| 那么 jwt 将会使用 对称算法 来生成 token
| 如果你没有定有,那么jwt 将会使用如下配置的公钥和私钥来生成 token
|
*/
'keys' => [
/*
|--------------------------------------------------------------------------
| Public Key
|--------------------------------------------------------------------------
|
| 公钥
|
*/
'public' => env('JWT_PUBLIC_KEY'),
/*
|--------------------------------------------------------------------------
| Private Key
|--------------------------------------------------------------------------
|
| 私钥
|
*/
'private' => env('JWT_PRIVATE_KEY'),
/*
|--------------------------------------------------------------------------
| Passphrase
|--------------------------------------------------------------------------
|
| 私钥的密码。 如果没有设置,可以为 null。
|
*/
'passphrase' => env('JWT_PASSPHRASE'),
],
/*
|--------------------------------------------------------------------------
| JWT time to live
|--------------------------------------------------------------------------
|
| 指定 access_token 有效的时间长度(以分钟为单位),默认为1小时,您也可以将其设置为空,以产生永不过期的标记
|
*/
'ttl' => env('JWT_TTL', 60),
/*
|--------------------------------------------------------------------------
| Refresh time to live
|--------------------------------------------------------------------------
|
| 指定 access_token 可刷新的时间长度(以分钟为单位)。默认的时间为 2 周。
| 大概意思就是如果用户有一个 access_token,那么他可以带着他的 access_token
| 过来领取新的 access_token,直到 2 周的时间后,他便无法继续刷新了,需要重新登录。
|
*/
'refresh_ttl' => env('JWT_REFRESH_TTL', 20160),
/*
|--------------------------------------------------------------------------
| JWT hashing algorithm
|--------------------------------------------------------------------------
|
| 指定将用于对令牌进行签名的散列算法。
|
*/
'algo' => env('JWT_ALGO', 'HS256'),
/*
|--------------------------------------------------------------------------
| Required Claims
|--------------------------------------------------------------------------
|
| 指定必须存在于任何令牌中的声明。
|
|
*/
'required_claims' => [
'iss',
'iat',
'exp',
'nbf',
'sub',
'jti',
],
/*
|--------------------------------------------------------------------------
| Persistent Claims
|--------------------------------------------------------------------------
|
| 指定在刷新令牌时要保留的声明密钥。
|
*/
'persistent_claims' => [
// 'foo',
// 'bar',
],
/*
|--------------------------------------------------------------------------
| Blacklist Enabled
|--------------------------------------------------------------------------
|
| 为了使令牌无效,您必须启用黑名单。
| 如果您不想或不需要此功能,请将其设置为 false。
|
*/
'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', true),
/*
| -------------------------------------------------------------------------
| Blacklist Grace Period
| -------------------------------------------------------------------------
|
| 当多个并发请求使用相同的JWT进行时,
| 由于 access_token 的刷新 ,其中一些可能会失败
| 以秒为单位设置请求时间以防止并发的请求失败。
|
*/
'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0),
/*
|--------------------------------------------------------------------------
| Providers
|--------------------------------------------------------------------------
|
| 指定整个包中使用的各种提供程序。
|
*/
'providers' => [
/*
|--------------------------------------------------------------------------
| JWT Provider
|--------------------------------------------------------------------------
|
| 指定用于创建和解码令牌的提供程序。
|
*/
'jwt' => Tymon\JWTAuth\Providers\JWT\Namshi::class,
/*
|--------------------------------------------------------------------------
| Authentication Provider
|--------------------------------------------------------------------------
|
| 指定用于对用户进行身份验证的提供程序。
|
*/
'auth' => Tymon\JWTAuth\Providers\Auth\Illuminate::class,
/*
|--------------------------------------------------------------------------
| Storage Provider
|--------------------------------------------------------------------------
|
| 指定用于在黑名单中存储标记的提供程序。
|
*/
'storage' => Tymon\JWTAuth\Providers\Storage\Illuminate::class,
],
];
1.我们在UserController
控制器中将login
方法进行修改以及新增一个logout
方法用来退出登录还有info
方法用来获取当前用户的信息。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18//用户登录
public function login(Request $request){
$token=Auth::guard('api')->attempt(['name'=>$request->name,'password'=>$request->password]);
if($token) {
return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
}
return $this->failed('账号或密码错误',400);
}
//用户退出
public function logout(){
Auth::guard('api')->logout();
return $this->success('退出成功...');
}
//返回当前登录用户信息
public function info(){
$user = Auth::guard('api')->user();
return $this->success(new UserResource($user));
}
2.添加一下路由routes/api.php
1
2//当前用户信息
Route::get('/users/info','UserController@info')->name('users.info');
3.接着我们打开postman
,请求http://你的域名/api/v1/login
.可以看到接口返回的token
.1
2
3
4
5
6
7{
"status": "success",
"code": 201,
"data": {
"token": "bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOlwvXC90ZXN0LmNvbVwvYXBpXC92MVwvbG9naW4iLCJpYXQiOjE1NTEzMzUyNzgsImV4cCI6MTU1MTMzODg3OCwibmJmIjoxNTUxMzM1Mjc4LCJqdGkiOiJrUzZSWHRoQVBkczR6ck4wIiwic3ViIjoxLCJwcnYiOiIyM2JkNWM4OTQ5ZjYwMGFkYjM5ZTcwMWM0MDA4NzJkYjdhNTk3NmY3In0.FLk-JPFBDTWcItPRN8SVGaLI0j2zgiWLLs_MNKxCafQ"
}
}
4.此时,我们打开Postman
直接访问http://你的域名/api/v1/users/info
,你会看到报了如下错误.1
Trying to get property 'id' of non-object
这是我们没有携带token导致的。报错不友好我们将在下面自动刷新用户认证
解决。
5.我们在Postman
的Header
头部分再加一个key
为Authorization
,value
为登陆成功后返回的token
值,然后再次进行请求,可以看到成功返回当前登陆用户的信息。
现在我想用户登录后,为了保证安全性,每个小时该用户的token都会自动刷新为全新的,用旧的token请求不会通过。我们知道,用户如果token不对,就会退到当前界面重新登录来获得新的token,我同时希望虽然刷新了token,但是能否不要重新登录,就算重新登录也是一周甚至一个月之后呢?给用户一种无感知的体验。
看着感觉很神奇,我们一起手摸手来实现。
1 | php artisan make:middleware Api/RefreshTokenMiddleware |
打开 app/Http/Middleware/Api
目录下的 RefreshTokenMiddleware.php
文件,填写以下内容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
namespace App\Http\Middleware\Api;
use Auth;
use Closure;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
// 注意,我们要继承的是 jwt 的 BaseMiddleware
class RefreshTokenMiddleware extends BaseMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
*/
public function handle($request, Closure $next)
{
// 检查此次请求中是否带有 token,如果没有则抛出异常。
$this->checkForToken($request);
// 使用 try 包裹,以捕捉 token 过期所抛出的 TokenExpiredException 异常
try {
// 检测用户的登录状态,如果正常则通过
if ($this->auth->parseToken()->authenticate()) {
return $next($request);
}
throw new UnauthorizedHttpException('jwt-auth', '未登录');
} catch (TokenExpiredException $exception) {
// 此处捕获到了 token 过期所抛出的 TokenExpiredException 异常,我们在这里需要做的是刷新该用户的 token 并将它添加到响应头中
try {
// 刷新用户的 token
$token = $this->auth->refresh();
// 使用一次性登录以保证此次请求的成功
Auth::guard('api')->onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()['sub']);
} catch (JWTException $exception) {
// 如果捕获到此异常,即代表 refresh 也过期了,用户无法刷新令牌,需要重新登录。
throw new UnauthorizedHttpException('jwt-auth', $exception->getMessage());
}
}
// 在响应头中返回新的 token
return $this->setAuthenticationHeader($next($request), $token);
}
}
打开 app/Http
目录下的 Kernel.php
文件,添加如下一行1
2
3
4protected $routeMiddleware = [
......
'api.refresh'=>\App\Http\Middleware\Api\RefreshTokenMiddleware::class,
];
接着我们将路由进行修改,添加上我们写好的中间件。routes/api.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use Illuminate\Http\Request;
Route::namespace('Api')->prefix('v1')->middleware('cors')->group(function () {
//用户注册
Route::post('/users','UserController@store')->name('users.store');
//用户登录
Route::post('/login','UserController@login')->name('users.login');
Route::middleware('api.refresh')->group(function () {
//当前用户信息
Route::get('/users/info','UserController@info')->name('users.info');
//用户列表
Route::get('/users','UserController@index')->name('users.index');
//用户信息
Route::get('/users/{user}','UserController@show')->name('users.show');
//用户退出
Route::get('/logout','UserController@logout')->name('users.logout');
});
});
1.此时我们再次不携带token,使用Postman
直接访问http://你的域名/api/v1/users/info
,返回如下错误1
2
3
4
5{
"status": "error",
"code": 422,
"message": "未登录或登录状态失效"
}
2.那随便输入token又会是怎么样呢?我们也来尝试一下1
2
3
4
5{
"status": "error",
"code": 400,
"message": "token不正确"
}
3.现在,我们再做一个如果token
过期了,但是刷新限制没有过期的情况,看看会有什么结果。我们先将config/jwt.php
里的ttl
从60
改成1
。意味着重新生成的token将会1分钟后过期。
然后我们重新登录获取到token
,替换/api/v1/users/info
原有的token,进行访问,可以正常返回用户的信息。
等过了一分钟,我们再进行访问,发现依旧可以返回用户信息,但是我们在返回的Headers
的Authorization
可以看到新的token
。
此时如果我们再次访问,则报出异常1
2
3
4
5{
"status": "error",
"code": 422,
"message": "未登录或登录状态失效"
}
我们替换上新的token
,再次访问,访问正常通过。
4.现在,我们接着继续做token
和刷新时间都过期的情况,会发生什么。我们再将config/jwt.php
里的refresh_ttl
从20160
改成2
。
重新按照3步骤执行一次,当刚过一分钟时,返回结果与3相同,都是正常返回信息并且在Headers
携带了新的token。
当2分钟过后,报如下错误信息。1
2
3
4
5{
"status": "error",
"code": 422,
"message": "未登录或登录状态失效"
}
5.为了后面的方便,我们将修改的ttl
和refresh_ttl
的时间复原。
上面可以看出,当token过期或者无效以及乱写,返回的HTTP状态码
都是422
。这是因为这个异常被我们上面自定义异常捕捉了1
UnauthorizedHttpException::class=>['未登录或登录状态失效',422],
所以,可以跟前端小伙伴商量一个状态码,专门表示接收到这个状态码就要退回重新登录了。当Header
头携带Authorization
时,就要及时自动替换新的token,不需要回到重新登录界面。这样用户就能完全无感知啦~
如果我们的系统不仅仅只有一种角色身份,还有其他的角色身份需要认证呢?目前我们的角色认证是认证Users
表的,如果我们再加入一个Admins
表,也要角色认证要如何操作?
我们将数据库的Users
表复制一份,将其命名为Admins
表,并且将其中的一个用户名进行修改,以示区别。
我们分别将User.php
模型文件,UserEnum.php
枚举文件,UserResource.php
资源文件,UserRequest.php
验证器文件UserController.php
控制器文件各复制一份,更改为Admin
的,并将其中内容也改为Admin
相关。因为就是复制粘贴,把user
改成admin
,由于篇幅问题具体修改过程我就不放代码了。具体的可以看下面的成品
打开config/auth.php
文件,修改如下内容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'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
'admin' => [
'driver' => 'jwt',
'provider' => 'admins',
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
'admins' => [
'driver' => 'eloquent',
'model' => App\Models\Admin::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
此时,guard守护就多了一个admin
,当Auth::guard('admin')
时,就会自动查找Admin
模型文件,这样就能跟上面的User
模型认证分开了。
我们需要再复制一个刷新用户认证的中间件,专门为admin
认证以及刷新token
.app/Http/Controllers/Middleware/Api/RefreshAdminTokenMiddleware.php
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
namespace App\Http\Middleware\Api;
use Auth;
use Closure;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
// 注意,我们要继承的是 jwt 的 BaseMiddleware
class RefreshAdminTokenMiddleware extends BaseMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
*/
public function handle($request, Closure $next)
{
// 检查此次请求中是否带有 token,如果没有则抛出异常。
$this->checkForToken($request);
// 使用 try 包裹,以捕捉 token 过期所抛出的 TokenExpiredException 异常
try {
// 检测用户的登录状态,如果正常则通过
if ($this->auth->parseToken()->authenticate()) {
return $next($request);
}
throw new UnauthorizedHttpException('jwt-auth', '未登录');
} catch (TokenExpiredException $exception) {
// 此处捕获到了 token 过期所抛出的 TokenExpiredException 异常,我们在这里需要做的是刷新该用户的 token 并将它添加到响应头中
try {
// 刷新用户的 token
$token = $this->auth->refresh();
// 使用一次性登录以保证此次请求的成功
Auth::guard('admin')->onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()['sub']);
} catch (JWTException $exception) {
// 如果捕获到此异常,即代表 refresh 也过期了,用户无法刷新令牌,需要重新登录。
throw new UnauthorizedHttpException('jwt-auth', $exception->getMessage());
}
}
// 在响应头中返回新的 token
return $this->setAuthenticationHeader($next($request), $token);
}
}
打开 app/Http 目录下的 Kernel.php 文件,添加如下一行1
2
3
4protected $routeMiddleware = [
......
'admin.refresh'=>\App\Http\Middleware\Api\RefreshAdminTokenMiddleware::class,
];
routes/api.php1
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
use Illuminate\Http\Request;
Route::namespace('Api')->prefix('v1')->middleware('cors')->group(function () {
//用户注册
Route::post('/users', 'UserController@store')->name('users.store');
//用户登录
Route::post('/login', 'UserController@login')->name('users.login');
Route::middleware('api.refresh')->group(function () {
//当前用户信息
Route::get('/users/info', 'UserController@info')->name('users.info');
//用户列表
Route::get('/users', 'UserController@index')->name('users.index');
//用户信息
Route::get('/users/{user}', 'UserController@show')->name('users.show');
//用户退出
Route::get('/logout', 'UserController@logout')->name('users.logout');
});
//管理员注册
Route::post('/admins', 'AdminController@store')->name('admins.store');
//管理员登录
Route::post('/admin/login', 'AdminController@login')->name('admins.login');
Route::middleware('admin.refresh')->group(function () {
//当前管理员信息
Route::get('/admins/info', 'AdminController@info')->name('admins.info');
//管理员列表
Route::get('/admins', 'AdminController@index')->name('admins.index');
//管理员信息
Route::get('/admins/{user}', 'AdminController@show')->name('admins.show');
//管理员退出
Route::get('/admins/logout', 'AdminController@logout')->name('admins.logout');
});
});
app/Http/Controllers/Api/AdminController.php
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
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\UserRequest;
use App\Http\Resources\Api\AdminResource;
use App\Models\Admin;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AdminController extends Controller
{
//返回用户列表
public function index(){
//3个用户为一页
$admins = Admin::paginate(3);
return AdminResource::collection($admins);
}
//返回单一用户信息
public function show(Admin $admin){
return $this->success(new AdminResource($admin));
}
//返回当前登录用户信息
public function info(){
$admins = Auth::guard('admin')->user();
return $this->success(new AdminResource($admins));
}
//用户注册
public function store(UserRequest $request){
Admin::create($request->all());
return $this->setStatusCode(201)->success('用户注册成功');x`
}
//用户登录
public function login(Request $request){
$token=Auth::guard('admin')->attempt(['name'=>$request->name,'password'=>$request->password]);
if($token) {
return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
}
return $this->failed('账号或密码错误',400);
}
//用户退出
public function logout(){
Auth::guard('admin')->logout();
return $this->success('退出成功...');
}
}
我们将admin
这边登陆返回的token放在admin
的请求用户信息接口,看看会不会串号。结果返回1
2
3
4
5
6
7
8
9
10
11{
"status": "success",
"code": 200,
"data": {
"id": 1,
"name": "guaosi123",
"status": "正常",
"created_at": "2019-02-26 08:12:31",
"updated_at": "2019-02-26 08:12:31"
}
}
我们再将token放在user
的请求用户信息接口,看看会不会串号。结果返回1
2
3
4
5
6
7
8
9
10
11
12
13{
{
"status": "success",
"code": 200,
"data": {
"id": 1,
"name": "guaosi123",
"status": "正常",
"created_at": "2019-02-26 08:12:31",
"updated_at": "2019-03-01 01:48:12"
}
}
}
看来jwt-auth
真的串号了,这个问题我们下面再开一个标题进行解决。
1.当我们编写登陆,退出,获取当前用户信息的时候,都需要1
Auth::guard('admin')
通过制定guard
的具体守护是哪一个。因为框架默认的guard
默认守护的是web
。
所以,我希望可以让guard
自动化,如果我请求的是users
的,我就守护api
。如果我请求的是admins
的,我就守护admin
。
接下来,就以admins
的为例,users
的保持不动
2.新建中间件1
php artisan make:middleware Api/AdminGuardMiddleware
打开app/Http/Middleware/Api/AdminGuardMiddleware.php
文件,填入以下内容1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace App\Http\Middleware\Api;
use Closure;
class AdminGuardMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
*/
public function handle($request, Closure $next)
{
config(['auth.defaults.guard'=>'admin']);
return $next($request);
}
}
3.添加中间件别名
打开 app/Http
目录下的 Kernel.php
文件,添加如下一行1
2
3
4protected $routeMiddleware = [
......
'admin.guard'=>\App\Http\Middleware\Api\AdminGuardMiddleware::class,
];
4.修改路由
接着我们将路由进行修改,添加上我们写好的中间件。routes/api.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16Route::middleware('admin.guard')->group(function () {
//管理员注册
Route::post('/admins', 'AdminController@store')->name('admins.store');
//管理员登录
Route::post('/admin/login', 'AdminController@login')->name('admins.login');
Route::middleware('admin.refresh')->group(function () {
//当前管理员信息
Route::get('/admins/info', 'AdminController@info')->name('admins.info');
//管理员列表
Route::get('/admins', 'AdminController@index')->name('admins.index');
//管理员信息
Route::get('/admins/{user}', 'AdminController@show')->name('admins.show');
//管理员退出
Route::get('/admins/logout', 'AdminController@logout')->name('admins.logout');
});
});
5.修改控制器app/Http/Controllers/Api/AdminController.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19//返回当前登录用户信息
public function info(){
$admins = Auth::user();
return $this->success(newAdminResource($admins));
}
//用户登录
public function login(Request $request){
$token=Auth::attempt(['name'=>$request->name,'password'=>$request->password]);
if($token) {
return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
}
return $this->failed('账号或密码错误',400);
}
//用户退出
public function logout(){
Auth::logout();
return $this->success('退出成功...');
}
6.测试结果
将admin
登陆后的token再次携带访问/api/v1/admins/info
,依旧可以正常输出当前用户信息。
user的自动区分请自己填写,这里就不再啰嗦一遍了。
首先,我们需要知道一个问题,jwt-auth
颁发的token
里面是不包含模型驱动
的。也就是说,通过这个令牌,我们不知道它到底是属于api
还是属于admin
的。
折腾了一晚上,百度了很多资料,想找找有没有解决办法。结果找到的都是没什么作用的,或者是让自动刷新失效了。最后自己追源码,找到了这种比较完美的方式。
我们先来看几个我们在中间件中用的函数1
2
3
4
5
6
7
8$this->checkForToken($request)
//这个函数只会检测是否携带token以及token是否能被当前密钥所解析
$this->auth->parseToken()->authenticate()
//将使用token进行登录,如果token过期,则抛出 TokenExpiredException 异常
$this->auth->refresh();
//刷新当前token
然后我们再来看一个有趣的函数1
2
3
4Auth::check();
//可以根据当前的`guard`来判断这个token是否属于这个 guard ,不是则抛出 TokenInvalidException 异常
//但是,当token过期时,无论是不是属于这个 guard ,它也是都抛出 TokenInvalidException 异常。这导致我们无法正常判断出到底是属于哪种问题
//所以,想要用check()来判断,是不可能的。
接着,我们继续看一个有意思的函数1
2
3Auth::payload();
//可以输出当前token的载荷信息(也就是token解析后的内容)
//但是,如果你这个token已经过期了,那这个函数将会报错
我们通过Auth::payload()
可以看到未过期token的载荷信息1
2
3
4
5
6
7
8
9{
"sub": "1",
"iss": "http://test.com/api/v1/admin/login",
"iat": 1551407332,
"exp": 1551407392,
"nbf": 1551407332,
"jti": "f9zwcMHaXBr5kQYp",
"prv": "df883db97bd05ef8ff85082d686c45e832e593a9"
}
我们其实是可以拿到这些荷载信息的。同时,我们也可以加入自己的信息,这样在中间件时候进行解析,拿到我们的负载,就可以进行判断是否是属于当前guard
的token了。
修改 app\Http\Controllers\Api\AdminController.php
中的 login
方法,在token
中加入我们定义的字段。1
2
3
4
5
6
7
8
9
10
11//用户登录
public function login(Request $request)
{
//获取当前守护的名称
$present_guard =Auth::getDefaultDriver();
$token = Auth::claims(['guard'=>$present_guard])->attempt(['name' => $request->name, 'password' => $request->password]);
if ($token) {
return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
}
return $this->failed('账号或密码错误', 400);
}
再修改中间件app/Http/Middleware/Api/RefreshAdminTokenMiddleware.php
,让其就算过期token
也能读取出里面的信息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
namespace App\Http\Middleware\Api;
use Auth;
use Closure;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
// 注意,我们要继承的是 jwt 的 BaseMiddleware
class RefreshAdminTokenMiddleware extends BaseMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
* @throws TokenInvalidException
*/
public function handle($request, Closure $next)
{
// 检查此次请求中是否带有 token,如果没有则抛出异常。
$this->checkForToken($request);
//1. 格式通过,验证是否是专属于这个的token
//获取当前守护的名称
$present_guard = Auth::getDefaultDriver();
//获取当前token
$token=Auth::getToken();
//即使过期了,也能获取到token里的 载荷 信息。
$payload = Auth::manager()->getJWTProvider()->decode($token->get());
//如果不包含guard字段或者guard所对应的值与当前的guard守护值不相同
//证明是不属于当前guard守护的token
if(empty($payload['guard'])||$payload['guard']!=$present_guard){
throw new TokenInvalidException();
}
//使用 try 包裹,以捕捉 token 过期所抛出的 TokenExpiredException 异常
//2. 此时进入的都是属于当前guard守护的token
try {
// 检测用户的登录状态,如果正常则通过
if ($this->auth->parseToken()->authenticate()) {
return $next($request);
}
throw new UnauthorizedHttpException('jwt-auth', '未登录');
} catch (TokenExpiredException $exception) {
// 3. 此处捕获到了 token 过期所抛出的 TokenExpiredException 异常,我们在这里需要做的是刷新该用户的 token 并将它添加到响应头中
try {
// 刷新用户的 token
$token = $this->auth->refresh();
// 使用一次性登录以保证此次请求的成功
Auth::onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()['sub']);
} catch (JWTException $exception) {
// 如果捕获到此异常,即代表 refresh 也过期了,用户无法刷新令牌,需要重新登录。
throw new UnauthorizedHttpException('jwt-auth', $exception->getMessage());
}
}
// 在响应头中返回新的 token
return $this->setAuthenticationHeader($next($request), $token);
}
}
这个中间件是通用的,可以直接替换User的刷新用户认证中间件噢
此时再次进行测试是否串号,最后结果可以成功阻止之前的串号问题,暂未发现其他BUG。
user的修复串号问题请自己修改,这里就不再啰嗦一遍了。
同一时间只允许登录唯一一台设备。例如设备 A 中用户如果已经登录,那么使用设备 B 登录同一账户,设备 A 就无法继续使用了。
我们在登陆,token
过期自动更换的时候,都会产生一个新的token
。
我们将token
都存到表中的last_token
字段。在登陆接口,获取到last_token
里的值,将其加入黑名单。
这样,只要我们无论在哪里登陆,之前的token
一定会被拉黑失效,必须重新登陆,我们的目的也就达到了。
修改 app\Http\Controllers\Api\AdminController.php
中的 login
方法,在登陆的时候,拉黑上一个token
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22//用户登录
public function login(Request $request)
{
//获取当前守护的名称
$present_guard =Auth::getDefaultDriver();
$token = Auth::claims(['guard'=>$present_guard])->attempt(['name' => $request->name, 'password' => $request->password]);
if ($token) {
//如果登陆,先检查原先是否有存token,有的话先失效,然后再存入最新的token
$user = Auth::user();
if ($user->last_token) {
try{
Auth::setToken($user->last_token)->invalidate();
}catch (TokenExpiredException $e){
//因为让一个过期的token再失效,会抛出异常,所以我们捕捉异常,不需要做任何处理
}
}
$user->last_token = $token;
$user->save();
return $this->setStatusCode(201)->success(['token' => 'bearer ' . $token]);
}
return $this->failed('账号或密码错误', 400);
}
再修改中间件app/Http/Middleware/Api/RefreshAdminTokenMiddleware.php
,更新的token
加到last_token
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
namespace App\Http\Middleware\Api;
use Auth;
use Closure;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
// 注意,我们要继承的是 jwt 的 BaseMiddleware
class RefreshAdminTokenMiddleware extends BaseMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @throws \Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException
*
* @return mixed
* @throws TokenInvalidException
*/
public function handle($request, Closure $next)
{
// 检查此次请求中是否带有 token,如果没有则抛出异常。
$this->checkForToken($request);
//1. 格式通过,验证是否是专属于这个的token
//获取当前守护的名称
$present_guard = Auth::getDefaultDriver();
//获取当前token
$token=Auth::getToken();
//即使过期了,也能获取到token里的 载荷 信息。
$payload = Auth::manager()->getJWTProvider()->decode($token->get());
//如果不包含guard字段或者guard所对应的值与当前的guard守护值不相同
//证明是不属于当前guard守护的token
if(empty($payload['guard'])||$payload['guard']!=$present_guard){
throw new TokenInvalidException();
}
//使用 try 包裹,以捕捉 token 过期所抛出的 TokenExpiredException 异常
//2. 此时进入的都是属于当前guard守护的token
try {
// 检测用户的登录状态,如果正常则通过
if ($this->auth->parseToken()->authenticate()) {
return $next($request);
}
throw new UnauthorizedHttpException('jwt-auth', '未登录');
} catch (TokenExpiredException $exception) {
// 3. 此处捕获到了 token 过期所抛出的 TokenExpiredException 异常,我们在这里需要做的是刷新该用户的 token 并将它添加到响应头中
try {
// 刷新用户的 token
$token = $this->auth->refresh();
// 使用一次性登录以保证此次请求的成功
Auth::onceUsingId($this->auth->manager()->getPayloadFactory()->buildClaimsCollection()->toPlainArray()['sub']);
//刷新了token,将token存入数据库
$user = Auth::user();
$user->last_token = $token;
$user->save();
} catch (JWTException $exception) {
// 如果捕获到此异常,即代表 refresh 也过期了,用户无法刷新令牌,需要重新登录。
throw new UnauthorizedHttpException('jwt-auth', $exception->getMessage());
}
}
// 在响应头中返回新的 token
return $this->setAuthenticationHeader($next($request), $token);
}
}
我们先登陆一次/api/v1/admin/login
,将获取到token
携带访问/api/v1/admins/info
。正常访问。
当我们再次请求登陆/api/v1/admin/login
,然后继续用原token
访问/api/v1/admins/info
,提示错误。
user的请自行添加,自行测试结果
开发中,我们也经常需要使用异步队列,来加快我们的响应速度。比如发送短信,发送验证码等。但是队列执行结果的成功或者失败只能通过日志来查看。这里,我们使用horizonl
来管理异步队列,完成登陆和刷新token
时,将token
存入last_token
的因为放在异步完成。
Horizon 提供了一个漂亮的仪表盘,并且可以通过代码配置你的 Laravel Redis 队列,同时它允许你轻易的监控你的队列系统中诸如任务吞吐量,运行时间和失败任务等关键指标。
horizon
的详细介绍可以查看手册。1
composer laravel/horizon
1 | php artisan vendor:publish --provider="Laravel\Horizon\HorizonServiceProvider" |
打开 .env
文件,将QUEUE_CONNECTION
从sync
改成redis
1
QUEUE_CONNECTION=redis
仪表盘不能通过接口访问。所以我们做验证的时候,可以通过指定的IP
才能正常通过进入仪表盘。IP
可以写在.env
文件里,当IP发生变化时进行修改。
在 .env
最后加上一行1
2
3HORIZON_IP=想通过访问的IP地址
比如
HORIZON_IP=127.0.0.1
修改改app/Providers/AuthServiceProvider.php
文件 里的 boot
方法1
2
3
4
5
6
7
8
9
10
11
12
13public function boot()
{
$this->registerPolicies();
Horizon::auth(function($request){
if(env('APP_ENV','local') =='local'{
return true;
}else{
$get_ip=$request->getClientIp();
$can_ip=en('HORIZON_IP''127.0.0.1');
return $get_ip == $can_ip;
}
});
}
创建一个专门负责保存last_token
的任务类1
php artisan make:job Api/SaveLastTokenJob
打开 app/Jobs/Api/SaveLastTokenJob.php
文件 ,填写以下内容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
namespace App\Jobs\Api;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class SaveLastTokenJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $model;
protected $token;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($model,$token)
{
//
$this->model=$model;
$this->token=$token;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
//
$this->model->last_token = $this->token;
$this->model->save();
}
}
将控制器与中间件里的1
2$user->last_token = $token;
$user->save();
统一替换为1
SaveLastTokenJob::dispatch($user,$token);
1 | php artisan horizon |
此时,进程处于阻塞状态。
打开浏览器输入http://你的域名/horizon
,可以看到Horizon
仪表盘。
我们可以使用Supervisor来守护我们的horizon阻塞进程。具体方法可以看我之前写的文章:安装和使用守护进程–Supervisor
确认horizon
已经正常启动。然后我们访问/api/v1/admin/login
这个登陆接口。打开数据库可以发现,last_token
与返回结果的token
相同。我们也可以再打开仪表盘,看任务完成情况
如果修改了job
类的源码,需要将horizon
重新启动,否则代码还是未改动前的。(应该是horzion
是将所有任务类常驻内存的原因)
到此,所有修改已经全部完成,如果还有新的更改也会实时更新。同时,本文中的所有修改都已经在正式项目中运行过了。
如果你已经看完了整篇文章,知道了修改的原因,但是不想受累自己修改一遍。我已经将修改后的上传到全球最大的同性交友网站了,可以直接点击这里直接搬走。或者复制下方的链接打开。
项目地址:
]]> 在电商网站或者外卖网站,通常都有会一种需求。当一个用户下单后没有支付,此时库存量已经减少,需要取消订单才能回复库存量。取消订单有2种方式:1.用户手动取消
,2.系统在指定时间过后,比如淘宝的30分钟,自动取消
。
那么,应该如何来实现系统在指定时间过后,自动取消订单?
以下提供三种方案参考:
1) 使用Linux
内置的crontab
定时任务,每隔几秒甚至几分钟轮训遍历一次数据库,找到超出时间间隔的订单,进行取消。这种办法没有失效性以及在没有订单的时间内属于浪费服务器资源。
2) 使用框架内置的延时处理机制。比如Laravel
的队列任务,可以指定多少分钟后执行。这样就能判断订单是否超出时间间隔,是否要取消订单恢复库存量。
3) 使用Redis
的keyspace notification
(键空间通知)。Redis
可以设置一个key
到多久时间后过期,比如:SETEX name 123 20
,设置name
在20秒后过期。此时,过期会触发事件发布
,所有redis客户端
都会订阅,获得相关信息。
1 | Linux系统 |
相关安装教程自行百度,这里跳过。
找到redis.conf存放位置,比如我的是usr/local/etc/redis/redis.conf
1
vim usr/local/etc/redis/redis.conf
找到notify-keyspace-events
,如果没有就在最后添加上1
notify-keyspace-events "Ex"
保存退出后,重启redis1
service redis-server restart /usr/local/etc/redis/redis.conf
开启一个终端,redis-cli
进入redis
1
redis-cli
开始订阅所有操作,等待接收消息。1
psubscribe __keyevent@0__:expired
此时会监听0号库所有过期
的key。
再开启一个终端,redis-cli
进入redis
1
redis-cli
新增一个5秒过期的键name
1
setex name 5 guaosi
5秒后,原终端会输出如下1
2
3
41) "pmessage"
2) "__keyevent@0__:expired"
3) "__keyevent@0__:expired"
4) "name"
此时,成功监听到key为name
过期。
编写test.php
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
class MRedis
{
private $redis;
/**
* 构造函数
*
* @param string $host 主机号
* @param int $port 端口号
*/
public function __construct($host = 'redis', $port = 6379)
{
$this->redis = new redis();
$this->redis->connect($host, $port);
}
public function expire($key = null, $time = 0)
{
return $this->redis->expire($key, $time);
}
public function psubscribe($patterns = array(), $callback)
{
$this->redis->psubscribe($patterns, $callback);
}
public function setOption()
{
$this->redis->setOption(Redis::OPT_READ_TIMEOUT, -1);
}
}
function callback($redis, $pattern, $chan, $msg)
{
// 回调函数,这里写处理逻辑
echo "Pattern: $pattern\n";
echo "Channel: $chan\n";
echo "Payload: $msg\n";
}
$redis = new MRedis();
//redis会有默认连接时间,对 redis客户端进行一些参数设置,使读取超时参数 为 -1,表示不超时。
$redis->setOption();
//这里输入订阅,以及订阅成功后触发的函数名
//监听库为0里的过期key
$redis->psubscribe(array('__keyevent@0__:expired'), 'callback');
使用cli模式执行1
php test.php
此时可以看到变成了阻塞进程
然后我们回到redis-cli
下,再生成一个过期key
1
setex name 5 guaosi
5秒后,cli模式下输出1
2
3Pattern: __keyevent@0__:expired
Channel: __keyevent@0__:expired
Payload: name
则变量$msg
就是过期的key的名称
,我们只能获取到key的名称,不能获得到原来设置的值。
以Laravel框架为例,Laravel自己本身已经支持Redis的订阅模式了,查看文档详情
确认Laravel
已经安装了predis
扩展,如果没有安装只需执行1
composer predis/predis ^1.1
编辑config/database.php
,在redis部分修改如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15'redis' => [
'client' => 'predis',
'default' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DATABASE', 0),
'persistent' => true, // 开启持久连接
'read_write_timeout' => 0,
//据Predis作者在配置文件中说明
//因为在底层网络资源上执行读取或写入操作时使用了超时,默认设置了timeout 为60s。
//到60s自动断开并报错.设置成0可以解决这个问题。
],
],
在app/Http/Controllers
下新建控制器OrderController.php
,填下以下测试内容1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Request;
class OrderController extends Controller{
//创建用户订单
public function store(Request $request)
{
//这里是接收到用户传来的下单信息,存入数据库后,返回一个订单id
//我们让返回的订单ID为2019
$order_id = 2019;
//因为一个项目中可能会有很多使用到setex的地方,所以给订单id加个前缀
$order_prefix_id = 'order_'.$order_id;
//将订单ID存入redis缓存中,并且设置过期时间为5秒
$key_name = $order_prefix_id; //我们在订阅中只能接收到$key_name的值
$expire_second = 5; //设置过期时间,单位为秒
$value = $order_id;
Redis::setex($key_name,$expire_second,$value);
echo "设置过期key=".$order_prefix_id."成功";
}
}
然后编辑routes/web.php
定义路由为/order
1
Route::get('/order', 'OrderController@store')->name('order.store');
最后编写command命令,让订阅监听在后台运行.
在app/Console/Commands
下新建OrderCancel.php
文件,填下以下测试内容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
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class OrderCancel extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'order:cancel';
/**
* The console command description.
*
* @var string
*/
protected $description = '过期订单处理';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
//项目中有可能用的redis不是0,所以这里用env配置里面获取的
$publish_num=env('REDIS_DATABASE', 0);
Redis::psubscribe(['__keyevent@'.$publish_num.'__:expired'], function ($message, $channel) {
//$message 就是我们从获取到的过期key的名称
$explode_arr=explode('_',$message);
$prefix=$explode_arr[0];
if($prefix=='order'){
$order_id=$explode_arr[1];
echo $order_id;
//这里就是编写过期的订单,过期后要如何处理的业务逻辑
//TODO
}
});
}
}
此时,在项目根目录下运行1
php artisan order:cancel
进行订阅监听,此时进程处于阻塞状态
然后用浏览器访问1
http://项目虚拟站点地址/order
浏览器输出1
设置过期key=order_2019成功
5秒后,订阅监听窗口输出1
2019
至此,完成我们的需求。
阻塞进程不做处理,在关闭窗口后就会自动跳出。需要使用Supervisor
守护进程,让阻塞进程保持持续运行状态,并且发送错误退出了也会自动自动。安装以及配置Supervisor
可以参考这里.
在Linux上有时候需要开启一个阻塞进程来监听操作。当ssh连上服务器,直接运行一个阻塞进程,然后退出服务器时,这个阻塞进程也会跟着关闭。
以下提供三种方案参考:
nohup ./xxx &
将进程后台挂起,不过有效期只有12小时,会被自动杀进程。screen
建立窗口,将阻塞进程运行在该窗口内,这个窗口可以被多个物理窗口所复用(就是就算这次退出了服务器连接,下次再连进来还是依旧存在的),貌似可以满足我们的要求了。但是如果阻塞进程出错,阻塞进程不会自动重新启动,需要我们手动干预。Supervisor
守护进程。它可以很方便的监听、启动、停止、重启一个或多个进程。用Supervisor管理的进程,当一个进程意外被杀死,supervisort监听到进程死后,会自动将它重新拉起,很方便的做到进程自动恢复的功能,不再需要自己写shell脚本来控制。1 | yum install epel-release |
1 | apt-get install supervisor |
在/etc/supervisor/
目录下有个conf.d
的文件夹和supervisord.conf
配置文件。打开配置文件1
vim supervisord.conf
我们可以看到1
2[include]
files = /etc/supervisor/conf.d/*.conf
意思是Supervisor
在启动的时候会加载conf.d目录下所有的conf配置文件。
下面给出2个参考配置的案例
laravel的horizon守护进程配置1
2cd /etc/supervisor/conf.d/
vim horizon.conf
填入以下内容1
2
3
4
5
6
7
8[program:horizon]
process_name=%(program_name)s
command=php /home/wwwroot/www.guaosi.com/artisan horizon ; 阻塞进程执行的命令
autostart=true ; 阻塞进程是否跟着Supervisor一起开机自动
autorestart=true ; 阻塞进程被异常退出是否自动重启
user=www ; 由哪个用户执行阻塞进程的命令
redirect_stderr=true
stdout_logfile=/home/wwwroot/www.guaosi.com/storage/logs/horizon.log ; 阻塞进程打印到控制台的内容写到哪里
1 | cd /etc/supervisor/conf.d/ |
填入以下内容1
2
3
4
5
6
7
8
9[program:yii-queue-worker]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php7.2 /home/wwwroot/www.guaosi.com/yii queue/listen --verbose=1 --color=0 ; 阻塞进程执行的命令
autostart=true ; 阻塞进程是否跟着Supervisor一起开机自动
autorestart=true ; 阻塞进程被异常退出是否自动重启
user=www ; 由哪个用户执行阻塞进程的命令
numprocs=10 ; 启动几个阻塞进程
redirect_stderr=true
stdout_logfile=/home/wwwroot/www.guaosi.com/runtime/logs/yii2-queue.log ; 阻塞进程打印到控制台的内容写到哪里
1 | systemctl start supervisord |
1 | supervisord -c /etc/supervisor/supervisord.conf |
新建文件supervisord.service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16#supervisord.service
[Unit]
Description=Supervisor daemon
[Service]
Type=forking
ExecStart=/usr/bin/supervisord -c /etc/supervisord.conf
ExecStop=/usr/bin/supervisorctl shutdown
ExecReload=/usr/bin/supervisorctl reload
KillMode=process
Restart=on-failure
RestartSec=42s
[Install]
WantedBy=multi-user.target
将文件拷贝到/usr/lib/systemd/system/
1
cp supervisord.service /usr/lib/systemd/system/
启动服务1
systemctl enable supervisord
验证一下是否为开机启动1
systemctl is-enabled supervisord
编辑/etc/rc.local
文件1
vi /etc/rc.local
在exit 0
之前加入以下命令1
/usr/local/bin/supervisord
保存并退出
最后修改rc.local
权限1
chmod +x /etc/rc.local
重新启动配置中的所有程序
1 | supervisorctl reload |
启动某个进程(program_name=你配置中写的程序名称)
1 | supervisorctl start program_name |
查看正在守候的进程
1 | supervisorctl |
停止某一进程 (program_name=你配置中写的程序名称)
1 | supervisorctl stop program_name |
重启某一进程 (program_name=你配置中写的程序名称)
1 | supervisorctl restart program_name |
停止全部进程
1 | supervisorctl stop all |
注意:显示用stop停止掉的进程,用reload或者update都不会自动重启。
被Supervisor
守护的进程都是常驻内存
的,即如果修改了被守护的进程的源码
,需要重启对这个进程的守护才能生效,否则还是未修改前的。1
supervisorctl restart program_name
随着网速的提升,越来越多人选择使用网络云盘来存储自己的文件资料。国内代表有百度云
以及腾讯云
,天翼云
等,国外则是oneDrive
,谷歌云盘
,dropbox
等。国内云盘虽然都打着大容量
的旗号,但是不开会员下载速度就被恶心限速
。国外的网盘则需要科学上网,并且容量也小,优点就是不限速。
微软的oneDrive虽然网页版在国内会被墙,但是客户端是正常使用的,并且不限速,只是普通的微软账号的oneDrive只有5G。
微软有一个产品,叫做office365
,如果是教育版则可以让oneDrive变成1T或者5T。用国内教育邮箱注册的office365一般是1T,并且只能使用网页版的office365套件。如果是使用国外教育邮箱注册的,则可以使用桌面版的office365套件(就是office办公软件全家桶,正版!)
以下提供3种办法:
1) 微软的Office 365开发者计划,免费获得一年的21TB OneDrive和Microsoft Office 365企业。不过只有1年有效期。
2) 临时教育邮箱申请office365,不过不支持桌面版office365。1
2
31)、进入注册地址https://products.office.com/en-us/student?tab=students
2)、输入如有乐享提供的临时邮箱,地址:https://51.ruyo.net/8263.html
3)、填入密码,和从临时邮箱获取的验证码
3) 去某宝或者某鱼购买国外的教育邮箱
账号,也不贵,前几天买的就10元,这个账号提供1T的oneDrive跟桌面版office365,没有限制时间。并且,它提供无限容量的谷歌网盘!这个是国内教育邮箱办不到的。
根据这篇文章做完,你将会获得如下功能的网盘
1) 1T或者5T的大容量不限速不用科学上网
2) 安全稳定,微软提供技术支持
3) 支持迅雷以及多线程下载工具不限速下载
4) 在线查看office各类文件
5) 在线观看电影,支持字幕(字幕获取待优化)
6) 在线播放音乐,支持歌词(支持歌词待做)
7) 在线查看代码
8) 支持文件密码访问保护
9) 离线BT下载
10) 下载以及在线类的,都是走oneDrive
服务器带宽,本服务器带宽无论多小都能正常使用
lnmp环境要求1
2
3
4
5
6
7
8
9PHP >= 7.1.3
OpenSSL PHP
PHP PDO 扩展
PHP Mbstring 扩展
PHP Tokenizer 扩展
PHP XML 扩展
PHP Ctype 扩展
PHP JSON 扩展
PHP Fileinfo 扩展
本文安装部分参考Rat’s,目录列表程序修改自OlaIndex,aria2自动上传脚本来自萌咖
接下来以我安装过程为例。环境为lnmp一键包环境,我创建的站点为https://cloud.guaosi.com,不知道如何安装lnmp和创建站点可以参考[这里](https://https://www.guaosi.com/2018/12/18/environment-by-lnmp/)
1 | lnmp vhost add |
添加一个cloud.guaosi.com
的站点
这里可以使用我修改的OlaIndex,或者使用OlaIndex原版,也可以使用OneIndex。都大同小异,选一个最好看最适合自己的就好了。下面以我修改的为例。
1 | cd /home/wwwroot/cloud.guaosi.com |
如果在composer install提示没有安装fileinfo扩展,可以参考我的安装php扩展:Fileinfo
1 | vim /usr/local/nginx/conf/vhost/cloud.guaosi.com.conf |
加入以下内容1
2
3location / {
try_files $uri $uri/ /index.php?$args;
}
不知道怎么加的,可以参考这里
安装完成后,访问 https://cloud.guaosi.com ,会自动跳转到安装界面,两个redirect_uri
都填写https://cloud.guaosi.com
即可。
然后点击申请
:
1) 跳转到微软获取秘钥(打不开可能要科学上网),然后将秘钥复制后粘贴到client_secret
,
2) 然后点击知道了,返回到快速启动
,将APP ID
内容复制到client_id
,
3) 最后点击保存
。最后点击绑定
,跳转到微软授权页面,成功后会看到目录列表显示。
如果失败了,就返回再点击绑定或者返回修改,多试几次。
绑定成功后,访问 https://cloud.guaosi.com/admin 进行后台相关设置,初始化密码为:12345678
,记得修改初始化密码。因为配置是走缓存的,所以修改了配置后,要点击清理缓存
才会生效。
1) 这里用的逗比的脚本,使用命令:1
2
3wget -N --no-check-certificate https://raw.githubusercontent.com/ToyoDAdoubiBackup/doubi/master/aria2.sh && chmod +x aria2.sh && bash aria2.sh
#备用地址
wget -N --no-check-certificate https://www.moerats.com/usr/shell/Aria2/aria2.sh && chmod +x aria2.sh && bash aria2.sh
2) 安装完成后,如果我们想修改密码、下载文件位置、端口的话,可以使用命令1
vim /root/.aria2/aria2.conf
3) 找到对应的选项进行修改,这里面有个rpc-secret
,是Aria2的密码,下面AriaNg会用到。
4) 重启Aria2,修改的配置文件才会生效1
service aria2 restart
1) 这一步和安装目录列表程序差不多,先添加一个域名,不想用域名的可以在添加域名那里填上IP或者IP:端口,然后将AriaNg程序上传到对应站点的根目录(比如我是aria.guaosi.com),可以使用命令1
2
3yum install unzip -y
cd /www/wwwroot/aria.guaosi.com
wget https://www.moerats.com/usr/down/aria-ng-0.2.0.zip && unzip aria-ng-0.2.0.zip
2) 这时候我们就可以使用域名访问AriaNg界面了,或者IP:端口。有的服务器提供商会默认关闭Aria的端口(6800)
,记得去开启。
3) 目前AriaNg还没连上我们服务器里的Aria2服务。在Aria2 RPC 协议
选择Https
。在Aria2 RPC 秘钥
填写你在Aria2获取的rpc-secret
。然后重新加载页面
。
4) 此时侧边栏Aria2 状态
应该是显示已连接
如果你想做成Https:
1) 打开Aria2配置文件1
vim /root/.aria2/aria2.conf
2) 修改如下位置1
2
3
4
5
6# 启用加密后 RPC 服务需要使用 https 或者 wss 协议连接
rpc-secure=true
# 在 RPC 服务中启用 SSL/TLS 加密时的证书文件(.pem/.crt)
rpc-certificate=你的ssl证书绝地路径
# 在 RPC 服务中启用 SSL/TLS 加密时的私钥文件(.key)
rpc-private-key=你的ssl证书绝地路径
3) 重启Aria2服务1
service aria2 restart
4) 在AriaNg界面的AriaNg 设置
- RPC栏目
。在Aria2 RPC 协议
选择Https
。
摘抄自萌咖
1) 确保服务器已经安装了curl扩展,如果没有安装,则执行1
2
3
4
5
apt-get install -y curl
yum install curl -y
2) 安装萌咖做的oneDrive的bash1
2#为了方便小白,本脚本内置萌咖大佬永久有效的应用参数,可以直接使用,如果你不放心可以自己获取参数,不过可能会遇到很多坑,建议直接使用脚本默认的参数
wget --no-check-certificate -qO- "https://raw.githubusercontent.com/0oVicero0/OneDrive/master/OneDrive.sh" |bash
3) 运行账号认证程序
运行命令onedrive -a,将返回的网址复制到浏览器打开,再登陆你的OneDrive for Business账号,登陆成功后复制地址栏中的地址(复制包括localhost的所有链接地址),粘贴到SSH客户端里,敲回车键即可。
如果返回以下字段:It seems like we have a refresh token, so we are ready to go
,那就恭喜你,设置成功!1
提示:如果你遇到bash: onedrive: command not found错误,则需要找到/usr/local/etc/OneDrive文件夹,修改onedrive和onedrive-d脚本,在第二行都加上export PATH=/usr/local/bin:$PATH代码,再保存就行了。
1) 使用命令
onedrive –help1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23#####################################################################
Usage: onedrive [OPTIONS] file1 [file2...]
onedrive-d folder
Options:
-d, --debug Enable debug mode
-a, --authorize Run authorization process
-f, --folder Upload files into this remote folder
-c, --creat Creat remote folder."
Directory names are separated with a slash, e.g.
rootFolder/subFolder
Do NOT use a trailing slash!
-h, --help Show this help
-r, --rename Rename the files during upload
For each file you specify you MUST also specify
the remote filename as the subsequent parameter
Be especially careful with globbing!
-s, --silent Silent mode for use in crontab scripts.
Return only exit code.
-ls,--list Show the itmes in this directory.
-l, --link Show the file share link.
#####################################################################
2、命令示范
如果我们要上传/root
文件夹里面的moerats.txt
,使用命令:1
2
3
4
5
6#此命令默认上传到OneDrive根目录
onedrive '/root/moerats.txt'
#如果上传到指定文件夹,就需要加-f参数
onedrive -f RATS '/root/moerats.txt' #上传到OneDrive根目录的RATS文件夹
onedrive -f RATS/RATS '/root/moerats.txt' #上传到OneDrive根目录RATS文件夹里的RATS文件夹
如果我们要将/root
文件夹及里面的文件夹和文件一起上传,使用命令:1
2
3
4
5
6#此命令默认上传到OneDrive根目录
onedrive-d '/root'
#如果上传到指定文件夹,就需要加-f参数
onedrive-d -f RATS '/root' #上传到OneDrive根目录的RATS文件夹
onedrive-d -f RATS/RATS '/root' #上传到OneDrive根目录RATS文件夹里的RATS文件夹
如果我们想直接查看OneDrive网盘目录的文件,使用命令:1
2
3
4
onedrive -l
onedrive -l /root
1) 创建shell脚本1
vim /usr/local/rcloneupload.sh
2) 填入以下内容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
GID="$1";
FileNum="$2";
File="$3";
MaxSize="15728640"
RemoteDIR=""; #上传到Onedrive的路径,默认为根目录,如果要上传到指定目录,请看后面。
LocalDIR="/usr/local/caddy/www/aria2/Download/"; #Aria2下载目录,记得最后面加上/
if [[ -z $(echo "$FileNum" |grep -o '[0-9]*' |head -n1) ]]; then FileNum='0'; fi
if [[ "$FileNum" -le '0' ]]; then exit 0; fi
if [[ "$#" != '3' ]]; then exit 0; fi
function LoadFile(){
IFS_BAK=$IFS
IFS=$'\n'
if [[ ! -d "$LocalDIR" ]]; then return; fi
if [[ -e "$File" ]]; then
if [[ $(dirname "$File") == $(readlink -f $LocalDIR) ]]; then
ONEDRIVE="onedrive";
else
ONEDRIVE="onedrive-d";
fi
FileLoad="${File/#$LocalDIR}"
while true
do
if [[ "$FileLoad" == '/' ]]; then return; fi
echo "$FileLoad" |grep -q '/';
if [[ "$?" == "0" ]]; then
FileLoad=$(dirname "$FileLoad");
else
break;
fi;
done;
if [[ "$FileLoad" == "$LocalDIR" ]]; then return; fi
if [[ -n "$RemoteDIR" ]]; then
Option=" -f $RemoteDIR";
else
Option="";
fi
EXEC="$(command -v $ONEDRIVE)";
if [[ -z "$EXEC" ]]; then return; fi
cd "$LocalDIR";
if [[ -e "$FileLoad" ]]; then
ItemSize=$(du -s "$FileLoad" |cut -f1 |grep -o '[0-9]*' |head -n1)
if [[ -z "$ItemSize" ]]; then return; fi
if [[ "$ItemSize" -ge "$MaxSize" ]]; then
echo -ne "\033[33m$File \033[0mtoo large to spik.\n";
return;
fi
eval "${EXEC}${Option}" \'"${FileLoad}"\';
if [[ $? == '0' ]]; then
rm -rf "$FileLoad";
fi
fi
fi
IFS=$IFS_BAK
}
LoadFile;
如果你想上传到指定的文件夹,修改如下:1
2
3) 启用脚本
先授权1
chmod +x rcloneupload.sh
然后再到aria2.conf
中加上一行1
on-download-complete=/root/rcloneupload.sh
最后重启Aria2
生效。
4) 如果你觉得上传速度较慢,可以编辑/usr/local/etc/OneDrive/onedrive.cfg
,修改threads线程数,默认2。1
2#这里提供个快速修改线程数的命令,这里默认修改为5,建议别太高!
sed -i "s#max_upload_threads=2#max_upload_threads=5#g" '/usr/local/etc/OneDrive/onedrive.cfg'
在AriaNg
上下载文件,实际上是服务器通过Aria2
先把文件下载到服务器,然后服务器再通过oneDrive的bash
命令将服务器上下载的文件传到oneDrive
。当上传成功后,脚本会自动删除服务器上的源文件
。
所以,这里要考虑到的问题就是服务器的磁盘容量以及上传速度。
1) 下载的文件不能大于服务器磁盘容量
。
2) 上传速度与服务器带宽有关,比如服务器的带宽是1M,那么上传速度就是130kb/s或者140kb/s。如果想离线上传快速,那么就需要加大带宽了。
目前有4种上传方法:
1) 使用oneDrive客户端上传(推荐)
2) 使用AriaNg离线下载(推荐)
3) 使用oneDrive网页版本上传
4) 使用服务器安装的oneDrive
的bash
命令上传
在编译安装php或者lnmp安装的时候,可能由于服务器的配置不够(内存小于1G),会将fileinfo扩展默认不安装,已保证php可以正常安装。可是有的时候我们又必须安装fileinfo扩展。
php源码包下载地址(下载与当前安装的php相同版本的源码包): http://www.php.net/downloads.php
如果是lnmp安装的,则在lnmp下载处/src/
下可以看到php的源码包。
以lnmp1.5安装位置为例,我安装的是php7.2.6版本1
2
3
4
5tar xvf php-7.2.6.tar.bz2
cd php-7.2.6/ext/fileinfo
phpize
./configure --with-php-config=/usr/local/php/bin/php-config
make && make install
当make && make install
时报错提示内存不够时,可以参考Linux虚拟内存设置解决
以lnmp安装位置为例1
vim /usr/local/php/etc/php.ini
在最后一行加上1
2[FileInfo]
extension = fileinfo.so
保存退出后,重启php-fpm1
service php-fpm restart
开发过程中,可能会碰到安装某个扩展需要扩大本服务器的内存,但是给的硬件内存只有那么点,该如何处理呢?
Linux上开启虚拟内存即可解决。
虚拟内存是为了满足物理内存不足采用的策略,利用磁盘空间虚拟出一块逻辑内存,用作虚拟内存的空间也就是交换分区。
作为物理内存的扩展,Linux会在物理内存不足时,使用交换分区的逻辑内存,内核会把暂时不用的内存块信息写到交换空间,这样物理内存就得到了释放,这块儿内存就可以用于其他目的,而需要用到这些内容的时候,这些信息就会被重新从交换分区读入物理内存。
Linux的内存管理采用的是分页存取机制,为了保证物理内存得到充分的利用,内核会在适当的时间把物理内存中不经常使用的数据块儿自动交换到虚拟内存中,而将充分使用的信息保留到物理内存中。
用拥有ROOT权限
的用户登入到系统,进行创建swap分区,通过下面指令创建1G的虚拟内存1
dd if=/dev/zero of=/swap/swap bs=1024 count=1024000
1 | mkswap /swap/swap |
1 | swapon /swap/swap |
虽然现在已经生效,但是等下次服务器重启之后。该swap虚拟磁盘会失效,为保证永久生效,还需往/etc/fstab文件添加分区信息:1
echo "/swap/swap swap swap defaults 0 0" /etc/fstab
现在为止,swap分区已经完成创建。
]]>本文适合:
1.已经在linux虚拟机上搭建过环境,可以正常访问的,但是没有搭建在真实服务器上。
2.需要有略微的linux
基础。
3.有自己的小项目或者demo,如果没有,会composer
安装框架也是可以的。
因为本文适合偷懒使用,如果没有丝毫搭建经验的,所以我建议最好是创建一台虚拟机,自己手动安装nginx
,php
,MySQL
,让内网可以上线访问。有了相关的认识,再来看本文效果是最好的。
当大家做好自己的项目或者是demo,都想放在服务器上线,这样其他人才能随时随地访问使用。但是新拿到一台服务器后,可是服务器里nginx
,php
,mysql
这些必须环境都没有安装,去装的话又是很繁琐。
那么,有没有一种快速高效的办法,类似windows上的phpstudy
一样的集成环境呢?答案是有的,比如宝塔
,lnmp
。这里,我们选择已经很成熟的lnmp
的1.5
版本来快速构建环境。
LNMP官网: LNMP
首先,我们要知道域名是什么?为什么域名可以知道我们服务器在哪里?甚至还可以知道最后要访问我们哪一个项目?
百度上有相关的定义,我这里就不阐述了,直接举个例子吧。比如,我在万网或者腾讯云上购买了一个域名,这个域名可以是guaosi.com
,guaosi.cn
或者guaosi.top
,这些域名都称之为一级域名。然后我买了guaosi.com
这个域名,那么,我就可以随心所欲在这个域名旗下生成我想要的域名了。给项目1的域名是www.guaosi.com
,项目2的域名是test.guaosi.com
。这些称之为二级域名,甚至还可以创建三级域名,四级域名,这些域名都是归于guaosi.com
旗下的,不需要购买。
以腾讯云的域名系统为例。已经购买了腾讯云的域名后,找到域名与网站
-云解析
-域名解析列表
-解析
。点击添加记录
添加想要的域名,比如我想添加www.guaosi.com
。那么,在主机记录
填写www
,记录值
填写服务器的地址,然后保存即可。
现在如果一个用户在电脑上访问了我的www.guaosi.com
域名,首先,电脑上会先查看自己本地的Hosts文件里是否有做了关于www.guaosi.com
的重定向,如果没有,那么电脑再访问由宽带运营商规定DNS(域名解析)服务器,查到www.guaosi.com
的服务器地址为xx.xx.xx.xx
,然后此时将请求发往IP为xx.xx.xx.xx
的服务器上。最后,具体是访问到这台服务器上的哪个项目,哪个文件,我们下面再说。
由上文的 域名如何知道我们服务器在哪里
,里面提及到,电脑上是先查看自己本地的Hosts文件里是否有做了关于www.guaosi.com
的重定向,如果没有,电脑才访问由宽带运营商规定DNS(域名解析)服务器。所以,我们可以修改Hosts文件来假装自己已经拥有了自己的域名。
编辑hosts文件,加入
1 | 环境地址 www.guaosi.com |
通过上文的 域名如何知道我们服务器在哪里
,进行配置即可。
根据官网的步骤安装即可,因为是一键包而且官网已经说得很详细了,我就不复现了。
建议安装MySQL8和PHP7.26版本,不要忘记设置的MySQL密码,并且密码的加密方式最好选择mysql_native_password
。
LNMP安装: LNMP安装
安装完毕后,默认是root账户只能在本服务器上被访问,外界访问不到,可以进到mysql客户端里进行修改。
LNMP相关软件目录及文件位置 : LNMP相关软件目录及文件位置
my.ini
位置: /etc/my.cnf
php.ini
位置: /usr/local/php/etc/php.ini
vhost
位置: /usr/local/nginx/conf/vhost
输入命令,新建虚拟站点1
lnmp vhost add
之后的选择,可以参考如下: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
381.Please enter domain(example: www.lnmp.org)
输入想要创建的域名。比如 www.guaosi.com 回车
2.Enter more domain name(example: lnmp.org *.lnmp.org)
输入更多域名。没有必要,回车
3.Default directory: /home/wwwroot/www.guaosi.com:
项目存放的目录。默认即可,回车
4.Allow Rewrite rule? (y/n)
vhost里的配置规则。默认即可,回车
5.Enable PHP Pathinfo? (y/n)
是否添加PHP Pathinfo。默认即可,回车
6.Allow access log? (y/n)
是否开启访问日志。默认即可,回车
7. Multiple PHP version found, Please select the PHP version.
选择运行该项目的php版本,回车
8. Create database and MySQL user with same name
是否在mysql里创建当前项目专用的数据库与账户。这步可以选择 n 跳过,但是为了安全起见,最好是一个项目存放一个数据库以及配置专用的MySQL账号密码,所以我这里选择y,回车
9. Enter current root password of Database (Password will not shown):
请输入当前mysql的root密码。输入完毕后,回车
10.Enter database name:
输入新建数据库的名称。输入完毕后,回车
11.Please enter password for mysql user
为新建的mysql账户设置密码。输入完毕后,回车
12.Add SSL Certificate
是否添加SSL证书。现在SSL证书都是可以免费拿到,比如腾讯云,阿里云的域名都可以免费申请,我们这边直接n跳过,自己来配置SSL证书
13.Press any key to start create virtul host.
直接回车,添加新的虚拟站点成功
成功后,会生成对应的项目目录 /home/wwwroot/www.guaosi.com
,成生成nginx关于这个项目的虚拟站点配置文件/usr/local/nginx/conf/vhost/www.guaosi.com.conf
输入1
vim /usr/local/nginx/conf/vhost/www.guaosi.com.conf
将 root
指向的地址,进行修改,将原来的1
root /home/wwwroot/www.guaosi.com/
修改为项目入口地址所在目录。
如果是yii2项目,修改为1
root /home/wwwroot/www.guaosi.com/web
如果是laravel或者tp5项目,修改为1
root /home/wwwroot/www.guaosi.com/public
将自己已经完成的项目或者demo,放到服务器的项目目录下(比如home/wwwroot/www.guaosi.com/
)。
以下提供四种方式,推荐使用git
,因为git有版本控制,可以回退版本以及分支开发,团队合作,是现在的主流趋势,是每个程序猿必须掌握的。
如果没有可以放的项目或者demo,可以直接composer下载thinkphp5或者laravel到项目目录下,可以通过域名成功访问到即代表成功.
需要服务器开了FTP的21端口或者sftp的22端口.建议把自己的项目打包成zip,然后通过ftp工具上传到项目目录,然后解压。工具推荐winscp
,FlashFXP
。
需要有git的使用基础,如果还不会git或者对git有兴趣,可以查查廖大神的git教程。点我查看git教程
已经将项目上传至github
或者gitee
这样的远程仓库,然后在项目目录下git clone
自己的项目下来即可。(如果是laravel或者tp5项目,记得composer update
,laravel记得修改.env
文件)
如果服务器已经做过ssh免密码登陆。那么可以把项目打包成zip,然后通过scp
进行上传1
2
3# 将本地的 /Users/guaosi/Documents/www.guaosi.com.zip 的文件复制到ip为192.168.120.204的服务器上的
scp /Users/guaosi/Documents/www.guaosi.com.zip root@192.168.120.204:/home/wwwroot/www.guaosi.com
# 最后记得解压
如果是从服务器上复制文件到本地1
2# 从ip为192.168.120.204的服务器上,通过root登录,然后复制 /opt/soft/ 下的nginx-0.5.38.tar.gz 文件 到 本地的 /Users/guaosi/Documents 目录下
scp root@192.168.120.204:/opt/soft/nginx-0.5.38.tar.gz /Users/guaosi/Documents
说几个在lnmp
集成环境下部署遇到的问题。
一个服务器其实是可以部署多个项目的。那么,回到之前欠下的问题如何知道最后要访问我们哪一个项目?
通过上面的虚拟站点配置文件,我们可以知道,我们为域名为www.guaosi.com
专门指定了访问目录root为/home/wwwroot/www.guaosi.com/public
。这样,当用户访问www.guaosi.com
的时候,他的电脑会先去DNS服务器查询到www.guaosi.com
解析的服务器IP地址,然后访问到这台服务器。因为访问的是www.guaosi.com
,那么,nginx后找到配置文件中站点为www.guaosi.com
,找到它对应的root指向目录,进行访问。
lnmp多站点(多项目)部署的时候,tp5会出现项目白屏的问题,报500错误。这是因为我们在vhost配置中的root指向的是public
,这样一来,访问网址就不需要加public
了,可是因为lnmp设置了目录访问权限,无法访问上级目录,所以需要进行修改。
1.修改fastcgi.conf
的配置1
vim /usr/local/nginx/conf/fastcgi.conf
2.然后在最后一行加入1
fastcgi_param PHP_ADMIN_VALUE $basedir if_not_empty;
3.保存退出
4.再修改www.guaosi.com.conf
的配置
1 | vim /usr/local/nginx/conf/vhost/www.guaosi.com.conf |
5.在root
下面一行加入 "open_basedir=/home/wwwroot/www.guaosi.com/:/tmp/:/proc/";
完整配置参考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
58server
{
listen 80;
#listen [::]:80;
server_name www.guaosi.com ;
index index.html index.htm index.php default.html default.htm default.php;
# yii2
# root /home/wwwroot/www.guaosi.com/web;
# thinkphp5跟laravel5
root /home/wwwroot/www.guaosi.com/public;
set $basedir "open_basedir=/home/wwwroot/www.guaosi.com/:/tmp/:/proc/";
include rewrite/none.conf;
#error_page 404 /404.html;
# Deny access to PHP files in specific directory
#location ~ /(wp-content|uploads|wp-includes|images)/.*\.php$ { deny all; }
include enable-php.conf;
# yii2 nginx规则
location / {
if (!-e $request_filename){
rewrite ^/(.*) /index.php last;
}
}
# thinkphp5 nginx规则
location / {
if (!-e $request_filename) {
rewrite ^(.*)$ /index.php?s=$1 last;
}
}
# laravel5 nginx规则
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$
{
expires 30d;
}
location ~ .*\.(js|css)?$
{
expires 12h;
}
location ~ /.well-known {
allow all;
}
location ~ /\.
{
deny all;
}
access_log /home/wwwlogs/www.guaosi.com.log;
}
保存退出
重启nginx
1 | lnmp nginx restart |
http://www.guaosi.com
看看是否正常吧在阿里云或者腾讯云购买的域名,可以免费申请ssl证书.申请通过后,下载并且上传到服务器上,解压。
修改修改www.guaosi.com.conf
的配置1
vim /usr/local/nginx/conf/vhost/www.guaosi.com.conf
在 listen 80;
后面加入下面代码1
2
3
4listen 443 ssl;
ssl on;
ssl_certificate 证书存在位置;
ssl_certificate_key 证书存在位置;
参考案例1
2
3
4
5
6
7
8
9
10
11server
{
listen 80;
listen 443 ssl;
ssl on;
ssl_certificate /usr/cert/www/ssl.crt;
ssl_certificate_key /usr/cert/www/ssl.key;
server_name www.guaosi.com ;
index index.html index.htm index.php default.html default.htm default.php;
......
如果想http强制https,只要让80端口的强制重定向https
即可。可以参考这样设置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
30server
{
#listen 80;
#listen [::]:80;
listen 443 ssl;
ssl on;
ssl_certificate /usr/cert/cloud/ssl.crt;
ssl_certificate_key /usr/cert/cloud/ssl.key;
server_name cloud.guaosi.com ;
index index.html index.htm index.php default.html default.htm default.php;
root /home/wwwroot/cloud.guaosi.com/public;
set $basedir "open_basedir=/home/wwwroot/cloud.guaosi.com/:/tmp/:/proc/";
include rewrite/none.conf;
#error_page 404 /404.html;
# Deny access to PHP files in specific directory
#location ~ /(wp-content|uploads|wp-includes|images)/.*\.php$ { deny all; }
include enable-php.conf;
location / {
try_files $uri $uri/ /index.php?$args;
}
access_log off;
}
server
{
listen 80;
server_name cloud.guaosi.com ;
return 301 https://$server_name$request_uri;
}
因为LNMP 1.2开始PHP防跨目录限制使用.user.ini
,该文件在网站根目录下,.user.ini
文件无法直接修改。所以需要先解锁.user.ini
文件。
执行下面的命令1
chattr -i /home/wwwroot/www.guaosi.com/.user.ini
在工作中,经常会遇到多个多维数组或者orm对象数组需要整合的情况。通过整合好数据,然后输出给前端。
比如现在,有2个orm对象,他们的表间关系存在不明显,互相没有做模型关联,但是id相同。
1 | [ |
1 | [ |
现在我想把这2个orm对象数组整合成一个数组,方便前端调用,想转成的数据结构如下所示(已转json格式,方便查看)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{
"1": {
"id": 1,
"name": "std_name_0",
"order_count": 0
},
"2": {
"id": 2,
"name": "std_name_1",
"order_count": 0
},
"3": {
"id": 3,
"name": "std_name_2",
"order_count": 2
},
"4": {
"id": 4,
"name": "std_name_3",
"order_count": 4
},
"5": {
"id": 5,
"name": "std_name_4",
"order_count": 6
},
"6": {
"id": 6,
"order_count": 8,
"name": ""
},
"7": {
"id": 7,
"order_count": 10,
"name": ""
},
"8": {
"id": 8,
"order_count": 12,
"name": ""
}
}
脑子里一闪间的想法,2个数组整合成一个,并且根据id来区别。容易,做2个foreach循环就行了,判断id相同的,放到一个数组里就行了,于是可能会想到了下面的代码1
2
3
4
5
6
7
8
9
10
11foreach ($name_data as $val){
foreach ($order_data as $v){
if($v->id == $val->id){
$data['id'] = $v->id;
$data['name'] = $val->name;
$data['order_count'] = $v->order_count;
$users_info[]=$data;
break;
}
}
}
运行后的结果(已转json格式,方便查看)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22[
{
"id": 2,
"name": "std_name_1",
"order_count": 0
},
{
"id": 3,
"name": "std_name_2",
"order_count": 2
},
{
"id": 4,
"name": "std_name_3",
"order_count": 4
},
{
"id": 5,
"name": "std_name_4",
"order_count": 6
}
]
很明显,这不是我们想要的结果。
想法一的想法太武断,只考虑了2个ID相等的情况,没有考虑到不同,所以导致只做了一半的事情,答案不正确。再次经过考虑后,可能有了如下的代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19foreach ($name_data as $val){
$flag = false;
foreach ($order_data as $v){
if($v->id == $val->id){
$users_info[$v->id]['id'] = $v->id;
$users_info[$v->id]['name'] = $val->name;
$users_info[$v->id]['order_count'] = $v->order_count;
$flag = true;
break;
}else{
$users_info[$v->id]['id'] = $v->id;
$users_info[$v->id]['order_count'] = $v->order_count;
}
}
if(!$flag){
$users_info[$val->id]['id'] = $val->id;
$users_info[$val->id]['name'] = $val->name;
}
}
运行过后的结果(已转json格式,方便查看)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{
"1": {
"id": 1,
"name": "std_name_0"
},
"2": {
"id": 2,
"order_count": 0,
"name": "std_name_1"
},
"3": {
"id": 3,
"order_count": 2,
"name": "std_name_2"
},
"4": {
"id": 4,
"order_count": 4,
"name": "std_name_3"
},
"5": {
"id": 5,
"order_count": 6,
"name": "std_name_4"
},
"6": {
"id": 6,
"order_count": 8
},
"7": {
"id": 7,
"order_count": 10
},
"8": {
"id": 8,
"order_count": 12
}
}
这次,跟我们想要的结果很接近了。其实,这样已经可以传给我们的前端,让前端自己去判断处理了。
那有没有一种简单点的办法,既可以实现我们要的数据结果,又简单方便好理解呢?答案是有的,其实想法二已经体现出来了,就是活用php的数组特性,充分利用id来判断相同可以整合的数据,于是想法三就诞生了。
1 | foreach ($name_data as $val){ |
运行过后的结果(已转json格式,方便查看)
1 | { |
以上三种想法可以看出来,想法三具有以下三个特点:
m*n
次,然而想法三是m+n
次,速度效率存在本质的区别。虽然这里只是以两个对象数组为例,其实还可以更多应用到其他多维数组,多个数组整合之类的,灵活变通,类似问题都会迎刃而解。
1 |
|
服务器上的项目,一般都是使用用户组www-data
或者www
来保证权限安全,不会使用root
的。但是git pull
下来的新文件或者修改的文件,则会把原有的文件的权限更改为644,用户组改为root。
如下图所示:
例如日志文件
,异步程序
,定时任务
,配置文件
之类的,由于最后运行用户的用户不对或者权限不够,可能会导致异常退出,文件无法读取,日志无法写入等等。所以,这个问题是值得引起我们重视的。
想法很简单,重新修改文件的权限。比如,回到站点上级目录,执行如下命令
1 | chmod -R 755 www.guaosi.com/ |
只要不嫌累,每次git pull 或者 git checkout . 后退回上级目录执行一次,还是可以舒舒服服解决这个问题的.
接下来是这篇文章的重点了,我们可以使用git内置的钩子函数来解决这个问题.
先来看一下怎么操作:
1.进入项目目录1
cd www.guaosi.com
2.进入.git目录1
cd .git/hooks/
3.新建post-merge文件1
vim post-merge
4.写入钩子内容1
2
3
4
pwd
echo "This is post-merge hook"
chmod -R 755 ./* && chown -R www-data:www-data ./*
5.给予运行权限1
chmod +x post-merge
1.进入项目目录1
cd www.guaosi.com
2.进入.git目录1
cd .git/hooks/
3.新建post-checkout文件1
vim post-checkout
4.写入钩子内容1
2
3
4
pwd
echo "This is post-checkout hook"
chmod -R 755 ./* && chown -R www-data:www-data ./*
5.给予运行权限1
chmod +x post-checkout
看了钩子内容应该大家都明白了,其实就是把手动运行的内容放入了钩子函数。在执行git pull
命令时,会自动调用post-merge
。在执行git checkout
时,会自动调用git checkout
.这样我们以后就不用再操心,怕遗忘权限有没有修改的问题了.
将做好的环境和代码,打包成镜像,可以让各个地方都可以直接使用,不再受到环境的限制。
1.虚拟机是模拟整个操作系统,包括硬件部分
2.docker是使用linux容器,通过进程隔离,拥有自己的文件系统,不会跟宿主机产生错乱。
3.docker没有自己的内核,使用的是宿主机的内核。
镜像就是模板。镜像好比类,容器是对象实例。
Docker本身是一个容器运行载体货或之为管理引擎。我们把应用程序和配置依赖打包好形成一个可交付的运行环境,这个打包好的运行环境就是image镜像文件。只有通过这个镜像文件才能生成docker容器。image文件可以看做是容器的模板。Docker根据image文件生成容器的实例。同一个image文件,可以生成多个同时运行的容器实例。
1.image文件生成的容器实例,本身也是一个文件,称之为镜像文件。
2.一个容器运行一种服务,当我们需要的时候,就可以通过docker客户端创建一个对应的运行实例,也就是我们的容器。
3.至于仓库,就是存放了一堆镜像的地方,我们可以把镜像发布到仓库中,需要的时候从仓库中拉下来就可以了。
至少要centos6.5以上。
1.yum install -y epel-release
2.yum install -y docker-io
3.安装后的配置文件: /etc/sysconfig/docker
4.启动Docker后台服务: service docker start
5.docker version验证
官网安装教程:
https://docs.docker.com/install/linux/docker-ce/centos/#install-docker-ce
使用镜像仓库进行安装
1.安装gcc
yum -y install gcc
2.安装gcc-c++
yum -y install gcc-c++
3.卸载之前的docker(如果有装过)1
2
3
4
5
6
7
8
9
10$ sudo yum remove docker \
docker-client \
docker-client-latest \
docker-common \
docker-latest \
docker-latest-logrotate \
docker-logrotate \
docker-selinux \
docker-engine-selinux \
docker-engine
4.安装需要的软件包
yum install -y yum-utils device-mapper-persistent-data lvm2
5.设置stable镜像仓库
yum-config-manager –add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
6.更新yum软件包索引
yum makecache fast
7.安装docker ce
yum -y install docker-ce
8.启动docker
systemctl start docker
9.测试
docker version
docker run hello-world
docker images
10.配置镜像加速
1 | mkdir -p /etc/docker |
加速服务器
1.网易云
{“registry-mirrors”:[“http://hub-mirror.c.163.com"]}
2.阿里云 (dev.alipay.com 里申请自己专属的线上hub)
{“registry-mirrors”:[“https://自己的专属hub.mirror.aliyuncs.com”]}
11.docker进程查看
ps -ef|grep docker|grep -v grep
12.卸载1
2
3systemctl stop docker
sudo yum remove docker-ce
sudo rm -rf /var/lib/docker
注册地址
vim /etc/sysconfig/docker
1 | other_args="--registry-mirror=https://自己的专属hub.mirror.aliyuncs.com" |
vim /etc/docker/daemon.json
1 | {"registry-mirrors":["https://自己的专属hub.mirror.aliyuncs.com"]} |
docker version
查看docker基础信息,版本号等等
docker info
查看docker详细信息,容器数量,内存等等
docker –help
查看docker命令
docker images
列出本地主机上的镜像
docker images -a
列出本地主机上的镜像(包括中间映象层)
docker images -q
列出本地主机上的所有镜像,而只显示Image ID的值
docker images -qa
a 加 q 的结合,列出本地主机上的镜像(包括中间映象层),而只显示Image ID的值
docker images –digests
显示镜像的摘要信息(DIGEST)
docker images –no-trunc
显示完整的镜像信息(完整的image id)
去 https://hub.docker.com 上查找镜像。(拉下来还是根据自己配置的,如阿里云上拉)
docker search php
在docker官方hub上查找php的镜像后进行罗列。
docker search -s 30 php
在docker官方hub上查找php并且stars超过30的镜像后进行罗列。
docker search –no-trunc php
在docker官方hub上查找php的镜像后进行罗列,将DESCRIPTION完整的显示。
docker search –automated php
在docker官方hub上查找php的镜像后进行罗列,将AUTOMATED是ok的进行显示。
下载镜像
docker pull php
与 docker pull php:latest 相同,下载最新版。
docker pull php:5.6
下载php5.6版本
删除镜像
docker rmi hello-world
相当于 docker rmi hello-world:3.2 ,删除最新版
docker rmi -f hello-world
强制删除
docker rmi -f 2cb0d9787c4d
根据镜像id进行强制删除
docker rmi -f hello-world nginx php:5.6
强制删除多个,默认删除latest版本
docker rmi -f $(docker images -qa)
强制清空删除所有镜像( $(docker images -qa) 相当于先找出所有的image id,包括中间映像层的,然后一口气全部删除),是一种组合命令
1 | docker run hello-world` |
先在本地查找是否有hello-world的镜像,没有再去远程镜像仓库查找。找到了镜像,会创建该镜像的容器出来然后运行。
docker run -it centos
-i 交互模式
-t 伪终端
以交互模式和伪终端模式运行centos。在一个镜像被run后,如果还在再run这个镜像,直接再执行这个命令即可,不会冲突。
docker run -d centos
以守护进程的形式在后台执行。但是,Docker容器后台运行,必须在容器中有一个前台进行的进程,一直挂起,否则docker容器就会自动退出.docker ps 里不会有显示
docker run -d centos /bin/sh -c “while true;do echo hello world;sleep 2;done”
运行容器的时候,同时让容器执行一段shell脚本,这个脚本永远为真,每隔2秒输出一次 “hello world”.此时docker ps 里会有显示
docker run -it –name mycentos1 centos
以交互模式和伪终端模式运行centos,并且取别名为mycentos1
在docker伪终端中
docker run -it -p 8080:80 nginx
以交互模式和伪终端模式运行nginx,此时暴露给宿主机的端口为8080,而docker容器内的端口为80(根据软件原来的端口定义).
docker run -it -P nginx
以交互模式和伪终端模式运行nginx,此时暴露给宿主机的端口是随机分配的,只能自己通过docker ps查看,而docker容器内的端口软件原来的端口定义.
或者以守护进程的形式启动
docker run -d nginx
无法进入交互可以使用
docker exec -it 11165c51310d /bin/bash
exit
代表退出当前docker并且停止,回到原有
ctrl+p+q
代表退出当前docker终端界面回到原有,docker容器后台运行
docker ps
(宿主机)列出当前所有正在运行的容器
docker ps -a
列出所有当前+历史所有运行过的容器
docker ps -l
列出最近一次运行过的容器
docker ps -n 3
列出最近三次运行过的容器
docker ps -qa
精简显示所有当前+历史所有运行过的容器的CONTAINER ID
docker start be2ce65efe84
根据CONTAINER ID,将已经停止的容器重新启动。
docker restart feffc090ef3e
根据CONTAINER ID,将已经启动的容器重新启动。此时容器内的文件不会被清除。
docker stop feffc090ef3e
根据CONTAINER ID,将已经启动的容器停止(缓慢安全停止)。再启动,所有文件复原。
docker kill feffc090ef3e
根据CONTAINER ID,将已经启动的容器停止(立刻马上停)。再启动,所有文件复原。
docker rm feffc090ef3e
根据CONTAINER ID,删除容器。
docker rm $(docker ps -q)
删除所有正在运行的容器
docker rm $(docker ps -aq)
删除所有正在运行和已经停止的容器
docker logs -f -t –tail 5 d14363d9eff8
查看指定容器ID内的打印输出,-f 表示从最后开始,-t表示展示时间,–tail 表示一开始展示多少行
docker top d14363d9eff8
查看指定容器ID内的进程
docker inspect d14363d9eff8
查看指定容器ID的内部细节
docker attach d14363d9eff8
进入指定容器ID的正在运行的容器并以交互行模式进行交互.
docker exec -it d14363d9eff8 /bin/bash
进入指定容器ID的正在运行的容器并以交互行模式进行交互.
docker exec -it 6a68ebda9254 ls -al /tmp
返回指定容器ID的正在运行的容器中指定命令内容,但是不进入容器交互中
docker cp 6a68ebda9254:/tmp/yum.log /usr/local
复制指定容器ID的正在运行的容器内的文件到宿主机上
Union文件系统是Docker镜像的基础。Union文件系统(UnionFs)是一种分层,轻量级并且高薪更的文件系统。它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。
特征: 一次同时加载多个文件系统,但是从外面看起来,只能看到一个文件系统,联合加载会把各层文件系统叠加起来,这样最终的文件系统会包含所有底层的文件和目录。
说白了,就是类似与一个同心圆
。比如tomcat,最底层是kernel,倒数第二层是centos,倒数第三层是jdk8,最外面一层是tomcat。
Docker镜像的最底层是bootfs,就是linux系统的引导文件系统,这个是公用的。一般是rootfs不同,代表着kernel内核,比如centos和Ubuntu不同.
Docker镜像都是只读的。
当容器启动时,一个新的可写层被加载到镜像的顶部。这一层通常称为“容器层”,”容器层”之下的都叫镜像层。
比如 tomcat,tomcat是容器层,jdk8,centos,kernel都是镜像层。
因为docker的镜像是只读的,不允许修改,如果想修改成自己的。需要做成自己的镜像.
docker commit -m ‘text’ -a ‘guaosi’ ce704066570d guaosi/nginx:1.2
将当前运行的指定容器ID做成新的镜像,可以保存原来容器内修改的文件。-m 是注释 -a 是作者名称 . guaosi/nginx是规范写法,后面需要加上版本号。注意,启动的时候也需要带上这个版本号。
卷的设计目的就是数据的持久化,完全独立于容器的生存周期,因此Docker不会在容器删除时删除其挂载的数据卷。
容器的持久化 和 容器间继承+共享数据
添加数据券相当于目录映射,容器开启或者关闭都不会影响到宿主机映射的内容,宿主机与容器实时同步。第一个参数是宿主机要映射的文件夹,第二个参数是容器想要映射的文件夹。
docker run -it -v /myHostFile:/myDockerFile:ro centos
使用交互模式,可读可写添加数据券,2个文件可以不存在,系统会自动创建。此时容器对该文件夹具有可读可写的权限。
docker run -it -v /myHostFile:/myDockerFile centos
使用交互模式,可读添加数据券,2个文件可以不存在,系统会自动创建。此时容器对该文件夹具有可读的权限,无法进行任何写操作。
docker inspect 4744cddb3964
查看容器的详情,可以通过Binds看到对应的映射数据券,RWc查看是否可读可写。true代表可读可写,false代表只可读。
DockerFile相当于image镜像的源文件
1.编写DockerFile文件
vim DockerFile(文件名随便)1
2
3
4
5# volume test
FROM centos
VOLUME ["/dataVolumeContainer1","/dataVolumeContainer2"]
CMD echo "finished,-------success1"
CMD /bin/bash
意思是集成centos这个镜像后,在容器的根目录下创建了2个数据券共享的文件夹,然后输出字符串,然后结束
最后的 /bin/bash相当于将docker run -it xxx
转为docker run -it xxx /bin/bash
2.构建DockerFile对应的镜像
docker build -f /usr/local/DockerFile -t guaosi/centos .
将编写好的DockerFile通过build命令创建出image镜像,DockerFile需要填写绝对路径,-t 后面写镜像的名称, . 代表镜像具体文件生成在当前目录下
3.检查
容器运行刚刚生成的镜像后,通过 docker inspect ff146b4fccd5
可以看到对应的容器数据券,Source
字段代表宿主机上所映射的文件夹。宿主机与容器实时同步,可读可写.
因为用DockerFile构建的镜像运行的容器,每一次运行宿主机上都会重新生成一个新的映射文件夹与之对应共享数据。此时同一镜像的不同容器想要想要进行数据共享,则需要--volumes-from
测试过程
1.
docker run -it –name dc01 guaosi/centos
创建一个别名为dc01的guaosi/centos的容器
2.
docker run -it –name dc02 –volumes-from dc01 guaosi/centos
docker run -it –name dc03 –volumes-from dc01 guaosi/centos
创建一个别名为dc02和dc03的guaosi/centos的容器,同时这个容器继承别名为dc01的guaosi/centos容器的数据共享(继承最好是同一个镜像)
3.此时对dc01容器,dc02容器,dc03容器其中的任意一个进行修改或者删除,其他两个都会同步到,现在这三个容器是实时同步。
4.如果此时删除dc01容器这个父容器,那么剩下两个子容器依旧可以数据券共享,实时同步,不会有任何影响(包括dc01里的文件也健在)。就算dc04继承dc03然后删除dc03,那么dc04与dc02依旧可以数据共享。
总结: 容器之间配置信息的传递,数据卷的生命周期一直持续到没有容器使用为止(DockerFile)。
1.每条保留字指令都必须为大写字母并且后面要跟随至少一个参数
2.指令按照从上到下,顺序执行
3.#代表注释
4.每条指令都会创建一个新的镜像层,并对镜像进行提交。
1.docker从基础镜像运行一个容器
2.执行一条指令并对容器做出修改
3.执行类型docker commit的操作提交一个新的镜像层
4.docker再基于刚提交的镜像运行一个新容器
5.执行dockerfile的下一条指令直到所有指令都执行完。
1 | FROM 基础镜像,当前新镜像是基于哪个镜像的 |
docker history 镜像id
vim myCentosDockerFile
1 | FROM centos |
docker build -f /usr/local/myCentosDockerFile -t guaosi/centos:1.5 /usr/local/MyCenntos/
docker run -it guaosi/centos:1.5
vim myCentosIp
1 | FROM centos |
docker build -f /usr/local/myCentosIp -t guaosi/Ip:1.0 /usr/local/MyCenntos/
docker run -it guaosi/Ip:1.0 -i
CMD的不能追加参数,比如想再加一个 -i
,追加了会导致都被换掉,而ENTRYPOINT可以正确追加参数
vim myCentosFather
1 | FROM centos |
docker build -f /usr/local/myCentosFather -t guaosi/father:1.0 /usr/local/MyCenntos/
vim myCentosSon
1 | FROM guaosi/son |
docker build -f /usr/local/myCentoSson -t guaosi/son:1.0 /usr/local/MyCenntos/
1 | $ sudo docker login --username=guaosi@vip.qq.com registry.cn-shenzhen.aliyuncs.com |
ImageId 是镜像ID,镜像版本号是自己设定的版本号
示例
1 | docker login --username=guaosi@vip.qq.com registry.cn-shenzhen.aliyuncs.com |
上传成功后,可以搜索得到
下载验证
1 | docker pull registry.cn-shenzhen.aliyuncs.com/guaosi/mycentos:1.5 |