整理的比较全面的面试题(四)

2019-10-28  本文已影响0人  在牛魔角上狂码

git

git和svn

  • 核心区别:
    • SVN 是集中式版本控制系统,版本库是集中放在中央服务器的,而干活的时候,用的都是自己的电脑,所以首先要从中央服务器哪里得到最新的版本,然后干活,干完后,需要把自己做完的活推送到中央服务器。集中式版本控制系统是必须联网才能工作,如果在局域网还可以,带宽够大,速度够快,如果在互联网下,如果网速慢的话,就纳闷了。
    • Git 是分布式版本控制系统,那么它就没有中央服务器的,每个人的电脑就是一个完整的版本库,这样,工作的时候就不需要联网了,因为版本都是在自己的电脑上。既然每个人的电脑都有一个完整的版本库,那多个人如何协作呢?比如说自己在电脑上改了文件 A,其他人也在电脑上改了文件 A,这时,你们两之间只需把各自的修改推送给对方,就可以互相看到对方的修改了。
  • Git把内容按元数据方式存储,而SVN是按文件:因为,.git目录是处于你的机器上的一个克隆版的版本库,它拥有中心版本库上所有的东西,例如标签,分支,版本记录等。.git目录的体积大小跟.svn比较,你会发现它们差距很大。
  • Git没有一个全局版本号,而SVN有:目前为止这是跟SVN相比Git缺少的最大的一个特征。
  • Git的内容的完整性要优于SVN: GIT的内容存储使用的是SHA-1哈希算法。这能确保代码内容的完整性,确保在遇到磁盘故障和网络问题时降低对版本库的破坏。
  • Git下载下来后,在OffLine状态下可以看到所有的Log,SVN不可以。
  • 刚开始用时,SVN必须先Update才能Commit,忘记了合并时就会出现一些错误,git还是比较少的出现这种情况。
  • 克隆一份全新的目录以同样拥有五个分支来说,SVN是同时复製5个版本的文件,也就是说重复五次同样的动作。而Git只是获取文件的每个版本的 元素,然后只载入主要的分支(master)在我的经验,克隆一个拥有将近一万个提交(commit),五个分支,每个分支有大约1500个文件的 SVN,耗了将近一个小时!而Git只用了区区的1分钟!
  • 版本库(repository):SVN只能有一个指定中央版本库。当这个中央版本库有问题时,所有工作成员都一起瘫痪直到版本库维修完毕或者新的版本库设立完成。而 Git可以有无限个版本库。或者,更正确的说法,每一个Git都是一个版本库,区别是它们是否拥有活跃目录(Git Working Tree)。如果主要版本库(例如:置於GitHub的版本库)发生了什麼事,工作成员仍然可以在自己的本地版本库(local repository)提交,等待主要版本库恢复即可。工作成员也可以提交到其他的版本库
  • 分支(Branch)在SVN,分支是一个完整的目录。且这个目录拥有完整的实际文件。如果工作成员想要开啟新的分支,那将会影响“全世界”!每个人都会拥有和你一样的分支。如果你的分支是用来进行破坏工作(安检测试),那将会像传染病一样,你改一个分支,还得让其他人重新切分支重新下载,十分狗血。而 Git,每个工作成员可以任意在自己的本地版本库开啟无限个分支。举例:当我想尝试破坏自己的程序(安检测试),并且想保留这些被修改的文件供日后使用, 我可以开一个分支,做我喜欢的事。完全不需担心妨碍其他工作成员。只要我不合并及提交到主要版本库,没有一个工作成员会被影响。等到我不需要这个分支时, 我只要把它从我的本地版本库删除即可。
  • 提交(Commit)在SVN,当你提交你的完成品时,它将直接记录到中央版本库。当你发现你的完成品存在严重问题时,你已经无法阻止事情的发生了。如果网路中断,你根本没办法提交!而Git的提交完全属於本地版本库的活动。而你只需“推”(git push)到主要版本库即可。Git的“推”其实是在执行“同步”(Sync)

git乘用命令

新建代码库

  • $ git init 在当前目录新建一个Git代码库
  • $ git init [project-name] 新建一个目录,将其初始化为Git代码库
  • $ git clone [url] 下载一个项目和它的整个代码历史

配置

  • 显示当前的Git配置 $ git config --list
  • 编辑Git配置文件 $ git config -e [--global]
  • 设置提交代码时的用户信息
    • $ git config [--global] user.name "[name]"
    • $ git config [--global] user.email "[email address]"

增加/删除文件

  • 添加指定文件到暂存区 $ git add [file1] [file2] ...
  • 添加指定目录到暂存区,包括子目录 $ git add [dir]
  • 添加当前目录的所有文件到暂存区 $ git add .
  • 添加每个变化前,都会要求确认
  • 对于同一个文件的多处变化,可以实现分次提交 $ git add-p
  • 删除工作区文件,并且将这次删除放入暂存区 $ git rm [file1] [file2] ...
  • 停止追踪指定文件,但该文件会保留在工作区 $ git rm --cached [file]
  • 改名文件,并且将这个改名放入暂存区 $ git mv [file-original] [file-renamed]

代码提交

  • 提交暂存区到仓库区 $ git commit -m [message]
  • 提交暂存区的指定文件到仓库区 $ git commit [file1] [file2] ... -m [message]
  • 提交工作区自上次commit之后的变化,直接到仓库区 $ git commit -a
  • 提交时显示所有diff信息 $ git commit -v
  • 使用一次新的commit,替代上一次提交
  • 如果代码没有任何新变化,则用来改写上一次commit的提交信息 $ git commit --amend -m [message]
  • 重做上一次commit,并包括指定文件的新变化 $ git commit --amend [file1] [file2] ...

