Dunwu Blog

大道至简,知易行难

系统扩展性架构

扩展性和伸缩性是不同的概念:

  • 扩展性(Extensibility) - 指对现有系统影响最小的情况下,系统功能可持续扩展或提升的能力。表现在系统基础设施稳定不需要经常变更,应用之间较少依赖和耦合,对需求变更可以敏捷响应。它是系统架构设计层面的开闭原则(对扩展开放、对修改关闭),架构设计考虑未来功能扩展,当系统增加新功能时,不需要对现有系统的结构和代码进行修改。
  • 伸缩性(Scalability) - 指系统能够通过增加减少自身资源规模的方式增减自己计算处理事务的能力。如果这种增减是成比例的,就被称作线性伸缩性。在网站架构中 ,通常指利用集群的方式增加服务器数量、提高系统的整体事务吞吐能力。

可扩展的基本思想

  • 面向流程拆分:将整个业务流程拆分为几个阶段,每个阶段作为一部分。
  • 面向服务拆分:将系统提供的服务拆分,每个服务作为一部分。
  • 面向功能拆分:将系统提供的功能拆分,每个功能作为一部分。

可扩展方式

典型的可扩展系统架构有:

  • 面向流程拆分:分层架构。
  • 面向服务拆分:SOA、微服务。
  • 面向功能拆分:微内核架构。

分层架构

分层架构的核心点时:需要保证各层之间的差异足够清晰,边界足够明显,让人看到架构图后就能看懂整个架构

分层架构是很常见的架构模式,它也叫 N 层架构,通常情况下,N 至少是 2 层。例如,C/S 架构、B/S 架构。常见的是 3 层架构(例如,MVC、MVP 架构)、4 层架构,5 层架构的比较少见,一般是比较复杂的系统才会达到或者超过 5 层,比如操作系统内核架构。

典型分层架构:

  • C/S 架构、B/S 架构
  • MVC 架构、MVP 架构
  • 逻辑分层架构

SOA

SOA 的全称是 Service Oriented Architecture,即“面向服务的架构”。

SOA 提出了 3 个关键概念。

  • 服务 - 所有业务功能都是一项服务,服务就意味着要对外提供开放的能力,当其他系统需要使用这项功能时,无须定制化开发。
  • ESB - ESB 的全称是 Enterprise Service Bus,即 “企业服务总线”。ESB 将企业中各个不同的服务连接在一起。因为各个独立的服务是异构的,如果没有统一的标准,则各个异构系统对外提供的接口是各式各样的。SOA 使用 ESB 来屏蔽异构系统对外提供各种不同的接口方式,以此来达到服务间高效的互联互通。
  • 松耦合 - 松耦合的目的是减少各个服务间的依赖和互相影响。因为采用 SOA 架构后,各个服务是相互独立运行的,甚至都不清楚某个服务到底有多少对其他服务的依赖。如果做不到松耦合,某个服务一升级,依赖它的其他服务全部故障,这样肯定是无法满足业务需求的。

微服务

微服务是去掉 ESB 后的 SOA。

微服务的问题:

  • 服务划分过细,服务间关系复杂 - 服务划分过细,单个服务的复杂度确实下降了,但整个系统的复杂度却上升了,因为微服务将系统内的复杂度转移为系统间的复杂度了。
  • 服务数量太多,团队效率急剧下降
  • 调用链太长,性能下降
  • 调用链太长,问题定位困难
  • 没有自动化支撑,无法快速交付
  • 没有服务治理,微服务数量多了后管理混乱

微服务拆分:

  • 基于业务逻辑拆分
  • 基于可扩展拆分 - 将已经成熟和改动不大的服务拆分为稳定服务,将经常变化和迭代的服务拆分为变动服务
  • 基于可靠性拆分 - 将系统中的业务模块按照优先级排序,将可靠性要求高的核心服务和可靠性要求低的非核心服务拆分开来,然后重点保证核心服务的高可用。

基础设施:

  • 服务发现、服务路由、服务容错:这是最基本的微服务基础设施。
  • 接口框架、API 网关:主要是为了提升开发效率,接口框架是提升内部服务的开发效率,API 网关是为了提升与外部服务对接的效率。
  • 自动化部署、自动化测试、配置中心:主要是为了提升测试和运维效率。
  • 服务监控、服务跟踪、服务安全:主要是为了进一步提升运维效率。

微内核

img

微内核的核心系统设计的关键技术有:插件管理、插件连接和插件通信。

插件管理

核心系统需要知道当前有哪些插件可用,如何加载这些插件,什么时候加载插件。常见的实现方法是插件注册表机制。核心系统提供插件注册表(可以是配置文件,也可以是代码,还可以是数据库),插件注册表含有每个插件模块的信息,包括它的名字、位置、加载时机(启动就加载,还是按需加载)等。

插件连接

插件连接指插件如何连接到核心系统。通常来说,核心系统必须制定插件和核心系统的连接规范,然后插件按照规范实现,核心系统按照规范加载即可。

常见的连接机制有 OSGi(Eclipse 使用)、消息模式、依赖注入(Spring 使用),甚至使用分布式的协议都是可以的,比如 RPC 或者 HTTP Web 的方式。

插件通信

插件通信指插件间的通信。虽然设计的时候插件间是完全解耦的,但实际业务运行过程中,必然会出现某个业务流程需要多个插件协作,这就要求两个插件间进行通信。由于插件之间没有直接联系,通信必须通过核心系统,因此核心系统需要提供插件通信机制。这种情况和计算机类似,计算机的 CPU、硬盘、内存、网卡是独立设计的配件,但计算机运行过程中,CPU 和内存、内存和硬盘肯定是有通信的,计算机通过主板上的总线提供了这些组件之间的通信功能。微内核的核心系统也必须提供类似的通信机制,各个插件之间才能进行正常的通信。

易扩展的系统架构

低耦合的系统更容易扩展、复用

可扩展架构的核心思想是模块化,并在此基础上,降低模块间的耦合性,提高模块的复用性

分层和分割不仅可以进行架构伸缩,也是模块化设计的重要手段,利用分层和分割的方式将软件分割为若干个低耦合的独立的组件模块,这些组件模块以消息传递及依赖调用的方式聚合成一个完整的系统。

在大型网站中,这些模块通过分布式部署的方式,独立的模块部署在独立的服务器上,从物理上分离模块间的耦合关系,进一步降低耦合性提高复用性。

利用分布式消息队列降低系统耦合性

事件驱动架构

事件驱动架构通过在低耦合的模块间传输事件消息,以保持模块的松散耦合,并借助事件消息的通信完成模块间合作。典型的事件驱动架构就是操作系统中常见的生产者消费者模式。在大型网站中,最常见的实现手段就是分布式消息队列。

分布式消息队列

消息生产者应用程序通过远程访问接口将消息推送给消息队列服务器,消息队列服务器将消息写入本地内存队列后立即返回成功响应给消息生产者。消息队列服务器根据消息订阅列表查找订阅该消息的消息消费者应用程序,将消息队列中的消息按照先进先出(FIFO)的原则将消息通过远程通信接口发送给消息消费者程序。

在伸缩性方面,由于消息队列服务器上的数据可以看作是即时处理的,因此类似于无状态的服务器,伸缩性设计比较简单。将新服务器加入分布式消息队列集群中,通知生产者服务器更改消息队列服务器列表即可。

在可用性方面,为了避免消费者进程处理缓慢,分布式消息队列服务器内存空间不足造成的问题,如果内存队列已满,会将消息写入磁盘,消息推送模块在将内存队列消息处理完成以后,将磁盘内容加载到内存队列继续处理。

利用分布式服务打造可复用的业务平台

巨无霸系统的问题:

  • 构建、部署困难
  • 代码分支管理困难
  • 数据库连接耗尽
  • 扩展业务困难

而解决巨无霸系统问题的方案就是拆分:

  • 通过纵向拆分将业务拆分多个应用或模块;
  • 通过横向拆分将可复用业务作为独立应用。

然后,需要通过一个分布式服务管理框架将这些应用或服务组织管理起来:通过接口分解系统耦合性,不同子系统通过相同的接口描述进行服务调用。常见的分布式服务管理框架如:Spring Cloud、Dubbo 等。

大型网站分布式服务的需求与特点:

  • 负载均衡
  • 失效转移
  • 高效的远程通信
  • 整合异构系统
  • 对应用最少侵入
  • 版本管理
  • 实时监控

可扩展的数据结构

传统的关系型数据库为了保证关系运算的正确性,在设计数据库表结构的时候,就需要指定表的 schema ——字段名称,数据类型等,并要遵循特定的设计范式。这些规范带来一个问题:难以面对需求变更带来的挑战,所以有人通过预先设计一些冗余字段来应对。

许多 NoSql 数据库使用 ColumnFamily 设计来设计可扩展的数据结构。

开放平台

很多大公司会利用开放平台提供大量开放性 API 使得企业和个人可以方便的接入业务。通过开放平台,可以构建生态圈,提升品牌价值以及竞争力。

开放平台不是一朝一夕完成的,这需要大量 OPEN API 的沉淀。系统架构在设计之初,应该有意识的将未来可能被复用的接口好好设计,以便于需要开放 OPEN API 时,可以便捷的暴露服务接口。

参考资料

系统安全性架构

关键词:XSS、CSRF、SQL 注入、DoS、消息摘要、加密算法、证书

认证

SSO

SSO(Single Sign On),即单点登录。所谓单点登录,就是同平台的诸多应用登陆一次,下一次就免登陆的功能。

SSO 需要解决多个异构系统之间的问题:

  • Session 共享问题
  • 跨域问题

Session 共享问题

分布式 Session 的几种实现策略:

  • 粘性 Session - 缺点:当服务器节点宕机时,将丢失该服务器节点上的所有 Session
  • 应用服务器间的 Session 复制共享 - 缺点:占用过多内存同步过程占用网络带宽以及服务器处理器时间
  • 基于缓存的 Session 共享 ✅ (推荐方案) - 不过需要程序自身控制 Session 读写,可以考虑基于 spring-session + redis 这种成熟的方案来处理。

Cookie 不能跨域!比如:浏览器不会把 www.google.com 的 cookie 传给 www.baidu.com。

这就存在一个问题:由于域名不同,用户在系统 A 登录后,浏览器记录系统 A 的 Cookie,但是访问系统 B 的时候不会携带这个 Cookie。

针对 Cookie 不能跨域 的问题,有几种解决方案:

  • 服务端生成 Cookie 后,返回给客户端,客户端解析 Cookie ,提取 Token (比如 JWT),此后每次请求都携带这个 Token。
  • 多个域名共享 Cookie,在返回 Cookie 给客户端的时候,在 Cookie 中设置 domain 白名单。
  • 将 Token 保存在 SessionStroage 中(不依赖 Cookie 就没有跨域的问题了)。

CAS

CAS 是实现 SSO 的主流方式。

CAS 分为两部分,CAS Server 和 CAS Client

  • CAS Server - 负责用户的认证工作,就像是把第一次登录用户的一个标识存在这里,以便此用户在其他系统登录时验证其需不需要再次登录。
  • CAS Client - 业务应用,需要接入 CAS Server。当用户访问我们的应用时,首先需要重定向到 CAS Server 端进行验证,要是原来登陆过,就免去登录,重定向到下游系统,否则进行用户名密码登陆操作。

术语:

  • Ticket Granting Ticket (TGT) - 可以认为是 CAS Server 根据用户名、密码生成的一张票,存在 Server 端。
  • Ticket Granting Cookie (TGC) - 其实就是一个 Cookie,存放用户身份信息,由 Server 发给 Client 端。
  • Service Ticket (ST) - 由 TGT 生成的一次性票据,用于验证,只能用一次。

CAS 工作流程:

img

  1. 用户访问 CAS Client A(业务系统),第一次访问,重定向到认证服务中心(CAS Server)。CAS Server 发现当前请求中没有 Cookie,再重定向到 CAS Server 的登录页面。重定向请求的 URL 中包含访问地址,以便认证成功后直接跳转到访问页面。
  2. 用户在登录页面输入用户名、密码等认证信息,认证成功后,CAS Server 生成 TGT,再用 TGT 生成一个 ST。然后返回 ST 和 TGC(Cookie)给浏览器。
  3. 浏览器携带 ST 再度访问之前想访问的 CAS Client A 页面。
  4. CAS Client A 收到 ST 后,向 CAS Server 验证 ST 的有效性。验证通过则允许用户访问页面。
  5. 此时,如果登录另一个 CAS Client B,会先重定向到 CAS Server,CAS Server 可以判断这个 CAS Client B 是第一次访问,但是本地有 TGC,所以无需再次登录。用 TGC 创建一个 ST,返回给浏览器。
  6. 重复类似 3、4 步骤。

img

以上了归纳总结如下:

  1. 访问服务 - 用户访问 SSO Client 资源。
  2. 定向认证 - SSO Client 重定向用户请求到 SSO Server。
  3. 用户认证 - 用户身份认证。
  4. 发放票据 - SSO Server 会产生一个 Service Ticket (ST) 并返回给浏览器。
  5. 验证票据 - 浏览器每次访问 SSO Client 时,携带 ST,SSO Client 向 SSO Server 验证票据。只有验证通过,才允许访问。
  6. 传输用户信息 - SSO Server 验证票据通过后,传输用户认证结果信息给 SSO Client。

Oauth 2.0

基本原理

OAuth 在”客户端”与”服务提供商”之间,设置了一个授权层(authorization layer)。”客户端”不能直接登录”服务提供商”,只能登录授权层,以此将用户与客户端区分开来。”客户端”登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。

“客户端”登录授权层以后,”服务提供商”根据令牌的权限范围和有效期,向”客户端”开放用户储存的资料。

OAuth 2.0 的运行流程如下图,摘自 RFC 6749。

(A)用户打开客户端以后,客户端要求用户给予授权。

(B)用户同意给予客户端授权。

(C)客户端使用上一步获得的授权,向认证服务器申请令牌。

(D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。

(E)客户端使用令牌,向资源服务器申请获取资源。

(F)资源服务器确认令牌无误,同意向客户端开放资源。

不难看出来,上面六个步骤之中,B 是关键,即用户怎样才能给于客户端授权。有了这个授权以后,客户端就可以获取令牌,进而凭令牌获取资源。

授权模式

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。OAuth 2.0 定义了四种授权方式。

  • 授权码模式(authorization code)
  • 简化模式(implicit)
  • 密码模式(resource owner password credentials)
  • 客户端模式(client credentials)

授权码模式

授权码模式(authorization code)是功能最完整、流程最严密的授权模式。它的特点就是通过客户端的后台服务器,与”服务提供商”的认证服务器进行互动。

它的步骤如下:

(A)用户访问客户端,后者将前者导向认证服务器。

(B)用户选择是否给予客户端授权。

(C)假设用户给予授权,认证服务器将用户导向客户端事先指定的”重定向 URI”(redirection URI),同时附上一个授权码。

(D)客户端收到授权码,附上早先的”重定向 URI”,向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。

(E)认证服务器核对了授权码和重定向 URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。

下面是上面这些步骤所需要的参数。

A 步骤中,客户端申请认证的 URI,包含以下参数:

  • response_type:表示授权类型,必选项,此处的值固定为”code”
  • client_id:表示客户端的 ID,必选项
  • redirect_uri:表示重定向 URI,可选项
  • scope:表示申请的权限范围,可选项
  • state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。

下面是一个例子。

1
2
3
GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com

C 步骤中,服务器回应客户端的 URI,包含以下参数:

  • code:表示授权码,必选项。该码的有效期应该很短,通常设为 10 分钟,客户端只能使用该码一次,否则会被授权服务器拒绝。该码与客户端 ID 和重定向 URI,是一一对应关系。
  • state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。

下面是一个例子。

1
2
3
HTTP/1.1 302 Found
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
&state=xyz

D 步骤中,客户端向认证服务器申请令牌的 HTTP 请求,包含以下参数:

  • grant_type:表示使用的授权模式,必选项,此处的值固定为”authorization_code”。
  • code:表示上一步获得的授权码,必选项。
  • redirect_uri:表示重定向 URI,必选项,且必须与 A 步骤中的该参数值保持一致。
  • client_id:表示客户端 ID,必选项。

下面是一个例子。

1
2
3
4
5
6
7
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb

E 步骤中,认证服务器发送的 HTTP 回复,包含以下参数:

  • access_token:表示访问令牌,必选项。
  • token_type:表示令牌类型,该值大小写不敏感,必选项,可以是 bearer 类型或 mac 类型。
  • expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间。
  • refresh_token:表示更新令牌,用来获取下一次的访问令牌,可选项。
  • scope:表示权限范围,如果与客户端申请的范围一致,此项可省略。

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}

从上面代码可以看到,相关参数使用 JSON 格式发送(Content-Type: application/json)。此外,HTTP 头信息中明确指定不得缓存。

简化模式

简化模式(implicit grant type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了”授权码”这个步骤,因此得名。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。

它的步骤如下:

(A)客户端将用户导向认证服务器。

(B)用户决定是否给于客户端授权。

(C)假设用户给予授权,认证服务器将用户导向客户端指定的”重定向 URI”,并在 URI 的 Hash 部分包含了访问令牌。

(D)浏览器向资源服务器发出请求,其中不包括上一步收到的 Hash 值。

(E)资源服务器返回一个网页,其中包含的代码可以获取 Hash 值中的令牌。

(F)浏览器执行上一步获得的脚本,提取出令牌。

(G)浏览器将令牌发给客户端。

下面是上面这些步骤所需要的参数。

A 步骤中,客户端发出的 HTTP 请求,包含以下参数:

  • response_type:表示授权类型,此处的值固定为”token”,必选项。
  • client_id:表示客户端的 ID,必选项。
  • redirect_uri:表示重定向的 URI,可选项。
  • scope:表示权限范围,可选项。
  • state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。

下面是一个例子。

1
2
3
GET /authorize?response_type=token&client_id=s6BhdRkqt3&state=xyz
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com

C 步骤中,认证服务器回应客户端的 URI,包含以下参数:

  • access_token:表示访问令牌,必选项。
  • token_type:表示令牌类型,该值大小写不敏感,必选项。
  • expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间。
  • scope:表示权限范围,如果与客户端申请的范围一致,此项可省略。
  • state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。

下面是一个例子。

1
2
3
HTTP/1.1 302 Found
Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA
&state=xyz&token_type=example&expires_in=3600

在上面的例子中,认证服务器用 HTTP 头信息的 Location 栏,指定浏览器重定向的网址。注意,在这个网址的 Hash 部分包含了令牌。

根据上面的 D 步骤,下一步浏览器会访问 Location 指定的网址,但是 Hash 部分不会发送。接下来的 E 步骤,服务提供商的资源服务器发送过来的代码,会提取出 Hash 中的令牌。

密码模式

密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向”服务商提供商”索要授权。

在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。

它的步骤如下:

(A)用户向客户端提供用户名和密码。

(B)客户端将用户名和密码发给认证服务器,向后者请求令牌。

(C)认证服务器确认无误后,向客户端提供访问令牌。

B 步骤中,客户端发出的 HTTP 请求,包含以下参数:

  • grant_type:表示授权类型,此处的值固定为”password”,必选项。
  • username:表示用户名,必选项。
  • password:表示用户的密码,必选项。
  • scope:表示权限范围,可选项。

下面是一个例子。

1
2
3
4
5
6
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=johndoe&password=A3ddj3w

C 步骤中,认证服务器向客户端发送访问令牌,下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}

上面代码中,各个参数的含义参见《授权码模式》一节。

整个过程中,客户端不得保存用户的密码。

客户端模式

客户端模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向”服务提供商”进行认证。严格地说,客户端模式并不属于 OAuth 框架所要解决的问题。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求”服务提供商”提供服务,其实不存在授权问题。

它的步骤如下:

(A)客户端向认证服务器进行身份认证,并要求一个访问令牌。

(B)认证服务器确认无误后,向客户端提供访问令牌。

A 步骤中,客户端发出的 HTTP 请求,包含以下参数:

  • granttype:表示授权类型,此处的值固定为”clientcredentials”,必选项。
  • scope:表示权限范围,可选项。
1
2
3
4
5
6
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials

认证服务器必须以某种方式,验证客户端身份。

B 步骤中,认证服务器向客户端发送访问令牌,下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"example_parameter":"example_value"
}

上面代码中,各个参数的含义参见《授权码模式》一节。

更新令牌

如果用户访问的时候,客户端的”访问令牌”已经过期,则需要使用”更新令牌”申请一个新的访问令牌。

客户端发出更新令牌的 HTTP 请求,包含以下参数:

  • granttype:表示使用的授权模式,此处的值固定为”refreshtoken”,必选项。
  • refresh_token:表示早前收到的更新令牌,必选项。
  • scope:表示申请的授权范围,不可以超出上一次申请的范围,如果省略该参数,则表示与上一次一致。

下面是一个例子。

1
2
3
4
5
6
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA

鉴权

RBAC

**RBAC(Role-Based Access Control)即:基于角色的权限控制**。通过角色关联用户,角色关联权限的方式间接赋予用户权限。

每个用户关联一个或多个角色,每个角色关联一个或多个权限,从而可以实现了非常灵活的权限管理。角色可以根据实际业务需求灵活创建,这样就省去了每新增一个用户就要关联一遍所有权限的麻烦。简单来说 RBAC 就是:用户关联角色,角色关联权限。

img

角色继承

角色继承(Hierarchical Role) 就是指角色可以继承于其他角色,在拥有其他角色权限的同时,自己还可以关联额外的权限。这种设计可以给角色分组和分层,一定程度简化了权限管理工作。

img

职责分离(Separation of Duty)

为了避免用户拥有过多权限而产生利益冲突,例如一个篮球运动员同时拥有裁判的权限(看一眼就给你判犯规狠不狠?),另一种职责分离扩展版的 RBAC 被提出。

职责分离有两种模式:

静态职责分离(Static Separation of Duty):用户无法同时被赋予有冲突的角色。

img

动态职责分离(Dynamic Separation of Duty):用户在一次会话(Session)中不能同时激活自身所拥有的、互相有冲突的角色,只能选择其一。

img

讲了这么多 RBAC,都还只是在用户和权限之间进行设计,并没有涉及到用户和对象之间的权限判断,而在实际业务系统中限制用户能够使用的对象是很常见的需求。

RBAC0 模型

