network

放弃 requests,拥抱 httpx

httpx 是新一代的 Python http 请求库,它几乎和 requests 的 API 无缝兼容,几乎不用改代码。相对于 requests 来说,有以下优点:

  • 支持 asyncio, 可以直接 async/await 啦
  • 支持 http 2, requests 一直都不支持
  • 实现了正确的 http 代理
  • Cookie 的 API 更友好
  • 有自己的网络连接池,requests 是基于第三方的 urllib3
  • 文档更有条理,更深入

还有就是 requests 有比较明显的内存泄漏,目前我还没有测试过 httpx,所以就不列到优点里了。

httpx 不支持在 client.request 中使用 proxies,必须在 Client 初始化时候指定,这样做应该是考虑到链接池的实现。

如何在 URL 中表示数组

我们知道 URL 后面的 query string 实际上是一个字典的形式。URL 的任何一个规范中都没有定义如何在 query 中传递数组,但是这个需求也是实际存在的,于是就诞生各种奇葩的形式,本文做一个总结。

常见的形式

http://www.baidu.com/search?q=url&tag=foo

这是一个正常的 URL,这里解析出来应该是一个字典 {“q”: “url”, “foo”: “bar”}。但是 Python 会强行解析成数组 {“q”: [“url”], “tag”: [“foo”]}。

使用 URL 表示数组有以下几种常见形式:

http://www.baidu.com/search?q=url&tag=foo&tag=bar

重复键表示数组,Python/Node 中可以正确解析成数组,Java 只读取第一个值,PHP 只读取最后一个值。

http://www.baidu.com/search?q=url&tag[]=foo&tag[]=bar

键后增加 [] 并重复表示数组。PHP/Node 可以解析为 tag=[foo, bar]。Python 会解析成

PHP 的 httpbuildquery 会生成这种格式。

In [6]: from urllib.parse import parse_qs

In [7]: parse_qs("tag=foo&tag=bar")
Out[7]: {"tag": ["foo", "bar"]}

In [8]: parse_qs("tag[]=foo&tag[]=bar")
Out[8]: {"tag[]": ["foo", "bar"]}

In [9]: parse_qs("tag=foo")
Out[9]: {"tag": ["foo"]}

http://www.baidu.com/search?q=url&tag[0]=foo&tag[1]=bar

使用数组形式表示。貌似没有原因能够处理,但是用的还挺多的。

http://www.baidu.com/search?q=url&tag=foo,bar

使用逗号分隔。貌似没有语言默认会处理这种,需要自己手工处理。但是我最喜欢这种。

一个更奇葩的例子

https://www.doi.gov/careers/explore-careers?f[0]=bureaus:20&f[1]=competencies:1638&f[2]=competencies:1642&f[3]=competencies:1648&f[4]=competencies:1656&f[5]=competencies:1661&f[6]=gslevels:17&f[7]=gslevels:158

总之,在不同的语言中,乃至于不同的 web 框架中对以上形式有不同的解析,非常混乱。

参考资料

  1. https://stackoverflow.com/questions/6243051/how-to-pass-an-array-within-a-query-string
  2. https://stackoverflow.com/questions/11889997/how-to-send-an-array-in-url-request/11890080
  3. https://stackoverflow.com/questions/1763508/passing-arrays-as-url-parameter
  4. https://stackoverflow.com/questions/1746507/authoritative-position-of-duplicate-http-get-query-keys

HAR 格式解析

HAR(HTTP Archive) 文件是一种常见的用来保存 HTTP 请求和响应的格式。本质上,HAR 文件其实就是一个 JSON 文件。

每一个 HAR Entry 都可以有以下记录存在:

  • log
    • creator
    • browser
    • pages
      • pageTimings
    • entries
      • request
        • queryString
        • postData
        • params
      • response
        • cookies
        • headers
        • content
      • cache
      • timings

log

这个是一个 HAR 文件的根字段,其他字段都是该字段的子字段

{
    "log": {
        "version" : "1.2",
        "creator" : {},
        "browser" : {},
        "pages": [],
        "entries": [],
        "comment": ""
    }
}

creator

