【书摘】Go Web 编程

复习一下 Go 和 Web 的相关知识。


更新历史

  • 2017.01.23: 完成初稿

Go 的类型系统没有层级。用户不需要在定义类型之间花费时间。Go 是垃圾回收型预研,为并发执行与通信提供了基本的支持。

Go 使用 package 来组织代码。main.main() 函数是每一个独立的可运行程序的入口点。Go 使用 UTF-8 字符串和标志符,所以天生支持多语言。

Go 的 if 允许声明一个变量,这个变量的作用域只能在该条件逻辑块内,如

if x := dosomething(); x > 10 {
    fmt.Println("Oh")
} else {
    fmt.Println("Yeah")
}

Go 里面有两个保留的函数:init 函数(能够应用于所有的 package)和 main 函数(只能应用于 package main)。这两个函数在定义时不能有任何的参数和返回值。虽然一个 package 里面可以写任意多个 init 函数,但这无论是对于可读性还是以后的可维护性来说,都强烈建议用户在一个 package 中每个文件只写一个 init 函数。

引入包的操作有两个需要注意

import (
    f "fmt"
    _ "github.com/wdxtub/wdx"
)

第一种引入方式是把包命名成一个比较好记忆的名字。第二个使用 _ 则是引入该包而不直接使用包里面的函数,主要是为了调用该包里的 init 函数。

如果匿名字段实现了一个方法,那么包含这个匿名字段的 struct 也能调用该方法。

interface 就是一组抽象方法的集合,它必须由其他非 interface 类型实现,而不能自我实现,Go 通过 interface 实现了鸭子类型:当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来像鸭子,那么这只鸟就可以被称为鸭子。

空 interface 有点类似于 C 中的 void* 类型。

fmt.Println 可以接受任意类型的数据,其源代码中可以看到:

type Stringer interface {
    String() string
}

也就是说,任何实现了 String 方法的类型都能作为参数被 fmt.Println 调用。

如何知道 interface 变量里面保存了什么类型的数值?可以使用 comma-ok 语法,比如 value, ok = element.(T) 如果 element 确实是 T 类型,那么 ok 为 true,反之为 false。

另外一种方式是使用 switch,比如:

for index, element := range list {
    switch value := element.(type) {
        case int:
            fmt.Printf("int")
        case string:
            fmt.Printf("string")
        default:
            fmt.Printf("unknown")
    }
}

在 Go 中进行并行程序开发时要注意:不要通过共享来通信,而要通过通信来共享。

对于普通的上网过程,浏览器本身是一个客户端,输入 URL 时首先会去请求 DNS 服务器,通过 DNS 获取相应的域名对应的 IP,然后通过 IP 地址找到 IP 对应的服务器后,要求建立 TCP 连接,等待浏览器发送完 HTTP Request 包之后,服务器收到请求包才开始处理请求包,调用自身服务,返回 HTTP Response 包,客户端收到来自服务器的响应后开始渲染这个 Response 包里的 body,收到全部内容后,断开与服务器之间的 TCP 连接。

以下均是服务器端的几个概念:

  • Request: 用户请求的信息,用来解析用户的请求信息,包括 post, get, cookie, url 等信息
  • Response: 服务器需要反馈给客户端的信息
  • Conn: 用户的每次请求链接
  • Handler: 处理请求和生成返回信息的处理逻辑

http 包执行流程

  1. 创建 Listen Socket,监听指定的端口,等待客户端请求到来
  2. Listen Socket 接受客户端的请求,得到 Client Socket,接下来通过 Client Socket 与客户端通信
  3. 处理客户端的请求,首先从 Client Socket 读取 HTTP 请求的协议头,如果是 post 方法,还可能要读取客户端提交的数据,然后交给相应的 handler 处理请求,handler 处理完毕准备好客户端需要的数据,通过 Client Socket 写给客户端

开发 Web 的一个原则就是,不能信任用户输入的任何信息,所以验证和过滤用户的输入信息就变得非常重要。一般有两方面的数据验证,一个是在页面端的 js 验证,一个是在服务端验证。

要使表单能够上传文件,第一步是添加 form 的 enctype 属性,有如下三种情况:

  • application/x-www-form-urlencoded 发送前编码所有字符(默认)
  • multipart/form-data 不对字符编码。在使用包含文件上传控件的表单时,必须使用该值
  • text/plain 空格转换为 +,但不对特殊字符编码

Web 开发中一个很重要的议题就是如何做好用户整个浏览过程的控制,经典的解决方案是 cookie 和 session,cookie 是一种客户端机制,把用户数据保存在客户端,而 session 机制是一种服务端的机制,服务器使用一种类似于散列表的结构来保存信息,每一个网站访客都会被分配给一个唯一的标志符,即 sessionID,它的存放形式无非两种,要么经过 url 传递,要么保存在客户端的 cookies 里(当然也可以保存到数据库里,更安全,但是效率会下降)