最简单的用户、角色、权限模型。这里面又包含了 2 种:

  1. 用户和角色是多对一关系,即:一个用户只充当一种角色,一种角色可以有多个用户担当。
  2. 用户和角色是多对多关系,即:一个用户可同时充当多种角色,一种角色可以有多个用户担当。

那么,什么时候该使用多对一的权限体系,什么时候又该使用多对多的权限体系呢?

如果系统功能比较单一,使用人员较少,岗位权限相对清晰且确保不会出现兼岗的情况,此时可以考虑用多对一的权限体系。其余情况尽量使用多对多的权限体系,保证系统的可扩展性。如:张三既是行政,也负责财务工作,那张三就同时拥有行政和财务两个角色的权限。

RBAC1 模型

相对于 RBAC0 模型,增加了子角色,引入了继承概念,即子角色可以继承父角色的所有权限。

img

使用场景:如某个业务部门,有经理、主管、专员。主管的权限不能大于经理,专员的权限不能大于主管,如果采用 RBAC0 模型做权限系统,极可能出现分配权限失误,最终出现主管拥有经理都没有的权限的情况。

而 RBAC1 模型就很好解决了这个问题,创建完经理角色并配置好权限后,主管角色的权限继承经理角色的权限,并且支持在经理权限上删减主管权限。

RBAC2 模型

基于 RBAC0 模型,增加了对角色的一些限制:角色互斥、基数约束、先决条件角色等。

  • 角色互斥:同一用户不能分配到一组互斥角色集合中的多个角色,互斥角色是指权限互相制约的两个角色。案例:财务系统中一个用户不能同时被指派给会计角色和审计员角色。
  • 基数约束:一个角色被分配的用户数量受限,它指的是有多少用户能拥有这个角色。例如:一个角色专门为公司 CEO 创建的,那这个角色的数量是有限的。
  • 先决条件角色:指要想获得较高的权限,要首先拥有低一级的权限。例如:先有副总经理权限,才能有总经理权限。
  • 运行时互斥:例如,允许一个用户具有两个角色的成员资格,但在运行中不可同时激活这两个角色。

RBAC3 模型

称为统一模型,它包含了 RBAC1 和 RBAC2,利用传递性,也把 RBAC0 包括在内,综合了 RBAC0、RBAC1 和 RBAC2 的所有特点,这里就不在多描述了。

img

什么是权限

说了这么久用户-角色-权限,可能小伙伴们都了解了什么是用户、什么是角色。但是有的小伙伴会好奇,那权限又是个什么玩意呢?

权限是资源的集合,这里的资源指的是软件中所有的内容,包括模块、菜单、页面、字段、操作功能(增删改查)等等。具体的权限配置上,目前形式多种多样,按照我个人的理解,可以将权限分为:页面权限、操作权限和数据权限(这种分类法,主要是结合自己在工作中的实际情况理解总结而来,若有不足之处,也请大家指出)。

页面权限:所有系统都是由一个个的页面组成,页面再组成模块,用户是否能看到这个页面的菜单、是否能进入这个页面就称为页面权限。

如下图:

img

客户列表、客户黑名单、客户审批页面组成了客户管理这个模块。对于普通用户,不能进行审批操作,即无客户审批页面权限,在他的账号登录后侧边导航栏只显示客户列表、客户黑名单两个菜单。

操作权限:用户凡是在操作系统中的任何动作、交互都是操作权限,如增删改查等。

数据权限:一般业务管理系统,都有数据私密性的要求:哪些人可以看到哪些数据,不可以看到哪些数据。

简单举个例子:某系统中有销售部门,销售专员负责推销商品,销售主管负责管理销售专员日常工作,经理负责组织管理销售主管作业。

如下图:

img

按照实际理解,‘销售专员张三’登录时,只能看到自己负责的数据;销售主管 2 登录时,能看到他所领导的所有业务员负责的数据,但看不到其他团队业务员负责的数据。

换另外一句话就是:我的客户只有我和我的直属上级以及直属上级的领导能看到,这就是我理解的数据权限。

要实现数据权限有多种方式:

  1. 可以利用 RBAC1 模型,通过角色分级来实现。
  2. 在‘用户-角色-权限’的基础上,增加用户与组织的关联关系,用组织决定用户的数据权限。

具体如何做呢?

① 组织层级划分:

img

② 数据可视权限规则制定:上级组织只能看到下级组织员工负责的数据,而不能看到其他平级组织及其下级组织的员工数据等。

通过以上两点,系统就可以在用户登录时,自动判断要给用户展示哪些数据了。

用户组的使用

当平台用户基数增大,角色类型增多时,如果直接给用户配角色,管理员的工作量就会很大。这时候我们可以引入一个概念“用户组”,就是将相同属性的用户归类到一起。

例如:加入用户组的概念后,可以将部门看做一个用户组,再给这个部门直接赋予角色(1 万员工部门可能就几十个),使部门拥有部门权限,这样这个部门的所有用户都有了部门权限,而不需要为每一个用户再单独指定角色,极大的减少了分配权限的工作量。

同时,也可以为特定的用户指定角色,这样用户除了拥有所属用户组的所有权限外,还拥有自身特定的权限。

用户组的优点,除了减少工作量,还有更便于理解、增加多级管理关系等。如:我们在进行组织机构配置的时候,除了加入部门,还可以加入科室、岗位等层级,来为用户组内部成员的权限进行等级上的区分。

关于用户组的详细疑难解答,请查看https://wen.woshipm.com/question/detail/88fues.html。在这里也十分感谢为我解答疑惑的朋友们!

实例分析

如何设计 RBAC 权限系统

首先,我们思考一下一个简单的权限系统应该具备哪些内容?

答案显而易见,RBAC 模型:用户-角色-权限。所以最基本的我们应该具备用户、角色、权限这三个内容。

接下来,我们思考,究竟如何将三者关联起来。回顾前文,角色作为枢纽,关联用户、权限。所以在 RBAC 模型下,我们应该:创建一个角色,并为这个角色赋予相应权限,最后将角色赋予用户

将这个问题抽象为流程,如下图:

img

现在,基本的流程逻辑已经抽象出来了,接下来,分析该如何设计呢?

  • 第一步,需要角色管理列表,在角色管理列表能快速创建一个角色,且创建角色的同时能为角色配置权限,并且支持创建成功的角色列表能随时进行权限配置的的修改;
  • 第二步,需要用户管理列表,在用户管理列表能快速添加一个用户,且添加用户时有让用户关联角色的功能。

简单来说权限系统设计就包含以上两步,接下来为大家进行实例分析。

实例分析

① 创建角色列表

img

在角色列表快速创建一个角色:点击创建角色,支持创建角色时配置权限。

img

② 创建用户列表

img

在用户列表快速创建一个用户:支持用户关联角色的功能。

img

上述案例是基于最简单的 RBAC0 模型创建,适用于大部分常规的权限管理系统。

下面再分析一下 RBAC1 中角色分级具体如何设计。

  1. 在 RBAC0 的基础上,加上角色等级这个字段。
  2. 权限分配规则制定:低等级角色只能在高等级角色权限基础上进行删减权限。

具体界面呈现如下图:

img

以上就是简单的 RBAC 系统设计,若需更复杂的,还请读者根据上面的分析自行揣摩思考,尽管样式不同,但万变不离其宗,理解清楚 RBAC 模型后,结合自己的业务就可以设计出一套符合自己平台需求的角色权限系统,具体的就不再多阐述了。

审计

TODO

网站攻击

互联网环境鱼龙混杂,网站被攻击是常见现象,所以了解一些常见的网站攻击手段十分必要。下面列举比较常见的 4 种攻击手段:

XSS

概念

跨站脚本(Cross-site scripting,通常简称为XSS) 是一种网站应用程序的安全漏洞攻击,是代码注入的一种。它允许恶意用户将代码注入到网页上,其他用户在观看网页时就会受到影响。这类攻击通常包含了 HTML 以及用户端脚本语言。

XSS 攻击示例:

假如有下面一个 textbox

1
<input type="text" name="address1" value="value1from" />

value1from 是来自用户的输入,如果用户不是输入 value1from,而是输入 "/><script>alert(document.cookie)</script><!- 那么就会变成:

1
2
3
4
5
<input type="text" name="address1" value="" />
<script>
alert(document.cookie)
</script>
<!- ">

嵌入的 JavaScript 代码将会被执行。攻击的威力,取决于用户输入了什么样的脚本。

攻击手段和目的

常用的 XSS 攻击手段和目的有:

  • 盗用 cookie,获取敏感信息。
  • 利用植入 Flash,通过 crossdomain 权限设置进一步获取更高权限;或者利用 Java 等得到类似的操作。
  • 利用 iframeframeXMLHttpRequest 或上述 Flash 等方式,以(被攻击)用户的身份执行一些管理动作,或执行一些一般的如发微博、加好友、发私信等操作。
  • 利用可被攻击的域受到其他域信任的特点,以受信任来源的身份请求一些平时不允许的操作,如进行不当的投票活动。
  • 在访问量极大的一些页面上的 XSS 可以攻击一些小型网站,实现 DDoS 攻击的效果。

应对手段

  • 过滤特殊字符 - 将用户所提供的内容进行过滤,从而避免 HTML 和 Jascript 代码的运行。如 > 转义为 &gt< 转义为 &lt 等,就可以防止大部分攻击。为了避免对不必要的内容错误转移,如 3<5 中的 < 需要进行文本匹配后再转移,如:<img src= 这样的上下文中的 < 才转义。
  • 设置 Cookie 为 HttpOnly - 设置了 HttpOnly 的 Cookie 可以防止 JavaScript 脚本调用,就无法通过 document.cookie 获取用户 Cookie 信息。

:point_right: 参考阅读:

CSRF

概念

**跨站请求伪造(Cross-site request forgery,CSRF)**,也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF。它是一种挟持用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。和跨站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

攻击手段和目的

可以如此理解 CSRF:攻击者盗用了你的身份,以你的名义发送恶意请求。

CSRF 能做的事太多:

  • 以你名义发送邮件,发消息
  • 用你的账号购买商品
  • 用你的名义完成虚拟货币转账
  • 泄露个人隐私

应对手段

  • 表单 Token - CSRF 是一个伪造用户请求的操作,所以需要构造用户请求的所有参数才可以。表单 Token 通过在请求参数中添加随机数的办法来阻止攻击者获得所有请求参数。
  • 验证码 - 请求提交时,需要用户输入验证码,以避免用户在不知情的情况下被攻击者伪造请求。
  • Referer check - HTTP 请求头的 Referer 域中记录着请求资源,可通过检查请求来源,验证其是否合法。

:point_right: 参考阅读:

SQL 注入

概念

**SQL 注入攻击(SQL injection)**,是发生于应用程序之数据层的安全漏洞。简而言之,是在输入的字符串之中注入 SQL 指令,在设计不良的程序当中忽略了检查,那么这些注入进去的指令就会被数据库服务器误认为是正常的 SQL 指令而运行,因此遭到破坏或是入侵。

攻击示例:

考虑以下简单的登录表单:

1
2
3
4
5
<form action="/login" method="POST">
<p>Username: <input type="text" name="username" /></p>
<p>Password: <input type="password" name="password" /></p>
<p><input type="submit" value="登陆" /></p>
</form>

我们的处理里面的 SQL 可能是这样的:

1
2
3
username:=r.Form.Get("username")
password:=r.Form.Get("password")
sql:="SELECT * FROM user WHERE username='"+username+"' AND password='"+password+"'"

如果用户的输入的用户名如下,密码任意

1
myuser' or 'foo' = 'foo' --

那么我们的 SQL 变成了如下所示:

1
SELECT * FROM user WHERE username='myuser' or 'foo' = 'foo' --'' AND password='xxx'

在 SQL 里面 -- 是注释标记,所以查询语句会在此中断。这就让攻击者在不知道任何合法用户名和密码的情况下成功登录了。

对于 MSSQL 还有更加危险的一种 SQL 注入,就是控制系统,下面这个可怕的例子将演示如何在某些版本的 MSSQL 数据库上执行系统命令。

1
2
sql:="SELECT * FROM products WHERE name LIKE '%"+prod+"%'"
Db.Exec(sql)

如果攻击提交 a%' exec master..xp_cmdshell 'net user test testpass /ADD' -- 作为变量 prod 的值,那么 sql 将会变成

1
sql:="SELECT * FROM products WHERE name LIKE '%a%' exec master..xp_cmdshell 'net user test testpass /ADD'--%'"

MSSQL 服务器会执行这条 SQL 语句,包括它后面那个用于向系统添加新用户的命令。如果这个程序是以 sa 运行而 MSSQLSERVER 服务又有足够的权限的话,攻击者就可以获得一个系统帐号来访问主机了。

虽然以上的例子是针对某一特定的数据库系统的,但是这并不代表不能对其它数据库系统实施类似的攻击。针对这种安全漏洞,只要使用不同方法,各种数据库都有可能遭殃。

攻击手段和目的

  • 数据表中的数据外泄,例如个人机密数据,账户数据,密码等。
  • 数据结构被黑客探知,得以做进一步攻击(例如 SELECT * FROM sys.tables)。
  • 数据库服务器被攻击,系统管理员账户被窜改(例如 ALTER LOGIN sa WITH PASSWORD='xxxxxx')。
  • 获取系统较高权限后,有可能得以在网页加入恶意链接、恶意代码以及 XSS 等。
  • 经由数据库服务器提供的操作系统支持,让黑客得以修改或控制操作系统(例如 xp_cmdshell “net stop iisadmin”可停止服务器的 IIS 服务)。
  • 破坏硬盘数据,瘫痪全系统(例如 xp_cmdshell “FORMAT C:”)。

应对手段

  • 使用参数化查询 - 建议使用数据库提供的参数化查询接口,参数化的语句使用参数而不是将用户输入变量嵌入到 SQL 语句中,即不要直接拼接 SQL 语句。例如使用 database/sql 里面的查询函数 PrepareQuery ,或者 Exec(query string, args ...interface{})
  • 单引号转换 - 在组合 SQL 字符串时,先针对所传入的参数进行字符替换(将单引号字符替换为连续 2 个单引号字符)。

:point_right: 参考阅读:

DoS

**拒绝服务攻击(denial-of-service attack, DoS)亦称洪水攻击**,是一种网络攻击手法,其目的在于使目标电脑的网络或系统资源耗尽,使服务暂时中断或停止,导致其正常用户无法访问。

当黑客使用网络上两个或以上被攻陷的电脑作为“僵尸”向特定的目标发动“拒绝服务”式攻击时,称为分布式拒绝服务攻击(distributed denial-of-service attack,缩写:DDoS attack、DDoS)。

攻击方式

  • 带宽消耗型攻击
  • 资源消耗型攻击

应对手段

  • 防火墙 - 允许或拒绝特定通讯协议,端口或 IP 地址。当攻击从少数不正常的 IP 地址发出时,可以简单的使用拒绝规则阻止一切从攻击源 IP 发出的通信。
  • 路由器、交换机 - 具有速度限制和访问控制能力。
  • 流量清洗 - 通过采用抗 DoS 软件处理,将正常流量和恶意流量区分开。

:point_right: 参考阅读:

加密技术

对于网站来说,用户信息、账户等等敏感数据一旦泄漏,后果严重,所以为了保护数据,应对这些信息进行加密处理。

信息加密技术一般分为:

  • 消息摘要
  • 加密算法
    • 对称加密
    • 非对称加密
  • 证书

消息摘要

常用数字签名算法:MD5、SHA 等。

应用场景:将用户密码以消息摘要形式保存到数据库中。

:point_right: 参考阅读: Java 编码和加密

加密算法

对称加密

对称加密指加密和解密所使用的密钥是同一个密钥。

常用对称加密算法:DES、AES 等。

应用场景:Cookie 加密、通信机密等。

非对称加密

非对称加密指加密和解密所使用的不是同一个密钥,而是一个公私钥对。用公钥加密的信息必须用私钥才能解开;反之,用私钥加密的信息只有用公钥才能解开。

常用非对称加密算法:RSA 等。

应用场景:HTTPS 传输中浏览器使用的数字证书实质上是经过权威机构认证的非对称加密公钥。

:point_right: 参考阅读: Java 编码和加密

密钥安全管理

保证密钥安全的方法:

  1. 把密钥和算法放在一个独立的服务器上,对外提供加密和解密服务,应用系统通过调用这个服务,实现数据的加解密。
  2. 把加解密算法放在应用系统中,密钥则放在独立服务器中,为了提高密钥的安全性,实际存储时,密钥被切分成数片,加密后分别保存在不同存储介质中。

证书

证书可以称为信息安全加密的终极手段。公开密钥认证(英语:Public key certificate),又称公开密钥证书、公钥证书、数字证书(digital certificate)、数字认证、身份证书(identity certificate)、电子证书或安全证书,是用于公开密钥基础建设的电子文件,用来证明公开密钥拥有者的身份。此文件包含了公钥信息、拥有者身份信息(主体)、以及数字证书认证机构(发行者)对这份文件的数字签名,以保证这个文件的整体内容正确无误。

透过信任权威数字证书认证机构的根证书、及其使用公开密钥加密作数字签名核发的公开密钥认证,形成信任链架构,已在 TLS 实现并在万维网的 HTTP 以 HTTPS、在电子邮件的 SMTP 以 STARTTLS 引入并广泛应用。

众所周知,常见的应用层协议 HTTP、FTP、Telnet 本身不保证信息安全。但是加入了 SSL/TLS 加密数据包机制的 HTTPS、FTPS、Telnets 是信息安全的。传输层安全性协议(Transport Layer Security, TLS),及其前身安全套接层(Secure Sockets Layer, SSL)是一种安全协议,目的是为互联网通信,提供安全及数据完整性保障。

证书原理

SSL/TLS 协议的基本思路是采用公钥加密法,也就是说,客户端先向服务器端索要公钥,然后用公钥加密信息,服务器收到密文后,用自己的私钥解密。

这里有两个问题:

(1)如何保证公钥不被篡改?

解决方法:将公钥放在数字证书中。只要证书是可信的,公钥就是可信的。

(2)公钥加密计算量太大,如何减少耗用的时间?

解决方法:每一次对话(session),客户端和服务器端都生成一个”对话密钥”(session key),用它来加密信息。由于”对话密钥”是对称加密,所以运算速度非常快,而服务器公钥只用于加密”对话密钥”本身,这样就减少了加密运算的消耗时间。

SSL/TLS 协议的基本过程是这样的:

  1. 客户端向服务器端索要并验证公钥。
  2. 双方协商生成”对话密钥”。
  3. 双方采用”对话密钥”进行加密通信。

:point_right: 参考阅读:

信息过滤

在网络中,广告和垃圾信息屡见不鲜,泛滥成灾。

常见的信息过滤与反垃圾手段有:

文本匹配

解决敏感词过滤。系统维护一份敏感词清单,如果信息中含有敏感词,则自动进行过滤或拒绝信息。

黑名单

黑名单就是将一些已经被识别出有违规行为的 IP、域名、邮箱等加入黑名单,拒绝其请求。

黑名单可以通过 Hash 表来实现,方法简单,复杂度小,适于一般应用场景。

但如果黑名单列表非常大时,Hash 表要占用很大的内存空间,这时就不再使用了。这种情况下,可以使用布隆过滤器来实现,即通过一个二进制列表和一组随机数映射函数来实现。

分类算法

对于海量信息,难以通过人工去审核。对广告贴。

垃圾邮件等内容的识别比较好的自动化方法就是采用分类算法。

简单来说,即将批量已分类的样本输入分类算法进行训练,得到一个分类模型,然后利用分类算法结合分类模型去对信息进行识别。想了解具体做法,需要去理解机器学习相关知识。

风险控制

网络给商务、金融领域带来极大便利的同时,也将风险带给了对网络安全一无所知的人们。由于交易双方信息的不对等,使得交易存在着风险,而当交易发生在网络上时,风险就更加难以控制了。

风险种类

  • 账户风险 - 盗用账户、恶意注册账户等
  • 买家风险 - 虚假询盘、恶意拒收、恶意下单、黄牛党抢购热门商品等
  • 卖家风险 - 虚假发货、出售违禁品、侵权等
  • 交易风险 - 信用卡盗刷、交易欺诈、洗钱、套现、电信诈骗等

风险控制手段

大型电商网站系统或金融系统都配备专业的风控团队进行风险控制。风险控制手段既包括人工审核也包括自动审核。

自动风控的技术手段主要有规则引擎和统计模型。

规则引擎

在交易中,买家、卖家的某些指标满足一定条件时,就会被认为存在风险。如:交易金额超过某个数值;用户来自黑名单;用户和上次登录的地址距离差距很大;用户在一定时间内频繁交易等等。

如果以上这些条件都通过 if … else … 式样的代码去实现,代码维护、扩展会非常不便。因此,就有了规则引擎来处理这类问题。规则引擎是一种将业务规则和规则处理逻辑相分离的技术,业务规则由运营人员通过管理界面去编辑,实现无需修改代码,即可实时的使用新规则。

统计模型

规则引擎虽然技术简单,但是随着规则不断增加,规模越来越大。可能会出现规则冲突,难以维护的情况,并且规则越多,性能也越差。

为了解决这种问题,就有了统计模型。统计模型会使用分类算法或更复杂的机器学习算法进行智能统计。根据历史交易中的信息训练分类,然后将经过采集加工后的交易信息输入分类算法,得到交易风险值,然后基于此,做出预测。

经过充分训练后的统计模型,准确率不低于规则引擎。但是,需要有领域专家、行业专家介入,建立合理的训练模型,并不断优化。

参考资料

秒杀系统设计

秒杀系统所要应对的场景就是:瞬时海量请求

秒杀系统的难点

  • 高并发:秒杀系统是极致的高并场景发自不用说。其高并发可以细分为二:
    • 并发读:主要是读取剩余库存量以及商品信息
    • 并发写:主要是下单后,系统写入订单记录
  • 超卖:秒杀系统中售卖的商品一般都是性价比很高,不怎么赚钱,甚至赔钱赚哟喝的商品。一旦出现超卖现象,会给商家带来巨大的经济损失。从系统层面来看,比如某秒杀商品本来库存 100 件,但是在高并发场景下,瞬时下单量超过 100 件,处理不当,让这些下单都成功了,就会出现超卖。
  • 恶意请求:有些人为了低价购入秒杀商品,通过在多台机器上跑脚本,模拟大量用户抢商品的请求(走自己的路,让别人无路可走)。
  • 数据库崩溃:海量请求下,如果没有 MQ 削峰,没有过载保护,让所有请求都打到数据库,那么数据库基本就挂了。数据库如果挂了,也会波及其他业务,从而可能让整个系统、网站陷入瘫痪。
  • 对现有业务造成冲击