分支

  • 列出所有本地分支 $ git branch
  • 列出所有远程分支 $ git branch -r
  • 列出所有本地分支和远程分支 $ git branch -a
  • 新建一个分支,但依然停留在当前分支 $ git branch [branch-name]
  • 新建一个分支,并切换到该分支 $ git checkout-b [branch]
  • 新建一个分支,指向指定commit $ git branch [branch] [commit]
  • 新建一个分支,与指定的远程分支建立追踪关系 $ git branch --track [branch] [remote-branch]
  • 切换到指定分支,并更新工作区 $ git checkout[branch-name]
  • 切换到上一个分支 $ git checkout -
  • 建立追踪关系,在现有分支与指定的远程分支之间 $ git branch--set-upstream [branch] [remote-branch]
  • 合并指定分支到当前分支 $ git merge[branch]
  • 选择一个commit,合并进当前分支 $ git cherry-pick [commit]
  • 删除分支 $ git branch -d [branch-name]
  • 删除远程分支
    • $ git push origin --delete [branch-name]
    • $ git branch -dr [remote/branch]

标签

  • 列出所有tag $ git tag
  • 新建一个tag在当前commit $ git tag [tag]
  • 新建一个tag在指定commit $ git tag [tag] [commit]
  • 删除本地tag $ git tag -d [tag]
  • 删除远程tag $ git push origin :refs/tags/[tagName]
  • 查看tag信息 $ git show [tag]
  • 提交指定tag $ git push [remote] [tag]
  • 提交所有tag $ git push [remote] --tags
  • 新建一个分支,指向某个tag $ git checkout -b [branch] [tag]

查看信息

  • 显示有变更的文件 $ git status
  • 显示当前分支的版本历史 $ git log
  • 显示commit历史,以及每次commit发生变更的件$ git log --stat
  • 搜索提交历史,根据关键词 $ git log-S [keyword]
  • 显示某个commit之后的所有变动,每个commi占据一行 $ git log[tag] HEAD --pretty=format:%s
  • 显示某个commit之后的所有变动,其"提交说明"必须符合搜索条件 $ git log[tag] HEAD --grep feature
  • 显示某个文件的版本历史,包括文件改名
    • $ git log--follow [file]
    • $ git whatchanged [file]
  • 显示指定文件相关的每一次diff $ git log -p [file]
  • 显示过去5次提交 $ git log-5 --pretty --oneline
  • 显示所有提交过的用户,按提交次数排序 $ git shortlog-sn
  • 显示指定文件是什么人在什么时间修改过 $ git blame[file]
  • 显示暂存区和工作区的差异 $ git diff
  • 显示暂存区和上一个commit的差异 $ git diff--cached [file]
  • 显示工作区与当前分支最新commit之间的差异 $ git diff HEAD
  • 显示两次提交之间的差异 $ git diff[first-branch]...[second-branch]
  • 显示今天你写了多少行代码 $ git diff--shortstat "@{0 day ago}"
  • 显示某次提交的元数据和内容变化 $ git show[commit]
  • 显示某次提交发生变化的文件 $ git show--name-only [commit]
  • 显示某次提交时,某个文件的内容 $ git show[commit]:[filename]
  • 显示当前分支的最近几次提交 $ git reflog

远程同步

  • 下载远程仓库的所有变动 $ git fetch[remote]
  • 显示所有远程仓库 $ git remote-v
  • 显示某个远程仓库的信息 $ git remote show[remote]
  • 增加一个新的远程仓库,并命名 $ git remote add[shortname] [url]
  • 取回远程仓库的变化,并与本地分支合并 $ git pull[remote] [branch]
  • 上传本地指定分支到远程仓库 $ git push [remote] [branch]
  • 强行推送当前分支到远程仓库,即使有冲突 $ git push[remote] --force
  • 推送所有分支到远程仓库 $ git push[remote] –all

撤销

  • 恢复暂存区的指定文件到工作区 $ git checkout[file]
  • 恢复某个commit的指定文件到暂存区和工作区 $ git checkout[commit] [file]
  • 恢复暂存区的所有文件到工作区 $ git checkout .
  • 重置暂存区的指定文件,与上一次commit保持一致,但工作区不变 $ git reset [file]
  • 重置暂存区与工作区,与上一次commit保持一致 $ git reset --hard
  • 重置当前分支的指针为指定commit,同时重置暂存区,但工作区不变 $ git reset[commit]
  • 重置当前分支的HEAD为指定commit,同时重置暂存区和工作区,与指定commit一致 $ git reset --hard [commit]
  • 重置当前HEAD为指定commit,但保持暂存区和工作区不变 $ git reset --keep [commit]
  • 新建一个commit,用来撤销指定commit
  • 后者的所有变化都将被前者抵消,并且应用到当前分支 $ git revert[commit]
  • 暂时将未提交的变化移除,稍后再移入 git stash git stash pop

常见的攻击

xss

Xss 跨站脚本攻击,例如攻击者向form表单输入了恶意的html代码,当用户访问列表页面时,这段html代码会自动执行,达到攻击效果,我们也遇到过一次,他在form中input输入了js代码,创建了html节点,创建了一个超链接标签,使网页跳转到恶意的菠菜网站. 防止:使用正则过滤,使用htmlentites函数实现html标签的实体化

sql注入

Sql注入没有过滤传入的参数,篡改sql语句,达到攻击效果,比如账号输入完成后使用单引号加井号完成了免密码登录,通过#吧#后面的数据过滤 防止 通过pdo防止首先将 sql 语句模板发送给Mysql Server,随后将绑定的字符变量再发送给Mysql server,这里的转义是在Mysql Server做的,它是根据你在连接PDO的时候,在charset里指定的编码格式来转换的。

csrf

Csrf跨站请求攻击, 全称是“跨站请求伪造”,而 XSS 的全称是“跨站脚本”。看起来有点相似,它们都是属于跨站攻击——不攻击服务器端而攻击正常访问网站的用户 防止:通过存储在cookie中token来防止;

Rabbitmq

参考链接

简介

RabbitMQ是实现了高级消息队列协议(AMQP)的开源消息代理软件(亦称面向消息的中间件)。RabbitMQ服务器是用[Erlang]语言编写的,而群集和故障转移是构建在[开放电信平台]框架上的。所有主要的编程语言均有与代理接口通讯的客户端[库]。用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。