cookie 是有时间限制的,根据生命期不同分成两种:会话 cookie 和持久 cookie。如果不设置过期时间,则表示这个 cookie 生命周期为凑够创建到浏览器关闭为止,只要关闭浏览器窗口,cookie 就消失了。这种生命期为浏览会话期的 cookie 被称为会话 cookie。会话 cookie 一般不保存在硬盘上而是保存在内存里。

如果设置了过期时间 setMaxAge(606024),浏览器就会把 cookie 保存到硬盘上,关闭后再次打开浏览器,这些 cookie 依然有效直到超过设定的过期时间。存储在硬盘上的 cookie 可以在不同的浏览器进程间共享,比如两个 IE 窗口。而对于保存在内存的 cookie,不同的浏览器有不同的处理方式。

session 机制本身并不复杂,然而实现和配置上的灵活性却使得具体情况复杂多变。这也要求我们不能把仅仅某一次的经验或者某一个浏览器,服务器的经验当做普适的。

session 的基本原理是由服务器为每个会话维护一份信息数据,客户端和服务端依靠一个全局唯一的标识来访问这份数据,以达到交互的目的。当用户访问 Web 应用时,服务端程序会随需要创建 session,这个过程可以概括为三个步骤:

  • 生成全局唯一标志符 sessionid
  • 开辟数据存储空间。一般会在内存中创建相应的数据结构,但这种情况下,系统一旦掉电,所有的会话数据就回丢失,如果是电子商务类网站,这将造成严重的后果。所以为了解决这类问题,你可以将会话数据写到文件里或存储在数据库中,这样虽然会增加 I/O 开销,但是可以实现某种程度的 session 持久化,也更有利于 session 的共享
  • 将 session 的全局唯一标志符发送给客户端

这里最关键的是如何发送 sessionid,一般有两种方式:cookie 和 URL 重写。

  1. Cookie 方式中服务端通过设置 Set-cookie 头就可以将 session 的标志符传送到客户端,而客户端此后的每一次请求都会带上这个标志符,另外包含 session 信息的 cookie 会将失效时间设置为 0(会话 cookie),即浏览器进程有效时间。至于浏览器怎么处理这个 0,不同浏览器有不同方案,但差别都不会太大。
  2. URL 重写方式,就是在返回给用户的页面里的所有 URL 后面追加 sessionid,这样用户收到响应后会自动带上 sessionid,这种做法比较麻烦,但是如果客户端禁用了 cookie,这样方案是首选。

session 劫持是一种广泛存在的比较严重的安全威胁,在 session 技术中,客户端和服务端通过 session 的标志符来维护会话,但这个标志符很容易就能被嗅探到,从而被其他人利用,是中间人攻击的一种类型。

如何有效防止 session 劫持呢?其中一个解决方案就是 sessionID 的值只允许 cookie 设置,而不是通过 URL 重置方式设置,同时设置 cookie 的 httponly 为 true,这个属性是设置是否可通过客户端脚本访问这个设置的 cookie,可以防止这个 cookie 被 XSS 读取从而引起 session 劫持,也更难获取 sessionID。然后我们需要在每个请求里面加上隐藏 token,每次提交都需要认证,这样来进行防范。

还有一个解决方案是给 session 额外设置一个创建时间的值,一旦超过,则销毁并重新生成,在一定程度上可以防止 session 劫持的问题。

Unmarshal 解析的时候 XML 元素和字段怎么对应起来呢?首先会读取 struct tag,如果没有,那么就寻找对应字段名。必须注意的是解析的时候 tag、字段名、XML 元素都是大小写敏感的,所以必须一一对应字段。

现在的网络编程几乎都是用 Socket 来编程。Socket 起源于 Unix,而 Unix 基本哲学之一就是『一切皆文件』,都可以用『打开 open - 读写 write/read - 关闭 close』模式来操作。Socket 就是该模式的一个实现,网络的 Socket 数据传输是一种特殊的 I/O,Socket 也是一种文件描述符。Socket 具有一个类似于打开文件的函数调用 Socket(),该函数返回一个整型的 Socket 描述符,随后的连接建立、数据传输等操作都是通过该 Socket 实现的。

常用的 Socket 类型有两种:流式 Socket(SOCK_STREAM)和数据报式 Socket(SOCK_DGRAM)。流式是一种面向连接的 Socket,针对于面向连接的 TCP 服务应用;数据报式 Socket 是一种无连接的 Socket,对应于无连接的 UDP 服务应用。

Socket 有两种:TCP Socket 和 UDP Socket,TCP 和 UDP 是协议,而要确定一个进程需要三元组,还要 IP 地址和端口。

WebSocket 是 HTML5 的重要特性,它实现了基于浏览器的远程 Socket,它使浏览器和服务器可以进行全双工通信。在 WebSocket 出现之前,为了实现即时通信,采用的技术都是『轮询』,这样会占用大量带宽。WebSocket 采用了一些特殊的报头,使得浏览器和服务器只需要做一个握手的动作,就可以在浏览器和服务器之间建立一条连接通道。且此连接会保持在活动状态,你可以使用 JavaScript 来向连接写入或从中接收数据,就像在使用一个常规的 TCP Socket 一样。