秒杀系统的思考

稳准快

秒杀系统架构的思考角度可以概括为:稳、准、快

  • 稳(高可用):系统架构要满足高可用,系统要能撑住活动。
  • 准(一致性):商品减库存方式非常关键,不能出现超卖。
  • 快(高性能):整个请求链路,从前端到后端,依赖组件都要做到协同优化。

img

前端优化

静态页面

把秒杀商品页面静态化,减少查数据库的 IO 开销。然后,可以将这些静态页面做 CDN 缓存,如果项目是前后端分离的,还可以在反向代理服务器侧设置静态缓存。

如每个商品都由 ID 来标识,那么 http://item.xxx.com/item.htm?id=xxxx 就可以作为唯一的 URL 标识。相应的页面可以提前做前端缓存,这样就不需要向后台查询商品信息。

按钮控制

在秒杀活动开启时间前,下单按钮禁用。

此外,按钮一旦点击之后,禁用一段时间,防止有人疯狂输出。

后端优化

隔离

秒杀活动,本质上还是一个营销活动,性质和打折、促销一样。

秒杀系统设计底线原则,是不应该影响现有业务。所以,为了避免防不胜防,百密一疏的情况下,秒杀系统崩了。

限流、熔断、降级、隔离

  • 隔离:将秒杀系统、数据与其他正常业务隔离。彼此隔离,自然互不影响。

  • 限流:设置阈值,超过阈值,拒绝请求。防止数据库被打死。

  • 降级:保证核心业务继续工作,非核心业务各安天命。

  • 熔断:不要影响别的系统。

缓存

缓存要预热,避免瞬间流量冲击。

此外,防止雪崩、穿透、击穿问题的常规处理要做好。

缓存也要保证高可用。

流量削峰

削峰的思路:排队、答题、分层过滤。

  • 排队:用消息队列来缓冲瞬时流量的方案。但是,消息队列自身也有上限,如果积压过多,也会处理不了。
  • 答题(摇一摇):可以限制秒杀器并延缓请求。
  • 分层过滤:采用漏斗式的设计尽可能拦截无效请求。

img

减库存

恶意下单

恶意下单的解决方案还是要结合安全和反作弊措施来制止:

  • 识别频繁下单不付款或重复下单不付款的卖家,阻断其下单。
  • 限制个人购买数

避免超卖

减库存在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行 SQL 语句来报错;再有一种就是使用 CASE WHEN 判断语句,例如这样的 SQL 语句:

1
UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END

在交易环节中,“库存”是个关键数据,也是个热点数据,因为交易的各个环节中都可能涉及对库存的查询。但是,我在前面介绍分层过滤时提到过,秒杀中并不需要对库存有精确的一致性读,把库存数据放到缓存(Cache)中,可以大大提升读性能。

URL 动态化

通过 MD5 之类的加密算法加密随机的字符串去做 url,然后通过前端代码获取 url 后台校验才能通过。

参考资料

Eclipse 快速入门

代码智能提示

Java 智能提示

Window -> Preferences -> Java -> Editor -> Content Assist -> Auto Activation

img

delay 是自动弹出提示框的延时时间,我们可以修改成 100 毫秒;triggers 这里默认是”.”,只要加上”abcdefghijklmnopqrstuvwxyz”或者”abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ”,嘿嘿!这下就能做到和 VS 一样的输入每个字母都能提示啦:

img

其它类型的文件比如 HTML、JavaScript、JSP 如果也能提供提示那不是更爽了?有了第二点设置的基础,其实这些设置都是一样的。

JavaScript 智能提示

Window -> Preferences -> JavaScript-> Editor -> Content Assist -> Auto-Activation

img

HTML 智能提示

Window -> Preferences -> Web -> HTML Files -> Editor -> Content Assist -> Auto-Activation

img

保存后,我们再来输入看看,感觉真是不错呀:

img

插件安装

很多教科书上说到 Eclipse 的插件安装都是通过 Help -> Install New SoftWare 这种自动检索的方式,操作起来固然是方便,不过当我们不需要某种插件时不太容易找到要删除哪些内容,而且以后 Eclipse 版本升级的时候,通过这种方式安装过的插件都得再重新装一次。另外一种通过 Link 链接方式,就可以解决这些问题。

我们以 Eclipse 的中文汉化包插件为例,先到官方提供的汉化包地址下载一个:http://www.eclipse.org/babel/downloads.php,注意选好自己的 Eclipse 版本:

img

我的版本是 Kepler,然后进入下载页面,单击红框框中的链接,即可下载汉化包了:

img

下载完解压缩后,会有个包含 features 和 plugin 目录的 eclipse 文件夹,把这个 eclipse 放在我们的 Eclipse 安装根目录,也就是和 eclipse.exe 同一级目录下。然后仍然在这一级目录下,新建一个 links 文件夹,并在该文件夹内,建一个 language.link 的文本文件。该文本文件的名字是可以任取的,后缀名是.link,而不是.txt 哟。好了,最后一步,编辑该文件,在里面写入刚才放入的语言包的地址,并用“\”表示路径,一定要有 path= 这个前缀。

img

保存文件后,重新打开 Eclipse,熟悉的中文界面终于看到了。虽然汉化不完全,不过也够用了不是么。如果仍然出现的是英文,说明汉化失败,重新检查下 language.link 文件中配置的信息是否和汉化包的目录一致。  其它的插件安装方法也是如此,当不需要某个插件时,只需删除存放插件的目录和 links 目录下相应的 link 文件,或者改变下 link 文件里面的路径变成无效路径即可;对 Eclipse 做高版本升级时,也只需把老版存放插件的目录和 links 目录复制过去就行了。

基本设置

在 Preference 的搜索项中搜索 Text Editors。
可以参考我的设置:
Show line numbers
Show print margin
Insert spaces for tabs

img
设置代码的字体类型和大小:

Window -> Preferences -> General -> Appearance -> Content Assist -> Colors and Fornts,只需修改 Basic 里面的 Text Font 就可以了。

推荐 Courier New。

img

img

设置文本文件及 JSP 文件编码

Window -> Preferences -> General -> Workspace -> Text file encoding -> Other:

img

img

设置 JDK 本地 JavaDOC API 路径及源码路径

img

img

还都生成的是无意义的变量名,这样可能会对含有相同类型的变量参数的调用顺序造成干扰;

这种问题,我们把 JDK 或者相应 Jar 包的源码导入进去就能避免了:

Window -> Preferences -> Java -> Installed JREs -> Edit:

img

选中设置好的 JRE 目录,编辑,然后全选 JRE system libraries 下的所有 Jar 包,点击右边的 Source Attachment;

img

External location 下,选中 JDK 安装目录下的 src.zip 文件,一路 OK 下来。

img

设置完,我们再来看看,幸福来的好突然有木有!

img

设置 Servlet 源码或其它 Jar 包源码

img

上一步已经设置过了 JDK 的源码或 JavaDoc 路径,为啥现在又出来了呢?其实这个不难理解,因为我们使用到的类的源码并不在 JDK 的源码包中。

仔细看,我们会发现这些 Jar 包其实都在 Tomcat 根目录下的 lib 文件夹中,但是翻遍了 Tomcat 目录也没有相应的 jar 或 zip 文件呀。既然本地没有,那就去官网上找找:

http://tomcat.apache.org/download-70.cgi这里有Tomcat的安装包和源码包;

img

可以自定义一个专门用于存放 JavaSource 和 JavaDoc 的文件夹,把下载文件放到该目录下,

然后再切换到 Eclipse 下,选中没有代码提示的类或者函数, 按下 F3,点击 Change Attached Source:

img

选择我们刚才下载好的 tomcat 源码文件,一路 OK。

img

然后再回过头看看我们的代码提示,友好多了:

img

其它 Jar 包源码的设置方式也一样。

反编译插件 JD-Eclipse

无论是开发还是调试,反编译必不可少,每次都用 jd-gui 打开去看,多麻烦,干脆配置下 JD 插件,自动关联.class:

先从 http://jd.benow.ca/ 上下载离线安装包 jdeclipse_update_site.zip,解压缩后把 features、plugins 这 2 个文件夹复制到 新建文件夹 jdeclipse,然后把 jdeclipse 文件夹整个复制到 Eclipse 根目录的 dropins 文件夹下,重启 Eclipse 即可。这种方式是不是比建 link 文件更方便了?

img

打开 Eclipse,Window -> Preferences -> General - > Editors ,把 .class 文件设置关联成 jd 插件的 editor

img

Validate 优化

我们在 eclipse 里经常看到这个进程,validating… 逐个的检查每一个文件。那么如何关闭一些 validate 操作呢?

img

打开 eclipse,点击【window】菜单,选择【preferences】选项。

img

在左侧点击【validation】选项,在右侧可以看到 eclipse 进行的自动检查都有哪些内容。

img

将 Manual(手动)保持不动,将 build 里面只留下 classpath dependency Validator,其他的全部去掉。

img

最后点击【OK】按钮,保存设置。

img

以后如果需要对文件进行校验检查的时候,在文件上点击右键,点击【Validate】进行检查。

常用快捷键

快捷键 描述
Ctrl+1 快速修复(最经典的快捷键,就不用多说了,可以解决很多问题,比如 import 类、try catch 包围等)
Ctrl+Shift+F 格式化当前代码
Ctrl+Shift+M 添加类的 import 导入
Ctrl+Shift+O 组织类的 import 导入(既有 Ctrl+Shift+M 的作用,又可以帮你去除没用的导入,很有用)
Ctrl+Y 重做(与撤销 Ctrl+Z 相反)
Alt+/ 内容辅助(帮你省了多少次键盘敲打,太常用了)
Ctrl+D 删除当前行或者多行
Alt+↓ 当前行和下面一行交互位置(特别实用,可以省去先剪切,再粘贴了)
Alt+↑ 当前行和上面一行交互位置(同上)
Ctrl+Alt+↓ 复制当前行到下一行(复制增加)
Ctrl+Alt+↑ 复制当前行到上一行(复制增加)
Shift+Enter 在当前行的下一行插入空行(这时鼠标可以在当前行的任一位置,不一定是最后)
Ctrl+/ 注释当前行,再按则取消注释
Alt+Shift+↑ 选择封装元素
Alt+Shift+← 选择上一个元素
Alt+Shift+→ 选择下一个元素
Shift+← 从光标处开始往左选择字符
Shift+→ 从光标处开始往右选择字符
Ctrl+Shift+← 选中光标左边的单词
Ctrl+Shift+→ 选中光标又边的单词
Ctrl+← 光标移到左边单词的开头,相当于 vim 的 b
Ctrl+→ 光标移到右边单词的末尾,相当于 vim 的 e
Ctrl+K 参照选中的 Word 快速定位到下一个(如果没有选中 word,则搜索上一次使用搜索的 word)
Ctrl+Shift+K 参照选中的 Word 快速定位到上一个
Ctrl+J 正向增量查找(按下 Ctrl+J 后,你所输入的每个字母编辑器都提供快速匹配定位到某个单词,如果没有,则在状态栏中显示没有找到了,查一个单词时,特别实用,要退出这个模式,按 escape 建)
Ctrl+Shift+J 反向增量查找(和上条相同,只不过是从后往前查)
Ctrl+Shift+U 列出所有包含字符串的行
Ctrl+H 打开搜索对话框
Ctrl+G 工作区中的声明
Ctrl+Shift+G 工作区中的引用
Ctrl+Shift+T 搜索类(包括工程和关联的第三 jar 包)
Ctrl+Shift+R 搜索工程中的文件
Ctrl+E 快速显示当前 Editer 的下拉列表(如果当前页面没有显示的用黑体表示)
F4 打开类型层次结构
F3 跳转到声明处
Alt+← 前一个编辑的页面
Alt+→ 下一个编辑的页面(当然是针对上面那条来说了)
Ctrl+PageUp/PageDown 在编辑器中,切换已经打开的文件
F5 单步跳入
F6 单步跳过
F7 单步返回
F8 继续
Ctrl+Shift+D 显示变量的值
Ctrl+Shift+B 在当前行设置或者去掉断点
Ctrl+R 运行至行(超好用,可以节省好多的断点)
Alt+Shift+R 重命名方法名、属性或者变量名 (是我自己最爱用的一个了,尤其是变量和类的 Rename,比手工方法能节省很多劳动力)
Alt+Shift+M 把一段函数内的代码抽取成方法 (这是重构里面最常用的方法之一了,尤其是对一大堆泥团代码有用)
Alt+Shift+C 修改函数结构(比较实用,有 N 个函数调用了这个方法,修改一次搞定)
Alt+Shift+L 抽取本地变量( 可以直接把一些魔法数字和字符串抽取成一个变量,尤其是多处调用的时候)
Alt+Shift+F 把 Class 中的 local 变量变为 field 变量 (比较实用的功能)
Alt+Shift+I 合并变量(可能这样说有点不妥 Inline)
Alt+Shift+V 移动函数和变量(不怎么常用)
Alt+Shift+Z 重构的后悔药(Undo)
Alt+Enter 显示当前选择资源的属性,windows 下的查看文件的属性就是这个快捷键,通常用来查看文件在 windows 中的实际路径
Ctrl+↑ 文本编辑器 上滚行
Ctrl+↓ 文本编辑器 下滚行
Ctrl+M 最大化当前的 Edit 或 View (再按则反之)
Ctrl+O 快速显示 OutLine
Ctrl+T 快速显示当前类的继承结构
Ctrl+W 关闭当前 Editer
Ctrl+L 文本编辑器 转至行
F2 显示工具提示描述

一篇文章让你掌握 Python

解释器

Linux/Unix 的系统上,Python 解释器通常被安装在 /usr/local/bin/python3.4 这样的有效路径(目录)里。

我们可以将路径 /usr/local/bin 添加到您的 Linux/Unix 操作系统的环境变量中,这样您就可以通过 shell 终端输入下面的命令来启动 Python 。

在 Linux/Unix 系统中,你可以在脚本顶部添加以下命令让 Python 脚本可以像 SHELL 脚本一样可直接执行:

1
#! /usr/bin/env python3.4

注释

Python 中的注释有三种形式:

  • # 开头
  • ''' 开始,以 ''' 结尾
  • """ 开始,以 """ 结尾
1
2
3
4
5
6
7
8
9
10
11
12
13
# 单行注释

'''
这是多行注释,用三个单引号
这是多行注释,用三个单引号
这是多行注释,用三个单引号
'''

"""
这是多行注释,用三个双引号
这是多行注释,用三个双引号
这是多行注释,用三个双引号
"""

数据类型

Python3 中有六个标准的数据类型:

  • Numbers(数字)
  • String(字符串)
  • List(列表)
  • Tuple(元组)
  • Sets(集合)
  • Dictionaries(字典)

操作符

Python 语言支持以下类型的运算符:

  • 算术运算符

  • 比较(关系)运算符

  • 赋值运算符

  • 逻辑运算符

  • 位运算符

  • 成员运算符

  • 身份运算符

  • 运算符优先级

算术运算符

运算符 描述 实例
+ 加 - 两个对象相加 a + b 输出结果 31
- 减 - 得到负数或是一个数减去另一个数 a - b 输出结果 -11
* 乘 - 两个数相乘或是返回一个被重复若干次的字符串 a * b 输出结果 210
/ 除 - x 除以 y b / a 输出结果 2.1
% 取模 - 返回除法的余数 b % a 输出结果 1
** 幂 - 返回 x 的 y 次幂 a**b 为 10 的 21 次方
// 取整除 - 返回商的整数部分 9//2 输出结果 4 , 9.0//2.0 输出结果 4.0

比较运算符

运算符 描述 实例
== 等于 - 比较对象是否相等 (a == b) 返回 False。
!= 不等于 - 比较两个对象是否不相等 (a != b) 返回 True.
> 大于 - 返回 x 是否大于 y (a > b) 返回 False。
< 小于 - 返回 x 是否小于 y。所有比较运算符返回 1 表示真,返回 0 表示假。这分别与特殊的变量 True 和 False 等价。注意,这些变量名的大写。 (a < b) 返回 True。
>= 大于等于 - 返回 x 是否大于等于 y。 (a >= b) 返回 False。
<= 小于等于 - 返回 x 是否小于等于 y。 (a <= b) 返回 True。

赋值运算符

运算符 描述 实例
= 简单的赋值运算符 c = a + b 将 a + b 的运算结果赋值为 c
+= 加法赋值运算符 c += a 等效于 c = c + a
-= 减法赋值运算符 c -= a 等效于 c = c - a
*= 乘法赋值运算符 c _= a 等效于 c = c _ a
/= 除法赋值运算符 c /= a 等效于 c = c / a
%= 取模赋值运算符 c %= a 等效于 c = c % a
**= 幂赋值运算符 c **= a 等效于 c = c ** a
//= 取整除赋值运算符 c //= a 等效于 c = c // a

位运算符

运算符 描述 实例
& 按位与运算符:参与运算的两个值,如果两个相应位都为 1,则该位的结果为 1,否则为 0 (a & b) 输出结果 12 ,二进制解释: 0000 1100
| 按位或运算符:只要对应的二个二进位有一个为 1 时,结果位就为 1。 (a | b) 输出结果 61 ,二进制解释: 0011 1101
^ 按位异或运算符:当两对应的二进位相异时,结果为 1 (a ^ b) 输出结果 49 ,二进制解释: 0011 0001
~ 按位取反运算符:对数据的每个二进制位取反,即把 1 变为 0,把 0 变为 1 (~a ) 输出结果 -61 ,二进制解释: 1100 0011, 在一个有符号二进制数的补码形式。
<< 左移动运算符:运算数的各二进位全部左移若干位,由”<<”右边的数指定移动的位数,高位丢弃,低位补 0。 a << 2 输出结果 240 ,二进制解释: 1111 0000
>> 右移动运算符:把”>>”左边的运算数的各二进位全部右移若干位,”>>”右边的数指定移动的位数 a >> 2 输出结果 15 ,二进制解释: 0000 1111

逻辑运算符

运算符 逻辑表达式 描述 实例
and x and y 布尔”与” - 如果 x 为 False,x and y 返回 False,否则它返回 y 的计算值。 (a and b) 返回 20。
or x or y 布尔”或” - 如果 x 是 True,它返回 x 的值,否则它返回 y 的计算值。 (a or b) 返回 10。
not not x 布尔”非” - 如果 x 为 True,返回 False 。如果 x 为 False,它返回 True。 not(a and b) 返回 False

成员运算符

运算符 描述 实例
in 如果在指定的序列中找到值返回 True,否则返回 False。 x 在 y 序列中 , 如果 x 在 y 序列中返回 True。
not in 如果在指定的序列中没有找到值返回 True,否则返回 False。 x 不在 y 序列中 , 如果 x 不在 y 序列中返回 True。

身份运算符

运算符 描述 实例
is is 是判断两个标识符是不是引用自一个对象 x is y, 如果 id(x) 等于 id(y) , is 返回结果 1
is not is not 是判断两个标识符是不是引用自不同对象 x is not y, 如果 id(x) 不等于 id(y). is not 返回结果 1

运算符优先级

运算符 描述
** 指数 (最高优先级)
~ + - 按位翻转, 一元加号和减号 (最后两个的方法名为 +@ 和 -@)
* / % // 乘,除,取模和取整除
+ - 加法减法
>> << 右移,左移运算符
& 位 ‘AND’
^ | 位运算符
<= < > >= 比较运算符
<> == != 等于运算符
= %= /= //= -= += *= **= 赋值运算符
is is not 身份运算符
in not in 成员运算符
not or and 逻辑运算符

控制语句

条件语句

1
2
3
4
5
6
if condition_1:
statement_block_1
elif condition_2:
statement_block_2
else:
statement_block_3

循环语句

while

1
2
while 判断条件:
statements

for

1
2
for <variable> in <sequence>:
<statements>

range()

1
2
for i in range(0, 10, 3) :
print(i)

break 和 continue

  • break 语句可以跳出 for 和 while 的循环体。
  • continue 语句被用来告诉 Python 跳过当前循环块中的剩余语句,然后继续进行下一轮循环。

pass

pass 语句什么都不做。它只在语法上需要一条语句但程序不需要任何操作时使用.例如:

1
2
while True:
pass # 等待键盘中断 (Ctrl+C)

函数

Python 定义函数使用 def 关键字,一般格式如下:

1
2
def  函数名(参数列表):
函数体

函数变量作用域

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/env python3
a = 4 # 全局变量

def print_func1():
a = 17 # 局部变量
print("in print_func a = ", a)
def print_func2():
print("in print_func a = ", a)
print_func1()
print_func2()
print("a = ", a)

以上实例运行结果如下:

1
2
3
in print_func a =  17
in print_func a = 4
a = 4

关键字参数

函数也可以使用 kwarg=value 的关键字参数形式被调用.例如,以下函数:

1
2
3
4
5
def parrot(voltage, state='a stiff', action='voom', type='Norwegian Blue'):
print("-- This parrot wouldn't", action, end=' ')
print("if you put", voltage, "volts through it.")
print("-- Lovely plumage, the", type)
print("-- It's", state, "!")

可以以下几种方式被调用:

1
2
3
4
5
6
parrot(1000)                                          # 1 positional argument
parrot(voltage=1000) # 1 keyword argument
parrot(voltage=1000000, action='VOOOOOM') # 2 keyword arguments
parrot(action='VOOOOOM', voltage=1000000) # 2 keyword arguments
parrot('a million', 'bereft of life', 'jump') # 3 positional arguments
parrot('a thousand', state='pushing up the daisies') # 1 positional, 1 keyword

以下为错误调用方法:

1
2
3
4
parrot()                     # required argument missing
parrot(voltage=5.0, 'dead') # non-keyword argument after a keyword argument
parrot(110, voltage=220) # duplicate value for the same argument
parrot(actor='John Cleese') # unknown keyword argument

可变参数列表

最后,一个最不常用的选择是可以让函数调用可变个数的参数.这些参数被包装进一个元组(查看元组和序列).在这些可变个数的参数之前,可以有零到多个普通的参数:

1
2
3
4
5
def arithmetic_mean(*args):
sum = 0
for x in args:
sum += x
return sum

返回值

Python 的函数的返回值使用 return 语句,可以将函数作为一个值赋值给指定变量:

1
2
3
def return_sum(x,y):
c = x + y
return c

异常

异常处理