注:

  • AMQP,即Advanced Message Queuing Protocol,高级消息队列协议,是应用层协议的一个开放标准,为面向消息的[中间件]设计。消息中间件主要用于组件之间的解耦,消息的发送者无需知道消息使用者的存在,反之亦然。\
  • AMQP的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。
  • Erlang是一种通用的面向[并发]的编程语言, 在[编程范型]上,Erlang属于多重范型编程语言,涵盖函数式、并发式及[分布式]。顺序执行的Erlang是一个及早求值, 单次赋值和动态类型的函数式编程语言。Erlang是一个结构化,动态类型编程语言,内建并行计算支持。
  • 消息队列是“”消费-生产者模型“”的一个典型的代表,一端往消息队列中不断写入消息,而另一端则可以读取或者订阅队列中的消息。

项目的使用

Rabbitmq 的队列容量可以认为是无限的,根据内存有关。 可以设置队列最大长度,当达到长度的时候,最先入队的消息将被丢弃。
流量削峰一般在秒杀活动中应用广泛

场景

秒杀活动,一般会因为流量过大,导致应用挂掉,为了解决这个问题,一般在应用前端加入消息队列。

作用:

  • 可以控制活动人数,超过此一定阀值的订单直接丢弃,先显示一个排队中,后端在处理,可能成功可能失败。
  • 可以缓解短时间的高流量压垮应用(应用程序按自己的最大处理能力获取订单)

为什么选择rabbitmq

  • Rabbit mq 是一个高级消息队列,在分布式的场景下,拥有高性能。,对负载均衡也有很好的支持。
  • 拥有持久化的机制,进程消息,队列中的信息也可以保存下来。
  • 实现消费者和生产者之间的解耦。
  • 对于高并发场景下,利用消息队列可以使得同步访问变为串行访问达到一定量的限流,利于数据库的操作。
  • 可以使用消息队列达到异步下单的效果,排队中,后台进行逻辑下单。

AMQP,即Advanced Message Queuing Protocol,高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。消息中间件主要用于组件之间的解耦,消息的发送者无需知道消息使用者的存在

rabbitMQ的优点(适用范围)

  • 基于erlang语言开发具有高可用高并发的优点,适合集群服务器。
  • 健壮、稳定、易用、跨平台、支持多种语言、文档齐全。
  • 有消息确认机制和持久化机制,可靠性高。
  • 开源

使用场景

  • 跨系统的异步通信,所有需要异步交互的地方都可以使用消息队列。就像我们除了打电话(同步)以外,还需要发短信,发电子邮件(异步)的通讯方式。
  • 多个应用之间的耦合,由于消息是平台无关和语言无关的,而且语义上也不再是函数调用,因此更适合作为多个应用之间的松耦合的接口。基于消息队列的耦合,不需要发送方和接收方同时在线。在企业应用集成(EAI)中,文件传输,共享数据库,消息队列,远程过程调用都可以作为集成的方法。
  • 应用内的同步变异步,比如订单处理,就可以由前端应用将订单信息放到队列,后端应用从队列里依次获得消息处理,高峰时的大量订单可以积压在队列里慢慢处理掉。由于同步通常意味着阻塞,而大量线程的阻塞会降低计算机的性能。
  • 消息驱动的架构(EDA),系统分解为消息队列,和消息制造者和消息消费者,一个处理流程可以根据需要拆成多个阶段(Stage),阶段之间用队列连接起来,前一个阶段处理的结果放入队列,后一个阶段从队列中获取消息继续处理。
  • 应用需要更灵活的耦合方式,如发布订阅,比如可以指定路由规则。
  • 跨局域网,甚至跨城市的通讯(CDN行业),比如北京机房与广州机房的应用程序的通信。

rabbitmq 三个主要角色

  • 生产者:消息的创建者,负责创建和推送数据到消息服务器;
  • 消费者:消息的接收方,用于处理数据和确认消息;
  • 代理:就是 RabbitMQ 本身,用于扮演“快递”的角色,本身不生产消息,只是扮演“快递”的角色。

消息发送流程

  • 首先客户端必须连接到 RabbitMQ 服务器才能发布和消费消息,客户端和 rabbit server 之间会创建一个 tcp 连接,一旦 tcp 打开并通过了认证(认证就是你发送给 rabbit 服务器的用户名和密码),你的客户端和 RabbitMQ 就创建了一条 amqp 信道(channel),信道是创建在“真实” tcp 上的虚拟连接,amqp 命令都是通过信道发送出去的,每个信道都会有一个唯一的 id,不论是发布消息,订阅队列都是通过这个信道完成的。
  • 生产者通过网络将消息发送给消费者,在中间会有一个应用RabbitMQ(转发和存储的功能),这时RabbitMQ收到消息后,根据消息指定的exchange(交换机)来查找绑定然后根据规则分发到不同的Queue(队列),queue将消息转发到具体的消费者。
  • 消费者收到消息后,会根据自己对消息的处理对RabbitMQ进行返回,如果返回ack,就表示已经确认这条消息,RabbitMQ会对这条消息进行处理(一般是删除)。
  • 如果消费者接收到消息后处理不了,就可能不对RabbitMQ进行处理,或者拒绝对消息处理,返回reject。

ACK消息确认机制

  • ACK消息确认机制, 保证数据能被正确处理而不仅仅是被Consumer(接收端)收到,我们就不能采用no-ack或者auto-ack,我们需要手动ack(manual-ack)。在数据处理完成后手动发送ack,这个时候Server才将Message删除。
  • ACK机制可以起到限流的作用,比如在消费者处理后,sleep一段时间,然后再ACK,这可以帮助消费者负载均衡。
  • 当然,除了ACK,有两个比较重要的参数也在控制着consumer(接收端)的load-balance,即prefetch和concurrency

Broker(消息队列服务器实体)

接收和分发消息的应用,RabbitMQ Server就是Message Broker。