"creator": {
    "name": "Firebug",
    "version": "1.6",
    "comment": ""
}

browser

同 creator 结构完全一样

pages

"pages": [
    {
        "startedDateTime": "2009-04-16T12:07:25.123+01:00",
        "id": "page_0",
        "title": "Test Page",
        "pageTimings": {...},
        "comment": ""
    }
]

pageTimings

"pageTimings": {
    "onContentLoad": 1720,
    "onLoad": 2500,
    "comment": ""
}

entries

"entries": [
    {
        "pageref": "page_0",
        "startedDateTime": "2009-04-16T12:07:23.596Z",
        "time": 50,
        "request": {...},
        "response": {...},
        "cache": {...},
        "timings": {},
        "serverIPAddress": "10.0.0.1",
        "connection": "52492",
        "comment": ""
    }
]

request

"request": {
    "method": "GET",
    "url": "http://www.example.com/path/?param=value",
    "httpVersion": "HTTP/1.1",
    "cookies": [],
    "headers": [],
    "queryString" : [],
    "postData" : {},
    "headersSize" : 150,
    "bodySize" : 0,
    "comment" : ""
}

queryString

"queryString": [
    {
        "name": "param1",
        "value": "value1",
        "comment": ""
    },
    {
        "name": "param1",
        "value": "value1",
        "comment": ""
    }
]

postData

"postData": {
    "mimeType": "multipart/form-data",
    "params": [],
    "text" : "plain posted data",
    "comment": ""
}

params

"params": [
    {
        "name": "paramName",
        "value": "paramValue",
        "fileName": "example.pdf",
        "contentType": "application/pdf",
        "comment": ""
    }
]

response

"response": {
    "status": 200,
    "statusText": "OK",
    "httpVersion": "HTTP/1.1",
    "cookies": [],
    "headers": [],
    "content": {},
    "redirectURL": "",
    "headersSize" : 160,
    "bodySize" : 850,
    "comment" : ""
 }

content

"content": {
    "size": 33,
    "compression": 0,
    "mimeType": "text/html; charset=utf-8",
    "text": "\n",
    "comment": ""
}

cookies

"cookies": [
    {
        "name": "TestCookie",
        "value": "Cookie Value",
        "path": "/",
        "domain": "www.janodvarko.cz",
        "expires": "2009-07-24T19:20:30.123+02:00",
        "httpOnly": false,
        "secure": false,
        "comment": ""
    }
]

headers

"headers": [
    {
        "name": "Accept-Encoding",
        "value": "gzip,deflate",
        "comment": ""
    },
    {
        "name": "Accept-Language",
        "value": "en-us,en;q=0.5",
        "comment": ""
    }
]

cache

"cache": {
    "beforeRequest": {},
    "afterRequest": {},
    "comment": ""
}

beforeRequest

"beforeRequest": {
    "expires": "2009-04-16T15:50:36",
    "lastAccess": "2009-16-02T15:50:34",
    "eTag": "",
    "hitCount": 0,
    "comment": ""
}

timings

"timings": {
    "blocked": 0,
    "dns": -1,
    "connect": 15,
    "send": 20,
    "wait": 38,
    "receive": 12,
    "ssl": -1,
    "comment": ""
}

参考资料

  1. http://www.softwareishard.com/blog/har-12-spec/#response

requests cookies 为空的一个坑

有时候,requests 返回的 cookies 会为空,原因是链接发生了 301/302 跳转,而 cookies 是跟着第一个响应返回的,第二个响应没有返回 Set-Cookie header。所以直接读取 r.cookies 是空的,而在 session.cookies 中是有数据的。

解决方法是直接读 s.cookies。

s = requests.Session()
r = s.get("http://httpbin.org/cookies/set?foo=bar")
cookies = requests.utils.dict_from_cookiejar(s.cookies)
s.cookies.clear()

不过需要注意的是如果在多线程环境中使用 session 需要注意锁的问题,建议把 session 设置成 thread local 的类型。

Get 和 Post 方法的选择和 URL 的设计