try 语句按照如下方式工作;

  • 首先,执行 try 子句(在关键字 try 和关键字 except 之间的语句)
  • 如果没有异常发生,忽略 except 子句,try 子句执行后结束。
  • 如果在执行 try 子句的过程中发生了异常,那么 try 子句余下的部分将被忽略。如果异常的类型和 except 之后的名称相符,那么对应的 except 子句将被执行。最后执行 try 语句之后的代码。
  • 如果一个异常没有与任何的 except 匹配,那么这个异常将会传递给上层的 try 中。
  • 不管 try 子句里面有没有发生异常,finally 子句都会执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import sys

try:
f = open('myfile.txt')
s = f.readline()
i = int(s.strip())
except OSError as err:
print("OS error: {0}".format(err))
except ValueError:
print("Could not convert data to an integer.")
except:
print("Unexpected error:", sys.exc_info()[0])
raise
finally:
# 清理行为

抛出异常

Python 使用 raise 语句抛出一个指定的异常。例如:

1
2
3
4
>>> raise NameError('HiThere')
Traceback (most recent call last):
File "<stdin>", line 1, in ?
NameError: HiThere

自定义异常

可以通过创建一个新的 exception 类来拥有自己的异常。异常应该继承自 Exception 类,或者直接继承,或者间接继承。

当创建一个模块有可能抛出多种不同的异常时,一种通常的做法是为这个包建立一个基础异常类,然后基于这个基础类为不同的错误情况创建不同的子类:

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
class Error(Exception):
"""Base class for exceptions in this module."""
pass

class InputError(Error):
"""Exception raised for errors in the input.

Attributes:
expression -- input expression in which the error occurred
message -- explanation of the error
"""

def __init__(self, expression, message):
self.expression = expression
self.message = message

class TransitionError(Error):
"""Raised when an operation attempts a state transition that's not
allowed.

Attributes:
previous -- state at beginning of transition
next -- attempted new state
message -- explanation of why the specific transition is not allowed
"""

def __init__(self, previous, next, message):
self.previous = previous
self.next = next
self.message = message

大多数的异常的名字都以”Error”结尾,就跟标准的异常命名一样。

面向对象

面向对象技术简介

  • 类(Class): 用来描述具有相同的属性和方法的对象的集合。它定义了该集合中每个对象所共有的属性和方法。对象是类的实例。

  • 类变量:类变量在整个实例化的对象中是公用的。类变量定义在类中且在函数体之外。类变量通常不作为实例变量使用。

  • 数据成员:类变量或者实例变量用于处理类及其实例对象的相关的数据。

  • 方法重写:如果从父类继承的方法不能满足子类的需求,可以对其进行改写,这个过程叫方法的覆盖(override),也称为方法的重写。

  • 实例变量:定义在方法中的变量,只作用于当前实例的类。

  • 继承:即一个派生类(derived class)继承基类(base class)的字段和方法。继承也允许把一个派生类的对象作为一个基类对象对待。例如,有这样一个设计:一个 Dog 类型的对象派生自 Animal 类,这是模拟”是一个(is-a)”关系(例图,Dog 是一个 Animal)。

  • 实例化:创建一个类的实例,类的具体对象。

  • 方法:类中定义的函数。

  • 对象:通过类定义的数据结构实例。对象包括两个数据成员(类变量和实例变量)和方法。

类定义

语法格式如下:

1
2
3
4
5
6
class ClassName:
<statement-1>
.
.
.
<statement-N>

类实例化后,可以使用其属性,实际上,创建一个类之后,可以通过类名访问其属性。

类对象

类对象支持两种操作:属性引用和实例化。

属性引用使用和 Python 中所有的属性引用一样的标准语法:obj.name

类对象创建后,类命名空间中所有的命名都是有效属性名。所以如果类定义是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/python3

class MyClass:
"""一个简单的类实例"""
i = 12345
def f(self):
return 'hello world'

# 实例化类
x = MyClass()

# 访问类的属性和方法
print("MyClass 类的属性 i 为:", x.i)
print("MyClass 类的方法 f 输出为:", x.f())

实例化类:

1
2
3
# 实例化类
x = MyClass()
# 访问类的属性和方法

以上创建了一个新的类实例并将该对象赋给局部变量 x,x 为空的对象。

执行以上程序输出结果为:

1
2
MyClass 类的属性 i 为: 12345
MyClass 类的方法 f 输出为: hello world

很多类都倾向于将对象创建为有初始状态的。因此类可能会定义一个名为 init() 的特殊方法(构造方法),像下面这样:

1
2
def __init__(self):
self.data = []

类定义了 init() 方法的话,类的实例化操作会自动调用 init() 方法。所以在下例中,可以这样创建一个新的实例:

1
x = MyClass()

当然, init() 方法可以有参数,参数通过 init() 传递到类的实例化操作上。例如:

1
2
3
4
5
6
7
8
>>> class Complex:
... def __init__(self, realpart, imagpart):
... self.r = realpart
... self.i = imagpart
...
>>> x = Complex(3.0, -4.5)
>>> x.r, x.i
(3.0, -4.5)

类的方法

在类地内部,使用 def 关键字可以为类定义一个方法,与一般函数定义不同,类方法必须包含参数 self,且为第一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/python3

#类定义
class people:
#定义基本属性
name = ''
age = 0
#定义私有属性,私有属性在类外部无法直接进行访问
__weight = 0
#定义构造方法
def __init__(self,n,a,w):
self.name = n
self.age = a
self.__weight = w
def speak(self):
print("%s 说: 我 %d 岁。" %(self.name,self.age))

# 实例化类
p = people('W3Cschool',10,30)
p.speak()

执行以上程序输出结果为:

1
W3Cschool 说: 我 10 岁。

继承

Python 同样支持类的继承,如果一种语言不支持继承就,类就没有什么意义。派生类的定义如下所示:

1
2
3
4
5
6
class DerivedClassName(BaseClassName1):
<statement-1>
.
.
.
<statement-N>

需要注意圆括号中基类的顺序,若是基类中有相同的方法名,而在子类使用时未指定,python 从左至右搜索 即方法在子类中未找到时,从左到右查找基类中是否包含方法。

BaseClassName(示例中的基类名)必须与派生类定义在一个作用域内。除了类,还可以用表达式,基类定义在另一个模块中时这一点非常有用:

1
class DerivedClassName(modname.BaseClassName):

实例

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
#!/usr/bin/python3

#类定义
class people:
#定义基本属性
name = ''
age = 0
#定义私有属性,私有属性在类外部无法直接进行访问
__weight = 0
#定义构造方法
def __init__(self,n,a,w):
self.name = n
self.age = a
self.__weight = w
def speak(self):
print("%s 说: 我 %d 岁。" %(self.name,self.age))

#单继承示例
class student(people):
grade = ''
def __init__(self,n,a,w,g):
#调用父类的构函
people.__init__(self,n,a,w)
self.grade = g
#覆写父类的方法
def speak(self):
print("%s 说: 我 %d 岁了,我在读 %d 年级"%(self.name,self.age,self.grade))



s = student('ken',10,60,3)
s.speak()

执行以上程序输出结果为:

1
ken 说: 我 10 岁了,我在读 3 年级

多继承

Python 同样有限的支持多继承形式。多继承的类定义形如下例:

1
2
3
4
5
6
class DerivedClassName(Base1, Base2, Base3):
<statement-1>
.
.
.
<statement-N>

需要注意圆括号中父类的顺序,若是父类中有相同的方法名,而在子类使用时未指定,python 从左至右搜索 即方法在子类中未找到时,从左到右查找父类中是否包含方法。

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
#!/usr/bin/python3

#类定义
class people:
#定义基本属性
name = ''
age = 0
#定义私有属性,私有属性在类外部无法直接进行访问
__weight = 0
#定义构造方法
def __init__(self,n,a,w):
self.name = n
self.age = a
self.__weight = w
def speak(self):
print("%s 说: 我 %d 岁。" %(self.name,self.age))

#单继承示例
class student(people):
grade = ''
def __init__(self,n,a,w,g):
#调用父类的构函
people.__init__(self,n,a,w)
self.grade = g
#覆写父类的方法
def speak(self):
print("%s 说: 我 %d 岁了,我在读 %d 年级"%(self.name,self.age,self.grade))

#另一个类,多重继承之前的准备
class speaker():
topic = ''
name = ''
def __init__(self,n,t):
self.name = n
self.topic = t
def speak(self):
print("我叫 %s,我是一个演说家,我演讲的主题是 %s"%(self.name,self.topic))

#多重继承
class sample(speaker,student):
a =''
def __init__(self,n,a,w,g,t):
student.__init__(self,n,a,w,g)
speaker.__init__(self,n,t)

test = sample("Tim",25,80,4,"Python")
test.speak() #方法名同,默认调用的是在括号中排前地父类的方法

执行以上程序输出结果为:

1
我叫 Tim,我是一个演说家,我演讲的主题是 Python

方法重写

如果你的父类方法的功能不能满足你的需求,你可以在子类重写你父类的方法,实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/python3

class Parent: # 定义父类
def myMethod(self):
print ('调用父类方法')

class Child(Parent): # 定义子类
def myMethod(self):
print ('调用子类方法')

c = Child() # 子类实例
c.myMethod() # 子类调用重写方法

执行以上程序输出结果为:

1
调用子类方法

类属性与方法

类的私有属性

__private_attrs:两个下划线开头,声明该属性为私有,不能在类地外部被使用或直接访问。在类内部的方法中使用时self.__private_attrs

类的方法

在类地内部,使用 def 关键字可以为类定义一个方法,与一般函数定义不同,类方法必须包含参数 self,且为第一个参数

类的私有方法

__private_method:两个下划线开头,声明该方法为私有方法,不能在类地外部调用。在类的内部调用 slef.__private_methods

实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/python3

class JustCounter:
__secretCount = 0 # 私有变量
publicCount = 0 # 公开变量

def count(self):
self.__secretCount += 1
self.publicCount += 1
print (self.__secretCount)

counter = JustCounter()
counter.count()
counter.count()
print (counter.publicCount)
print (counter.__secretCount) # 报错,实例不能访问私有变量

执行以上程序输出结果为:

1
2
3
4
5
6
7
1
2
2
Traceback (most recent call last):
File "test.py", line 16, in <module>
print (counter.__secretCount) # 报错,实例不能访问私有变量
AttributeError: 'JustCounter' object has no attribute '__secretCount'

类的专有方法:

  • **init :** 构造函数,在生成对象时调用
  • **del :** 析构函数,释放对象时使用
  • **repr :** 打印,转换
  • **setitem :** 按照索引赋值
  • **getitem:** 按照索引获取值
  • **len:** 获得长度
  • **cmp:** 比较运算
  • **call:** 函数调用
  • **add:** 加运算
  • **sub:** 减运算
  • **mul:** 乘运算
  • **div:** 除运算
  • **mod:** 求余运算
  • **pow:** 乘方

运算符重载

Python 同样支持运算符重载,我么可以对类的专有方法进行重载,实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/python3

class Vector:
def __init__(self, a, b):
self.a = a
self.b = b

def __str__(self):
return 'Vector (%d, %d)' % (self.a, self.b)

def __add__(self,other):
return Vector(self.a + other.a, self.b + other.b)

v1 = Vector(2,10)
v2 = Vector(5,-2)
print (v1 + v2)

以上代码执行结果如下所示:

1
Vector(7,8)

标准库概览

操作系统接口

os 模块提供了不少与操作系统相关联的函数。

1
2
3
4
5
6
>>> import os
>>> os.getcwd() # 返回当前的工作目录
'C:\\Python34'
>>> os.chdir('/server/accesslogs') # 修改当前的工作目录
>>> os.system('mkdir today') # 执行系统命令 mkdir
0

文件通配符

glob 模块提供了一个函数用于从目录通配符搜索中生成文件列表:

1
2
3
>>> import glob
>>> glob.glob('*.py')
['primes.py', 'random.py', 'quote.py']

命令行参数

通用工具脚本经常调用命令行参数。这些命令行参数以链表形式存储于 sys 模块的 argv 变量。例如在命令行中执行 python demo.py one two three 后可以得到以下输出结果:

1
2
3
>>> import sys
>>> print(sys.argv)
['demo.py', 'one', 'two', 'three']

错误输出重定向和程序终止

sys 还有 stdin,stdout 和 stderr 属性,即使在 stdout 被重定向时,后者也可以用于显示警告和错误信息。

1
2
>>> sys.stderr.write('Warning, log file not found starting a new one\n')
Warning, log file not found starting a new one

字符串正则匹配

re 模块为高级字符串处理提供了正则表达式工具。对于复杂的匹配和处理,正则表达式提供了简洁、优化的解决方案:

1
2
3
4
5
>>> import re
>>> re.findall(r'\bf[a-z]*', 'which foot or hand fell fastest')
['foot', 'fell', 'fastest']
>>> re.sub(r'(\b[a-z]+) \1', r'\1', 'cat in the the hat')
'cat in the hat'

数学

math 模块为浮点运算提供了对底层 C 函数库的访问:

1
2
3
4
5
>>> import math
>>> math.cos(math.pi / 4)
0.70710678118654757
>>> math.log(1024, 2)
10.0

参考资料

Java 容器之 List

ListCollection 的子接口,其中可以保存各个重复的内容。

List 简介

List 是一个接口,它继承于 Collection 的接口。它代表着有序的队列。

AbstractList 是一个抽象类,它继承于 AbstractCollectionAbstractList 实现了 List 接口中除 size()get(int location) 之外的函数。

AbstractSequentialList 是一个抽象类,它继承于 AbstractListAbstractSequentialList 实现了“链表中,根据 index 索引值操作链表的全部函数”。

ArrayList 和 LinkedList

ArrayListLinkedListList 最常用的实现。

  • ArrayList 基于动态数组实现,存在容量限制,当元素数超过最大容量时,会自动扩容;LinkedList 基于双向链表实现,不存在容量限制。
  • ArrayList 随机访问速度较快,随机插入、删除速度较慢;LinkedList 随机插入、删除速度较快,随机访问速度较慢。
  • ArrayListLinkedList 都不是线程安全的。

Vector 和 Stack

VectorStack 的设计目标是作为线程安全的 List 实现,替代 ArrayList

  • Vector - VectorArrayList 类似,也实现了 List 接口。但是, Vector 中的主要方法都是 synchronized 方法,即通过互斥同步方式保证操作的线程安全。
  • Stack - Stack 也是一个同步容器,它的方法也用 synchronized 进行了同步,它实际上是继承于 Vector 类。

ArrayList

ArrayList 是一个数组队列,相当于动态数组。**ArrayList 默认初始容量大小为 10 ,添加元素时,如果发现容量已满,会自动扩容为原始大小的 1.5 倍**。因此,应该尽量在初始化 ArrayList 时,为其指定合适的初始化容量大小,减少扩容操作产生的性能开销。

ArrayList 定义:

1
2
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable

从 ArrayList 的定义,不难看出 ArrayList 的一些基本特性:

  • ArrayList 实现了 List 接口,并继承了 AbstractList,它支持所有 List 的操作。
  • ArrayList 实现了 RandomAccess 接口,支持随机访问RandomAccess 是一个标志接口,它意味着“只要实现该接口的 List 类,都支持快速随机访问”。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象;这就是快速随机访问。
  • ArrayList 实现了 Cloneable 接口,默认为浅拷贝
  • ArrayList 实现了 Serializable 接口,支持序列化,能通过序列化方式传输。
  • ArrayList非线程安全的。

ArrayList 的数据结构

ArrayList 包含了两个重要的元素:elementDatasize

1
2
3
4
5
6
// 默认初始化容量
private static final int DEFAULT_CAPACITY = 10;
// 对象数组
transient Object[] elementData;
// 数组长度
private int size;
  • size - 是动态数组的实际大小,默认初始容量大小为 10。
  • elementData - 是一个 Object 数组,用于保存添加到 ArrayList 中的元素。正是由于实际存储元素的是 Object 数组,所以其天然支持随机访问。

ArrayList 构造方法

ArrayList 类实现了三个构造函数:

  • 第一个是默认构造方法,ArrayList 会创建一个空数组;
  • 第二个是创建 ArrayList 对象时,传入一个初始化值;
  • 第三个是传入一个集合类型进行初始化。

当 ArrayList 新增元素时,如果所存储的元素已经超过其当前容量,它会计算容量后再进行动态扩容。数组的动态扩容会导致整个数组进行一次内存复制。因此,初始化 ArrayList 时,指定数组初始大小,有助于减少数组的扩容次数,从而提高系统性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public ArrayList() {
// 创建一个空数组
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
// 根据初始化值创建数组大小
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
// 初始化值为 0 时,创建一个空数组
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}

ArrayList 定制序列化

ArrayList 具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化。为此,ArrayList 定制了其序列化方式。具体做法是:

  • 存储元素的 Object 数组(即 elementData)使用 transient 修饰,使得它可以被 Java 序列化所忽略。
  • ArrayList 重写了 writeObject()readObject() 来控制序列化数组中有元素填充那部分内容。

:bulb: 不了解 Java 序列化方式,可以参考:Java 序列化

ArrayList 访问元素

ArrayList 访问元素的实现主要基于以下关键性源码:

1
2
3
4
5
6
7
8
9
// 获取第 index 个元素
public E get(int index) {
rangeCheck(index);
return elementData(index);
}

E elementData(int index) {
return (E) elementData[index];
}

实现非常简单,其实就是**通过数组下标访问数组元素,其时间复杂度为 O(1)**,所以很快。

ArrayList 添加元素

ArrayList 添加元素有两种方法:一种是添加元素到数组末尾,另外一种是添加元素到任意位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 添加元素到数组末尾
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}

// 添加元素到任意位置
public void add(int index, E element) {
rangeCheckForAdd(index);

ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}

两种添加元素方法的不同点是:

  • 添加元素到任意位置,会导致在该位置后的所有元素都需要重新排列
  • 而添加元素到数组末尾,在没有发生扩容的前提下,是不会有元素复制排序过程的。

两种添加元素方法的共同点是:添加元素时,会先检查容量大小,如果发现容量不足,会自动扩容为原始大小的 1.5 倍

ArrayList 添加元素的实现主要基于以下关键性源码:

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
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}

ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
modCount++;

// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}

private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}

ArrayList 执行添加元素动作(add 方法)时,调用 ensureCapacityInternal 方法来保证容量足够。

  • 如果容量足够时,将数据作为数组中 size+1 位置上的元素写入,并将 size 自增 1。
  • 如果容量不够时,需要使用 grow 方法进行扩容数组,新容量的大小为 oldCapacity + (oldCapacity >> 1),也就是旧容量的 1.5 倍。扩容操作实际上是调用 Arrays.copyOf() 把原数组拷贝为一个新数组,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。

ArrayList 删除元素

ArrayList 的删除方法和添加元素到任意位置方法有些相似。

ArrayList 在每一次有效的删除操作后,都要进行数组的重组,并且删除的元素位置越靠前,数组重组的开销就越大。具体来说,ArrayList 会**调用 System.arraycopy()index+1 后面的元素都复制到 index 位置上。

1
2
3
4
5
6
7
8
9
10
11
12
13
public E remove(int index) {
rangeCheck(index);

modCount++;
E oldValue = elementData(index);

int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // clear to let GC do its work

return oldValue;
}

ArrayList 的 fail-fast

ArrayList 使用 modCount 来记录结构发生变化的次数。结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化。

在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果发生改变,ArrayList 会抛出 ConcurrentModificationException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();

// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);

// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}

if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}

LinkedList

LinkedList 基于双链表结构实现。由于是双链表,所以顺序访问会非常高效,而随机访问效率比较低。

LinkedList 定义:

1
2
3
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable

LinkedList 的定义,可以得出 LinkedList 的一些基本特性:

  • LinkedList 实现了 List 接口,并继承了 AbstractSequentialList ,它支持所有 List 的操作。
  • LinkedList 实现了 Deque 接口,也可以被当作队列(Queue)或双端队列(Deque)进行操作,此外,也可以用来实现栈。
  • LinkedList 实现了 Cloneable 接口,默认为浅拷贝
  • LinkedList 实现了 Serializable 接口,支持序列化
  • LinkedList非线程安全的。

LinkedList 的数据结构

LinkedList 内部维护了一个双链表

LinkedList 通过 Node 类型的头尾指针(firstlast)来访问数据。

1
2
3
4
5
6
// 链表长度
transient int size = 0;
// 链表头节点
transient Node<E> first;
// 链表尾节点
transient Node<E> last;
  • size - 表示双链表中节点的个数,初始为 0
  • firstlast - 分别是双链表的头节点和尾节点

NodeLinkedList 的内部类,它表示链表中的元素实例。Node 中包含三个元素:

  • prev 是该节点的上一个节点;
  • next 是该节点的下一个节点;
  • item 是该节点所包含的值。
1
2
3
4
5
6
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
...
}

LinkedList 的序列化

LinkedListArrayList 一样也定制了自身的序列化方式。具体做法是:

  • size (双链表容量大小)、firstlast (双链表的头尾节点)修饰为 transient,使得它们可以被 Java 序列化所忽略。
  • 重写了 writeObject()readObject() 来控制序列化时,只处理双链表中能被头节点链式引用的节点元素。

LinkedList 访问元素

LinkedList 访问元素的实现主要基于以下关键性源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}

Node<E> node(int index) {
// assert isElementIndex(index);

if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}

获取 LinkedList 第 index 个元素的算法是:

  • 判断 index 在链表前半部分,还是后半部分。
  • 如果是前半部分,从头节点开始查找;如果是后半部分,从尾结点开始查找。

LinkedList 这种访问元素的性能是 O(N) 级别的(极端情况下,扫描 N/2 个元素);相比于 ArrayListO(1),显然要慢不少。

推荐使用迭代器遍历 LinkedList ,不要使用传统的 for 循环。注:foreach 语法会被编译器转换成迭代器遍历,但是它的遍历过程中不允许修改 List 长度,即不能进行增删操作。

LinkedList 添加元素

LinkedList 有多种添加元素方法:

  • add(E e):默认添加元素方法(插入尾部)
  • add(int index, E element):添加元素到任意位置
  • addFirst(E e):在头部添加元素
  • addLast(E e):在尾部添加元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean add(E e) {
linkLast(e);
return true;
}

public void add(int index, E element) {
checkPositionIndex(index);

if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}

public void addFirst(E e) {
linkFirst(e);
}

public void addLast(E e) {
linkLast(e);
}