Virtual host(虚拟消息服务器)

出于多租户和安全因素设计的,把AMQP的基本组件划分到一个虚拟的分组中,类似于网络中的namespace概念。当多个不同的用户使用同一个RabbitMQ server提供的服务时,可以划分出多个vhost,每个用户在自己的vhost创建exchange/queue等

Connection(TCP连接)

publisher(发送端)/consumer(接收端)和broker(消息队列服务器实体)之间的TCP连接。断开连接的操作只会在client端进行,Broker(消息队列服务器实体)不会断开连接,除非出现网络故障或broker(消息队列服务器实体)服务出现问题。

Channel(connection内部建立的逻辑连接)

如果每一次访问RabbitMQ都建立一个Connection,在消息量大的时候建立TCP Connection的开销将是巨大的,效率也较低。Channel是在connection内部建立的逻辑连接,如果应用程序支持多线程,通常每个thread创建单独的channel进行通讯,AMQP method包含了channel id帮助客户端和message broker识别channel,所以channel之间是完全隔离的。Channel作为轻量级的Connection极大减少了操作系统建立TCP connection的开销。

Exchange(交换机)

message到达broker(消息队列服务器实体)的第一站,根据分发规则,匹配查询表中的routing key,分发消息到queue中去。

Queue(队列)

消息最终被送到这里等待consumer(接收端)取走。一个message可以被同时拷贝到多个queue中。

Binding(交换机和队列之间的虚拟连接)

exchange和queue之间的虚拟连接,binding中可以包含routing key。Binding信息被保存到exchange中的查询表中,用于message的分发依据。

发送/接收信息

Send.py(发送信息)

  • 权限验证
  • 链接参数: virtual_host, 在多租户系统中隔离exchange, queue
  • 建立链接
  • 从链接中获得信道
  • 声明交换机
  • consumer(接收端)创建队列, 如果没有就创建
    • 队列一旦被创建, 再进行的重复创建会简单的失效, 所以建议在producer(发送端)和consumer同时创建队列, 避免队列创建失败
    • 创建队列回调函数, callback.
    • auto_delete=True, 如果queue失去了最后一个subscriber会自动删除, 队列中的message也会失效.
    • 默认auto_delete=False, 没有subscriber的队列会cache message, subscriber出现后将缓存的message发送.
  • delivery_mode=2表示让消息持久化, 重启RabbitMQ也不丢失. 考虑成本, 开启此功能, 建议把消息存储到SSD上.
  • 发布消息到exchange
  • 关闭链接

receive.py(接收信息)

  • 权限验证
  • 链接参数: virtual_host, 在多租户系统中隔离exchange, queue
  • 建立链接
  • 从链接中获得信道
  • 声明交换机, 直连方式, 后面将会创建binding将exchange和queue绑定在一起
  • consumer(接收端)创建队列, 如果没有就创建
    • 队列一旦被创建, 再进行的重复创建会简单的失效, 所以建议在producer(发送端)和consumer(接收端)同时创建队列, 避免队列创建失败
    • 创建队列回调函数, callback.
    • auto_delete=True, 如果queue失去了最后一个subscriber会自动删除, 队列中的message也会失效.
    • 默认auto_delete=False, 没有subscriber的队列会cache message, subscriber出现后将缓存的message发送.
  • 通过binding将队列queue和交换机exchange绑定
  • 处理接收到的消息的回调函数,method_frame携带了投递标记, header_frame表示AMQP信息头的对象
  • 订阅队列, 我们设置了不进行ACK, 而把ACK交给了回调函数来完成
  • 关闭连接

如何确保消息正确地发送至RabbitMQ

  • RabbitMQ使用发送方确认模式,确保消息正确地发送到RabbitMQ。
  • 发送方确认模式:将信道设置成confirm模式(发送方确认模式),则所有在信道上发布的消息都会被指派一个唯一的ID。一旦消息被投递到目的队列后,或者消息被写入磁盘后(可持久化的消息),信道会发送一个确认给生产者(包含消息唯一ID)。如果RabbitMQ发生内部错误从而导致消息丢失,会发送一条nack(not acknowledged,未确认)消息。
  • 发送方确认模式是异步的,生产者应用程序在等待确认的同时,可以继续发送消息。当确认消息到达生产者应用程序,生产者应用程序的回调方法就会被触发来处理确认消息。

如何确保消息接收方消费了消息

  • 接收方消息确认机制:消费者接收每一条消息后都必须进行确认(消息接收和消息确认是两个不同操作)。只有消费者确认了消息,RabbitMQ才能安全地把消息从队列中删除。
  • 这里并没有用到超时机制,RabbitMQ仅通过Consumer的连接中断来确认是否需要重新发送消息。也就是说,只要连接不中断,RabbitMQ给了Consumer足够长的时间来处理消息。

下面罗列几种特殊情况:

  • 如果消费者接收到消息,在确认之前断开了连接或取消订阅,RabbitMQ会认为消息没有被分发,然后重新分发给下一个订阅的消费者。(可能存在消息重复消费的隐患,需要根据bizId去重)
  • 如果消费者接收到消息却没有确认消息,连接也未断开,则RabbitMQ认为该消费者繁忙,将不会给该消费者分发更多的消息。

避免消息重复投递或重复消费

在消息生产时,MQ内部针对每条生产者发送的消息生成一个inner-msg-id,作为去重和幂等的依据(消息投递失败并重传),避免重复的消息进入队列;在消息消费时,要求消息体中必须要有一个bizId(对于同一业务全局唯一,如支付ID、订单ID、帖子ID等)作为去重和幂等的依据,避免同一条消息被重复消费。

消息基于什么传输

由于TCP连接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈。RabbitMQ使用信道的方式来传输数据。信道是建立在真实的TCP连接内的虚拟连接,且每条TCP连接上的信道数量没有限制。

消息如何分发

若该队列至少有一个消费者订阅,消息将以循环(round-robin)的方式发送给消费者。每条消息只会分发给一个订阅的消费者(前提是消费者能够正常处理消息并进行确认)。

