聊聊我的源码阅读方法

简介: 本次代码阅读的项目来自 500lines 的子项目 web-server。 500 Lines or Less不仅是一个项目,也是一本同名书,有源码,也有文字介绍。这个项目由多个独立的章节组成,每个章节由领域大牛试图用 500 行或者更少(500 or less)的代码,让读者了解一个功能或需求的简单实现。


本次代码阅读的项目来自 500lines 的子项目 web-server500 Lines or Less不仅是一个项目,也是一本同名书,有源码,也有文字介绍。这个项目由多个独立的章节组成,每个章节由领域大牛试图用 500 行或者更少(500 or less)的代码,让读者了解一个功能或需求的简单实现。本文包括下面几个部分:


  • 导读
  • 项目结构介绍
  • 简易HTTP服务
  • echo服务
  • 文件服务
  • 文件目录服务和cgi服务
  • 服务重构
  • 小结
  • 小技巧


导读



我们之前已经埋头阅读了十二个项目的源码,是时候空谈一下如何阅读源码了。


python项目很多,优秀的也不少。学习这些项目的源码,可以让我们更深入的理解API,了解项目的实现原理和细节。仅仅会用项目API,并不符合有进阶之心的你我。个人觉得看书,做题和重复照轮子,都不如源码阅读。我们学习的过程,就是从模仿到创造的过程,看优秀的源码,模仿它,从而超越它。


选择合适项目也需要一定的技巧,这里讲讲我的方法:


  1. 项目小巧一点,刚开始的时候功力有限,代码量小的项目,更容易读下去。初期阶段的项目,建议尽量在5000行以下。
  2. 项目纵向贯穿某个方向,逐步的打通整个链条。比如围绕http服务的不同阶段,我们阅读了gunicorn,wsgi,http-server,bottle,mako。从服务到WSGI规范,从web框架到模版引擎。
  3. 项目横行可以对比,比如CLI部分,对比getopt和argparse;比如blinker和flask/django-signal的差别。


选择好项目后,就是如何阅读源码了。我们之前的代码阅读方法我称之为:概读法 。具体的讲就是根据项目的主要功能,仅分析其核心实现,对于辅助的功能,增强的功能可以暂时不用理会,避免陷入太多细节。简单举个例子: “研表究明,汉字的序顺并不定一影阅响读,比如你看完这句话后才发现这里的字全是乱的”,我们了解项目主要的功能,就可以初步达到目的。


哈哈,愚人节快乐


概读法,有一个弊端:我们知道代码是这样实现的,但是无法解读为什么这样实现?所以是时候介绍一下另外一种代码阅读方法:历史对比法。历史对比法主要是对比代码的需求变化和版本历史,从而学习需求如何被实现。一般项目中,使用gitlog种的commit -message来展现历史和需求。本篇的500lines-webserver项目中直接提供了演化示例,用来演示历史对比法再适合不过。


项目结构



本次代码阅读是用的版本是 fba689d1 , 项目目录结构如下表:


目录 描述
00-hello-web 简易http服务
01-echo-request-info 可以显示请求的http服务
02-serve-static 静态文件服务
03-handlers 支持目录展现的http文件服务
04-cgi cgi实现
05-refactored 重构http服务


简易HTTP服务



http服务非常简单,这样启动服务:


serverAddress = ('', 8080)
server = BaseHTTPServer.HTTPServer(serverAddress, RequestHandler)
server.serve_forever()


只响应get请求的Handler:


class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
    ...
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-type", "text/html")
        self.send_header("Content-Length", str(len(self.Page)))
        self.end_headers()
        self.wfile.write(self.Page)


服务的效果,可以配合下面的请求示例:


# curl -v http://127.0.0.1:8080
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.64.1
> Accept: */*
>
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: BaseHTTP/0.3 Python/2.7.16
< Date: Wed, 31 Mar 2021 11:57:03 GMT
< Content-type: text/html
< Content-Length: 49
<
<html>
<body>
<p>Hello, web!</p>
</body>
</html>
* Closing connection 0


本文不打算详细介绍http协议细节的实现,如果想了解http协议细节的请看第2篇博文,或者我之前的[python http 源码阅读]

echo服务



echo服务是在简易http服务上演进的,支持对用户的请求回声。所以我们对比一下2个文件,就知道更改了哪些内容:image.png

image.png


更改的重点在 do_GET 的实现,图片可能不太清晰,我把代码贴在下面:


# hello
def do_GET(self):
    self.send_response(200)
    ...
    self.wfile.write(self.Page)
# echo        
def do_GET(self):
    page = self.create_page()
    self.send_page(page)


可以看到echo的 do_GET 中调用了 create_pagesend_page 2个方法 。短短两行代码,非常清晰的显示了echo和hello的差异。因为echo要获取客户端请求并原样输出,固定的页面肯定部满足需求。需要先使用模版创建页面,再发送页面给用户。hello的 do_GET 方法的实现重构成send_page函数的主体,新增的create_page就非常简单:


def create_page(self):
    values = {
        'date_time'   : self.date_time_string(),
        'client_host' : self.client_address[0],
        'client_port' : self.client_address[1],
        'command'     : self.command,
        'path'        : self.path
    }
    page = self.Page.format(**values)
    return page


单看echo的代码,会觉得平淡无奇。对比了hello和echo的差异,才能够感受到大师的手艺。代码展示了如何写出可读的代码和如何实现新增需求:


  • create-page和send-page函数名称清晰可读,可以望文生义。
  • create和send的逻辑自然平等。举个反例:更改成函数名称为create_page和_do_GET,功能不变,大家就会觉得别扭。
  • hello中的do_GET函数的5行实现代码完全没变,只是重构成新的send_page函数。这样从测试角度,只需要对变化的部分(create_page)增加测试用例。


对比是用的命令是 vimdiff 00-hello-web/server.py 01-echo-request-info/server.py 也可以是用ide提供的对比工具。


文件服务



文件服务可以展示服务本地html页面:


# Classify and handle request.
def do_GET(self):
    try:
        # Figure out what exactly is being requested.
        full_path = os.getcwd() + self.path
        # 文件不存在
        if not os.path.exists(full_path):
            raise ServerException("'{0}' not found".format(self.path))
        # 处理html文件
        elif os.path.isfile(full_path):
            self.handle_file(full_path)
        ...
    # 处理异常
    except Exception as msg:
        self.handle_error(msg)


文件和异常的处理:


def handle_file(self, full_path):
    try:
        with open(full_path, 'rb') as reader:
            content = reader.read()
        self.send_content(content)
    except IOError as msg:
        msg = "'{0}' cannot be read: {1}".format(self.path, msg)
        self.handle_error(msg)
def handle_error(self, msg):
    content = self.Error_Page.format(path=self.path, msg=msg)
    self.send_content(content)


目录下还提供了一个status-code的版本,一样对比一下:


image.png


如果文件不存在,按照http协议规范,应该报404错误:


def handle_error(self, msg):
    content = ...
    self.send_content(content, 404)
def send_content(self, content, status=200):
    self.send_response(status)
    ...


这里利用了python函数参数支持默认值的特性,让send_content函数稳定下来,即使后续有30x/50x错误,也不用修改send_content函数。


文件目录服务和CGI服务



文件服务需要升级支持文件目录。通常如果一个目录下有index.html就展示该文件;没有该文件,就显示目录列表,方便使用者查看,不用手工输入文件名称。


同样我把版本的迭代对比成下图,主要展示RequestHandler的变化:


image.png


do_GET要处理三种逻辑:html文件,目录和错误。如果继续用if-else方式就会让代码丑陋,也不易扩展,所以这里使用策略模式进行了扩展:


# 有序的策略
Cases = [case_no_file(),
         case_existing_file(),
         case_always_fail()]
# Classify and handle request.
def do_GET(self):
    try:
        # Figure out what exactly is being requested.
        self.full_path = os.getcwd() + self.path
        # 选择策略
        for case in self.Cases:
            if case.test(self):
                case.act(self)
                break
    # Handle errors.
    except Exception as msg:
        self.handle_error(msg)


html,文件不存在和异常的3种策略实现:


class case_no_file(object):
    '''File or directory does not exist.'''
    def test(self, handler):
        return not os.path.exists(handler.full_path)
    def act(self, handler):
        raise ServerException("'{0}' not found".format(handler.path))
class case_existing_file(object):
    '''File exists.'''
    def test(self, handler):
        return os.path.isfile(handler.full_path)
    def act(self, handler):
        handler.handle_file(handler.full_path)
class case_always_fail(object):
    '''Base case if nothing else worked.'''
    def test(self, handler):
        return True
    def act(self, handler):
        raise ServerException("Unknown object '{0}'".format(handler.path))


目录的实现就很简单了,再扩展一下 case_directory_index_filecase_directory_no_index_file 策略即可; cgi 的支持也一样,增加一个 case_cgi_file 策略。


class case_directory_index_file(object):
    ...
class case_directory_no_index_file(object):
    ...
class case_cgi_file(object):
    ...

服务重构



实现功能后,作者对代码进行了一次重构:


image.png


重构后RequestHandler代码简洁了很多,只包含http协议细节的处理。handle_error处理异常,返回404错误;send_content生成http的响应。


class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
    # Classify and handle request.
    def do_GET(self):
        try:
            # Figure out what exactly is being requested.
            self.full_path = os.getcwd() + self.path
            # Figure out how to handle it.
            for case in self.Cases:
                if case.test(self):
                    case.act(self)
                    break
        # Handle errors.
        except Exception as msg:
            self.handle_error(msg)
    # Handle unknown objects.
    def handle_error(self, msg):
        content = self.Error_Page.format(path=self.path, msg=msg)
        self.send_content(content, 404)
    # Send actual content.
    def send_content(self, content, status=200):
        self.send_response(status)
        self.send_header("Content-type", "text/html")
        self.send_header("Content-Length", str(len(content)))
        self.end_headers()
        self.wfile.write(content)


请求处理策略也进行了重构,构建了base_case父类,约定了处理的模版和步骤,并且默认提供了html文件的读取办法。


class base_case(object):
    '''Parent for case handlers.'''
    def handle_file(self, handler, full_path):
        try:
            with open(full_path, 'rb') as reader:
                content = reader.read()
            handler.send_content(content)
        except IOError as msg:
            msg = "'{0}' cannot be read: {1}".format(full_path, msg)
            handler.handle_error(msg)
    def index_path(self, handler):
        return os.path.join(handler.full_path, 'index.html')
    def test(self, handler):
        assert False, 'Not implemented.'
    def act(self, handler):
        assert False, 'Not implemented.'


html文件的处理函数就很简单,实现判断函数和执行函数,其中执行函数还是还复用父类的html处理函数。


class case_existing_file(base_case):
    '''File exists.'''
    def test(self, handler):
        return os.path.isfile(handler.full_path)
    def act(self, handler):
        self.handle_file(handler, handler.full_path)


策略最长就是不存在index.html页面的目录:


class case_directory_no_index_file(base_case):
    '''Serve listing for a directory without an index.html page.'''
    # How to display a directory listing.
    Listing_Page = '''\
        <html>
        <body>
        <ul>
        {0}
        </ul>
        </body>
        </html>
        '''
    def list_dir(self, handler, full_path):
        try:
            entries = os.listdir(full_path)
            bullets = ['<li>{0}</li>'.format(e) for e in entries if not e.startswith('.')]
            page = self.Listing_Page.format('\n'.join(bullets))
            handler.send_content(page)
        except OSError as msg:
            msg = "'{0}' cannot be listed: {1}".format(self.path, msg)
            handler.handle_error(msg)
    def test(self, handler):
        return os.path.isdir(handler.full_path) and \
               not os.path.isfile(self.index_path(handler))
    def act(self, handler):
        self.list_dir(handler, handler.full_path)

list_dir动态生成一个文件目录列表的html文件。


小结



我们一起使用历史对比法,阅读了500lines-webserver的代码演进过程,清晰的了解如何一步一步的实现一个文件目录服务。


  • RequestHandler的do_GET方法处理http请求
  • 使用send_content输出response,包括状态码,响应头和body。
  • 读取html文件展示html页面
  • 展示目录
  • 支持cgi


在学习过程中,我们还额外获得了如何扩充代码,编写可维护代码和重构代码示例,希望大家和我一样有所收获。


小技巧



前面介绍了,请求的处理使用策略模式。可以先看看来自python-patterns项目的策略模式实现:


class Order:
    def __init__(self, price, discount_strategy=None):
        self.price = price
        self.discount_strategy = discount_strategy
    def price_after_discount(self):
        if self.discount_strategy:
            discount = self.discount_strategy(self)
        else:
            discount = 0
        return self.price - discount
    def __repr__(self):
        fmt = "<Price: {}, price after discount: {}>"
        return fmt.format(self.price, self.price_after_discount())
def ten_percent_discount(order):
    return order.price * 0.10
def on_sale_discount(order):
    return order.price * 0.25 + 20
def main():
    """
    >>> Order(100)
    <Price: 100, price after discount: 100>
    >>> Order(100, discount_strategy=ten_percent_discount)
    <Price: 100, price after discount: 90.0>
    >>> Order(1000, discount_strategy=on_sale_discount)
    <Price: 1000, price after discount: 730.0>
    """


ten_percent_discount提供9折,on_sale_discount提供75折再减20的优惠。不同的订单可以使用不同的折扣模式,比如示例调整成下面:


order_amount_list = [80, 100, 1000]
for amount in order_amount_list:
    if amount < 100:
        Order(amount)
        break;
    if amount < 1000:
        Order(amount, discount_strategy=ten_percent_discount)
        break;
    Order(amount, discount_strategy=on_sale_discount)


对应的业务逻辑是:


  • 订单金额小于100不打折
  • 订单金额小于1000打9折
  • 订单金额大于等于1000打75折并优惠20


如果我们把打折的条件和折扣方式实现在一个类中,那就和web-server类似:


class case_discount(object):
    def test(self, handler):
        # 打折条件 
        ...
    def act(self, handler):
        # 计算折扣
        ...


参考链接



目录
相关文章
|
7月前
|
开发框架 Java API
java反射机制的原理与简单使用
java反射机制的原理与简单使用
|
7月前
|
Java C++
Java反射的简单使用
Java反射的简单使用
49 3
|
6月前
|
XML 安全 Java
一篇文章讲明白JAVA常用的工具类
一篇文章讲明白JAVA常用的工具类
77 0
|
前端开发 Java 编译器
Java的第十六篇文章——枚举、反射和注解(后期再学一遍)
Java的第十六篇文章——枚举、反射和注解(后期再学一遍)
|
设计模式 人工智能 程序员
感觉自己的代码很乱?因为你不懂套路
编程教室开了这么久,已经有很多人从完全零基础的小白成为了会写代码的菜鸟程序员,能够自己独立开发程序。不过到此阶段,常常会遇到瓶颈,感觉功能可以实现
|
JSON 前端开发 数据可视化
umi3源码探究简析
作为蚂蚁金服整个生态圈最为核心的部分,umi可谓是王冠上的红宝石,因而个人认为对于整个umi架构内核的学习及设计哲学的理解,可能比如何使用要来的更为重要;作为一个使用者,希望能从各位大佬的源码中汲取一些养分以及获得一些灵感
245 0
|
XML 缓存 Java
Java注解怎么用
Java注解怎么用
230 0
|
Java
Java面向对象进阶6——权限修饰符(含源码阅读)
在上面举例的代码中,brand , colour两个变量是没用访问修饰符的,但是可以在同一个包的测试类中使用是不会报错的,但是如果使用别的包中的类就会报错
106 0
Java面向对象进阶6——权限修饰符(含源码阅读)
|
存储 缓存 JSON
tinydb 源码阅读
TinyDB是一个小型,简单易用,面向文档的数据库;代码仅1800行,纯python编写。TinyDB项目大小刚好,学习它可以了解NOSQL数据库的实现。
444 0
tinydb 源码阅读