LinkedList 添加元素的实现主要基于以下关键性源码:

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
private void linkFirst(E e) {
final Node<E> f = first;
final Node<E> newNode = new Node<>(null, e, f);
first = newNode;
if (f == null)
last = newNode;
else
f.prev = newNode;
size++;
modCount++;
}

void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}

void linkBefore(E e, Node<E> succ) {
// assert succ != null;
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}

算法如下:

  • 将新添加的数据包装为 Node
  • 如果往头部添加元素,将头指针 first 指向新的 Node,之前的 first 对象的 prev 指向新的 Node
  • 如果是向尾部添加元素,则将尾指针 last 指向新的 Node,之前的 last 对象的 next 指向新的 Node

LinkedList 删除元素

LinkedList 删除元素的实现主要基于以下关键性源码:

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
public boolean remove(Object o) {
if (o == null) {
// 遍历找到要删除的元素节点
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
// 遍历找到要删除的元素节点
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}

E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;

if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}

if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}

x.item = null;
size--;
modCount++;
return element;
}

算法说明:

  • 遍历找到要删除的元素节点,然后调用 unlink 方法删除节点;
  • unlink 删除节点的方法:
    • 如果当前节点有前驱节点,则让前驱节点指向当前节点的下一个节点;否则,让双链表头指针指向下一个节点。
    • 如果当前节点有后继节点,则让后继节点指向当前节点的前一个节点;否则,让双链表尾指针指向上一个节点。

ArrayList vs. LinkedList

  • 是否保证线程安全: ArrayListLinkedList 都是不同步的,也就是不保证线程安全;
  • 底层数据结构: ArrayList 底层使用的是 Object 数组LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)
  • 插入和删除是否受元素位置的影响:
    • ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
    • LinkedList 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)addFirst(E e)addLast(E e)removeFirst()removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element)remove(Object o),remove(int index)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。
  • 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了 RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
  • 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!就连 LinkedList 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList

List 常见问题

Arrays.asList 问题点

在业务开发中,我们常常会把原始的数组转换为 List 类数据结构,来继续展开各种 Stream 操作。通常,我们会使用 Arrays.asList 方法可以把数组一键转换为 List

【示例】Arrays.asList 转换基本类型数组

1
2
3
int[] arr = { 1, 2, 3 };
List list = Arrays.asList(arr);
log.info("list:{} size:{} class:{}", list, list.size(), list.get(0).getClass());

【输出】

1
11:26:33.214 [main] INFO io.github.dunwu.javacore.container.list.AsList示例 - list:[[I@ae45eb6] size:1 class:class [I

数组元素个数为 3,但转换后的列表个数为 1。

由此可知, Arrays.asList 第一个问题点:不能直接使用 Arrays.asList 来转换基本类型数组

其原因是:Arrays.asList 方法传入的是一个泛型 T 类型可变参数,最终 int 数组整体作为了一个对象成为了泛型类型 T:

1
2
3
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

直接遍历这样的 List 必然会出现 Bug,修复方式有两种,如果使用 Java8 以上版本可以使用 Arrays.stream 方法来转换,否则可以把 int 数组声明为包装类型 Integer 数组:

【示例】转换整型数组为 List 的正确方式

1
2
3
4
5
6
7
int[] arr1 = { 1, 2, 3 };
List list1 = Arrays.stream(arr1).boxed().collect(Collectors.toList());
log.info("list:{} size:{} class:{}", list1, list1.size(), list1.get(0).getClass());

Integer[] arr2 = { 1, 2, 3 };
List list2 = Arrays.asList(arr2);
log.info("list:{} size:{} class:{}", list2, list2.size(), list2.get(0).getClass());

【示例】Arrays.asList 转换引用类型数组

1
2
3
4
5
6
7
8
9
String[] arr = { "1", "2", "3" };
List list = Arrays.asList(arr);
arr[1] = "4";
try {
list.add("5");
} catch (Exception ex) {
ex.printStackTrace();
}
log.info("arr:{} list:{}", Arrays.toString(arr), list);

抛出 java.lang.UnsupportedOperationException

抛出异常的原因在于 Arrays.asList 第二个问题点:**Arrays.asList 返回的 List 不支持增删操作**。Arrays.asList 返回的 List 并不是我们期望的 java.util.ArrayList,而是 Arrays 的内部类 ArrayList

查看源码,我们可以发现 Arrays.asList 返回的 ArrayList 继承了 AbstractList,但是并没有覆写 addremove 方法。

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
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
private static final long serialVersionUID = -2764017481108945198L;
private final E[] a;

ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}

// ...

@Override
public E set(int index, E element) {
E oldValue = a[index];
a[index] = element;
return oldValue;
}

}

public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
public void add(int index, E element) {
throw new UnsupportedOperationException();
}

public E remove(int index) {
throw new UnsupportedOperationException();
}
}

Arrays.asList 第三个问题点:**对原始数组的修改会影响到我们获得的那个 List**。ArrayList 其实是直接使用了原始的数组。

解决方法很简单,重新 new 一个 ArrayList 初始化 Arrays.asList 返回的 List 即可:

1
2
3
4
5
6
7
8
9
String[] arr = { "1", "2", "3" };
List list = new ArrayList(Arrays.asList(arr));
arr[1] = "4";
try {
list.add("5");
} catch (Exception ex) {
ex.printStackTrace();
}
log.info("arr:{} list:{}", Arrays.toString(arr), list);

List.subList 问题点

List.subList 直接引用了原始的 List,也可以认为是共享“存储”,而且对原始 List 直接进行结构性修改会导致 SubList 出现异常。

1
2
3
4
5
6
7
8
private static List<List<Integer>> data = new ArrayList<>();

private static void oom() {
for (int i = 0; i < 1000; i++) {
List<Integer> rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
data.add(rawList.subList(0, 1));
}
}

出现 OOM 的原因是,循环中的 1000 个具有 10 万个元素的 List 始终得不到回收,因为它始终被 subList 方法返回的 List 强引用。

解决方法是:

1
2
3
4
5
6
private static void oomfix() {
for (int i = 0; i < 1000; i++) {
List<Integer> rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
data.add(new ArrayList<>(rawList.subList(0, 1)));
}
}

【示例】子 List 强引用原始的 List

1
2
3
4
5
6
7
8
9
10
11
12
13
private static void wrong() {
List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
List<Integer> subList = list.subList(1, 4);
System.out.println(subList);
subList.remove(1);
System.out.println(list);
list.add(0);
try {
subList.forEach(System.out::println);
} catch (Exception ex) {
ex.printStackTrace();
}
}

抛出 java.util.ConcurrentModificationException

解决方法:

一种是,不直接使用 subList 方法返回的 SubList,而是重新使用 new ArrayList,在构造方法传入 SubList,来构建一个独立的 ArrayList;

另一种是,对于 Java 8 使用 Stream 的 skip 和 limit API 来跳过流中的元素,以及限制流中元素的个数,同样可以达到 SubList 切片的目的。

1
2
3
4
//方式一:
List<Integer> subList = new ArrayList<>(list.subList(1, 4));
//方式二:
List<Integer> subList = list.stream().skip(1).limit(3).collect(Collectors.toList());

参考资料

SQL

::: info 概述

SQL(Structured Query Language,结构化查询语言) 是一种高级的非过程化编程语言,用于管理 RDBMS(Relational Database Management System,关系数据库管理系统)

本文主要介绍关系型数据库的基本语法,限于篇幅,本文侧重说明用法,不会展开讲解特性、原理。

注:本文语法主要针对 Mysql,但大部分的语法对其他关系型数据库也适用。

:::

SQL 简介

数据库术语

  • 数据库(database) - 保存有组织的数据的容器(通常是一个文件或一组文件)。
  • 数据表(table) - 某种特定类型数据的结构化清单。
  • 模式(schema) - 关于数据库和表的布局及特性的信息。模式定义了数据在表中如何存储,包含存储什么样的数据,数据如何分解,各部分信息如何命名等信息。数据库和表都有模式。
  • 行(row) - 表中的一条记录。
  • 列(column) - 表中的一个字段。所有表都是由一个或多个列组成的。
  • 主键(primary key) - 一列(或一组列),其值能够唯一标识表中每一行。

SQL 语法

SQL(Structured Query Language),标准 SQL 由 ANSI 标准委员会管理,从而称为 ANSI SQL。各个 DBMS 都有自己的实现,如 PL/SQL、Transact-SQL 等。

SQL 语法结构

img

SQL 语法结构包括:

  • 子句 - 是语句和查询的组成成分(在某些情况下,这些都是可选的)。
  • 表达式 - 可以产生任何标量值,或由列和行的数据库表。
  • 谓词 - 给需要评估的 SQL 三值逻辑(3VL)(true/false/unknown)或布尔真值指定条件,并限制语句和查询的效果,或改变程序流程。
  • 查询 - 基于特定条件检索数据。这是 SQL 的一个重要组成部分。
  • 语句 - 可以持久地影响纲要和数据,也可以控制数据库事务、程序流程、连接、会话或诊断。

SQL 语法要点

  • SQL 语句不区分大小写,但是数据库表名、列名和值是否区分,依赖于具体的 DBMS 以及配置。

例如:SELECTselectSelect 是相同的。

  • 多条 SQL 语句必须以分号(;)分隔

  • 处理 SQL 语句时,所有空格都被忽略。SQL 语句可以写成一行,也可以分写为多行。

1
2
3
4
5
6
7
-- 一行 SQL 语句
UPDATE user SET username='robot', password='robot' WHERE username = 'root';

-- 多行 SQL 语句
UPDATE user
SET username='robot', password='robot'
WHERE username = 'root';
  • SQL 支持三种注释
1
2
3
4
5
6
7
8
9
10
11
SELECT prod_name -- 这是一条注释
FROM Products;

# 这是一条注释
SELECT prod_name
FROM Products;

/* SELECT prod_name, vend_id
FROM Products; */
SELECT prod_name
FROM Products;

SQL 分类

  • DDL - DDL,英文叫做 Data Definition Language,即“数据定义语言”
    • DDL 用于定义数据库对象
    • DDL 定义操作包括创建(CREATE)、删除(DROP)、修改(ALTER);而被操作的对象包括:数据库、数据表和列、视图、索引。
  • DML - DML,英文叫做 Data Manipulation Language,即“数据操作语言”
    • DML 用于访问数据库的数据
    • DML 访问操作包括插入(INSERT)、删除(DELETE)、修改(UPDATE)、查询(SELECT)。这四个指令合称 CRUD,英文单词为 Create, Read, Update, Delete,即增删改查。
  • TCL - TCL,英文叫做 Transaction Control Language,即“事务控制语言”
    • TCL 用于管理数据库中的事务,实际上就是用于管理由 DML 语句所产生的数据变更,它还允许将语句分组为逻辑事务。
    • TCL 的核心指令是 COMMITROLLBACK
  • DCL - DCL,英文叫做 Data Control Language,即“数据控制语言”
    • DCL 用于对数据访问权限进行控制,它可以控制特定用户账户对数据表、查看表、预存程序、用户自定义函数等数据库对象的控制权。
    • DCL 的核心指令是 GRANTREVOKE
    • DCL 以控制用户的访问权限为主,因此其指令作法并不复杂,可利用 DCL 控制的权限有:CONNECTSELECTINSERTUPDATEDELETEEXECUTEUSAGEREFERENCES
    • 根据不同的 DBMS 以及不同的安全性实体,其支持的权限控制也有所不同。

数据定义(CREATE、ALTER、DROP)

DDL 的主要功能是定义数据库对象(如:数据库、数据表、视图、索引等)。

数据库(DATABASE)

以下为数据库定义示例:

::: tabs#数据库定义

@tab 创建数据库

1
CREATE DATABASE IF NOT EXISTS db_tutorial;

@tab 删除数据库

1
DROP DATABASE IF EXISTS db_tutorial;

@tab 选择数据库

1
USE db_tutorial;

:::

数据表(TABLE)

以下为数据表定义示例:

::: tabs#数据表定义

@tab 创建数据表

利用 CREATE TABLE 创建表,必须给出下列信息:

  • 新表的名字,在关键字 CREATE TABLE 之后给出;
  • 表列的名字和定义,用逗号分隔;
  • 有的 DBMS 还要求指定表的位置。
1
2
3
4
5
6
CREATE TABLE user (
id INT(10) UNSIGNED NOT NULL COMMENT 'Id',
username VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '用户名',
password VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '密码',
email VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '邮箱'
) COMMENT ='用户表';

@tab 删除数据表

1
2
DROP TABLE IF EXISTS user;
DROP TABLE CustCopy;

@tab 复制表

1
2
CREATE TABLE vip_user AS
SELECT * FROM user;

@tab 数据表添加列

1
2
ALTER TABLE user
ADD age int(3);

@tab 数据表删除列

1
2
ALTER TABLE user
DROP COLUMN age;

@tab 数据表修改列

1
2
ALTER TABLE user
MODIFY COLUMN age tinyint;

@tab 修改表的编码格式

utf8mb4 编码是 utf8 编码的超集,兼容 utf8,并且能存储 4 字节的表情字符。如果表的编码指定为 utf8,在保存 emoji 字段时会报错。

1
ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

:::

以下为数据表信息查看示例:

::: tabs#数据表查看

@tab 查看表的基本信息

1
2
SELECT * FROM information_schema.tables
WHERE table_schema = 'test' AND table_name = 'user';

@tab 查看表的列信息

1
2
SELECT * FROM information_schema.columns
WHERE table_schema = 'test' AND table_name = 'user';

:::

视图(VIEW)

“视图”是基于 SQL 语句的结果集的可视化的表。视图是虚拟的表,本身不存储数据,也就不能对其进行索引操作。对视图的操作和对普通表的操作一样。

视图的作用:

  • 简化复杂的 SQL 操作,比如复杂的连接。
  • 只使用实际表的一部分数据。
  • 通过只给用户访问视图的权限,保证数据的安全性。
  • 更改数据格式和表示。

以下为视图定义示例:

::: tabs#视图定义

@tab 创建视图

创建一个名为 ProductCustomers 的视图,它联结三个表,返回已订购了任意产品的所有顾客的列表。

1
2
3
4
5
CREATE VIEW ProductCustomers AS
SELECT cust_name, cust_contact, prod_id
FROM Customers, Orders, OrderItems
WHERE Customers.cust_id = Orders.cust_id
AND OrderItems.order_num = Orders.order_num;

检索订购了产品 RGAN01 的顾客

1
2
3
SELECT cust_name, cust_contact
FROM ProductCustomers
WHERE prod_id = 'RGAN01';

@tab 删除视图

1
DROP VIEW top_10_user_view;

:::

索引(INDEX)

“索引”是数据库为了提高查找效率的一种数据结构

日常生活中,我们可以通过检索目录,来快速定位书本中的内容。索引和数据表,就好比目录和书,想要高效查询数据表,索引至关重要。在数据量小且负载较低时,不恰当的索引对于性能的影响可能还不明显;但随着数据量逐渐增大,性能则会急剧下降。因此,设置合理的索引是数据库查询性能优化的最有效手段

更新一个包含索引的表需要比更新一个没有索引的表花费更多的时间,这是由于索引本身也需要更新。因此,理想的做法是仅仅在常常被搜索的列(以及表)上面创建索引。

“唯一索引”表明此索引的每一个索引值只对应唯一的数据记录。

以下为视图定义示例:

::: tabs#索引定义

@tab 创建索引

1
CREATE INDEX idx_email ON user(email);

@tab 创建唯一索引

1
CREATE UNIQUE INDEX uniq_name ON user(name);

@tab 删除索引

1
2
ALTER TABLE user DROP INDEX idx_email;
ALTER TABLE user DROP INDEX uniq_name;

@tab 添加主键

1
ALTER TABLE user ADD PRIMARY KEY (id);

@tab 删除主键

1
ALTER TABLE user DROP PRIMARY KEY;

:::

约束(CONSTRAINT)

约束(constraint)管理如何插入或处理数据库数据的规则。

如果存在违反约束的数据行为,行为会被约束终止。约束可以在创建表时规定(通过 CREATE TABLE 语句),或者在表创建之后规定(通过 ALTER TABLE 语句)。

定义约束的语法:

1
2
3
4
5
6
CREATE TABLE table_name (
column_name1 data_type(size) constraint_name,
column_name2 data_type(size) constraint_name,
column_name3 data_type(size) constraint_name,
....
);

约束类型

  • NOT NULL - 指示字段不能存储 NULL 值。
  • UNIQUE KEY - 保证字段的每行必须有唯一的值。
  • PRIMARY KEY - PRIMARY KEY 的作用是唯一标识一条记录,不能重复,不能为空,即相当于 NOT NULL + UNIQUE。确保字段(或两个列多个列的结合)有唯一标识,有助于更容易更快速地找到表中的一个特定的记录。
  • FOREIGN KEY - 保证一个表中的数据匹配另一个表中的值的参照完整性。
  • CHECK - 用于检查字段取值范围的有效性。
  • DEFAULT - 表明字段的默认值。如果插入数据时,该字段没有赋值,就会被设置为默认值。

以下为约束定义示例:

::: tabs#约束定义

@tab NOT NULL

1
2
3
CREATE TABLE demo (
id INT UNSIGNED NOT NULL
);

@tab UNIQUE KEY

1
2
3
4
CREATE TABLE demo2 (
id INT UNSIGNED NOT NULL,
name VARCHAR(50) NOT NULL UNIQUE KEY
);

@tab PRIMARY KEY

1
2
3
4
CREATE TABLE demo3 (
id INT UNSIGNED NOT NULL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE KEY
);

@tab FOREIGN KEY

1
2
3
4
5
6
CREATE TABLE demo4 (
id INT UNSIGNED NOT NULL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE KEY,
fid INT UNSIGNED,
FOREIGN KEY (fid) REFERENCES demo3(id)
);

@tab CHECK

1
2
3
4
5
CREATE TABLE demo5 (
id INT UNSIGNED NOT NULL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE KEY,
age INT CHECK (age > 0)
);

@tab DEFAULT

1
2
3
4
5
CREATE TABLE demo6 (
id INT UNSIGNED NOT NULL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE KEY,
age INT DEFAULT 0
);

:::

增删改查(CRUD)

增删改查,又称为 **CRUD**,是数据库基本操作中的基本操作。

插入数据(INSERT)

INSERT INTO 语句用于向表中插入新记录。

以下为插入数据示例:

::: tabs#插入数据

@tab 插入完整的行

1
2
3
4
5
6
-- 下面两条 SQL 等价
INSERT INTO Customers
VALUES ('1000000006', 'Toy Land', '123 Any Street', 'New York', 'NY', '11111', 'USA', NULL, NULL);

INSERT INTO Customers(cust_id, cust_name, cust_address, cust_city, cust_state, cust_zip, cust_country, cust_contact, cust_email)
VALUES ('1000000006', 'Toy Land', '123 Any Street', 'New York', 'NY','11111', 'USA', NULL, NULL);

@tab 插入行的一部分

1
2
INSERT INTO customers(cust_id, cust_name, cust_address, cust_city, cust_state, cust_zip, cust_country)
VALUES ('1000000006', 'Toy Land', '123 Any Street', 'New York', 'NY', '11111', 'USA');

@tab 插入查询出来的数据

1
2
3
INSERT INTO Customers(cust_id, cust_contact, cust_email, cust_name, cust_address, cust_city, cust_state, cust_zip, cust_country)
SELECT cust_id, cust_contact, cust_email, cust_name, cust_address, cust_city, cust_state, cust_zip, cust_country
FROM CustNew;

@tab 从一个表复制到另一个表

1
2
3
4
5
6
7
SELECT *
INTO CustCopy
FROM Customers;

-- MariaDB、MySQL、Oracle、PostgreSQL 和 SQLite
CREATE TABLE CustCopy AS
SELECT * FROM Customers;

:::

更新数据(UPDATE)

UPDATE 语句用于更新表中的记录。

::: tabs#更新数据

@tab 更新单列

更新客户 1000000005 的电子邮件地址

1
2
3
UPDATE Customers
SET cust_email = 'kim@thetoystore.com'
WHERE cust_id = '1000000005';

@tab 更新多列

1
2
3
UPDATE customers
SET cust_contact = 'Sam Roberts', cust_email = 'sam@toyland.com'
WHERE cust_id = '1000000006';

@tab 从表中删除特定的行

1
2
DELETE FROM Customers
WHERE cust_id = '1000000006';

:::

删除数据(DELETE)

  • DELETE 语句用于删除表中的记录。
  • TRUNCATE TABLE 可以清空表,也就是删除所有行。

以下为删除数据示例:

::: tabs#删除数据

@tab 删除表中的指定数据

1
DELETE FROM user WHERE username = 'robot';

@tab 清空表中的数据

1
TRUNCATE TABLE user;

@tab 批量删除大量数据

如果要根据时间范围批量删除大量数据,最简单的语句如下:

1
2
DELETE FROM order
WHERE timestamp < SUBDATE(CURDATE(), INTERVAL 3 MONTH);

上面的语句,大概率执行会报错,提示删除失败,因为需要删除的数据量太大了,所以需要分批删除。

可以先通过一次查询,找到符合条件的历史订单中最大的那个订单 ID,然后在删除语句中把删除的条件转换成按主键删除。

1
2
3
4
5
6
SELECT max(id) FROM order
WHERE timestamp < SUBDATE(CURDATE(), INTERVAL 3 MONTH);

-- 分批删除,? 填上一条语句查到的最大 ID
DELETE FROM order
WHERE id <= ? ORDER BY id LIMIT 1000;

:::

查询数据(SELECT)

  • SELECT 语句用于从数据库中查询数据。
  • DISTINCT 用于返回唯一不同的值。它作用于所有列,也就是说所有列的值都相同才算相同。
  • LIMIT 限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。
    • ASC :升序(默认)
    • DESC :降序

SELECT 的用法

以下为查询数据示例:

::: tabs#删除数据

@tab 查询单列

1
2
SELECT prod_name
FROM Products;

@tab 查询多列

1
2
SELECT prod_id, prod_name, prod_price
FROM Products;

@tab 查询所有列

1
2
SELECT *
FROM Products;

@tab 查询去重

1
2
SELECT DISTINCT vend_id
FROM Products;

@tab 限制查询数量

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
-- SQL Server 和 Access
SELECT TOP 5 prod_name
FROM Products;

-- DB2
SELECT prod_name
FROM Products
FETCH FIRST 5 ROWS ONLY;

-- Oracle
SELECT prod_name
FROM Products
WHERE ROWNUM <=5;