消息怎么路由

从概念上来说,消息路由必须有三部分:交换器、路由、绑定。生产者把消息发布到交换器上;绑定决定了消息如何从路由器路由到特定的队列;消息最终到达队列,并被消费者接收。

    1. 消息发布到交换器时,消息将拥有一个路由键(routing key),在消息创建时设定。
    1. 通过队列路由键,可以把队列绑定到交换器上。
    1. 消息到达交换器后,RabbitMQ会将消息的路由键与队列的路由键进行匹配(针对不同的交换器有不同的路由规则)。如果能够匹配到队列,则消息会投递到相应队列中;如果不能匹配到任何队列,消息将进入 “黑洞”。

常用的交换器主要分为一下三种:

  • direct:如果路由键完全匹配,消息就被投递到相应的队列
  • fanout:如果交换器收到消息,将会广播到所有绑定的队列上
  • topic:可以使来自不同源头的消息能够到达同一个队列。 使用topic交换器时,可以使用通配符,比如:“ * ” 匹配特定位置的任意文本, “ . ” 把路由键分为了几部分,“#” 匹配所有规则等。特别注意:发往topic交换器的消息不能随意的设置选择键(routing_key),必须是由"."隔开的一系列的标识符组成。

确保消息不丢失

息持久化的前提是:将交换器/队列的durable属性设置为true,表示交换器/队列是持久交换器/队列,在服务器崩溃或重启之后不需要重新创建交换器/队列(交换器/队列会自动创建)。

如果消息想要从Rabbit崩溃中恢复,那么消息必须:

  • 在消息发布前,通过把它的 “投递模式” 选项设置为2(持久)来把消息标记成持久化
  • 将消息发送到持久交换器
  • 消息到达持久队列

RabbitMQ确保持久性消息能从服务器重启中恢复的方式是,将它们写入磁盘上的一个持久化日志文件,当发布一条持久性消息到持久交换器上时,Rabbit会在消息提交到日志文件后才发送响应(如果消息路由到了非持久队列,它会自动从持久化日志中移除)。一旦消费者从持久队列中消费了一条持久化消息,RabbitMQ会在持久化日志中把这条消息标记为等待垃圾收集。如果持久化消息在被消费之前RabbitMQ重启,那么Rabbit会自动重建交换器和队列(以及绑定),并重播持久化日志文件中的消息到合适的队列或者交换器上。

重要的组件

  • ConnectionFactory(连接管理器):应用程序与Rabbit之间建立连接的管理器,程序代码中使用。
  • Channel(信道):消息推送使用的通道。
  • Exchange(交换器):用于接受、分配消息。
  • Queue(队列):用于存储生产者的消息。
  • RoutingKey(路由键):用于把生成者的数据分配到交换器上。
  • BindingKey(绑定键):用于把交换器的消息绑定到队列上。

vhost

vhost 可以理解为虚拟 broker ,即 mini-RabbitMQ server。其内部均含有独立的 queue、exchange 和 binding 等,但最最重要的是,其拥有独立的权限系统,可以做到 vhost 范围的用户控制。当然,从 RabbitMQ 的全局角度,vhost 可以作为不同权限隔离的手段(一个典型的例子就是不同的应用可以跑在不同的 vhost 中)。

保证消息持久化成功的条件

  1. 声明队列必须设置持久化 durable 设置为 true.
  2. 消息推送投递模式必须设置持久化,deliveryMode 设置为 2(持久)。
  3. 消息已经到达持久化交换器。
  4. 消息已经到达持久化队列。

rabbitmq 持久化有什么缺点

持久化的缺地就是降低了服务器的吞吐量,因为使用的是磁盘而非内存存储,从而降低了吞吐量。可尽量使用 ssd 硬盘来缓解吞吐量的问题。

几种广播类型

三种广播模式:

  • fanout: 所有bind到此exchange的queue都可以接收消息(纯广播,绑定到RabbitMQ的接受者都能收到消息);
  • direct: 通过routingKey和exchange决定的那个唯一的queue可以接收消息;
  • topic:所有符合routingKey(此时可以是一个表达式)的routingKey所bind的queue可以接收消息;

延迟消息队列实现

  1. 通过消息过期后进入死信交换器,再由交换器转发到延迟消费队列,实现延迟功能;
  2. 使用 RabbitMQ-delayed-message-exchange 插件实现延迟功能。

集群的作用

  • 高可用:某个服务器出现问题,整个 RabbitMQ 还可以继续使用;
  • 高容量:集群可以承载更多的消息量。

节点的类型

  • 磁盘节点:消息会存储到磁盘。
  • 内存节点:消息都存储在内存中,重启服务器消息丢失,性能高于磁盘类型。

集群搭建注意问题

  • 各节点之间使用“–link”连接,此属性不能忽略。
  • 各节点使用的 erlang cookie 值必须相同,此值相当于“秘钥”的功能,用于各节点的认证。
  • 整个集群中必须包含一个磁盘节点。

rabbitmq 每个节点是其他节点的完整拷贝吗

不是,原因有以下两个:

  1. 存储空间的考虑:如果每个节点都拥有所有队列的完全拷贝,这样新增节点不但没有新增存储空间,反而增加了更多的冗余数据;
  2. 性能的考虑:如果每条消息都需要完整拷贝到每一个集群节点,那新增节点并没有提升处理消息的能力,最多是保持和单节点相同的性能甚至是更糟。

集群中唯一一个磁盘节点崩溃了会发生什么

如果唯一磁盘的磁盘节点崩溃了,不能进行以下操作:

  • 不能创建队列
  • 不能创建交换器
  • 不能创建绑定
  • 不能添加用户
  • 不能更改权限
  • 不能添加和删除集群节点

唯一磁盘节点崩溃了,集群是可以保持运行的,但你不能更改任何东西。

API 接口

接口的规范

接口规范采用rustful接口规范