HTTP 中常用的方法有 GET/POST/PUT/DELETE 等,在设计 API 或者表单的时候一般有两种方案:

  1. 只使用 GET 和 POST,GET 主要用来读取数据,POST 用来创建或者更新数据。
    这是远古时代的做法,因为那时候好多软件压根不支持 PUT/DELETE,现在已经不流行了。
  2. RESTful 的方法,GET/POST/PUT/DELETE 分别用来增删改查。

URL 的设计

为了探讨两种方案,首先我们来看一下 URL 的设计。URL 是 Universal Resource Locator 的缩写,也就是一个 URL 表示的是唯一的一个资源,所以这个资源的 id 或者说主键应该是放在 URL 路径中的。

比如一个好的设计:

http://example.com/post/1234

不好的设计:

http://example.com/post?id=1234

而控制这个资源展示方式的其他字段可以作为参数:

http://exmaple.com/post/1234?lang=zh&utm_source=google

你可以理解为这个 ID 是名词、主语,而其他形容词、状语。

Restful 的设计

好多人对于 GET/POST 区别的理解是 GET 参数在 url 里,而 POST 参数在 body 里面,这样理解是不对的。

在上述的两种方案中,GET 都是用来读取资源的,一般来说不要对资源进行任何更新操作,也就是没有副作用。比如说

不好的设计:

GET http://example.com/post/1234?action=delete

上面的设计意图通过 GET 操作来删除一个资源,这样非常不好。比如说如果浏览器具有预缓存页面的功能,
那么预先读取这个链接的时候就把对应的资源删掉了。

一般来说,GET 方法还要求幂等性,也就是无论多少次操作,最终结果和操作一次都是一样的。
GET 操作的参数受到 url 长度的限制,当参数超过 1k 的时候,可以使用 POST 代替。
不过这时候你首先应该想一下这么多参数是不是都有用,是不是设计有问题。

POST 方法可以用来创建资源,比如说:

POST http://example.com/post/

content=xxxxxxx&author=xxxx&date=xxxx

POST 操作具有副作用,也就是说会更改服务器上的状态。另外 POST 操作一般不是幂等的,每次 POST 操作都应该创建一个新的资源。

PUT 操作用来更新资源,也是幂等的。也就是说任意多次的 PUT 操作,导致资源处于的状态都是一致的。

PUT http://example.com/post/1234

content=yyyyyy

DELETE 用来删除资源,值得注意的是,根据规范 DELETE 方法不能带有 body。

DELETE http://example.com/post/1234

Python urllib 模块

YN:网络访问的时候一定要记得设置一个合理的超时

在 Python2 中,有两个 urllib:urllib 和 urllib2,urllib 基本只使用 urllib.urlencode(), urllib.quote 函数,其他功能都被对应的 urllib2 中的函数替代了。

Python3 把这两个模块进行了合并并拆分成了子模块,只使用 urllib 就好了。

发送 http 请求

直接使用 urlopen

urllib.request.urlopen(url, data=None, [timeout, ] *, context) -> http.client.HTTPResponse

用于发送 GET 或者 POST 请求,如果有 data,则发送的是 POST 请求。返回一个 file-like 的 http.client.HTTPResponse 对象,这个对象也可以作为一个 context manager。

info() | 返回 headers
gerurl() | 返回 url,常用于判定是否被重定向
getcode() |
read()/readlines() | 返回文件内容

>>> import urllib.request
>>> with urllib.request.urlopen("http://www.python.org/") as f:
...     print(f.read(300))
...
b"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n\n\n<html
xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">\n\n<head>\n
<meta http-equiv="content-type" content="text/html; charset=utf-8" />\n
<title>Python Programming "

使用 Request 对象

如果需要更改默认的 header 等数据或者使用 PUT、DELETE 等方法,urlopen 还可以接受一个 Request 对象,可以在 Request 对象中更改。

Request 对象的定义:

class urllib.request.Request(url, data=None, headers={}, origin_req_host=None, unverifiable=False, method=None)

一个 request 可以指定 method, url,可以使用 req.add_header 添加 header。

可以使用 addheader 方法再添加额外的 header,但是实际上是 setheader,并不能添加重复的,不要被名字迷惑了。