-- MySQL、MariaDB、PostgreSQL 或者 SQLite
SELECT prod_name
FROM Products
LIMIT 5;
-- 检索从第 5 行起的 5 行数据
SELECT prod_name
FROM Products
LIMIT 5 OFFSET 5;
-- MySQL 和 MariaDB 中,上面的示例可以简化如下
SELECT prod_name
FROM Products
LIMIT 5, 5;

:::

SELECT 的执行顺序

关键字的顺序是不能颠倒的:

1
SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ...

SELECT 语句的执行顺序(在 MySQL 和 Oracle 中,SELECT 执行顺序基本相同):

1
FROM > WHERE > GROUP BY > HAVING > SELECT 的字段 > DISTINCT > ORDER BY > LIMIT

比如你写了一个 SQL 语句,那么它的关键字顺序和执行顺序是下面这样的:

1
2
3
4
5
6
7
SELECT DISTINCT player_id, player_name, count(*) as num -- 顺序 5
FROM player JOIN team ON player.team_id = team.team_id -- 顺序 1
WHERE height > 1.80 -- 顺序 2
GROUP BY player.team_id -- 顺序 3
HAVING num > 2 -- 顺序 4
ORDER BY num DESC -- 顺序 6
LIMIT 2 -- 顺序 7

过滤数据(WHERE)

数据库表一般包含大量的数据,很少需要检索表中的所有行。通常只会根据特定操作或报告的需要提取表数据的子集。只检索所需数据需要指 定搜索条件(search criteria),搜索条件也称为过滤条件(filter condition)。

WHERE

在 SQL 语句中,数据根据 WHERE 子句中指定的搜索条件进行过滤。

WHERE 子句的基本格式如下:

1
SELECT ……(列名) FROM ……(表名) WHERE ……(子句条件)

WHERE 的常见用法:

1
2
3
4
5
6
SELECT column1, column2 FROM table_name WHERE condition;
SELECT * FROM table_name WHERE condition1 AND condition2;
SELECT * FROM table_name WHERE condition1 OR condition2;
SELECT * FROM table_name WHERE NOT condition;
SELECT * FROM table_name WHERE condition1 AND (condition2 OR condition3);
SELECT * FROM table_name WHERE EXISTS (SELECT column_name FROM table_name WHERE condition)

WHERE 可以与 SELECTUPDATEDELETE 一起使用。

::: tabs#WHERE 示例

@tab SELECT 语句中的 WHERE 子句

检索所有价格小于 10 美元的产品。

1
2
3
SELECT prod_name, prod_price
FROM Products
WHERE prod_price < 10;

检索所有不是供应商 DLL01 制造的产品

1
2
3
4
5
6
7
8
9
-- 下面两条查询语句作用相同

SELECT vend_id, prod_name
FROM Products
WHERE vend_id <> 'DLL01';

SELECT vend_id, prod_name
FROM Products
WHERE vend_id != 'DLL01';

检索价格在 5 美元和 10 美元之间的所有产品

1
2
3
SELECT prod_name, prod_price
FROM Products
WHERE prod_price BETWEEN 5 AND 10;

检索所有没有邮件地址的顾客

1
2
3
SELECT cust_name
FROM CUSTOMERS
WHERE cust_email IS NULL;

@tab UPDATE 语句中的 WHERE 子句

1
2
3
UPDATE Customers
SET cust_name = 'Jack Jones'
WHERE cust_name = 'Kids Place';

@tab DELETE 语句中的 WHERE 子句

1
2
DELETE FROM Customers
WHERE cust_name = 'Kids Place';

:::

比较操作符

操作符 描述
= 等于
<> 不等于。注释:在 SQL 的一些版本中,该操作符可被写成 !=
> 大于
< 小于
>= 大于等于
<= 小于等于
IS NULL 是否为空

【示例】查询所有价格小于 10 美元的产品

1
2
3
SELECT prod_name, prod_price
FROM Products
WHERE prod_price < 10;

【示例】查询所有不是供应商 DLL01 制造的产品

1
2
3
SELECT vend_id, prod_name
FROM Products
WHERE vend_id != 'DLL01';

【示例】查询邮件地址为空的客户

1
2
3
SELECT cust_name
FROM CUSTOMERS
WHERE cust_email IS NULL;

范围操作符

操作符 描述
BETWEEN 在某个范围内
IN 指定针对某个列的多个可能值

BETWEEN 操作符在 WHERE 子句中使用,作用是选取介于某个范围内的值。

IN 操作符用来指定条件范围,范围中的每个条件都可以进行匹配。IN 取一组由逗号分隔、括在圆括号中的合法值。

为什么要使用 IN 操作符?其优点如下。

  • 在有很多合法选项时,IN 操作符的语法更清楚,更直观。
  • 在与其他 AND 和 OR 操作符组合使用 IN 时,求值顺序更容易管理。
  • IN 操作符一般比一组 OR 操作符执行得更快(在上面这个合法选项很 少的例子中,你看不出性能差异)。
  • IN 的最大优点是可以包含其他 SELECT 语句,能够更动态地建立 WHERE 子句。

以下为范围操作符使用示例:

::: tabs#范围操作符

@tab IN 示例

下面两条 SQL 的语义等价:

1
2
3
4
5
6
7
8
9
SELECT prod_name, prod_price
FROM Products
WHERE vend_id IN ( 'DLL01', 'BRS01' )
ORDER BY prod_name;

SELECT prod_name, prod_price
FROM Products
WHERE vend_id = 'DLL01' OR vend_id = 'BRS01'
ORDER BY prod_name;

@tab BETWEEN 示例

1
2
3
SELECT prod_name, prod_price
FROM Products
WHERE prod_price BETWEEN 5 AND 10;

:::

逻辑操作符

操作符 描述
AND 并且(与)
OR 或者(或)
NOT 否定(非)

ANDORNOT 是用于对过滤条件的逻辑处理指令。

  • AND 优先级高于 OR,为了明确处理顺序,可以使用 ()AND 操作符表示左右条件都要满足。

  • OR 操作符表示左右条件满足任意一个即可。

  • NOT 操作符用于否定其后条件。

以下为逻辑操作符使用示例:

::: tabs#逻辑操作符

@tab AND 示例

检索由供应商 DLL01 制造且价格小于等于 4 美元的所有产品的名称和价格

1
2
3
SELECT prod_id, prod_price, prod_name
FROM Products
WHERE vend_id = 'DLL01' AND prod_price <= 4;

@tab OR 示例

检索由供应商 DLL01 或供应商 BRS01 制造的所有产品的名称和价格

1
2
3
SELECT prod_id, prod_price, prod_name
FROM Products
WHERE vend_id = 'DLL01' OR vend_id = 'BRS01';

@tab NOT 示例

检索除 DLL01 之外的所有供应商制造的产品

1
2
3
4
SELECT prod_name
FROM Products
WHERE NOT vend_id = 'DLL01'
ORDER BY prod_name;

和下面的示例作用相同

1
2
3
4
SELECT prod_name
FROM Products
WHERE vend_id <> 'DLL01'
ORDER BY prod_name;

@tab ANDOR 优先级示例

SQL 在处理 OR 操作符前,优先处理 AND 操作符。

下面的示例中,SQL 会理解为由供应商 BRS01 制造的价格为 10 美元以上的所有产品,以及由供应商 DLL01 制造的所有产品,而不管其价格如何。

1
2
3
4
SELECT prod_name, prod_price
FROM Products
WHERE vend_id = 'DLL01' OR vend_id = 'BRS01'
AND prod_price >= 10;

任何时候使用具有 AND 和 OR 操作符的 WHERE 子句,都应该使用圆括号明确地分组操作符。

1
2
3
4
SELECT prod_name, prod_price
FROM Products
WHERE (vend_id = 'DLL01' OR vend_id = 'BRS01')
AND prod_price >= 10;

:::

通配符

LIKE 操作符在 WHERE 子句中使用,作用是确定字符串是否匹配模式。只有字段是文本值时才使用 LIKE不要滥用通配符,通配符位于开头处匹配会非常慢

LIKE 支持以下通配符匹配选项:

  • % 表示任何字符出现任意次数。
  • _ 表示任何字符出现一次。
  • [] 必须匹配指定位置的一个字符。

说明:并不是所有 DBMS 都支持 []。只有微软的 Access 和 SQL Server 支持 []

以下为通配符使用示例:

::: tabs#逻辑操作符

@tab % 示例

检索所有产品名以 Fish 开头的产品

1
2
3
SELECT prod_id, prod_name
FROM Products
WHERE prod_name LIKE 'Fish%';

检索产品名中包含 bean bag 的产品

1
2
3
SELECT prod_id, prod_name
FROM Products
WHERE prod_name LIKE '%bean bag%';

检索产品名中以 F 开头,y 结尾的产品

1
2
3
SELECT prod_name
FROM Products
WHERE prod_name LIKE 'F%y';

@tab _ 示例

1
2
SELECT * FROM Products
WHERE prod_name LIKE '__ inch teddy bear';

@tab [] 示例

找出所有名字以 J 或 M 开头的联系人:

1
2
3
4
SELECT cust_contact
FROM Customers
WHERE cust_contact LIKE '[JM]%'
ORDER BY cust_contact;

:::

子查询

子查询(subquery),即嵌套在其他查询中的查询。

子查询可以分为关联子查询和非关联子查询。

  • 子查询从数据表中查询了数据结果,如果这个数据结果只执行一次,然后这个数据结果作为主查询的条件进行执行,那么这样的子查询叫做非关联子查询

  • 如果子查询需要执行多次,即采用循环的方式,先从外部查询开始,每次都传入子查询进行查询,然后再将结果反馈给外部,这种嵌套的执行方式就称为关联子查询

假如需要列出订购物品 RGAN01 的所有顾客,应该怎样检索?下面列出具体的步骤。

(1) 检索包含物品 RGAN01 的所有订单的编号。

1
2
3
SELECT order_num
FROM OrderItems
WHERE prod_id = 'RGAN01';

输出

1
2
3
4
order_num
-----------
20007
20008

(2) 检索具有前一步骤列出的订单编号的所有顾客的 ID。

1
2
3
SELECT cust_id
FROM Orders
WHERE order_num IN (20007,20008);

输出

1
2
3
4
cust_id
----------
1000000004
1000000005

(3) 检索前一步骤返回的所有顾客 ID 的顾客信息。

1
2
3
SELECT cust_name, cust_contact
FROM Customers
WHERE cust_id IN ('1000000004','1000000005');

现在,结合这两个查询,把第一个查询(返回订单号的那一个)变为子查询。

1
2
3
4
5
SELECT cust_id
FROM orders
WHERE order_num IN (SELECT order_num
FROM orderitems
WHERE prod_id = 'RGAN01');

再进一步结合第三个查询

1
2
3
4
5
6
7
SELECT cust_name, cust_contact
FROM customers
WHERE cust_id IN (SELECT cust_id
FROM orders
WHERE order_num IN (SELECT order_num
FROM orderitems
WHERE prod_id = 'RGAN01'));

联结和组合

联结(JOIN)

**在 SELECT, UPDATE 和 DELETE 语句中,“联结”可以用于联合多表查询。联结使用 JOIN 关键字,并且条件语句使用 ON 而不是 WHERE**。

联结可以替换子查询,并且一般比子查询的效率更快

JOIN 有以下类型:

  • 内联结 - 内联结又称等值联结,用于获取两个表中字段匹配关系的记录,使用 INNER JOIN 关键字。在没有条件语句的情况下返回笛卡尔积
    • 笛卡尔积 - “笛卡尔积”也称为交叉联结(CROSS JOIN)。由没有联结条件的表关系返回的结果为笛卡儿积。检索出的行的数目将是第一个表中的行数乘以第二个表中的行数。
    • 自联结(=) - “自联结(=)”可以看成内联结的一种,只是联结的表是自身而已。
    • 自然联结(NATURAL JOIN) - “自然联结”会自动联结所有同名列。自然联结使用 NATURAL JOIN 关键字。
  • 外联结
    • 左联结(LEFT JOIN) - “左外联结”会获取左表所有记录,即使右表没有对应匹配的记录。左外联结使用 LEFT JOIN 关键字。
    • 右联结(RIGHT JOIN) - “右外联结”会获取右表所有记录,即使左表没有对应匹配的记录。右外联结使用 RIGHT JOIN 关键字。

SQL JOIN

内联结(INNER JOIN)

内联结又称等值联结,用于获取两个表中字段匹配关系的记录,使用 INNER JOIN 关键字。在没有条件语句的情况下返回笛卡尔积

1
2
3
4
5
6
7
8
SELECT vend_name, prod_name, prod_price
FROM vendors INNER JOIN products
ON vendors.vend_id = products.vend_id;

-- 也可以省略 INNER 使用 JOIN,与上面一句效果一样
SELECT vend_name, prod_name, prod_price
FROM vendors JOIN products
ON vendors.vend_id = products.vend_id;
笛卡尔积

“笛卡尔积”也称为交叉联结(CROSS JOIN),它的作用就是可以把任意表进行联结,即使这两张表不相关。但通常进行联结还是需要筛选的,因此需要在联结后面加上 WHERE 子句,也就是作为过滤条件对联结数据进行筛选。

笛卡尔积是一个数学运算。假设我有两个集合 X 和 Y,那么 X 和 Y 的笛卡尔积就是 X 和 Y 的所有可能组合,也就是第一个对象来自于 X,第二个对象来自于 Y 的所有可能。

【示例】求 t1 和 t2 两张表的笛卡尔积

1
2
3
-- 以下两条 SQL,执行结果相同
SELECT * FROM t1, t2;
SELECT * FROM t1 CROSS JOIN t2;
自联结(=)

“自联结”可以看成内联结的一种,只是联结的表是自身而已

给与 Jim Jones 同一公司的所有顾客发送一封信件:

1
2
3
4
5
6
7
8
9
10
11
-- 子查询方式
SELECT cust_id, cust_name, cust_contact
FROM customers
WHERE cust_name = (SELECT cust_name
FROM customers
WHERE cust_contact = 'Jim Jones');

-- 自联结方式
SELECT c1.cust_id, c1.cust_name, c1.cust_contact
FROM customers AS c1, customers AS c2
WHERE c1.cust_name = c2.cust_name AND c2.cust_contact = 'Jim Jones';
自然联结(NATURAL JOIN)

“自然联结”会自动联结所有同名列。自然联结使用 NATURAL JOIN 关键字。

1
2
3
SELECT *
FROM Products
NATURAL JOIN Customers;

外联结(OUTER JOIN)

外联结返回一个表中的所有行,并且仅返回来自此表中满足联结条件的那些行,即两个表中的列是相等的。外联结分为左外联结、右外联结、全外联结(Mysql 不支持)。

左联结(LEFT JOIN)

“左外联结”会获取左表所有记录,即使右表没有对应匹配的记录。左外联结使用 LEFT JOIN 关键字。

1
2
3
SELECT customers.cust_id, orders.order_num
FROM customers LEFT JOIN orders
ON customers.cust_id = orders.cust_id;
右联结(RIGHT JOIN)

“右外联结”会获取右表所有记录,即使左表没有对应匹配的记录。右外联结使用 RIGHT JOIN 关键字。

1
2
3
SELECT customers.cust_id, orders.order_num
FROM customers RIGHT JOIN orders
ON customers.cust_id = orders.cust_id;

组合(UNION)

UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。

UNION 基本规则:

  • 所有查询的列数和列顺序必须相同。
  • 每个查询中涉及表的列的数据类型必须相同或兼容。
  • 通常返回的列名取自第一个查询。

主要有两种情况需要使用组合查询:

  • 在一个查询中从不同的表返回结构数据;
  • 对一个表执行多个查询,按一个查询返回数据。

把 Illinois、Indiana、Michigan 等州的缩写传递给 IN 子句,检索出这些州的所有行

1
2
3
SELECT cust_name, cust_contact, cust_email
FROM Customers
WHERE cust_state IN ('IL','IN','MI');

找出所有 Fun4All

1
2
3
SELECT cust_name, cust_contact, cust_email
FROM Customers
WHERE cust_name = 'Fun4All';

组合这两条语句

1
2
3
4
5
6
7
SELECT cust_name, cust_contact, cust_email
FROM customers
WHERE cust_state IN ('IL', 'IN', 'MI')
UNION
SELECT cust_name, cust_contact, cust_email
FROM customers
WHERE cust_name = 'Fun4All';

UNION 默认从查询结果集中自动去除了重复的行;如果想返回所有的匹配行,可使用 UNION ALL

1
2
3
4
5
6
7
SELECT cust_name, cust_contact, cust_email
FROM customers
WHERE cust_state IN ('IL', 'IN', 'MI')
UNION ALL
SELECT cust_name, cust_contact, cust_email
FROM customers
WHERE cust_name = 'Fun4All';

JOIN vs UNION

  • JOIN 中联结表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。
  • UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。

排序和分组

ORDER BY

ORDER BY 用于对结果集进行排序。ORDER BY 子句取一个或多个列的名字,据此对输出进行排序。ORDER BY 支持两种排序方式:

  • ASC :升序(默认)
  • DESC :降序

单列排序示例:

1
2
3
SELECT prod_name
FROM Products
ORDER BY prod_name;

可以按多个列进行排序,并且为每个列指定不同的排序方式。

多列排序示例:

1
2
SELECT * FROM Products
ORDER BY prod_price DESC, prod_name ASC;

按列位置排序(不推荐):

1
2
3
SELECT prod_id, prod_price, prod_name
FROM Products
ORDER BY 2, 3;

GROUP BY

GROUP BY 子句将记录分组到汇总行中,GROUP BY 为每个组返回一个记录。

GROUP BY 要点:

  • GROUP BY 子句可以包含任意数目的列,因而可以对分组进行嵌套,更细致地进行数据分组。
  • 如果在 GROUP BY 子句中嵌套了分组,数据将在最后指定的分组上进行汇总。换句话说,在建立分组时,指定的所有列都一起计算(所以不能从个别的列取回数据)。
  • GROUP BY 子句中列出的每一列都必须是检索列或有效的表达式(但不能是聚集函数)。如果在 SELECT 中使用表达式,则必须在 GROUP BY 子句中指定相同的表达式。不能使用别名。
  • 大多数 SQL 实现不允许 GROUP BY 列带有长度可变的数据类型(如文本或备注型字段)。
  • 除聚集计算语句外,SELECT 语句中的每一列都必须在 GROUP BY 子句中给出。
  • 如果分组列中包含具有 NULL 值的行,则 NULL 将作为一个分组返回。如果列中有多行 NULL 值,它们将分为一组。
  • GROUP BY 子句必须出现在 WHERE 子句之后,ORDER BY 子句之前。

分组示例:

1
2
SELECT cust_name, COUNT(cust_address) AS addr_num
FROM Customers GROUP BY cust_name;

分组后排序示例:

1
2
3
SELECT cust_name, COUNT(cust_address) AS addr_num
FROM Customers GROUP BY cust_name
ORDER BY cust_name DESC;

HAVING

HAVING 用于对汇总的 GROUP BY 结果进行过滤。HAVING 要求存在一个 GROUP BY 子句。

WHEREHAVING 可以在相同的查询中。

HAVING vs WHERE

  • HAVING 非常类似于 WHEREWHEREHAVING 都是用于过滤。
  • WHERE 过滤行,而 HAVING 过滤分组。

使用 WHEREHAVING 过滤数据示例:

过滤两个以上订单的分组

1
2
3
4
SELECT cust_id, COUNT(*) AS orders
FROM Orders
GROUP BY cust_id
HAVING COUNT(*) >= 2;

列出具有两个以上产品且其价格大于等于 4 的供应商:

1
2
3
4
5
SELECT vend_id, COUNT(*) AS num_prods
FROM Products
WHERE prod_price >= 4
GROUP BY vend_id
HAVING COUNT(*) >= 2;

检索包含三个或更多物品的订单号和订购物品的数目:

1
2
3
4
SELECT order_num, COUNT(*) AS items
FROM orderitems
GROUP BY order_num
HAVING COUNT(*) >= 3;

要按订购物品的数目排序输出,需要添加 ORDER BY 子句

1
2
3
4
5
SELECT order_num, COUNT(*) AS items
FROM orderitems
GROUP BY order_num
HAVING COUNT(*) >= 3
ORDER BY items, order_num;

函数

🔔 注意:不同数据库的函数往往各不相同,因此不可移植。本节主要以 Mysql 的函数为例。

字符串函数

函数 说明
CONCAT() 合并字符串
LEFT()RIGHT() 左边或者右边的字符
LOWER()UPPER() 转换为小写或者大写
LTRIM()RTIM() 去除左边或者右边的空格
LENGTH() 长度
SOUNDEX() 转换为语音值

其中, SOUNDEX() 可以将一个字符串转换为描述其语音表示的字母数字模式。

以下为部分字符串函数的使用示例

拼接字符串值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- Access 和 SQL Server
SELECT vend_name + ' (' + vend_country + ')'
FROM Vendors
ORDER BY vend_name;

-- DB2、Oracle、PostgreSQL、SQLite 和 Open Office Base
SELECT vend_name || ' (' || vend_country || ')'
FROM Vendors
ORDER BY vend_name;

-- MySQL 或 MariaDB
SELECT Concat(vend_name, ' (', vend_country, ')')
FROM Vendors
ORDER BY vend_name;

去除字符串中的空格:

1
2
3
4
5
6
7
8
9
-- Access 和 SQL Server
SELECT RTRIM(vend_name) + ' (' + RTRIM(vend_country) + ')'
FROM Vendors
ORDER BY vend_name;

-- DB2、Oracle、PostgreSQL、SQLite 和 Open Office Base
SELECT RTRIM(vend_name) || ' (' || RTRIM(vend_country) || ')'
FROM Vendors
ORDER BY vend_name;

时间函数

  • 日期格式:YYYY-MM-DD
  • 时间格式:HH:MM:SS
函 数 说 明
ADDDATE() 增加一个日期(天、周等)
ADDTIME() 增加一个时间(时、分等)
CURRENT_DATE() 返回当前日期
CURRENT_TIME() 返回当前时间
DATE() 返回日期时间的日期部分
DATEDIFF() 计算两个日期之差
DATE_ADD() 高度灵活的日期运算函数
DATE_FORMAT() 返回一个格式化的日期或时间串
DAY() 返回一个日期的天数部分
DAYOFWEEK() 对于一个日期,返回对应的星期几
HOUR() 返回一个时间的小时部分
MINUTE() 返回一个时间的分钟部分
MONTH() 返回一个日期的月份部分
NOW() 返回当前日期和时间
SECOND() 返回一个时间的秒部分
TIME() 返回一个日期时间的时间部分
YEAR() 返回一个日期的年份部分