使用SSL(https)来提供URL

  • 首先,使用https可以在数据包被抓取时多一层加密。我们现在的APP使用环境大部分都是在路由器WIFI环境下,一旦路由器被入侵,那么黑客可以非常容易的抓取到用户通过路由器传输的数据,如果使用http未经加密,那么黑客可以很轻松的获取用户的信息,甚至是账户信息。
  • 其次,即使使用https,也要在API数据传输设计时,正确的采用加密。例如直接将token信息放在URL中的做法,即使你使用了https,黑客抓不到你具体传输的数据,但是可以抓到你请求的URL啊!因此,使用https进行请求时,要采用POST、PUT或者HEAD的方式传输必要的数据。

使用GET、POST、PUT、DELETE这几种请求模式

请求模式也可以说是动作、数据传输方式,通常我们在web中的form有GET、POST两种,而在HTTP中,存在下发这几种。

GET (选择):从服务器上获取一个具体的资源或者一个资源列表。
POST (创建): 在服务器上创建一个新的资源。
PUT(更新):以整体的方式更新服务器上的一个资源。
PATCH (更新):只更新服务器上一个资源的一个属性。
DELETE(删除):删除服务器上的一个资源。
HEAD : 获取一个资源的元数据,如数据的哈希值或最后的更新时间。
OPTIONS:获取客户端能对资源做什么操作的信息。

在URI中体现资源,而非动作

  • 在构建API的URL的时候,URI中应该仅包含资源(对象),而不要加入动作。比如 /user/1/update ,其中update就是一个动作,虽然我们希望通过这个URI来实现用户ID为1的用户进行信息更新,但是按照RESTful的规范,作为动作,应该用上面的PUT来表示,所以请求更新用户信息,应该使用 PUT /user/1 来表示更新用户ID为1的用户信息。
  • 如果去对应上面的请求模式,GET表示显示、列出、展示,POST表示提交、创建,PUT表示更新,DELETE表示删除。

版本

  • API的开发直接关系了APP是否可以正常使用,如果原本运行正常的API,突然改动,那么之前使用这个API的APP可能无法正常运行。APP是不可能强迫用户主动升级的,因此,通过API版本来解决这个问题。也就是说,API的多个版本是同时运行的,而且都要保证可以正常使用。
  • 按照RESTful的规范,不同的版本也应该用相同的API URL,通过header信息来判断版本,再调用不同版本的程序进行处理。但是这明显会给开发带来巨大的成本。解决办法有两种:1.新版本兼容旧版本,所有旧版本的动作、字段、操作,都在新版本中可以被实现,但明显这样的维护成本很大;2.不同的版本,用不同的URL来提供服务,比如再URL中通过v1、v2来区分版本号,我则更喜欢采用子域名的方式,比如v2.api.xxx.com/user的方式。

HTTP响应码
在用户发出请求,服务端对请求进行响应时,给予正确的HTTP响应状态码,有利于让客户端正确区分遇到的情况。

  • 200 OK - [GET]:服务器成功返回用户请求的数据,该操作是幂等的(Idempotent)。
  • 201 CREATED - [POST/PUT/PATCH]:用户新建或修改数据成功。
  • 202 Accepted - [* ] :表示一个请求已经进入后台排队(异步任务)
  • 204 NO CONTENT - [DELETE]:用户删除数据成功。
  • 400 INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误,服务器没有进行新建或修改数据的操作,该操作是幂等的。
  • 401 Unauthorized - [* ]:表示用户没有权限(令牌、用户名、密码错误)。
  • 403 Forbidden - [* ] 表示用户得到授权(与401错误相对),但是访问是被禁止的。
  • 404 NOT FOUND - [* ]:用户发出的请求针对的是不存在的记录,服务器没有进行操作,该操作是幂等的。
  • 406 Not Acceptable - [GET]:用户请求的格式不可得(比如用户请求JSON格式,但是只有XML格式)。
  • 410 Gone -[GET]:用户请求的资源被永久删除,且不会再得到的
  • 422 Unprocesable entity - [POST/PUT/PATCH] 当创建一个对象时,发生一个验证错误。
  • 500 INTERNAL SERVER ERROR - [* ]:服务器发生错误,用户将无法判断发出的请求是否成功。

返回值结构

用JSON进行返回,而非xml。

原因:

  1. JSON可以很好的被很多程序支持,javascript的ajax可以直接将JSON转换为对象。
    1. JSON的格式在容量上比xml小很多,可以减低宽带占用,提高传输效率。

那么,返回值应该怎么去部署呢?

  • 首先,字段的合理返回,数据的包裹。因为返回值中,我们常常要对数据进行区分分组,或者按照从属关系打包,所以,我们再返回时,最好有包裹的思想,把数据存放在不同的包裹中进行返回。可以使用data来作为数据包,将所有数据统一以这个字段进行包裹。除了data,也可以用list等其他形式的包裹,命名都是自己来根据自己的需要确定的。
  • 总之,不要不分包,直接把所有数据和一些你想返回的全局数据混在一起进行返回。
  • 其次,错误码。错误码的作用是方便查找错误原因,通常情况下,我喜欢用error_code来表示,当error_code=0时,表示没有发生错误,当error_code>0时,发生了错误,并且提供较为详细的文档,告诉客户端对应的error_code值所产生的错误的原因和位置。
  • 最后,空白压缩和字符转换。也就是返回的JSON结果不要换行和空格,用一行返回结果,使整个结果文本容量最小。同时,中文等字符或结果中有引号,都进行字符转换,防止结果无法被正确识别。