使用 Opener

urilib2.build_opener 返回一个打开器 (OpenerDirector),用于设定发出请求要经过的一些处理,可以设定代理,处理 cookie 等。OpenerDirector 有一个属性 addheaders,把他设定为一个包含键值 tuple 的 list,这样使用 opener 发送的每一个请求都会添加上这个 header。

Then you could use opener.open instead of urllib2.urlopen

Typical usage:

import urllib2
import urllib
from cookielib import CookieJar
cj = CookieJar()
opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cj))
# input-type values from the html form
formdata = { "username" : username, "password": password, "form-id" : "1234" }
data_encoded = urllib.urlencode(formdata)
response = opener.open("https://page.com/login.php", data_encoded)
import urllib.request
opener = urllib.request.build_opener()
opener.addheaders = [("User-agent", "Mozilla/5.0")]
opener.open("http://www.example.com/")

From http://stackoverflow.com/questions/3334809/python-urllib2-how-to-send-cookie-with-urlopen-request

异常:URLError HTTPError

URLError 是 IOError 的子类,HTTPError.code is the http code

urllib.parse

urlparse and urlunparse is not as good as urlsplit

urlsplit

return a five element tuple by scheme://netloc/path?query#fragment

Attribute Index Value Value if not present
scheme 0 URL scheme specifier scheme parameter
netloc 1 Network location part empty string
path 2 Hierarchical path empty string
query 3 Query component empty string
fragment 4 Fragment identifier empty string
username   User name None
password   Password None
hostname   Host name (lower case) None
port   Port number as integer, if present None

note,the netloc contains domain and port

>>> urlsplit("www.cwi.nl/%7Eguido/Python.html")
SplitResult(scheme="", netloc="", path="www.cwi.nl/%7Eguido/Python.html", query="", fragment="")
# notice the netloc will be wrong if "//" is missing
>>> urlsplit("//www.cwi.nl/%7Eguido/Python.html")
SplitResult(scheme="", netloc="www.cwi.nl", path="/%7Eguido/Python.html", query="", fragment="")

urlunsplit joins the parse result。

parse_qs(qs, keep_blank_values=False, encoding="utf-8")

parse a query string to { key: [values] } pair, note without ‘?’

parse_qsl

return a list of k,v tuple

quote/quote_plus

unquote

urlencode

Linux 上的 DNS 缓存

Linux 内核中没有 DNS 缓存

Firefox 内置了 DNS 缓存

nscd 可以提供本地的 DNS 缓存,好多机器开了,但是据说这个服务有很多问题。

Python 使用了 getaddrinfo 函数,会使用系统的 DNS 缓存

nslookup 和是 dig 这样的工具会 bypass 掉 DNS 缓存。

另外 Go 语言好像也不会使用本机的 DNS 缓存,即使开了

https://wiki.archlinux.org/index.php/dnsmasq 可以用来做本地缓存

还可以使用 systemd 提供的 resolved

  1. https://stackoverflow.com/questions/11020027/dns-caching-in-linux

HTTP 认证介绍

周末给一个库添加 http 代理的支持,发现对 http basic auth 不甚了解,阅读了一下相关的文档,写篇备忘。

http 中的认证主要是 basic auth 和 digest auth 两种,其中 digest auth 比较复杂,而且也没有提升安全性,已经不建议使用了。

RFC 7235 [1] 描述了客户端(通常是浏览器)和服务器如何通过 http 进行身份认证的一些机制。客户端和 http 代理之间也可以使用 http auth 来做验证。
 

验证流程

  1. 当客户端访问一个页面时,如果只有验证后才能访问,或者验证后有更多内容,服务器应该发送 401 Unauthorized,提出一个 chanllenge,设定 WWW-Authenticate header,并指定验证的 type 和 realm,具体定义下文有讲。
  2. 客户端这时通常应该提示用户输入密钥,一般是浏览器弹出用户名密码对话框供用户填写,然后使用Authorization header 发送验证的密钥。如果验证通过的话,应该正常访问(200 OK),验证通过但是没有权限的话应该返回 403 Forbidden。
  3. 如果验证不通过,应该服务器返回 401,客户端可以重试。