部分日期和时间处理函数使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- SQL Server
SELECT order_num
FROM Orders
WHERE DATEPART(yy, order_date) = 2012;

-- Access
SELECT order_num
FROM Orders
WHERE DATEPART('yyyy', order_date) = 2012;

-- PostgreSQL
SELECT order_num
FROM Orders
WHERE DATE_PART('year', order_date) = 2012;

-- Oracle
SELECT order_num
FROM Orders
WHERE to_number(to_char(order_date, 'YYYY')) = 2012;

-- MySQL 和 MariaDB
SELECT order_num
FROM Orders
WHERE YEAR(order_date) = 2012;

数学函数

常见 Mysql 数学函数:

函数 说明
ABS() 返回一个数的绝对值
COS() 返回一个角度的余弦
EXP() 返回一个数的指数值
PI() 返回圆周率
SIN() 返回一个角度的正弦
SQRT() 返回一个数的平方根
TAN() 返回一个角度的正切

聚合函数

函 数 说 明
AVG() 返回某列的平均值
COUNT() 返回某列的行数
MAX() 返回某列的最大值
MIN() 返回某列的最小值
SUM() 返回某列值之和

AVG() 通过对表中行数计数并计算其列值之和,求得该列的平均值。

使用 DISTINCT 可以让汇总函数值汇总不同的值。

::: tabs#聚合函数示例

@tab AVG() 示例

使用 AVG() 返回 Products 表中所有产品的平均价格:

1
2
SELECT AVG(prod_price) AS avg_price
FROM Products;

@tab COUNT() 示例

COUNT() 函数进行计数。可利用 COUNT() 确定表中行的数目或符合特定条件的行的数目。

返回 Customers 表中顾客的总数:

1
2
SELECT COUNT(*) AS num_cust
FROM Customers;

只对具有电子邮件地址的客户计数:

1
2
SELECT COUNT(cust_email) AS num_cust
FROM Customers;

@tab MAX() 示例

返回 Products 表中最贵物品的价格:

1
2
SELECT MAX(prod_price) AS max_price
FROM Products;

@tab MIN() 示例

返回 Products 表中最便宜物品的价格

1
2
SELECT MIN(prod_price) AS min_price
FROM Products;

@tab SUM() 示例

返回订单中所有物品数量之和

1
2
3
SELECT SUM(quantity) AS items_ordered
FROM OrderItems
WHERE order_num = 20005;

:::

转换函数

函 数 说 明 示例
CAST() 转换数据类型 SELECT CAST("2017-08-29" AS DATE); -> 2017-08-29

事务

不能回退 SELECT 语句,回退 SELECT 语句也没意义;也不能回退 CREATEDROP 语句。