鉴权

  • 其实也就是客户端的权限控制。一般而言,我会采用给客户端分发一个token来确定该客户端的唯一身份。客户端在请求时,通过这个token,判断发出请求的客户端所对应的用户,及其相关信息和权限。
  • token信息不是用来进行处理的数据,虽然可以通过POST、PUT等进行数据提交或传输,但是从RESTful规范来讲,它不属于操作数据,在服务端进行处理时,仅是利用token进行鉴权处理,所以,我的建议是通过header来发送token。
  • 国内大部分API对PUT、DELETE请求进行了阉割,更不用提HEAD、PACTH、OPTIONS请求。实际上,国内大部分开放API仅支持GET和POST两种,部分API支持将app key信息通过header进行发送。在面对这种情况下,我们不得不抛弃标准的RESTful规范,在url中加入get、add、update、delete等动作词汇,以补充由于请求支持不完善带来的动作区分问题。如果仅支持GET和POST,那么所有需要保密的数据,绝对不可以用GET来进行请求,而必须用POST。

接口的安全

保证接口的可用性

接口的效率

接口的版本控制

算法

时间复杂度

**算法分析 **

  • 同一问题可用不同算法解决,而一个算法的质量优劣将影响到算法乃至程序的效率.算法分析的目的在 于选择合适算法和改进算法.一个算法的评价主要从时间复杂度和空间复杂度来考虑.
  • 计算机科学中,算法的时间复杂度是一个函数,它定性描述了该算法的运行时间。这是一个关于代 表算法输入值的字符串的长度的函数。时间复杂度常用大O符号表述,不包括这个函数的低阶项和首 项系数。使用这种方式时,时间复杂度可被称为是渐近的,它考察当输入值大小趋近无穷时的情况

**时间复杂度 **

在计算机科学中,算法的时间复杂度是一个函数,它定性描述了该算法的运行时间。这是一个关于 代表算法输入值的字符串的长度的函数。时间复杂度常用大O符号表述,不包括这个函数的低阶项和 首项系数。

**计算方法 **

1.一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示,若有某个辅 助函数f(n),使得T(n)/f(n)的极限值(当n趋近于无穷大时)为不等于零的常数,则称f(n)是T(n)的同 数量级函数。记作T(n)=O(f(n)),称O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。 分析:随着模块n的增大,算法执行的时间的增长率和 f(n) 的增长率成正比,所以 f(n) 越小,算法的 时间复杂度越低,算法的效率越高。

  1. 在计算时间复杂度的时候,先找出算法的基本操作,然后根据相应的各语句确定它的执行次数, 再找出 T(n) 的同数量级(它的同数量级有以下:1,log2n,n,n log2n ,n的平方,n的三次方,2 的n次方,n!),找出后,f(n) = 该数量级,若 T(n)/f(n) 求极限可得到一常数c,则时间复杂度T(n) = O(f(n))
    3.在pascal中比较容易理解,容易计算的方法是:看看有几重for循环,只有一重则时间复杂度为 O(n),二重则为O(n^2),依此类推,如果有二分则为O(logn),二分例如快速幂、二分查找,如果一 个for循环套一个二分,那么时间复杂度则为O(nlogn)。

**分类 **

  • 按数量级递增排列,常见的时间复杂度有: 常数阶O(1),对数阶O(log 2的n次方 ),线性阶O(n), 线性对数阶O(nlog2n),平方阶O(n2),立方阶O(n3),..., k次方阶O(nk),指数阶O(2n)。
  • 随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行 效率越低。

理解

  • 整个算法的执行时间与基本操 作重复执行的次数成正比。
  • 基本操作重复执行的次数是问题规模n的某个函数f(n),于是算法的时间量度可以记为:T(n) = O(f(n)) 如果按照这么推断,T(n)应该表示的是算法的时间量度,也就是算法执行的时间。
  • 而该页对“语句频度”也有定义:指的是该语句重复执行的次数。
  • 如果是基本操作所在语句重复执行的次数,那么就该是f(n)。 上边的n都表示的问题规模。

总结
时间频度

一个算法执行所耗费的时间,从理论上是不能算出来的,必须上机运行测试才能知道.但我们不可能也没 有必要对每个算法都上机测试,只需知道哪个算法花费的时间多,哪个算法花费的时间少就可以了.并且 一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多. 一个算法中的语句执行次数称为语句频度或时间频度.记为T(n).