注意,如果客户端已经知道需要密钥访问,那么可以在第一个请求直接发送对应的密钥,这样就避免了 401 Unauthorized。

MDN 上的流程图
 

代理验证的不同

如果代理服务器需要验证的话,流程是类似的,有两点细节不同:

  1. 代理服务器应该发送 407 Proxy Authentication Required 而不是 401。使用的 headers 也变成了 Proxy-Authenticate 和 Proxy-Authorization 。
  2. 服务器的头部 WWW-Authenticate 是 end-to-end 的,也就是代理服务器不应该篡改,应该原样传递。而代理服务器的 Proxy-头部是 hop-by-hop 的,也就是不能向下传递。

实现细节

服务器或者代理服务器随着 4XX 发送的头部为

WWW-Authenticate: <type> realm=<realm>
or
Proxy-Authenticate: <type> realm=<realm>

其中 type 指定了使用的验证的类型,也就是用户名和密码加密方式的不同,IANA 钦定了一批方法 [2]。然鹅,一般来说常用的只有两个 Basic 和 Digest。而其中 Digest 的实现可能会要求服务器明文存储密码,于是大家又 angry 了 [3],这里也不推荐使用。所以这里只介绍 Basic 类型。

realm 指定了验证的领域,也就是说相同 realm 下的用户名和密码是一样的,如果你访问的两个页面在同一个 realm,那么浏览器在第二次访问就不会问你密码了。

客户端发送对应的头部和密钥来获得访问权限

Authorization: <type> <credentials>
or
Proxy-Authorization: <type> <credentials>

其中,type 就是刚刚的那个 Basic 或者 Digest。credentials 按照对应的方法计算。对于 Basic 类型 credentials = base64(username + ':' + password)

一个例子,假设用户名和密码分别是:aladdin 和 opensesame。那么客户端应该发送的 header 是:Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l

需要注意的地方

  1. 因为 http 协议本身是无状态的,所以 Auth 应该是无状态的,每次请求都应该携带。
     
  2. 如果是 http 协议的话,对于 Basic Auth,那么密码都是明文发送的,可以使用 https 来避免这个问题。

  3. 可以使用:https://username:[email protected]/ 这种形式来预先输入账号密码,但是这种形式已经不鼓励了。不过在设定一些环境变量时,比如 http_proxy,也只能用这种方法来制定用户名和密码

参考

  1. 对应的 RFC https://tools.ietf.org/html/rfc7235
  2. IANA 注册的 auth 类型 http://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml
  3. 为什么不要使用 digest 验证  https://stackoverflow.com/questions/2384230/what-is-digest-authentication
  4. MDN 的文章还提供了如何让 apache 和 nginx 使用 basic auth https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication

HTTP 缓存介绍

和缓存相关的 header 共有如下几种

  • Pragma
  • Cache-Control
  • Expires
  • Last-Modified
  • Etag
  • If-Modified-Since
  • If-Non-Match

Expires 由服务器返回,用于指定当前页面过期时间,使用绝对时间表示。

Cache-Control 指定了相对过期的时间,由当前时间多久后过期的秒数表示。

Cache-Control: max-age=86400

Last-Modified 是由服务器给出了文档的过期时间,当第二次请求该文档的时候,浏览器可以使用 If-Modified-Since 头部指定该过期时间,如果文档还没有过期,那么服务器应该返回 304,否则返回 200 和新文档。

Etag 是由服务器给出的文档的哈希值,当第二次请求该文档的时候,浏览器可以使用 If-None-Match 头部指定该哈希值,如果文档没有变动,那么服务器应该返回 304,如果有变动,那么哈希值也变了,应该返回 200 和新文档。

可以看出 Etag 相比 Last-Modified 更准确一些,所以两个头部都有的前提下,应该是 Etag 优先。

实际使用中,为了兼容性考虑,应该把这几种头部都结合起来使用。

参考

  1. https://www.mnot.net/cache_docs/
  2. http://stackoverflow.com/questions/499966/etag-vs-header-expires