MySQL 默认采用隐式提交策略(autocommit,每执行一条语句就把这条语句当成一个事务然后进行提交。当出现 START TRANSACTION 语句时,会关闭隐式提交;当 COMMITROLLBACK 语句执行后,事务会自动关闭,重新恢复隐式提交。

通过 set autocommit=0 可以取消自动提交,直到 set autocommit=1 才会提交;autocommit 标记是针对每个连接而不是针对服务器的。

事务处理指令:

  • START TRANSACTION - 指令用于标记事务的起始点。
  • SAVEPOINT - 指令用于创建保留点。
  • ROLLBACK TO - 指令用于回滚到指定的保留点;如果没有设置保留点,则回退到 START TRANSACTION 语句处。
  • COMMIT - 提交事务。
  • RELEASE SAVEPOINT:删除某个保存点。
  • SET TRANSACTION:设置事务的隔离级别。

事务处理示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 开始事务
START TRANSACTION;

-- 插入操作 A
INSERT INTO `user`
VALUES (1, 'root1', 'root1', 'xxxx@163.com');

-- 创建保留点 updateA
SAVEPOINT updateA;

-- 插入操作 B
INSERT INTO `user`
VALUES (2, 'root2', 'root2', 'xxxx@163.com');

-- 回滚到保留点 updateA
ROLLBACK TO updateA;

-- 提交事务,只有操作 A 生效
COMMIT;

(以下为 DCL 语句用法)

权限控制

GRANTREVOKE 可在几个层次上控制访问权限:

  • 整个服务器,使用 GRANT ALLREVOKE ALL
  • 整个数据库,使用 ON database.*;
  • 特定的表,使用 ON database.table;
  • 特定的列;
  • 特定的存储过程。

新创建的账户没有任何权限。

账户用 username@host 的形式定义,username@% 使用的是默认主机名。

MySQL 的账户信息保存在 mysql 这个数据库中。

1
2
USE mysql;
SELECT user FROM user;

创建账户

1
CREATE USER myuser IDENTIFIED BY 'mypassword';

修改账户名

1
2
UPDATE user SET user='newuser' WHERE user='myuser';
FLUSH PRIVILEGES;

删除账户

1
DROP USER myuser;

查看权限

1
SHOW GRANTS FOR myuser;

授予权限

1
GRANT SELECT, INSERT ON *.* TO myuser;

删除权限

1
REVOKE SELECT, INSERT ON *.* FROM myuser;

更改密码

1
SET PASSWORD FOR myuser = 'mypass';

存储过程

存储过程的英文是 Stored Procedure。它可以视为一组 SQL 语句的批处理。一旦存储过程被创建出来,使用它就像使用函数一样简单,我们直接通过调用存储过程名即可。

定义存储过程的语法格式:

1
2
3
4
CREATE PROCEDURE 存储过程名称 ([参数列表])
BEGIN
需要执行的语句
END

存储过程定义语句类型:

  • CREATE PROCEDURE 用于创建存储过程
  • DROP PROCEDURE 用于删除存储过程
  • ALTER PROCEDURE 用于修改存储过程

使用存储过程

创建存储过程的要点:

  • DELIMITER 用于定义语句的结束符
  • 存储过程的 3 种参数类型:
    • IN:存储过程的入参
    • OUT:存储过程的出参
    • INPUT:既是存储过程的入参,也是存储过程的出参
  • 流控制语句:
    • BEGIN…ENDBEGIN…END 中间包含了多个语句,每个语句都以(;)号为结束符。
    • DECLAREDECLARE 用来声明变量,使用的位置在于 BEGIN…END 语句中间,而且需要在其他语句使用之前进行变量的声明。
    • SET:赋值语句,用于对变量进行赋值。
    • SELECT…INTO:把从数据表中查询的结果存放到变量中,也就是为变量赋值。每次只能给一个变量赋值,不支持集合的操作。
    • IF…THEN…ENDIF:条件判断语句,可以在 IF…THEN…ENDIF 中使用 ELSEELSEIF 来进行条件判断。
    • CASECASE 语句用于多条件的分支判断。

创建存储过程示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DROP PROCEDURE IF EXISTS `proc_adder`;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` PROCEDURE `proc_adder`(IN a int, IN b int, OUT sum int)
BEGIN
DECLARE c int;
if a is null then set a = 0;
end if;

if b is null then set b = 0;
end if;

set sum = a + b;
END
;;
DELIMITER ;

使用存储过程示例:

1
2
3
set @b=5;
call proc_adder(2,@b,@s);
select @s as sum;

存储过程的利弊

存储过程的优点:

  • 执行效率高:一次编译多次使用。
  • 安全性强:在设定存储过程的时候可以设置对用户的使用权限,这样就和视图一样具有较强的安全性。
  • 可复用:将代码封装,可以提高代码复用。
  • 性能好
    • 由于是预先编译,因此具有很高的性能。
    • 一个存储过程替代大量 T_SQL 语句 ,可以降低网络通信量,提高通信速率。

存储过程的缺点:

  • 可移植性差:存储过程不能跨数据库移植。由于不同数据库的存储过程语法几乎都不一样,十分难以维护(不通用)。
  • 调试困难:只有少数 DBMS 支持存储过程的调试。对于复杂的存储过程来说,开发和维护都不容易。
  • 版本管理困难:比如数据表索引发生变化了,可能会导致存储过程失效。我们在开发软件的时候往往需要进行版本管理,但是存储过程本身没有版本控制,版本迭代更新的时候很麻烦。
  • 不适合高并发的场景:高并发的场景需要减少数据库的压力,有时数据库会采用分库分表的方式,而且对可扩展性要求很高,在这种情况下,存储过程会变得难以维护,增加数据库的压力,显然就不适用了。

_综上,存储过程的优缺点都非常突出,是否使用一定要慎重,需要根据具体应用场景来权衡_。

触发器

触发器是特殊的存储过程,它在特定的数据库活动发生时自动执行。触发器可以与特定表上的 INSERT、UPDATE 和 DELETE 操作(或组合)相关联。

触发器是一种与表操作有关的数据库对象,当触发器所在表上出现指定事件时,将调用该对象,即表的操作事件触发表上的触发器的执行。

触发器的一些常见用途

  • 保证数据一致。例如,在 INSERT 或 UPDATE 操作中将所有州名转换为大写。
  • 基于某个表的变动在其他表上执行活动。例如,每当更新或删除一行时将审计跟踪记录写入某个日志表。
  • 进行额外的验证并根据需要回退数据。例如,保证某个顾客的可用资金不超限定,如果已经超出,则阻塞插入。
  • 计算计算列的值或更新时间戳。

触发器特性

可以使用触发器来进行审计跟踪,把修改记录到另外一张表中。

MySQL 不允许在触发器中使用 CALL 语句 ,也就是不能调用存储过程。

BEGINEND

当触发器的触发条件满足时,将会执行 BEGINEND 之间的触发器执行动作。

🔔 注意:在 MySQL 中,分号 ; 是语句结束的标识符,遇到分号表示该段语句已经结束,MySQL 可以开始执行了。因此,解释器遇到触发器执行动作中的分号后就开始执行,然后会报错,因为没有找到和 BEGIN 匹配的 END。

这时就会用到 DELIMITER 命令(DELIMITER 是定界符,分隔符的意思)。它是一条命令,不需要语句结束标识,语法为:DELIMITER new_delemiternew_delemiter 可以设为 1 个或多个长度的符号,默认的是分号 ;,我们可以把它修改为其他符号,如 $ - DELIMITER $ 。在这之后的语句,以分号结束,解释器不会有什么反应,只有遇到了 $,才认为是语句结束。注意,使用完之后,我们还应该记得把它给修改回来。

NEWOLD

  • MySQL 中定义了 NEWOLD 关键字,用来表示触发器的所在表中,触发了触发器的那一行数据。
  • INSERT 型触发器中,NEW 用来表示将要(BEFORE)或已经(AFTER)插入的新数据;
  • UPDATE 型触发器中,OLD 用来表示将要或已经被修改的原数据,NEW 用来表示将要或已经修改为的新数据;
  • DELETE 型触发器中,OLD 用来表示将要或已经被删除的原数据;
  • 使用方法: NEW.columnName (columnName 为相应数据表某一列名)

触发器指令

提示:为了理解触发器的要点,有必要先了解一下创建触发器的指令。

CREATE TRIGGER 指令用于创建触发器。

语法:

1
2
3
4
5
6
7
8
CREATE TRIGGER trigger_name
trigger_time
trigger_event
ON table_name
FOR EACH ROW
BEGIN
trigger_statements
END;

说明:

  • trigger_name:触发器名
  • trigger_time: 触发器的触发时机。取值为 BEFOREAFTER
  • trigger_event: 触发器的监听事件。取值为 INSERTUPDATEDELETE
  • table_name: 触发器的监听目标。指定在哪张表上建立触发器。
  • FOR EACH ROW: 行级监视,Mysql 固定写法,其他 DBMS 不同。
  • trigger_statements: 触发器执行动作。是一条或多条 SQL 语句的列表,列表内的每条语句都必须用分号 ; 来结尾。

创建触发器示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- SQL Server
CREATE TRIGGER customer_state
ON Customers
FOR INSERT, UPDATE
AS
UPDATE Customers
SET cust_state = Upper(cust_state)
WHERE Customers.cust_id = inserted.cust_id;

-- Oracle 和 PostgreSQL
CREATE TRIGGER customer_state
AFTER INSERT OR UPDATE
FOR EACH ROW
BEGIN
UPDATE Customers
SET cust_state = Upper(cust_state)
WHERE Customers.cust_id = :OLD.cust_id
END;

查看触发器示例:

1
SHOW TRIGGERS;

删除触发器示例:

1
DROP TRIGGER IF EXISTS trigger_insert_user;

游标

游标(CURSOR)是一个存储在 DBMS 服务器上的数据库查询,它不是一条 SELECT 语句,而是被该语句检索出来的结果集。在存储过程中使用游标可以对一个结果集进行移动遍历。

游标主要用于交互式应用,其中用户需要对数据集中的任意行进行浏览和修改。

游标要点

  • 能够标记游标为只读,使数据能读取,但不能更新和删除。
  • 能控制可以执行的定向操作(向前、向后、第一、最后、绝对位置、相对位置等)。
  • 能标记某些列为可编辑的,某些列为不可编辑的。
  • 规定范围,使游标对创建它的特定请求(如存储过程)或对所有请求可访问。
  • 指示 DBMS 对检索出的数据(而不是指出表中活动数据)进行复制,使数据在游标打开和访问期间不变化。

使用游标的步骤:

  1. 定义游标:通过 DECLARE cursor_name CURSOR FOR <语句> 定义游标。这个过程没有实际检索出数据。
  2. 打开游标:通过 OPEN cursor_name 打开游标。
  3. 取出数据:通过 FETCH cursor_name INTO var_name ... 获取数据。
  4. 关闭游标:通过 CLOSE cursor_name 关闭游标。
  5. 释放游标:通过 DEALLOCATE PREPARE 释放游标。

游标使用示例:

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
DELIMITER $
CREATE PROCEDURE getTotal()
BEGIN
DECLARE total INT;
-- 创建接收游标数据的变量
DECLARE sid INT;
DECLARE sname VARCHAR(10);
-- 创建总数变量
DECLARE sage INT;
-- 创建结束标志变量
DECLARE done INT DEFAULT false;
-- 创建游标
DECLARE cur CURSOR FOR SELECT id,name,age from cursor_table where age>30;
-- 指定游标循环结束时的返回值
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true;
SET total = 0;
-- 打开游标
OPEN cur;
FETCH cur INTO sid, sname, sage;
WHILE(NOT done)
DO
SET total = total + 1;
FETCH cur INTO sid, sname, sage;
END WHILE;
-- 关闭游标
CLOSE cur;
SELECT total;
END $
DELIMITER ;

-- 调用存储过程
call getTotal();

参考资料

红黑树

平衡二叉树

平衡二叉树的严格定义是这样的:二叉树中任意一个节点的左右子树的高度相差不能大于 1。

完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。

img

平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些

什么是红黑树

红黑树的英文是“Red-Black Tree”,简称 R-B Tree。它是一种不严格的平衡二叉查找树。

红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:

  • 根节点是黑色的;
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;

img

为什么说红黑树是“近似平衡”的?

平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。

所以,“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化的太严重

如果我们将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树的高度是多少呢

红色节点删除之后,有些节点就没有父节点了,它们会直接拿这些节点的祖父节点(父节点的父节点)作为父节点。所以,之前的二叉树就变成了四叉树。

img

前面红黑树的定义里有这么一条:从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点。我们从四叉树中取出某些节点,放到叶节点位置,四叉树就变成了完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度还要小。

现在把红色节点加回去,高度会变成多少呢

在红黑树中,红色节点不能相邻,也就是说,有一个红色节点就要至少有一个黑色节点,将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过 log2n,所以加入红色节点之后,最长路径不会超过 2log2n,也就是说,红黑树的高度近似 2log2n。

所以,红黑树的高度只比高度平衡的 AVL 树的高度(log2n)仅仅大了一倍,在性能上,下降得并不多。这样推导出来的结果不够精确,实际上红黑树的性能更好。

为什么需要红黑树

AVL 树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高了。

红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。

所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。

红黑树平衡调整

插入操作的平衡调整

红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上

  • 如果插入节点的父节点是黑色的,那我们什么都不用做,它仍然满足红黑树的定义。
  • 如果插入的节点是根节点,那我们直接改变它的颜色,把它变成黑色就可以了。

除此之外,其他情况都会违背红黑树的定义,于是我们就需要进行调整,调整的过程包含两种基础的操作:左右旋转改变颜色

红黑树的平衡调整过程是一个迭代的过程。我们把正在处理的节点叫作关注节点。关注节点会随着不停地迭代处理,而不断发生变化。最开始的关注节点就是新插入的节点。

新节点插入之后,如果红黑树的平衡被打破,那一般会有下面三种情况。我们只需要根据每种情况的特点,不停地调整,就可以让红黑树继续符合定义,也就是继续保持平衡。

CASE 1:如果关注节点是 a,它的叔叔节点 d 是红色,我们就依次执行下面的操作:

  • 将关注节点 a 的父节点 b、叔叔节点 d 的颜色都设置成黑色;
  • 将关注节点 a 的祖父节点 c 的颜色设置成红色;
  • 关注节点变成 a 的祖父节点 c;
  • 跳到 CASE 2 或者 CASE 3。

img

CASE 2:如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的右子节点,我们就依次执行下面的操作:

  • 关注节点变成节点 a 的父节点 b;
  • 围绕新的关注节点 b 左旋;
  • 跳到 CASE 3。

img

CASE 3:如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的左子节点,我们就依次执行下面的操作:

  • 围绕关注节点 a 的祖父节点 c 右旋;
  • 将关注节点 a 的父节点 b、兄弟节点 c 的颜色互换。
  • 调整结束。

img

删除操作的平衡调整

针对删除节点初步调整

CASE 1:如果要删除的节点是 a,它只有一个子节点 b,那我们就依次进行下面的操作:

  • 删除节点 a,并且把节点 b 替换到节点 a 的位置,这一部分操作跟普通的二叉查找树的删除操作一样;
  • 节点 a 只能是黑色,节点 b 也只能是红色,其他情况均不符合红黑树的定义。这种情况下,我们把节点 b 改为黑色;
  • 调整结束,不需要进行二次调整。

img

CASE 2:如果要删除的节点 a 有两个非空子节点,并且它的后继节点就是节点 a 的右子节点 c。我们就依次进行下面的操作:

  • 如果节点 a 的后继节点就是右子节点 c,那右子节点 c 肯定没有左子树。我们把节点 a 删除,并且将节点 c 替换到节点 a 的位置。这一部分操作跟普通的二叉查找树的删除操作无异;
  • 然后把节点 c 的颜色设置为跟节点 a 相同的颜色;
  • 如果节点 c 是黑色,为了不违反红黑树的最后一条定义,我们给节点 c 的右子节点 d 多加一个黑色,这个时候节点 d 就成了“红 - 黑”或者“黑 - 黑”;
  • 这个时候,关注节点变成了节点 d,第二步的调整操作就会针对关注节点来做。

CASE 3:如果要删除的是节点 a,它有两个非空子节点,并且节点 a 的后继节点不是右子节点,我们就依次进行下面的操作:

  • 找到后继节点 d,并将它删除,删除后继节点 d 的过程参照 CASE 1;
  • 将节点 a 替换成后继节点 d;
  • 把节点 d 的颜色设置为跟节点 a 相同的颜色;
  • 如果节点 d 是黑色,为了不违反红黑树的最后一条定义,我们给节点 d 的右子节点 c 多加一个黑色,这个时候节点 c 就成了“红 - 黑”或者“黑 - 黑”;
  • 这个时候,关注节点变成了节点 c,第二步的调整操作就会针对关注节点来做。

针对关注节点进行二次调整

CASE 1:如果关注节点是 a,它的兄弟节点 c 是红色的,我们就依次进行下面的操作:

  • 围绕关注节点 a 的父节点 b 左旋;
  • 关注节点 a 的父节点 b 和祖父节点 c 交换颜色;
  • 关注节点不变;
  • 继续从四种情况中选择适合的规则来调整。

CASE 2:如果关注节点是 a,它的兄弟节点 c 是黑色的,并且节点 c 的左右子节点 d、e 都是黑色的,我们就依次进行下面的操作:

  • 将关注节点 a 的兄弟节点 c 的颜色变成红色;
  • 从关注节点 a 中去掉一个黑色,这个时候节点 a 就是单纯的红色或者黑色;
  • 给关注节点 a 的父节点 b 添加一个黑色,这个时候节点 b 就变成了“红 - 黑”或者“黑 - 黑”;
  • 关注节点从 a 变成其父节点 b;
  • 继续从四种情况中选择符合的规则来调整。

CASE 3:如果关注节点是 a,它的兄弟节点 c 是黑色,c 的左子节点 d 是红色,c 的右子节点 e 是黑色,我们就依次进行下面的操作:

  • 围绕关注节点 a 的兄弟节点 c 右旋;
  • 节点 c 和节点 d 交换颜色;
  • 关注节点不变;
  • 跳转到 CASE 4,继续调整。

CASE 4:如果关注节点 a 的兄弟节点 c 是黑色的,并且 c 的右子节点是红色的,我们就依次进行下面的操作:

  • 围绕关注节点 a 的父节点 b 左旋;
  • 将关注节点 a 的兄弟节点 c 的颜色,跟关注节点 a 的父节点 b 设置成相同的颜色;
  • 将关注节点 a 的父节点 b 的颜色设置为黑色;
  • 从关注节点 a 中去掉一个黑色,节点 a 就变成了单纯的红色或者黑色;
  • 将关注节点 a 的叔叔节点 e 设置为黑色;
  • 调整结束。

参考资料

Samba 应用

samba 是在 Linux 和 UNIX 系统上实现 SMB 协议的一个免费软件。

samba 提供了在不同计算机(即使操作系统不同)上共享服务的能力。

关键词:samba, selinux

安装配置 samba

本文将以一个完整的示例来展示如何配置 samba 来实现 Linux 和 Windows 的文件共享。

目标:假设希望共享 Linux 服务器上的 /share/fs 目录。

查看是否已经安装 samba

  • CentOS:rpm -qa | grep samba
  • Ubuntu:dpkg -l | grep samba

安装 samba 工具

  • CentOS:yum install -y samba samba-client samba-common
  • Ubuntu:sudo apt-get install -y samba samba-client

配置 samba

samba 服务的配置文件是 /etc/samba/smb.conf,如果没有则 samba 无法启动。

执行以下命令,编辑配置文件:

1
vim /etc/samba/smb.conf

修改配置如下:

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
[global]
workgroup = SAMBA
security = user

passdb backend = tdbsam

printing = cups
printcap name = cups
load printers = yes
cups options = raw

[homes]
comment = Home Directories
valid users = %S, %D%w%S
browseable = No
read only = No
inherit acls = Yes

[printers]
comment = All Printers
path = /var/tmp
printable = Yes
create mask = 0600
browseable = No

[print$]
comment = Printer Drivers
path = /var/lib/samba/drivers
write list = @printadmin root
force group = @printadmin
create mask = 0664
directory mask = 0775

[fs]
comment = share folder
path = /share/fs
browseable = yes
writable = yes
read only = no
guest ok = yes
create mask = 0777
directory mask = 0777
public = yes
valid users = root

说明:

  • 我在这里添加了一个 [fs] 标签,这就是共享区域的配置。
  • 这里设置 path 属性为 /share/fs,意味着准备共享 /share/fs 目录,需要根据实际需要设置路径。/share/fs 目录的权限要设置为 777chmod 777 /share/fs
  • browseablewritable 等属性就比较容易理解了,即配置共享目录的访问权限。
  • valid users 属性指定允许访问的用户,需要注意的是指定的用户必须是 Linux 机器上实际存在的用户。

创建 samba 用户

创建的 samba 用户必须是 Linux 机器上实际存在的用户。

1
2
3
4
$ sudo smbpasswd -a root
New SMB password:
Retype new SMB password:
Added user root.

根据提示输入 samba 用户的密码。当 samba 服务成功安装、启动后,通过 Windows 系统访问机器共享目录时,就要输入这里配置的用户名、密码。

  • 查看 samba 服务器中已拥有哪些用户 - pdbedit -L
  • 删除 samba 服务中的某个用户 - smbpasswd -x 用户名

启动 samba 服务

CentOS 6

1
2
$ sudo service samba restart  # 重启 samba
$ sudo service smb restart # 重启 samba

CentOS 7

1
2
3
4
$ sudo systemctl start smb.service     # 启动 samba
$ sudo systemctl restart smb.service # 重启 samba
$ sudo systemctl enable smb.service # 设置开机自动启动
$ sudo systemctl status smb.service # 查询 samba 状态

Ubuntu 16.04.3

1
$ sudo service smbd restart

为 samba 添加防火墙规则

1
2
$ sudo firewall-cmd --permanent --zone=public --add-service=samba
$ sudo firewall-cmd --reload

测试 samba 服务

1
$ smbclient //localhost/fs -U root

输入 samba 用户的密码,如果成功,就会进入 smb: \>

访问 samba 服务共享的目录

Windows:

访问:\\<你的ip>\<你的共享路径>

img

Mac:

与 Windows 类似,直接在 Finder 中访问 smb://<你的ip>/<你的共享路径> 即可。

配置详解

samba 默认配置

你可以从 这里 获取到默认配置文件:

1
2
$ cp /etc/samba/smb.conf /etc/samba/smb.conf.bak
$ wget "https://git.samba.org/samba.git/?p=samba.git;a=blob_plain;f=examples/smb.conf.default;hb=HEAD" -O /etc/samba/smb.conf

smb.conf 默认内容如下:

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
[global]
workgroup = SAMBA
security = user

passdb backend = tdbsam

printing = cups
printcap name = cups
load printers = yes
cups options = raw

[homes]
comment = Home Directories
valid users = %S, %D%w%S
browseable = No
read only = No
inherit acls = Yes

[printers]
comment = All Printers
path = /var/tmp
printable = Yes
create mask = 0600
browseable = No

[print$]
comment = Printer Drivers
path = /var/lib/samba/drivers
write list = root
create mask = 0664
directory mask = 0775

全局参数 [global]

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
[global]

config file = /usr/local/samba/lib/smb.conf.%m
说明:config file可以让你使用另一个配置文件来覆盖缺省的配置文件。如果文件 不存在,则该项无效。这个参数很有用,可以使得samba配置更灵活,可以让一台samba服务器模拟多台不同配置的服务器。比如,你想让PC1(主机名)这台电脑在访问Samba Server时使用它自己的配置文件,那么先在/etc/samba/host/下为PC1配置一个名为smb.conf.pc1的文件,然后在smb.conf中加入:config file=/etc/samba/host/smb.conf.%m。这样当PC1请求连接Samba Server时,smb.conf.%m就被替换成smb.conf.pc1。这样,对于PC1来说,它所使用的Samba服务就是由smb.conf.pc1定义的,而其他机器访问Samba Server则还是应用smb.conf。

workgroup = WORKGROUP
说明:设定 Samba Server 所要加入的工作组或者域。

server string = Samba Server Version %v
说明:设定 Samba Server 的注释,可以是任何字符串,也可以不填。宏%v表示显示Samba的版本号。

netbios name = smbserver
说明:设置Samba Server的NetBIOS名称。如果不填,则默认会使用该服务器的DNS名称的第一部分。netbios name和workgroup名字不要设置成一样了。

interfaces = lo eth0 192.168.12.2/24 192.168.13.2/24
说明:设置Samba Server监听哪些网卡,可以写网卡名,也可以写该网卡的IP地址。

hosts allow = 127.192.168.1 192.168.10.1
说明:表示允许连接到Samba Server的客户端,多个参数以空格隔开。可以用一个IP表示,也可以用一个网段表示。hosts deny 与hosts allow 刚好相反。
例如:
# 表示容许来自172.17.2.*.*的主机连接,但排除172.17.2.50
hosts allow=172.17.2.EXCEPT172.17.2.50
# 表示容许来自172.17.2.0/255.255.0.0子网中的所有主机连接
hosts allow=172.17.2.0/255.255.0.0
# 表示容许来自M1和M2两台计算机连接
hosts allow=M1,M2
# 表示容许来自SC域的所有计算机连接
hosts allow=@SC
max connections = 0
说明:max connections用来指定连接Samba Server的最大连接数目。如果超出连接数目,则新的连接请求将被拒绝。0表示不限制。

deadtime = 0
说明:deadtime用来设置断掉一个没有打开任何文件的连接的时间。单位是分钟,0代表Samba Server不自动切断任何连接。

time server = yes/no
说明:time server用来设置让nmdb成为windows客户端的时间服务器。

log file = /var/log/samba/log.%m
说明:设置Samba Server日志文件的存储位置以及日志文件名称。在文件名后加个宏%m(主机名),表示对每台访问Samba Server的机器都单独记录一个日志文件。如果pc1、pc2访问过Samba Server,就会在/var/log/samba目录下留下log.pc1和log.pc2两个日志文件。

max log size = 50
说明:设置Samba Server日志文件的最大容量,单位为kB,0代表不限制。

security = user
说明:设置用户访问Samba Server的验证方式,一共有四种验证方式。
1. share:用户访问Samba Server不需要提供用户名和口令, 安全性能较低。
2. user:Samba Server共享目录只能被授权的用户访问,由Samba Server负责检查账号和密码的正确性。账号和密码要在本Samba Server中建立。
3. server:依靠其他Windows NT/2000或Samba Server来验证用户的账号和密码,是一种代理验证。此种安全模式下,系统管理员可以把所有的Windows用户和口令集中到一个NT系统上,使用Windows NT进行Samba认证, 远程服务器可以自动认证全部用户和口令,如果认证失败,Samba将使用用户级安全模式作为替代的方式。
4. domain:域安全级别,使用主域控制器(PDC)来完成认证。

passdb backend = tdbsam
说明:passdb backend就是用户后台的意思。目前有三种后台:smbpasswd、tdbsam和ldapsam。sam应该是security account manager(安全账户管理)的简写。

smbpasswd:该方式是使用smb自己的工具smbpasswd来给系统用户(真实
用户或者虚拟用户)设置一个Samba密码,客户端就用这个密码来访问Samba的资源。
1. smbpasswd文件默认在/etc/samba目录下,不过有时候要手工建立该文件。
2. tdbsam:该方式则是使用一个数据库文件来建立用户数据库。数据库文件叫passdb.tdb,默认在/etc/samba目录下。passdb.tdb用户数据库可以使用smbpasswd –a来建立Samba用户,不过要建立的Samba用户必须先是系统用户。我们也可以使用pdbedit命令来建立Samba账户。pdbedit命令的参数很多,我们列出几个主要的。
pdbedit –a username:新建Samba账户。
pdbedit –x username:删除Samba账户。
pdbedit –L:列出Samba用户列表,读取passdb.tdb数据库文件。
pdbedit –Lv:列出Samba用户列表的详细信息。
pdbedit –c “[D]” –u username:暂停该Samba用户的账号。
pdbedit –c “[]” –u username:恢复该Samba用户的账号。
3. ldapsam:该方式则是基于LDAP的账户管理方式来验证用户。首先要建立LDAP服务,然后设置“passdb backend = ldapsam:ldap://LDAP Server”

encrypt passwords = yes/no
说明:是否将认证密码加密。因为现在windows操作系统都是使用加密密码,所以一般要开启此项。不过配置文件默认已开启。

smb passwd file = /etc/samba/smbpasswd
说明:用来定义samba用户的密码文件。smbpasswd文件如果没有那就要手工新建。

username map = /etc/samba/smbusers
说明:用来定义用户名映射,比如可以将root换成administrator、admin等。不过要事先在smbusers文件中定义好。比如:root = administrator admin,这样就可以用administrator或admin这两个用户来代替root登陆Samba Server,更贴近windows用户的习惯。

guest account = nobody
说明:用来设置guest用户名。

socket options = TCP_NODELAY SO_RCVBUF=8192 SO_SNDBUF=8192
说明:用来设置服务器和客户端之间会话的Socket选项,可以优化传输速度。

domain master = yes/no
说明:设置Samba服务器是否要成为网域主浏览器,网域主浏览器可以管理跨子网域的浏览服务。

local master = yes/no
说明:local master用来指定Samba Server是否试图成为本地网域主浏览器。如果设为no,则永远不会成为本地网域主浏览器。但是即使设置为yes,也不等于该Samba Server就能成为主浏览器,还需要参加选举。

preferred master = yes/no
说明:设置Samba Server一开机就强迫进行主浏览器选举,可以提高Samba Server成为本地网域主浏览器的机会。如果该参数指定为yes时,最好把domain master也指定为yes。使用该参数时要注意:如果在本Samba Server所在的子网有其他的机器(不论是windows NT还是其他Samba Server)也指定为首要主浏览器时,那么这些机器将会因为争夺主浏览器而在网络上大发广播,影响网络性能。如果同一个区域内有多台Samba Server,将上面三个参数设定在一台即可。

os level = 200
说明:设置samba服务器的os level。该参数决定Samba Server是否有机会成为本地网域的主浏览器。os level从0到255,winNT的os level是32,win95/98的os level是1。Windows 2000的os level是64。如果设置为0,则意味着Samba Server将失去浏览选择。如果想让Samba Server成为PDC,那么将它的os level值设大些。

domain logons = yes/no
说明:设置Samba Server是否要做为本地域控制器。主域控制器和备份域控制器都需要开启此项。

logon . = %u.bat
说明:当使用者用windows客户端登陆,那么Samba将提供一个登陆档。如果设置成%u.bat,那么就要为每个用户提供一个登陆档。如果人比较多,那就比较麻烦。可以设置成一个具体的文件名,比如start.bat,那么用户登陆后都会去执行start.bat,而不用为每个用户设定一个登陆档了。这个文件要放置在[netlogon]的path设置的目录路径下。

wins support = yes/no
说明:设置samba服务器是否提供wins服务。

wins server = wins服务器IP地址
说明:设置Samba Server是否使用别的wins服务器提供wins服务。

wins proxy = yes/no
说明:设置Samba Server是否开启wins代理服务。

dns proxy = yes/no
说明:设置Samba Server是否开启dns代理服务。

load printers = yes/no
说明:设置是否在启动Samba时就共享打印机。

printcap name = cups
说明:设置共享打印机的配置文件。

printing = cups
说明:设置Samba共享打印机的类型。现在支持的打印系统有:bsd, sysv, plp, lprng, aix, hpux, qnx

共享参数 [共享名]

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
[共享名]

comment = 任意字符串
说明:comment是对该共享的描述,可以是任意字符串。

path = 共享目录路径
说明:path用来指定共享目录的路径。可以用%u、%m这样的宏来代替路径里的unix用户和客户机的Netbios名,用宏表示主要用于[homes]共享域。例如:如果我们不打算用home段做为客户的共享,而是在/home/share/下为每个Linux用户以他的用户名建个目录,作为他的共享目录,这样path就可以写成:path = /home/share/%u; 。用户在连接到这共享时具体的路径会被他的用户名代替,要注意这个用户名路径一定要存在,否则,客户机在访问时会找不到网络路径。同样,如果我们不是以用户来划分目录,而是以客户机来划分目录,为网络上每台可以访问samba的机器都各自建个以它的netbios名的路径,作为不同机器的共享资源,就可以这样写:path = /home/share/%m 。

browseable = yes/no
说明:browseable用来指定该共享是否可以浏览。

writable = yes/no
说明:writable用来指定该共享路径是否可写。

available = yes/no
说明:available用来指定该共享资源是否可用。

admin users = 该共享的管理者
说明:admin users用来指定该共享的管理员(对该共享具有完全控制权限)。在samba 3.0中,如果用户验证方式设置成“security=share”时,此项无效。
例如:admin users =bobyuan,jane(多个用户中间用逗号隔开)。

valid users = 允许访问该共享的用户
说明:valid users用来指定允许访问该共享资源的用户。
例如:valid users = bobyuan,@bob,@tech(多个用户或者组中间用逗号隔开,如果要加入一个组就用“@+组名”表示。)

invalid users = 禁止访问该共享的用户
说明:invalid users用来指定不允许访问该共享资源的用户。
例如:invalid users = root,@bob(多个用户或者组中间用逗号隔开。)

write list = 允许写入该共享的用户
说明:write list用来指定可以在该共享下写入文件的用户。
例如:write list = bobyuan,@bob

public = yes/no
说明:public用来指定该共享是否允许guest账户访问。

guest ok = yes/no
说明:意义同“public”。

几个特殊共享:
[homes]
comment = Home Directories
browseable = no
writable = yes
valid users = %S
; valid users = MYDOMAIN\%S

[printers]
comment = All Printers
path = /var/spool/samba
browseable = no
guest ok = no
writable = no
printable = yes

[netlogon]
comment = Network Logon Service
path = /var/lib/samba/netlogon
guest ok = yes
writable = no
share modes = no

[Profiles]
path = /var/lib/samba/profiles
browseable = no
guest ok = yes

常见问题

你可能没有权限访问网络资源

问题现象:

  • 出现 NT_STATUS_ACCESS_DENIED 错误
  • Windows 下成功登陆 samba 后,点击共享目录仍然提示——你可能没有权限访问网络资源。

解决步骤:

  1. 检查是否配置了防火墙规则
1
2
3
4
5
6
# 一种方法是强行关闭防火墙
$ sudo service iptables stop

# 另一种方法是配置防火墙规则
$ sudo firewall-cmd --permanent --zone=public --add-service=samba
$ sudo firewall-cmd --reload
  1. 关闭 selinux
1
2
3
4
5
# 将 /etc/selinux/config 文件中的 SELINUX 设为 disabled
$ sed -i 's/SELINUX=enforcing/SELINUX=disabled/' /etc/selinux/config

# 重启生效
$ reboot

window 下对 samba 的清理操作

  1. windows 清除访问 samba 局域网密码缓存
    • 在 dos 窗口中输入 control userpasswords2 或者 control keymgr.dll,然后【高级】/【密码管理】,删掉保存的该机器密码。
  2. windows 清除连接的 linux 的 samba 服务缓存
    1. 打开 win 的命令行。
    2. 输入 net use,就会打印出当前缓存的连接上列表。
    3. 根据列表,一个个删除连接: net use 远程连接名称 /del;或者一次性全部删除:net use * /del

参考资料

如何学习编程语言

前言

很多人喜欢争论什么什么编程语言好,我认为这个话题如果不限定应用范围,就毫无意义。

每种编程语言必然有其优点和缺点,这也决定了它有适合的应用场景和不适合的应用场景。现代软件行业,想一门编程语言包打天下是不现实的。这中现状也造成了一种现象,一个程序员往往要掌握多种编程语言。

学习任何一门编程语言,都会面临的第一个问题都是:如何学习 XX 语言?

我不想说什么多看、多学、多写、多练之类的废话。世上事有难易乎?无他,唯手熟尔。谁不知道熟能生巧的道理?

我觉得有必要谈谈的是:如何由浅入深的学习一门编程语言?学习所有编程语言有没有一个相对统一的学习方法?

曾几何时,当我还是一名小菜鸟时,总是叹服那些大神掌握多门编程语言。后来,在多年编程工作和学习中,我陆陆续续也接触过不少编程语言:C、C++、Java、C#、Javascript、shell 等等。每次学习一门新的编程语言,掌握程度或深或浅,但是学习的曲线却大抵相似。

下面,我按照个人的学习经验总结一下,学习编程语言的基本步骤。

学习编程语言的步骤

基本语法

首先当然是了解语言的最基本语法。

控制台输出,如 C 的 printf,Java 的 System.out.println 等。

普通程序员的第一行代码一般都是输出 “Hello World” 吧。

  • 基本数据类型

    不同编程语言的基本数据类型不同。基本数据类型是的申请内存空间变得方便、规范化。

  • 变量

    不同编程语言的声明变量方式有很大不同。有的如 Java 、C++ 需要明确指定变量数据类型,这种叫强类型定义语言。有的语言(主要是脚本语言),如 Javascript、Shell 等,不需要明确指定数据类型,这种叫弱类型定义语言。

    还需要注意的一点是变量的作用域范围和生命周期。不同语言变量的作用域范围和生命周期不一定一样,这个需要在代码中细细体会,有时会为此埋雷。

  • 逻辑控制语句

    编程语言都会有逻辑控制语句,哪怕是汇编语言。

    掌握条件语句、循环语句、中断循环语句(break、continue)、选择语句。一般区别仅仅在于关键字、语法格式略有不同。

  • 运算符

    掌握基本运算符,如算术运算符、关系运算符、逻辑运算符、赋值运算符等。

    有些语言还提供位运算符、特殊运算符,视情节掌握。

  • 注释(没啥好说的)

  • 函数

    编程语言基本都有函数。注意语法格式:是否支持出参;支持哪些数据作为入参,有些语言允许将函数作为参数传入另一个参数(即回调);返回值;如何退出函数(如 Java、C++的 return,)。

数组、枚举、集合

枚举只有部分编程语言有,如 Java、C++、C#。

但是数组和集合(有些语言叫容器)一般编程语言都有,只是有的编程语言提供的集合比较丰富。使用方法基本类似。

常用类

比较常用的类(当然有些语言中不叫类,叫对象或者其他什么,这个不重要,领会精神)请了解其 API 用法,如:字符串、日期、数学计算等等。

语言特性

语言特性这个特字反映的就是各个编程语言自身的”独特个性”,这涉及的点比较多,简单列举一些。

编程模式

比较流行的编程模式大概有:

面向对象编程,主要是封装、继承、多态;函数式编程,主要是应用 Lambda;过程式编程,可以理解为实现需求功能的特定步骤。

每种编程模式都有一定的道理,我从不认为只有面向对象编程才是王道。

Java 是面向对象语言,从 Java8 开始也支持函数编程(引入 Lambda 表达式);C++ 可以算是半面向对象,半面向过程式语言。

语言自身特性

每个语言自身都有一些重要特性需要了解。例如,学习 C、C++,你必须了解内存的申请和释放,了解指针、引用。而学习 Java,你需要了解 JVM,垃圾回收机制。学习 Javascript,你需要了解 DOM 操作等。

代码组织、模块加载、库管理

一个程序一般都有很多个源代码文件。这就会引入这些问题:如何将代码文件组织起来?如何根据业务需要,选择将部分模块启动时进行加载,部分模块使用懒加载(或者热加载)?

最基本的引用文件就不提了,如 C、C++的#include,Java 的 import 等。

针对代码组织、模块加载、库管理这些问题,不同语言会有不同的解决方案。

如 Java 可以用 maven、gradle 管理项目依赖、组织代码结构;Javascript (包括 Nodejs、jquery、react 等等库)可以用 npm、yarn 管理依赖,用 webpack 等工具管理模块加载。

容错处理

程序总难免会有 bug。

所以为了代码健壮性也好,为了方便定位问题也好,代码中需要有容错处理。常见的手段有:

  • 异常
  • 断言
  • 日志
  • 调试
  • 单元测试

输入输出和文件处理

这块知识比较繁杂。建议提纲挈领的学习一下,理解基本概念,比如输入输出流、管道等等。至于 API,用到的时候再查一下即可。

回调机制

每种语言实现回调的方式有所不同,如 .Net 的 delegate (大量被用于 WinForm 程序);Javascript 中函数天然支持回调:Javascript 函数允许传入另一个函数作为入参,然后在方法中调用它。其它语言的回调方式不一一列举。

序列化和反序列化

首先需要了解的是,序列化和反序列化的作用是为了在不同平台之间传输对象。

其次,要知道序列化存在多种方式,不同编程语言可能有多种方案。根据应用的序列化方式,选择性了解即可。

进阶特性

以下学习内容属于进阶性内容。可以根据开发需要去学习、掌握。需要注意的是,学习这些特性的态度应该是不学则已,学则死磕。因为半懂半不懂,特别容易引入问题。

对于半桶水的同学,我想说:放过自己,也放过别人,活着不好吗?

  • 并发编程:好处多多,十分重要,但是并发代码容易出错,且出错难以定位。要学习还是要花很大力气的,需要了解大量知识,如:进程、线程、同步、异步、读写锁等等。

  • 反射 - 让你可以动态编程(慎用)。

  • 泛型 - 集合(或者叫容器)的基石。精通泛型,能大大提高你的代码效率。

  • 元数据 - 描述数据的数据。Java 中叫做注解。

库和框架

学习一门编程语言,难免需要用到围绕它构建的技术生态圈——库和框架。这方面知识范围太庞大,根据实际应用领域去学习吧。比如搞 JavaWeb,你多多少少肯定要用到 Spring、Mybatis、Hibernate、Shiro 等大量开发框架;如果做 Javascript 前端,你可能会用到 React、Vue、Angular 、jQuery 等库或框架。

小结

总结以上,编程语言学习的道路是任重而道远的,未来是光明的。

最后一句话与君共勉:路漫漫兮其修远,吾将上下而求索。