注意

  • 为方便区分,以下用py2指代python 2.7.6,用py3指代python 3.4.3

  • 代码在这里


摘要

bones7456同学和BUPTGuo同学制作和完善了SimpleHTTPServerWithUploadpy2版本。由于python的2.7和3.4有较多不同特性,因此我根据以上两位同学的思路,重写了很多函数,制作了基于py3的版本。主要改动如下:

  • 改写为基于py3的版本

  • 移除了StringIO,不使用copyfile()。需要传输的信息全都用str。处理完逻辑后,再用utf-8编码为bytes,直接用wfile.write()进行网络传输。

  • 修改html的部分标签顺序


正文

1. 背景介绍

如同这篇文章所说

如果你急需一个简单的Web Server,但你又不想去下载并安装那些复杂的HTTP服务程序,那么Python是一个不错的选择。

py2中内置了一个SimpleHTTPServer模块,从名字可以看出这是一个简单的HTTP服务器程序。在终端输入如下命令:

#if it is py2
$ python -m SimpleHTTPServer [8000]

#if it is py3
$ python3 -m http.server [8000]

就可以在目录下快速建立一个HTTP服务器。用这个方法可以方便地共享文件,只需要在浏览器中输入http://ip:8000就可以访问并下载文件了,其中ip是你的局域网ip。不过python内置的模块并没有提供上传功能。如这里所说:

但是,某一天,你需要从同学哪里复制一个文件到本机,然后你就会跟你同学说,XX,共享下某目录。当你以为可以用http来访问他的8000端口的时候,他却告诉你,不好意思,我是windows啦~~

为此,bones7456同学对这个模块进行了改造,添加了上传功能,这样就让局域网内的分享变得更加方便了。后来BUPTGuo同学进行了一些改进。在这里再次感谢两位同学的成果和开源精神~


2. 基于py3的模块

经过以上两位同学改造的模块是基于py2的,由于py2py3有较多不同特性,直接用$ python3 xxx运行会产生很多错误。所以,让我们撸起袖子开始改造轮子吧~

改造过程从分析输出的错误信息开始。先把py2的代码全部复制到一个文件py3server.py,然后根据错误信息一步步修改:

2.1 实现访问和下载

  • print

    ......
        File "py3server.py", line 57
            print r, info, "by: ", self.client_address
                  ^
    SyntaxError: Missing parentheses in call to 'print'
    

    这个很明显,直接全部改成print()

  • BaseHTTPServer

    ......
    Traceback (most recent call last):
        File "py3server.py", line 16, in <module>
            import BaseHTTPServer
    ImportError: No module named 'BaseHTTPServer'
    

    py2BaseHTTPServer模块在py3中变成了http.server模块。后面的class SimpleHTTPRequestHandler将要继承该模块下的一个handler,所以也要改。最后在定义test()的地方也要修改。

    ps:对于handler,从STM32开发的经历来看,我觉得应该是一种类似于中断处理程序的东西。

  • stringIO

    通过google我们可以知道,py3区分了BytesIOStringIO,而py2中只有stringIO。这个区别会带来很多问题。后面可以看到,为了代码不太丑陋,xxIO被愉快地弃用了。不过首先让我们from io import StringIO, BytesIO,看看接下来会发生什么。

    以上这样改完之后,我们发现$ python3 py3server.py已经能运行了~再用浏览器来访问一下。结果。。是一大堆错误信息。。不用急,一步一步分析。

  • unquote

    ......
        File "py3server.py", line 214, in translate_path
            path = posixpath.normpath(urllib.unquote(path))
    AttributeError: 'module' object has no attribute 'unquote'
    

    错误信息中最关键的是最后的内容。这里是版本问题,py3中应当用urllib.parse.unquote()urllib.parse.quote(),而不是直接urllib.unquote()。修改后运行,再通过浏览器访问。

  • stringIO

    .....
        File "py3server.py", line 42, in do_GET
            self.copyfile(f, self.wfile)
        File "py3server.py", line 236, in copyfile
            shutil.copyfileobj(source, outputfile)
        File "/usr/lib/python3.4/shutil.py", line 70, in copyfileobj
            fdst.write(buf)
        File "/usr/lib/python3.4/socket.py", line 394, in write
            return self._sock.send(b)
    TypeError: 'str' does not support the buffer interface
    

    这是类型错误,发生在浏览器进入根目录的时候。错误信息的意思是说copyfileobj()只接受buffer-like对象,而不能用str-like对象作为参数传入。阅读代码,追踪出问题的f,它是由do_GET()得到的,后者又经历了send_head()

    send_head()的最后可以看到它return了一个f,而它是由上面几行的f = open(path, 'rb')得到的。按理来说,f一个二进制打开的的文件,应该是buffer-like的对象,应该不会错在这里才对。(补充一下,打开的文件f=open(filname, 'rb')可以算是buffer-like,但是如果用data=f.read(),那么data是一个bytes对象。如果用copyfileobj(),会报错提示缺少read属性。)

    别急,我们再来仔细看看send_head()。可以发现,这个函数首先对所请求的path进行检查,如果path是目录则return list_directory(path)。如果path不是目录,那就说明已经定位到文件了(如果存在),因此下半部分就是要展示(传输)文件了。

    在这儿我们可以简单验证一下。在根目录下新建一个readme.txt,里面输入hello, world,保存退出。然后在地址栏输入http://ip:port:8000/,这时还是会出现刚才的错误。但是如果输入http://ip:port:8000/readme.txt,就能发现屏幕上出现了hello world(另外可以看看终端的输出,不再是错误信息,而是..."GET /readme.txt HTTP/1.1" 200 -)。说明我们刚才的猜测是对的。

    至此,我们暂时把文件保存为py3server_v1.py,以便参照。接下来的v2,我们要让目录页也能正确显示。

  • 正确显示目录

    接下来我们进入list_directory()内部,可以看到里面赫然写着f = StringIO()。好嘛,这不就是红果果的str-like对象吗!把这个传回给一个只接受buffer-like对象的家伙可不会出错嘛!从这里也能看出,py3对于数据类型的区分更严格了。另外,关于py3StringIOBytesIO的内容,可以参考这里这里

    话不多说,我们先试着把这一行改成f = BytesIO(),重启服务,刷新网页。结果上一个错误没了,其他错误又冒出来一大堆。。

    ......
        File "py3server.py", line 40, in do_GET
            f = self.send_head()
        File "py3server.py", line 144, in send_head
            return self.list_directory(path)
        File "py3server.py", line 176, in list_directory
            f.write('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">')
    TypeError: 'str' does not support the buffer interface
    

    阅读错误信息,又是刚才的TypeError: 'str' does not support the buffer interface。得,这不又回来了?这刚才的力气白花了。。

    看官且慢!其实如果再往上读两行,发现出错地点是不一样滴~可以看到,错误出现在f.write()身上。原来的f是一个StringIO对象,用f.write()时的参数可以直接用字符串,比如f.write('hi')。不过当我们用了f = BytesIO()后,给f写值时就需要先进行编码了,也就是f.write('hi'.encode('utf-8')),或者用f.write(b'hi')。我试过用这种方式把相应的地方进行改动,包括这里提到的用f.seek(0)回到文件最开头,同时修改do_POST()的内容。不过,这里不打算对这样的修改再作进一步描述了,因为这种削足适履的方法会让代码变得很丑。。

    让我们回想一下,最终不就是要把内容复制到wfile上吗(放到这上面的内容,应该会有一个handler把它带到网络上进行传输)?产生这些错误,都是因为copyfileobj()(躺枪_(:з」∠)_)。不用它,直接写入wfile怎么样?

    结果证明是可行的。从这里可以看到可以用wfile.write()方法,写入类型为bytes的参数。现在问题就简单了,把StringIO还是BytesIO全部扔掉,然后把要传输的内容全都用字符串表示f = 'hello',接着f.encode('utf-8'),最后直接self.wfile.write(f)进行传输就行了。

    有了这些说明,再去读这部分重写的代码,应该就很容易了。结构是这样的:

    # in list_directory()
    ...
    f = (html_in_str)
    f = f.encode('utf-8')
    length = len(f)	# 注意, 内容长度是编码后的长度
    self.send_response(200)
    self.send_header("Content-type", "text/html")
    self.send_header("Content-Length", str(length))
    self.end_headers()
    return f
    
    # in do_GET()
    ...
    self.wfile.write(f)
    

    我们将这个文件定为py3server_v2.py,对应的改动有以下几点。

    • do_GET()现在的内容:

      f = self.send_head()
      if f:
          self.wfile.write(f)
      
    • do_HEAD()现在的内容:

      f = self.send_head()
      
    • send_head()的最后,将return f改为

      data = f.read()
      f.close()
      return data
      
    • list_directory()改动较大,参考py3server_v2.py文件(包含html的修改)。

    • 去掉copyfile(),去掉from io import StringIO, BytesIO,减少冗余。

  • py3原生版本的方法

    http.server模块的思路是这样:

    建立一个list对象r—>元素为字符串,分别写入html—>用join连成字符串—>编码成r_encoded—>…

    到这一步两者总体思路是类似的。但是下一步,py3的原生版本还是做了f = BytesIO(),然后 f.write(r_encoded)

    我没有想明白,为什么一定要用BytesIO呢?直接新建字符串对象,然后编码传入wfile,同样是在内存中操作数据啊。

    可能是存在内存操作分配更加方便,整存整取,回收等原因?那也不应该啊,因为str和list都是很常见的对象,如果有很多缺点那还得了。。还是有其他原因?另一方面,py3server.py的方式目前也能工作正常。我在知乎提到了这个问题,希望能够得到解答。

