更多:Racket系统编程


作者: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连接,listenertcp-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))))

上面的代码并不完全正确,因为但线程结束后,inout流依然是打开的。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))))

现在inout以及调用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->urlurl-pathpath/param-pathurl-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-handlecust定义之后,加上以下代码:

(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 ReferenceConcurrency and ParallelismReflection and Security两章。

参考论文:

GRacket[Flatt99]

内存计数[Wick04]

安全删除[Flatt04]

delimited continuations [Flatt07]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值