时间复杂度

  • 一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数 f(n),使得当n趋近于无穷大时,T(n)/f(n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数. 记作T(n)=O(f(n)),称O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度.
  • 在各种不同算法中,若算法中语句执行次数为一个常数,则时间复杂度为O(1),另外,在时间频度不相同 时,时间复杂度有可能相同,如T(n)=n2+3n+4与T(n)=4n2+2n+1它们的频度不同,但时间复杂度 相同,都为O(n2).
  • 按数量级递增排列,常见的时间复杂度有: 常数阶O(1),对数阶O(log2n),线性阶O(n), 线性对数阶O(nlog2n),平方阶O(n2),立方阶O(n3),...,
  • k次方阶O(nk),指数阶O(2n).随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越 低.

空间复杂度

  • 与时间复杂度类似,空间复杂度是指算法在计算机内执行时所需存储空间的度量.记作: S(n)=O(f(n)) 我们一般所讨论的是除正常占用内存开销外的辅助存储单元规模.
    • O(1): 表示算法的运行时间为常量
    • O(n): 表示该算法是线性算法
    • O(㏒2n): 二分查找算法
    • O(n2): 对数组进行排序的各种简单算法,例如直接插入排序的算法。
    • O(n3): 做两个n阶矩阵的乘法运算
    • O(2n): 求具有n个元素集合的所有子集的算法
    • O(n!): 求具有N个元素的全排列的算法
    • O(n²)表示当n很大的时候,复杂度约等于Cn²,C是某个常数,简单说就是当n足够大的 时候,n的线性增长,复杂度将沿平方增长。
  • 一个算法执行所耗费的时间,从理论上是不能算出来的,必须上机运行测试才能知道。 但我们不可能也没有必要对每个算法都上机测试,只需知道哪个算法花费的时间多,哪 个算法花费的时间少就可以了。并且一个算法花费的时间与算法中语句的执行次数成正 比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为 语句频度或时间频度。记为T(n)。
  • 一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示, 若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f(n)的极限值为不等于零的常 数,则称f(n)是T(n)的同数量级函数。记作T(n)=O(f(n)),称O(f(n))

性能比较

[图片上传失败...(image-8270e-1572255684151)]

排序 算法

交换排序:交换排序的基本思想是,比较两个记录键值的大小,如果这两个记录键值的大小出现逆 序,则交换这两个记录,这样将键值较小的记录向序列前部移动,键值较大的记录向序列后部移动。

冒泡排序

冒泡排序(Bubble Sort,台湾译为:泡沫排序或气泡排序)是一种简单的排序算法。它重复 地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访 数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的 名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

步骤

  1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
  2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一 点,最后的元素应该会是最大的数。 3. 针对所有的元素重复以上的步骤,除了最后一个。
  3. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比 较。

冒泡排序理解起来是最简单,但是时间复杂度(O(n^2))也是最大的之一

快速排序

快速排序是由东尼∙霍尔所发展的一种排序算法。在平均状况下,排序 n 个项目要Ο(n log n) 次比较。在最坏状况下则需要Ο(n2)次比较,但这种状况并不常见。事实上,快速排序通常明 显比其他Ο(n log n) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很 有效率地被实现出来,且在大部分真实世界的数据,可以决定设计的选择,减少所需时间的 二次方项之可能性。

步骤

  1. 从数列中挑出一个元素,称为 “基准”(pivot)
  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大 的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
  3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排 序。

快排也是一个高效的排序算法,它的时间复杂度也是O(nlogn)

选择排序

  • 选择排序(Selection sort)是一种简单直观的排序算法。首先在未排序序 列中找到最小元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最 小元素,然后放到排序序列末尾。以此类推,直到所有元素均排序完毕。
  • 选择排序包括两种,分别是直接选择排序和堆排序,选择排序的基本思想是每一次在ni+1(i=1,2,3,...,n-1)个记录中选取键值最小的记录作为有序序列的第i个记录

堆排序

堆积排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全 二叉树的结构,并同时满足堆性质:即子结点的键值或索引总是小于(或者大于)它的父节 点。

步骤

堆排序是指利用堆积树(堆)这种数据结构所设计的一种排序算法,利用数组的特点快速定位指定 索引的元素。堆分为大根堆和小根堆,是完全二叉树。大根堆的要求是每个节点的值都不大于其父 节点的值,即A[PARENT[i]] >= A[i]。在数组的非降序排序中,需要使用的就是大根堆,因为根据 大根堆的要求可知,最大的值一定在堆顶。

堆排序是一种高效的排序算法,它的时间复杂度是O(nlogn)。原理是:先把数组转为一个最 大堆,然后把第一个元素跟第i元素交换,然后把剩下的i-1个元素转为最大堆,然后再把第一 个元素与第i-1个元素交换,以此类推

插入排序

插入排序(Insertion Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构 建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入 排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向 前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。

步骤

  1. 从第一个元素开始,该元素可以认为已经被排序
  2. 取出下一个元素,在已经排序的元素序列中从后向前扫描
  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置
  4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
  5. 将新元素插入到该位置中
  6. 重复步骤2

插入排序跟冒泡排序有点相似,时间复杂度也是O(n^2)

希尔排序

  • 希尔排序,也称递减增量排序算法,是插入排序的一种高速而稳定的改进版本。 希尔排序是基于插入排序的以下两点性质而提出改进方法的:
    • 1、插入排序在对几乎已经排好序的数据操作时, 效率高, 即可以达到线性排序的效率
    • 2、但插入排序一般来说是低效的, 因为插入排序每次只能将数据移动一位>
  • 希尔排序其实可以理解是插入排序的一个优化版,它的效率跟增量有关,增量要取多 少,根据不同的数组是不同的,所以希尔排序是一个不稳定的排序算法,它的时间复杂 度为O(nlogn)到O(n^2)之间

归并排序

归并排序(Merge sort,台湾译作:合并排序)是建立在归并操作上的一种有效的排序算 法。该算法是采用分治法(pide and Conquer)的一个非常典型的应用

步骤

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置
  3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针 到下一位置
  4. 重复步骤3直到某一指针达到序列尾
  5. 将另一序列剩下的所有元素直接复制到合并序列尾

归并排序的时间复杂度也是O(nlogn)。原理是:对于两个排序好的数组,分别遍历这两 个数组,获取较小的元素插入新的数组中,那么,这么新的数组也会是排序好的。

顺序查找

** 说明**

顺序查找适合于存储结构为顺序存储或链接存储的线性表。

基本思想
顺序查找也称为线形查找,属于无序查找算法。从数据结构线形表的一端开始,顺 序扫描,依次将扫描到的结点关键字与给定值k相比较,若相等则表示查找成功;若扫描结束仍没有 找到关键字等于k的结点,表示查找失败。

**复杂度分析 **

查找成功时的平均查找长度为:(假设每个数据元素的概率相等) ASL = 1/n(1+2+3+…+n) = (n+1)/2 ; 当查找不成功时,需要n+1次比较,时间复杂度为O(n);

二分查找

二分搜索(binary search),也称折半搜索(half-interval search)、对数搜索(logarithmic search),是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。

思路

  • 查找的k可能在数组下标区间a,b
  • 计算区间ab的中间点m
  • k < tarr[m] 将区间缩小为a,m继续二分查找
  • k >arr[m] 将区间缩小为m,b,继续二分查找

** 复杂度分析**

最坏情况下,关键词比较次数为log2(n+1),且期望时间复杂度为O(log2n);

其他链接

优惠券

优惠券业务设计

链接: https://www.jianshu.com/p/269a54846a9c

技术架构设计:https://blog.csdn.net/HUXU981598436/article/details/78048919

优惠券表设计:

https://blog.csdn.net/t_332741160/article/details/86591243

优惠券业务设计

https://blog.csdn.net/egworkspace/article/details/80414953

https://learnku.com/articles/28758#edfe81

原文:https://learnku.com/articles/28772#7b3802

上一篇下一篇

猜你喜欢

热点阅读