WebSocket 的协议颇为简单,在第一次握手通过以后,连接便建立成功,其后的通讯数据都是以 \x00 开头,以 \xFF 结尾。

REST 是一种架构风格,汲取了 WWW 的成功经验:无状态,以资源为中心,充分利用 HTTP 协议和 URI 协议,提供统一的接口定义,使得它作为一种设计 Web 服务的方法而变得流行。在某种意义上,通过强调 URI 和 HTTP 等早期 Internet 标准,REST 是对大型应用程序服务器时代之前的 Web 方式的回归。

RPC 就是想实现函数调用模式的网络化。客户端就像调用本地函数一样,然后客户端把这些参数打包之后通过网络传递到服务端,服务端解包到处理过程中执行,然后执行的结果反馈给客户端。

RPC(Remote Procedure Call Protocol) 远程过程调用协议,是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。它假定某些传输协议的存在,如 TCP 或 UDP,以便为通信程序之间携带信息数据。通过它可以使函数调用模式网络化。在 OSI 网络通信模型中,RPC 跨越了传输层和应用层。RPC 使得开发包括网络分布式多程序在内的应用程序更加容易。

运行时,一次客户机对服务器的 RPC 调用,其内部操作大致有如下十步:

  1. 调用客户端句柄;执行传送参数
  2. 调用本地系统内核发送网络消息
  3. 消息传送到远程主机
  4. 服务器句柄得到消息并取得参数
  5. 执行远程过程
  6. 执行的过程将结果返回服务器句柄
  7. 服务器句柄返回结果,调用远程系统内核
  8. 消息传回本地主机
  9. 客户句柄由内核接收消息
  10. 客户接受句柄返回的数据

很多 Web 应用程序中的安全问题都是由于轻信了第三方提供的数据造成的。在使用第三方提供的数据,包括用户提供的数据时,首先检验这些数据的合法性非常重要,这个过程叫做过滤。

加密的本质就是扰乱数据,某些不可恢复的数据扰乱我们称为单向加密算法或者散列算法。另外还有一种双向加密方式,也就是可以对加密后的数据进行解密。

XSS 攻击:跨站脚本攻击(Cross-Site Scripting)是一种常见的 web 安全漏洞,它允许攻击者将恶意代码植入到提供给其他用户使用的页面中。不同于大说书攻击(一般只涉及攻击者和受害者),XSS 涉及到三方,即攻击者、客户端与 Web 应用。XSS 的攻击目标是为了盗取存储在客户端的 cookie 或者其他网站用于识别客户端身份的敏感信息。一旦获取到合法用户的信息后,攻击者甚至可以假冒合法用户与网站进行交互。

XSS 通常可以分为两大类:一类是存储型 XSS,主要出现在让用户输入数据,供其他浏览此页的用户进行查看的地方,包括留言、评论、博客日志和各类表单等。应用程序中查询数据,在页面中显示出来,攻击者在相关页面输入恶意的脚本数据后,用户浏览此类页面就可能受到攻击。这个流程简单可以描述为:恶意用户的 HTML 输入 Web 程序 - 进入数据库 - Web 程序 - 用户浏览器。另一类是反射型 XSS,主要做法是将脚本代码加入 URL 地址的请求参数里,请求参数进入程序后在页面直接输出,用户点击类似的恶意链接就可能受到攻击。

XSS 目前主要的手段和目的如下:

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

防治 SQL 注入的方法:

  1. 严格限制 Web 应用的数据库操作权限,给此用户提供仅仅能够满足其工作的最低权限,从而最大限度的减少注入攻击对数据库的危害
  2. 检查输入的数据是否具有所期望的数据格式,严格限制变量的类型
  3. 对进入数据库的特殊字符进行转义处理
  4. 所有的查询语句建议使用数据库提供的参数化查询接口,参数化的语句使用参数而不是将用户输入变量嵌入到 SQL 语句中,即不要直接拼接 SQL 语句
  5. 在应用发布之前建议使用专业的 SQL 注入检测工具进行检测,及时修补发现的 SQL 注入漏洞
  6. 避免网站打印出 SQL 错误信息,比如类型错误、字段不匹配等,把代码里的 SQL 语句暴露出来,以防止攻击者利用这些错误信息进行 SQL 注入

目前用的最多的密码存储方案是将明文密码做单向哈希后存储,常用算法包括 SHA-256, SHA-1, MD5 等,可用 rainbow table 破解。

安全性比较好的网站,都会用一种『加盐』的方式来存储密码,就是常说的 salt,先将用户输入的密码进行一次 MD5(或其他哈希算法)加密,将得到的 MD5 值前后加上一些只有管理员自己知道的随机串,再进行一次 MD5 加密。

专家级方案是 scrypt,由著名的 FreeBSD 黑客 Colin Percival 为其备份服务 Tarsnap 开发的。