作者:Matthew Flatt
原文:https://docs.racket-lang.org/more/index.html
与快速:通过画图了解Racket给人的印象相不同,Racket并不是花瓶。在DrRacket图形化的外观之下,隐藏这一个复杂的线程和进程管理工具箱,这也是本章的主题。
具体的,我们展示如何构建一个安全的,多线程,可扩展的网络服务。我们会用到比之前更多的语法和函数,你可以点击不认识的语法或函数来查看文档。注意,最后几节的内容被认为是比较难的。如果你是个Racket新手或者没什么编程经验,可以先看看The Racket Guide。
为了领会本教程的精神,我们建议你先将DrRacket放在一边并在终端中使用Racket。你还需要一个文档编辑器和一个浏览器。
1 开始
下载安装DrRacket,打开终端输入racket
。
$ racket
Welcome to Racket v8.4 [cs].
>
进入Racket命令行,可以输入Racket代码执行,也可以执行命令,更多命令参考官方文档。
2 准备
在启动Racket命令的目录下创建文件"serve.rkt"并输入以下内容:
#lang racket
(define (go)
'yep-it-works)
3 运行
回到Racket命令行,加载并运行上面的代码:
> (enter! "server.rkt")
> (go)
'yep-it-works
修改"serve.rkt",然后运行(enter! "serve.rkt")
重新加载模块,观察输入的变化。
4 Hello World
我们将会使用serve
函数实现网络服务,参数中的端口号用来接收客户端连接。
(define (serve port-no)
...)
服务器通过listener
接收TCP连接,listener
由tcp-listen
函数创建。为了方便交互式开发,我们使用#t
作为tcp-listen
的第三个参数,它让我们可以马上复用端口号而不用等待TCP超时。
(define (serve port-no)
(define listener (tcp-listen port-no 5 #t))
...)
服务还需要循环接收客户端连接。
(define (serve port-no)
(define listener (tcp-listen port-no 5 #t))
(define (loop)
(accept-and-handle listener)
(loop))
(loop))
accept-and-handle
函数通过tcp-accept
函数接收连接,它会返回两个值,一个输入流,一个输出流。
(define (accept-and-handle listener)
(define-values (in out) (tcp-accept listener))
(handle in out)
(close-input-port in)
(close-output-port out))
接下来我们读取输入流并丢弃请求头,并向输出流写入"Hello world"。
(define (handle in out)
; 丢弃请求头
(regexp-match #rx"(\r\n|^)\r\n" in)
; 发送响应
(display "HTTP/1.0 200 OKay\r\n" out)
(display "Server: k\r\nContent-Type: text/html\r\n\r\n" out)
(display "<html><body>Hello, world!</body></html>" out)
注意,regexp-match
函数直接操作输入流,比操作独立的行容易。
将上面三个函数复制到"serve.rkt",并在命令行中重新载入。
> (enter! "serve.rkt")
[reloading serve.rkt]
> (serve 8080)
现在,在浏览器中输入http://localhost:8080,你将会看到来自服务端的问候。
5 服务线程
按下ctrl+c
结束服务循环,然后重新启动服务。
> (serve 8080)
tcp-listen: listen on 8080 failed (address already in use)
发生错误的原因是listener
依然在监听着原来的端口。
为了避免这个问题,我们将监听循环放到一个独立的线程,并让serve
函数立即返回一个可以关闭服务线程和TCP监听器的函数。
(define (serve port-no)
(define listener (tcp-listen port-no 5 #t))
(define (loop)
(accept-and-handle listener)
(loop))
(define t (thread loop))
(lambda ()
(kill-thread t)
(tcp-close listener)))
重新加载并启动服务:
> (enter! "serve.rkt")
[re-loading serve.rkt]
> (define stop (serve 8081))
现在服务地址为http://localhost:8081,此时你可以关闭服务并重启它。
> (stop)
> (define stop (serve 8081))
> (stop)
> (define stop (serve 8081))
> (stop)
6 连接线程
同样,我们可以为每个连接创建一个线程。
(define (accept-and-handle listener)
(define-values (in out) (tcp-accept listener))
(thread
(lambda ()
(handle in out)
(close-input-port in)
(close-output-port out))))
现在我们的服务可以同时处理多个线程了。为了验证这一点,可以在调用handle
之前插入一行代码(sleep (random 10))
。然后在浏览器同时发起多个请求,有些会很快返回,有些会延时,但不同请求的延时只取决于发起请求的时间。
7 结束连接
一个恶意的客户端连上服务器后可能不会发送HTTP请求头,导致连接线程一直空转。为了避免这种情况,我们希望给每个连接线程实现超时。
一种方式是创建另一个线程,等待10秒后结束调用handle
的线程。Racket中的线程十分轻量,所以这种方式也可以工作良好。
(define (accept-and-handle listener)
(define-values (in out) (tcp-accept listener))
(define t (thread
(lambda ()
(handle in out)
(close-input-port in)
(close-output-port out))))
; 监控线程
(thread (lambda ()
(sleep 10)
(kill-thread t))))
上面的代码并不完全正确,因为但线程结束后,in
和out
流依然是打开的。Racket提供了一种通用的资源关闭机制:custodian
。custodian(监管器)是一种除内存外的资源的容器,通过custodian-shutdown-all
函数来关闭容器内的所有资源,包括线程,流或其他有限的资源。
当创建一个线程或流时,它会被放入由current-custodian
参数所指定的监管器中。为了将连接相关的资源都放入监管器,我们将资源创建进行参数化。
(define (accept-and-handle listener)
(define cust (make-custodian))
(parameterize ([current-custodian cust])
(define-values (in out) (tcp-accept listener))
(thread (lambda ()
(handle in out)
(close-input-port in)
(close-output-port out))))
; 监控线程
(thread (lambda ()
(sleep 10)
(custodian-shutdown-all cust))))
现在in
,out
以及调用handle
的线程都属于cust
。而且handle
函数中创建的资源,比如打开文件,也属于cust
,因此也会在cust
关闭是被一起释放。
serve
函数也可以使用custodian关闭资源。
(define (serve port-no)
(define main-cust (make-custodian))
(parameterize([current-custodian main-cust])
(define listener (tcp-listen port-no 5 #t))
(define (loop)
(accept-and-handle listener)
(loop))
(thread loop))
(lambda ()
(custodian-shutdown-all main-cust)))
main-cust
不仅拥有TCP监听器和主服务线程,还拥有每个连接创建的custodian。因此现在的服务关闭程序会立刻关闭所有活跃的连接,以及主服务循环。
更新你的代码,在Racket命令行输入以下内容:
> (enter! "serve.rkt")
[re-loading serve.rkt]
> (define stop (serve 8081))
> (define-values (cin cout) (tcp-connect "localhost" 8081))
10秒钟之后尝试从cin
读取数据,你就会看到服务端已经关闭了这个连接。
> (read-line cin)
#<eof>
如果你立刻停止服务程序,不必等10秒也能看到连接被关闭。
> (define-values (cin2 cout2) (tcp-connect "localhost" 8081))
> (stop)
> (read-line cin2)
#<eof>
8 路由转发
本章让我们实现一个简单的路由分发函数来处理来自不同URL的请求。
为了解析URL和格式化HTML输出,我们需要依赖下面两个库。
(require xml net/url)
xml
库提供了xexpr->string
函数将xexpr表达式转化为HTML。
> (xexpr->string '(html (head (title "hello")) (body "Hi!")))
"<html><head><title>Hello</title></head><body>Hi!</body></html>"
假设dispatch
函数接收一个URL作为参数并返回一个xexpr表达式。
(define (handle in out)
(define req
; 提取请求URL
(regexp-match #rx"^GET (.+) HTTP/[0-9]+\\.[0-9]+"
(read-line in)))
(when req
; 丢弃剩下的请求头
(regexp-match #rx"(\r\n|^)\r\n" in)
; 转发
(let ([xexpr (dispatch (list-ref req 1))])
; 发送响应
(display "HTTP/1.0 200 Okay\r\n" out)
(display "Server: k\r\nContent-Type: text/html\r\n\r\n" out)
(display (xexpr->string xexpr) out))))
net/url
库提供了string->url
,url-path
,path/param-path
和url-query
函数用来提取URL中的各个部分。
> (define u (string->url "http://localhost:8080/foo/bar?x=bye"))
> (url-path u)
(list (path/param "foo" '()) (path/param "bar" '()))
> (map path/param-path (url-path u))
'("foo" "bar")
> (url-query u)
'((x . "bye"))
路径和处理器函数通过哈希表存储。
(define (dispatch str-path)
; 将请求转化为URL
(define url (string->url str-path))
; 提取路径
(define path (map path/param-path (url-path url)))
; 查找处理器
(define h (hash-ref dispatch-table (car path) #f))
(if h
; 调用处理器
(h (url-query url))
; 没有处理器
`(html (head (title "Error"))
(body
(font ((color "red"))
"Unknown page: "
,str-path)))))
(define dispatch-table (make-hash))
现在启动并访问服务器,你将看到一个错误页面。
我们可以通过下面的代码为"hello"绑定一个处理器。
(hash-set! dispatch-table "hello"
(lambda (query)
`(html (body "Hello, World!"))))
使用(enter! "serve.rkt")
重启服务器,打开http://localhost:8081/hello,就可以看到最初的页面了。
9 Servlet和Session
使用查询参数,处理器可以响应用户通过表单提供的值。
下面的函数可以创建一个HTML表单。label
参数是一个用于显示的字符串,next-url
参数表示表单提交的地址,hidden
参数告诉表单是否隐藏表单项。
(define (build-request-page label next-url hidden)
`(html
(head (title "Enter a Number to Add"))
(body ([bgcolor "white"])
(form ([action ,next-url] [method "get"])
,label
(input ([type "text"] [name "number"]
[value ""]))
(input ([type "hidden"] [name "hidden"]
[value ,hidden]))
(input ([type "submit"] [name "enter"]
[value "Enter"]))))))
使用上面的帮助函数,我们可以创建用户指定数量的"Hello"字符串。
(define (many query)
(build-request-page "Number of greetings:" "/reply" ""))
(define (reply query)
(define n (string->number (cdr (assq 'number query))))
`(html (body ,@(for/list ([i (in-range n)])
" hello"))))
(hash-set! dispatch-table "many" many)
(hash-set! dispatch-table "reply" reply)
重启服务器,在浏览器输入http://localhost:8081/many,输入一个数字,你将看到一个新页面。
10 限制内存使用
在上一个例子中,用户可以输入一个非常大的数字导致服务器内存耗尽。事实上,客户端发送一个巨大的HTTP请求也会导致类似的问题。
这类问题的解决方案是限制连接使用的内存。在accept-and-handle
的cust
定义之后,加上以下代码:
(custodian-limit-memory cust (* 50 1024 1024))
我们假设50M对于所有Servlet都已足够。垃圾收集器开销意味着系统实际使用的内存可能是50M的好几倍。但是重要的是不同连接之间不会相互影响。
加上上面的代码,上个例子的问题就解决了。
"many"的例子只是冰山一角。除了限制处理时间和内存消耗之外,还有许多安全问题。Racket的racket/sandbox
库为所有安全问题提供了支持。
11 Continuations
作为系统编程的例子,网络服务展现出了许多系统和安全的问题。示例也引出了Racket另一个经典话题:Continuations。事实上,在这方面,网络服务也需要受限的Continuations。
Continuations实在不好翻译,大概意思是说可以中断某个函数,然后再后面又回到中断的地方继续执行。但它又不同于函数调用,因为这个函数从中断的地方就已经结束了。
Continuations所解决的问题是session和计算跨连接的用户输入[Queinnec00]。这个问题的正解是在客户端计算,但许多问题也能通过其他技术很好的解决(比如利用浏览器的回退按钮)。
随着跨连接计算越来越复杂,通过URL传递参数也变得繁琐。例如,我们实现一个两数相加的服务,通过隐藏表单来记录第一个数。
(define (sum query)
(build-request-page "First number:" "/one" ""))
(define (one query)
(build-request-page "Second number:"
"/two"
(cdr (assq 'number query))))
(define (two query)
(let ([n (string->number (cdr (assq 'hidden query)))]
[m (string->number (cdr (assq 'number query)))])
`(html (body "The sum is " ,(number->string (+ m n))))))
(hash-set! dispatch-table "sum" sum)
(hash-set! dispatch-table "one" one)
(hash-set! dispatch-table "two" two)
虽然上面的代码可以运行,但是我们希望以一种更直观的方式书写代码:
(define (sum2 query)
(define m (get-number "First number:"))
(define n (get-number "Second number:"))
`(html (body "The sum is " ,(number->string (+ m n)))))
(hash-set! dispatch-table "sum2" sum2)
问题是get-number
必须为当前连接返回一个HTML响应(输入数字的表单页面),并通过一个新的连接获得响应(用户输入的数字)。也就是说,它需要将build-request-page
生成的页面转化为查询结果。
(define (get-number label)
(define query
... (build-request-page label ...) ...)
(number->string (cdr (assq 'number query))))
Continuations让我们可以实现这一操作。send/suspend
函数首先生成一个URL表示当前计算,同时作为continuation。然后它将生成的URL传递给一个用来生成页面的函数,该页面会作为当前连接的响应,而continuation则被终止。最后send/suspend
从生成的URL的请求(在一个新连接中)中恢复中断的计算。
此时,get-number
实现如下:
(define (get-number label)
(define query
; 为当前计算生成一个URL
(send/suspend
; k-url就是生成的URL
(lambda (k-url)
; 为当前连接生成页面
(build-request-page label k-url ""))))
; 在新的连接中请求生成的URL时,回到这里
(string->number (cdr (assq 'number query))))
实现send/suspend
之前,需要先引入一个依赖。
(require racket/control)
我们使用prompt
来标记servlet启动的位置,然后就可以在这里终止它了。修改handle
函数如下:
(define (handle in out)
....
(let ([xexpr (prompt (dispatch (list-ref req 1)))])
....))
现在让我们来实现send/suspend
函数。我们使用let/cc
捕获当前计算到一个prompt,并将它绑定到k
。
(define (send/suspend mk-page)
(let/cc k
...))
第二步生成一个URL,并注册到分发器。
(define (send/suspend mk-page)
(let/cc k
(define tag (format "k~a" (current-inexact-milliseconds)))
(hash-set! dispatch-table tag k)
...))
最后我们终止当前计算,将mk-page
生成的页面作为响应返回。
(define (send/suspend mk-page)
(let/cc k
(define tag (format "k~a" (current-inexact-milliseconds)))
(hash-set! dispatch-table tag k)
(abort (mk-page (string-append "/" tag)))))
当用户提交表单时,与表单URL关联的处理器是作为continuation的旧计算。调用continuation会恢复旧计算,并将参数传递会该计算。
更新代码并运行,在浏览器输入http://localhost:8081/sum2。
不过我在想,
dispatch-table
真的不会爆炸吗?
12 下一步
Racket发行版包含一个可用于生产环境的网络服务,它实现了本文提到了所有问题。更多参考继续:Racket网络编程,文档Web Applications in Racket,或者论文Krishnamurthi07。
如果你因为入门走到这里,那么接下来可以看The Racket Guide。
如果你对文中的话题感兴趣,可以看The Racket Reference的Concurrency and Parallelism和Reflection and Security两章。
参考论文:
GRacket[Flatt99]
内存计数[Wick04]
安全删除[Flatt04]
delimited continuations [Flatt07]