如果抛开这个问题不管,现在介个基于py3py3server_v2.py服务端已经可以用浏览器正常访问和下载了。还有一些细节,比如fs = os.fstat(f.fileno()),这里不再详细描述,通过搜索引擎可以很快了解。让我们先去吃点东西。下一节,我们通过重写do_POST()来实现上传功能。

2.2 实现上传

有了上面一节的说明,又有前面两位同学的思路和框架,重写上传功能应该是驾轻就熟了。所以下面直接进入重点。

py3server_v2.py中,我们已经写好了一个简单的用于上传的前端部件。

<form ENCTYPE="multipart/form-data" method="post">
	<input name="file" type="file"/>
	<input type="submit" value="upload"/>
</form>

下面还要进行一些修改

  • do_POST()处理POST请求

    首先去掉f=StringIO(),直接用字符串,写入一个基本的html页面,用于呈现upload之后的信息(上传成功/失败),并和响应头信息一起发回给浏览器。

  • deal_post_data()处理POST数据

    如果不逐行研究其意义的话,改起来也是很快的,找到py2py3的区别,同时注意编码即可。具体代码可以参见py3server_v3.py

  • 还是想理解这几段代码?

    do_POST()的代码还好,只要懂一点HTMLHTTP就能看懂。而deal_post_data()一开始我也看不太懂。幸好之前测试的时候发现了wfilerfile的秘密,我们可以用这个来看看这个函数到底是deal了什么数据:

    首先在服务端,我们注释掉do_POST()deal_post_data()两个函数。然后重新写一个do_POST()读出准备接收的所有数据:

    def do_POST(self):
        for i in range(8):
            print(self.rfile.readline().decode('utf-8'))
    

    接着我们在客户端新建一个准备上传的文件,命名为1.txt,里面写入内容test。打开chrome开发者工具的NetworkChoose File选择1.txt,点击upload。看看发生了什么有意思的事~

    终端输出的内容+Network中的内容,有这些做参照,加上py3server_v3.py中的小注释,代码比较很容易懂了。这个算作思考题吧:)

至此,我们完成了上传功能的重写。py3_SimpleHTTPServerWithUpload.py大功告成。我们也可以像bones7456同学一样,喂它做一个alias,以后就可以方便地在局域网中共享文件了~

3. 其他内容

  • 这里还有另外几个文件传输的小脚本可以作为参考或者练练手。

  • GitHub的通过网站新建文件时的preview貌似要去访问服务器,而没有像jser一样用js本地化的预览。

  • BUPTGuo同学还在gist上面留了几个TODO

    TODO: 点击中文目录时,终端输出为 unicode 编码,回头尝试修改

    TODO: 尝试 ipv6支持

以下是对于TODO的一些思考和尝试。

  1. 终端输出的可能不算是unicode。根据这里这里,称其为percent-encodingurl-encoding比较合适。

    中文这两个字为例。根据这里: python3中的字符串是以Unicode编码的。如果知道字符的整数编码,还可以用十六进制这么写str:

    >>> '\u4e2d\u6587' #this is unicode
    '中文'
    

    Unicode表示的str通过encode()方法可以编码为指定的bytes,以便在网络上传输。

    >>> '中文'.encode('utf-8')
    b'\xe4\xb8\xad\xe6\x96\x87'
    

    如果在server的根目录下建立一个叫做中文的目录,然后在浏览器中访问。通过观察Chrome的开发者工具,可以看到Request Header里面的url对应的中文是这样的

    %E4%B8%AD%E6%96%87
    

    通过对比可以看到,utf-8-encoding之后的编码的\x变成了%。在这里可以看到,两者都是转义字符,只不过应用场景不一样。

    另一方面,利用Chrome的开发者工具,可以看到在Request Headers里面,不管是用GET还是POST,如果路径是中文,url那一段就会被percent-encoding。所以我觉得,这一步编码应该是浏览器做的,在服务端的终端只是把收到的GET或者POSTurl打印出来了。

    阅读代码后发现,不管是在py2BaseHTTPServer.py还是在py3http.server.py,打印这行信息靠的是log_message()中用的sys.stderr.write()函数(方法)。

    同时也发现,请求信息存放于self.requestline,其中包含了路径信息,可以用正则表达式路径提取出来。比如在do_GET()的最后加上这么几行:

    path = re.match(r'.* /(.*)/ HTTP',self.requestline).group(1)
    print(path)
    

    至于如何实现,和do_GET()一样,我们可以重写log_message()或者调用它的log_request()。后者如下:

    def log_request(self, code='-', size='-'):
        path1 = self.requestline
        m = re.match(r'.* /(.*)/ HTTP', path1)
        if m:
            path2 = m.group(1)
            path3 = urllib.parse.unquote(path2)
            path4 = path1.replace(path2, path3)
            self.requestline = path4
        self.log_message('"%s" %s %s',self.requestline, str(code), str(size))
    
    def do_POST(self):
        for i in range(8):
            print(self.rfile.readline().decode('utf-8'))
    

    不过感觉这个没有必要,终端输出反正没人看。。复杂了还容易出错。另外,英文路径编码前后是一样的。

  2. 【ipv6支持】估计要牵涉到更底层吧,到BaseHTTPServer这一层才import了socket,相当于对SimpleHTTPServer隐藏了socket。从这里我们可以知道,如果要用ipv6,则需要s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)

    所以我们的目的就是要修改socket的参数,首先找到test()这个函数,然后找到它的参数ServerClass = http.server.HTTPServer,在python目录中找到http这个文件夹中的server.py,搜索HTTPServer,得知它是继承了socketserver.TCPServer这个类,再去python目录下找到socketserver.py这个文件,在TCPServer这个类中可以看到address_family = socket.AF_INET(第415行),也就是默认用的ipv4。如果修改为AF_INET6(可能需要sudo)并保存,然后在浏览器地址栏输入http://[::1]:8000,就可以通过ipv6访问了([::1]是ipv6形式的localhost)。同时也注意到,ipv4仍然能够访问。(以上内容基于py3,但py2类似)

    还有其他测试方法:

    $ ping6 xxxx%eth0:8000
    $ nc -zv -6 localhost 8000
    $ nc -zv -6 ::1 8000
    

    不过话又说回来,要这么往下改就比较复杂了,失去了原来的轻便。



4. 参考

bones7456同学

BUPTGuo同学

google

Stack Overflow的答友们

python-docs及源文件

liaoxuefeng老师

ztelur

许伟林

Lesca技术宅

chaimg

由于前期一些搜索内容忘了保存地址了,所以参考资料的出处可能有一些遗漏。。。

—20170106补充—

再谈python中的url编码



后记(20161009 更新)

Jekyll默认使用的kramdown并不能很好地支持markdown的代码段,也就是类似下面这种形式的code block

code

一番寻觅之后找到了Redcarpet, 食用方式如下:

  1. $ gem install redcarpet

  2. 修改_config.yml,注释掉markdown: kramdown,下面加上一行markdown: redcarpet

  3. 如果有Gemfile,则添加一行gem "redcarpet"

  4. $ jekyll build + $ Jekyll serve


后后记(2016.10.10 00:12更新)

替换为Redcarpet后,GitHub竟然给我发来一封邮件说不再支持Redcarpet,而且可能随时停用这个markdown的渲染引擎,让我使用默认的kramdown,因为它已经支持了全部特效(包括我需要的code block)。无语了,难道要我全都使用Liquid的highlight-code-language这样的语法吗(这在原生的markdown语法中是没有的)。。。

后来无意中发现,其实kramdown还是支持code block的,只是它对markdown进行parse要求更加严格了,有点像python靠是否对齐来判断是否属于同一级。具体来说,就是在*或者1.后面不能接空格 ,而应该用tab对齐,否则下面用tab缩进的code block就不能正常显示。

目前已经换回kramdown,行首空格全部换为tab