01 框架推导过程 jinja2 wsgiref 三大主流框架初始

1.纯手撸一个简易版本的web框架
    1.手动处理socket请求
    2.手动处理http数据,获取用户访问的url
    3.根据用户访问url的不同 响应不同的结果
2.wsgiref模块(web服务端网关接口)
    封装了socket代码
    请求来的时候将http数据格式 拆封成一个大字典
    响应走的时候将数据打包成符合http协议要求的数据格式
        启动
            server = make_server('127.0.0.1',8080,run)
            # 实时监测127.0.0.1:8080地址 一旦有客户端来连接 会自动加括号调用run方法
            server.serve_forever()  # 启动服务端
3.如何给html页面传递数据 
    1.如何将当前日期渲染到html页面  >>>  用r模式读取html文件成字符串 利用字符串的替换
    2.如何将一个字典渲染到html页面上并且还能够简单快捷取值操作  
        模板的渲染  
        jinja2模块
            1.模板的渲染(传数据)
            2.模板语法(极其贴近python语法)
        
            from  jinja2 import Template
            将文本格式的html数据传入Template
            render()
        
        模板语法
            {{ data }}
            {% for user in user_list %}
            {% endfor %}
            
            模板语法 只有两种情况 
                {{}}:变量相关
                {%%}:逻辑相关

4.对不同的功能拆封成不同的文件
    templates文件夹(专门放html文件)
    urls.py
    views.py
    
    对于我们这个建议版本的web框架 如果想要新增一个功能只需要在
    上面两个文件中各写一点代码即可

HTTP协议
    1.四大特性
        1.基于TCP/IP作用于应用层之上的协议
        2.基于请求响应
        3.无状态
            cookie
            session
            token
        4.无连接
            keep-alive
            
            websocket(主要用在聊天室相关功能)
        
    2.请求响应数据格式
        请求首行(请求方式 协议版本)
        请求头(一堆k:v键值对)  就类似于开路先锋 携带了一些服务端可以提前知道的信息
        
        请求体(发送post请求 携带的数据都会放在请求体中)
        
        
        
        响应数据格式同上
    3.响应状态码
        1XX
        2XX:200
        3XX:301/302
        4XX:404/403
        5XX:500

 参考:https://www.cnblogs.com/suwanbin/p/11520959.html

一、手撸简单web服务端

#数据类型转换技巧

data = b'hello world'
#类型转换要不就用encode、decode,要不就用下面这种直接干脆
data = str(data,encoding='utf-8')
data = bytes(data,encoding='utf-8')

我们一般是使用浏览器当做客户端,然后基于HTTP协议自己写服务端代码作为服务端

import socket
server = socket.socket() #不传参数是Tcp协议
server.bind(('127.0.0.1',8080))
server.listen(5)

while True:
    conn, addr = server.accept()
    data = conn.recv(1024) #接收请求,洪水攻击会让这里一直处于接收状态
    print(data)
    data = data.decode('utf-8')
    conn.send(b'HTTP/1.1 200 OK\r\n\r\nhello world!')
    #HTTP协议格式发送响应给客户端,响应首行(协议版本和响应状态)+响应头(空\r\n)+(\r\n)+响应体hello world!

    # conn.send(b'hello world!')
    #这里如果直接发送'hello world!'客户端会报错响应无效

    conn.close()
"""
#请求首行
b'GET / HTTP/1.1\r\n
#请求头
Host: 127.0.0.1:8080\r\n

Connection: keep-alive\r\n
Cache-Control: max-age=0\r\n
Upgrade-Insecure-Requests: 1\r\n
#这个是浏览器的标识,头里面没有用这个就不是浏览器,爬虫反扒措施
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36\r\n
Sec-Fetch-Mode: navigate\r\n
Sec-Fetch-User: ?1\r\n
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3\r\n
Sec-Fetch-Site: none\r\n
Accept-Encoding: gzip, deflate, br\r\n
Accept-Language: zh-CN,zh;q=0.9\r\n
\r\n  #这里是那个空白行
#请求体 因为是ge请求所以这里请求体为空
'

b'GET /favicon.ico HTTP/1.1\r\n  #这下面的先不用管
Host: 127.0.0.1:8080\r\n
Connection: keep-alive\r\n
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36\r\n
Accept: image/webp,image/apng,image/*,*/*;q=0.8\r\n
Referer: http://127.0.0.1:8080/\r\n
Accept-Encoding: gzip, deflate, br\r\n
Accept-Language: zh-CN,zh;q=0.9\r\n
\r\n'
"""

二、手撸 根据不同url做出不同响应

上面的代码已经实现了基本请求响应,那如何根据不同的请求作出不同的响应呢?

我们输入不同的url,看看服务器端会返回什么

#分析请求

 

 

我们发现客户端请求什么,服务端收到的数据请求首行里面就会出现什么内容,那么我们能不能把这个数据取出来返回出去呢?

#获得请求内容作出响应

import socket
server = socket.socket() 
server.bind(('127.0.0.1',8080))
server.listen(5)

while True:
    conn, addr = server.accept()
    data = conn.recv(1024)
    conn.send(b'HTTP/1.1 200 OK\r\n\r\n')
    #流式协议,合着发和分开发是一样的,这里先发,后面发送响应体
    # print(data)
    data = data.decode('utf-8')
    current_path = data.split('\r\n')[0].split(' ')[1]
    # print(current_path)
    if current_path == '/index':
        # conn.send(b'index')
        #既然我可以发送内容,那么是不是也可以根据不同请求发送不同前端代码回去呢??
        with open(r'templates\01 纯手撸html文件.html', 'rb') as f:
            conn.send(f.read())
    elif current_path == '/login':
        conn.send(b'login')
    else:
        conn.send(b'hello world!')
    conn.close()
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
</head>
<body>
<h1>我发送前端页面了</h1>
</body>
</html>
html文件

#请求页面成功接收

 

 三、基于wsgiref模块实现服务端

前面处理 scoket 和 http 的那堆代码通常是不变的,且与业务逻辑没什么关系,如果每个项目都要写一遍,那岂不是很麻烦?那封装成模块嘛~

不过这个操作已经有人帮我们做了,并且封装的更加强大,就是 wsgiref 模块

3.1 wsgiref帮我们做了:

1.在请求来的时候,自动解析 HTTP 数据,并打包成一个字典,便于对请求发过来的数据进行操作
2. 发响应之前,自动帮忙把数据打包成符合 HTTP 协议的格式(响应数据格式,不需要再手动写 conn.send(b'HTTP/1.1 200 OK\r\n\r\n') 了)

 

from wsgiref.simple_server import make_server

def run(env,response):
    # env是请求相关的数据
    # response是响应相关的数据
    print(env)  #发现env就是一个大字典,可以看到其中有个 PATH_INFO 是我们要的东西,如'登录'(还有浏览器版本啊,USER-AGENT啊,客户端系统环境变量啊之类的信息)
    response('200 OK',[])
    current_path = env.get('PATH_INFO')
    # print(current_path)
    if current_path == '/index':
        return [b'index']
    elif current_path == '/login':
        return [b'login']
    else:
        return [b'404 error']

if __name__ == '__main__':
    server = make_server('127.0.0.1',8080,run) # 实时监测127.0.0.1:8080地址 一旦有客户端来连接 会自动加括号调用run方法
    server.serve_forever()  # 启动服务端

#伏笔

3.2 拆分服务端代码

服务端代码、路由配置、视图函数,照目前的写法全都冗在一块儿,后期功能扩展时,这个文件会变得很长,不方便维护,所以选择把他拆分开来

就是将服务端代码拆分成如下三部分:

  • server.py 放服务端代码

  • urls.py 放路由与视图函数对应关系

  • views.py 放视图函数/类(处理业务逻辑)

views.py

def index(env):
    return 'index'

def login(env):
    return 'login'

urls.py

from views import *

urls = [
    ('/index', index),
    ('/login', login),
]

server.py

from wsgiref.simple_server import make_server  # 导模块
from urls import urls  # 引入 urls.py 里的 urls列表(命名的不是很规范)


def run(env, response):
    response('200 OK', [])
    current_path = env.get('PATH_INFO')

    func = None
    for url in urls:
        if current_path == url[0]:
            func = url[1]
            break

    if func:
        res = func(env)
    else:
        res = '404 Not Found.'
    return [res.encode('utf-8')]  # 注意这里返回的是一个列表(可迭代对象才行),wsgiref 模块规定的,可能还有其他的用途吧

if __name__ == '__main__':
    server = make_server('127.0.0.1', 8080, run)
    server.serve_forever()

3.3 支持新的请求地址(添加新页面/新功能)

经过上面的拆分后,后续想要支持其他 url,只需要在 urls.py 中添加一条对应关系,在 views.py 中把该函数实现,重启服务器即可访问

以支持 http://127.0.0.1:8080/new_url 访问为例

urls.py

 

from views import *

urls = [
    ('/index', index),
    ('/login', login),
    ('/new_url', new_url),
]

views.py

def index(env):
    return 'index'

def login(env):
    return 'login'

def new_url(env):
    # 这里可以写一堆逻辑代码
    return 'new_url'

重启服务器,打开浏览器即可访问 http://127.0.0.1:8080/new_url

扩展性高了很多,且逻辑更清晰了,更不容易弄错(框架的好处提现,也是为什么脱离了框架不会写的原因,这块代码写的太少,不常用到,没了框架又写不出来)

四、动态静态网页--拆分模板文件

 

前面写了那么多,都只是一直在返回纯文本信息,而我们一般请求页面返回的都是浏览器渲染好的华丽的页面,那要怎么放回华丽的页面呢?
页面嘛,就是 HTML + CSS + JS 渲染出来的,所以我们也可以把 HTML文件当成数据放在响应体里直接返回回去
新建一个功能的步骤还是复习一下

在 urls.py 里面加一条路由与视图函数的对应关系
在 views.py 里面加上那个视图函数,并写好内部逻辑代码
重启服务器,浏览器打开页面访问

4.1 返回静态页面--案例

这里咱们就接着上面的 new_url 写,用他来返回 一个网页

新建一个 templates 文件夹,专门用来放 HTML 文件,便于管理

templates/new_url.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h1>New URL</h1>
    <h5>Wellcome!</h5>
</body>
</html>

views.py

def index(env):
    return 'index'

def login(env):
    return 'login'

def new_url(env):
    # 读取并把 new_url 文件返回给客户端(浏览器)
    with open(r'templates/new_url.html', 'rb') as f:
        html_data = f.read()
    return html_data.decode('utf-8')  # 因为 run 函数那里做了 encode, 而二进制数据没有 encode这个方法,所以这里先解码一下,然后那边再编码一下

重启服务器,使用浏览器访问

 

上面提到了静态页面,那什么是静态页面?什么又是动态页面呢?

  • 静态网页:纯html网页,数据是写死的,所有同url的请求拿到的数据都是一样的

  • 动态网页:后端数据拼接,数据不是写死的,是动态拼接的,比如:

    ​ 后端实时获取当前时间“传递”(塞)给前端页面展示

    ​ 后端从数据库获取数据“传递”给前端页面展示

4.2 实现返回时间--插值思路(动态页面)

# 要怎么在 html 里插入时间呢?

往 html 里的插入?那替换好像也可以达到效果啊?

html_data = f.read() ? 好像 html 被读出出来了,而且还是二进制的,二进制可以 decode 变成字符串,字符串有 replace方法可以替换字符串,那我随便在网页里写点内容,然后替换成时间?

 

# templates/get_time.html 编写展示页面

put_times_here 用来做占位符,一会儿给他替换成时间

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h1>北京时间:</h1>
    <h1>put_time_here</h1>
</body>
</html>

urls.py 路由与视图函数对应关系

from views import *

urls = [
    ('/index', index),
    ('/login', login),
    ('/new_url', new_url),
    ('/get_time', get_time),
]

views.py 实现视图函数

def index(env):
    return 'index'

def login(env):
    return 'login'

def new_url(env):
    # 读取并把 new_url 文件返回给客户端(浏览器)
    with open(r'templates/new_url.html', 'rb') as f:
        html_data = f.read()
    return html_data

def get_time(env):
    # 读取并把 get_time 文件返回给客户端(浏览器)
    with open(r'templates/get_time.html', 'rb') as f:
        html_data = f.read().decode('utf-8')
    import time
    html_data = html_data.replace('put_time_here', time.strftime("%Y-%m-%d %X"))
    return html_data

重启服务器并打开浏览器访问 http://127.0.0.1:8080/get_time

关键思路:相当于占位符,字符串替换,后期把前端要替换的字符的格式统一规定下,方便阅读与统一处理,这其实也就是目前的模版语法的雏形

我们只需要把处理好的字符串(HTML格式的)返回给浏览器,待浏览器渲染即可有页面效果

五、利用 jinja2 模块实现动态页面

jinja2模块有着一套 模板语法,可以帮我更方便地在 html 写代码(就想写后台代码一样),让前端也能够使用后端的一些语法操作后端传入的数据

5.1 安装 jinja2

jinja2 并不是 python 解释器自带的,所以需要我们自己安装(命令行执行,pip3 install jinja2)

​ 由于 flask 框架是依赖于 jinja2 的,所下载 flask 框架也会自带把 jinja2 模块装上

5.2 初步使用

这里只是知道有模板语法这么一个东西可以让我们很方便的往 html 写一些变量一样的东西,并不会讲 jinja2 的语法,后续会有的

案例--展示字典信息

urls.py

from views import *

urls = [
    ('/index', index),
    ('/login', login),
    ('/new_url', new_url),
    ('/get_time', get_time),
    ('/show_dic', show_dic),
]

views.py

def index(env):
    return 'index'


def login(env):
    return 'login'


def new_url(env):
    # 读取并把 new_url 文件返回给客户端(浏览器)
    with open(r'templates/new_url.html', 'rb') as f:
        html_data = f.read()
    return html_data


def get_time(env):
    # 读取并把 get_time 文件返回给客户端(浏览器)
    with open(r'templates/get_time.html', 'rb') as f:
        html_data = f.read().decode('utf-8')
    import time
    html_data = html_data.replace('put_time_here', time.strftime("%Y-%m-%d %X"))
    return html_data


def show_dic(env):
    user = {
        "username": "jason",
        "age": 18,
    }
    with open(r'templates/show_dic.html', 'rb') as f:
        html_data = f.read()

    # 使用 jinja2 的模板语法来将数据渲染到页面上(替换占位符)
    from jinja2 import Template
    tmp = Template(html_data)
    res = tmp.render(dic=user)  # 将字典 user 传递给前端页面,前端页面通过变量名 dic 就能够获取到该字典
    return res

templates/show_dic.html 写页面

jinja2 给字典扩展了点语法支持({{ dic.username }}

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h1>Nice to meet you~ i'm {{ dic.username }} , and i'm {{ dic.age }} years old.</h1>
    <p>username: {{ dic['username']}}</p>
    <p>age: {{ dic.get('age')}}</p>
</body>
</html>

重启服务器并打开浏览器访问 http://127.0.0.1:8080/show_dic

5.3 为什么说动态?

如果你改变了字典里的值,那么请求这个页面,显示的数据也会跟着改变(注意这个字典一般都是其他地方获取过来的)

模板语法(贴近python语法): 前端也能够使用后端的一些语法操作后端传入的数据
    {{data.password}}  # jinja2 多给字典做了 点语法支持
    模板语法还有很多,先不考虑
    
    for 循环
    {%for user_dict in user_list%}
        <tr>
            <td>{{user_dict.id}}</td>
            <td>{{user_dict.name}}</td>
            <td>{{user_dict.password}}</td>
        </tr>
    {%endfor%}

六、进阶案例--渲染数据库数据到页面

6.1 思路

pymsql 从数据库取数据(指定成 列表套字典 的格式(DictCursor))
后台 python 代码处理数据
交由 jinja2 模块语法渲染到 html 页面上

数据条数不定怎么办?
    有多少条记录就显示多少条呗...循环?
    表格格式先写好,然后循环渲染数据到标签上(特定语法表示循环)

6.2 数据准备

创建数据库 django_test_db,然后执行如下 SQL 命令

/*
 Navicat MySQL Data Transfer

 Source Server         : localhost-E
 Source Server Type    : MySQL
 Source Server Version : 50645
 Source Host           : localhost:3306
 Source Schema         : django_test_db

 Target Server Type    : MySQL
 Target Server Version : 50645
 File Encoding         : 65001

 Date: 15/09/2019 00:41:09
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for user_info
-- ----------------------------
DROP TABLE IF EXISTS `user_info`;
CREATE TABLE `user_info`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of user_info
-- ----------------------------
INSERT INTO `user_info` VALUES (1, 'jason', '123');
INSERT INTO `user_info` VALUES (2, 'tank', '123');
INSERT INTO `user_info` VALUES (3, 'jerry', '123');
INSERT INTO `user_info` VALUES (4, 'egon', '456');

SET FOREIGN_KEY_CHECKS = 1;
View Code

6.3 配路由与视图函数

urls.py

from views import *

urls = [
    ('/index', index),
    ('/login', login),
    ('/new_url', new_url),
    ('/get_time', get_time),
    ('/show_dic', show_dic),
    ('/get_users', get_users),
]

views.py

def index(env):
    return 'index'


def login(env):
    return 'login'


def new_url(env):
    # 读取并把 new_url 文件返回给客户端(浏览器)
    with open(r'templates/new_url.html', 'rb') as f:
        html_data = f.read()
    return html_data


def get_time(env):
    # 读取并把 get_time 文件返回给客户端(浏览器)
    with open(r'templates/get_time.html', 'rb') as f:
        html_data = f.read().decode('utf-8')
    import time
    html_data = html_data.replace('put_time_here', time.strftime("%Y-%m-%d %X"))
    return html_data


def show_dic(env):
    user = {
        "username": "jason",
        "age": 18,
    }
    with open(r'templates/show_dic.html', 'rb') as f:
        html_data = f.read()

    # 使用 jinja2 的模板语法来将数据渲染到页面上(替换占位符)
    from jinja2 import Template
    tmp = Template(html_data)
    res = tmp.render(dic=user)  # 将字典 user 传递给前端页面,前端页面通过变量名 dic 就能够获取到该字典
    return res


# 先写个空函数在这里占位置,去把 pymysql 查数据的写了再过来完善
def get_users(env):
    # 从数据库取到数据
    import op_mysql
    user_list = op_mysql.get_users()

    with open(r'templates/get_users.html', 'r', encoding='utf-8') as f:
        html_data = f.read()

    from jinja2 import Template  # 其实这个引入应该放在页面最上方去的,但为了渐进式演示代码推进过程,就放在这里了
    tmp = Template(html_data)
    res = tmp.render(user_list=user_list)
    return res

op_mysql.py 如果你的配置不一样要自己改过来

import pymysql


def get_cursor():
    server = pymysql.connect(
        # 根据自己电脑上 mysql 的情况配置这一块的内容
        host='127.0.0.1',
        port=3306,
        user='root',
        password='000000',
        charset='utf8',  # 千万注意这里是 utf8 !
        database='django_test_db',
        autocommit=True
    )
    cursor = server.cursor(pymysql.cursors.DictCursor)
    return cursor


def get_users():
    cursor = get_cursor()  # 连接数据库

    sql = "select * from user_info"  # 把用户的所有信息查出来(一般不会把密码放回给前端的,这里只是为了做演示)
    affect_rows = cursor.execute(sql)
    user_list = cursor.fetchall()
    return user_list

templates/get_users.html 用户信息展示页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <!--  引入jquery bootstrap 文件的 CDN  -->
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
    <link href="https://cdn.bootcss.com/twitter-bootstrap/3.4.1/css/bootstrap.min.css" rel="stylesheet">
    <script src="https://cdn.bootcss.com/twitter-bootstrap/3.4.1/js/bootstrap.min.js"></script>
</head>
<body>
    <div class="container">
        <div class="row">

            <div class="col-md-8 col-md-offset-2">
                <h2 class="text-center">用户数据展示</h2>
                <table class="table table-hover table-bordered table-striped">
                    <thead>
                    <tr>
                        <th>id</th>
                        <th>username</th>
                        <th>password</th>
                    </tr>
                    </thead>
                    <tbody>
                    <!-- jinja2 的模版语法(for循环) -->
                    {%for user_dict in user_list%}
                    <tr>
                        <td>{{user_dict.id}}</td>
                        <td>{{user_dict.username}}</td>
                        <td>{{user_dict.password}}</td>
                    </tr>
                    {%endfor%}
                    </tbody>
                </table>
            </div>

        </div>
    </div>
</body>
</html>

用浏览器访问 http://127.0.0.1:8080/get_users,重启服务器,在切回浏览器即可看到页面效果

七、推导流程与小总结

1.纯手撸web框架
    1.手动书写socket代码,返回‘hello world!’
    2.手动处理http数据,发什么返回什么
  3.返回html文件 2.基于wsgiref模块帮助我们处理scoket以及http数据(代替上面的步骤) wsgiref模块 1.请求来的时候 解析http数据帮你打包成一个字典传输给你 便于你操作各项数据 2.响应走的时候 自动帮你把数据再打包成符合http协议格式的样子 再返回给前端 3.封装路由与视图函数对应关系 以及视图函数文件 网站用到的所有的html文件全部放在了templates文件夹下 1.urls.py 路由与视图函数对应关系 2.views.py 视图函数 (视图函数不单单指函数 也可以是类) 3.templates 模板文件夹 4.基于jinja2实现模板的渲染 模板的渲染 后端生成好数据 通过某种方式传递给前端页面使用(前端页面可以基于模板语法更加快捷简便使用后端传过来的数据)

流程图

小扩展

在不知道是要 encode 还是 decode 的时候,可以用一下方法

数据类型转换技巧(处理编码)(数据 + encoding)

# 转成 bytes 类型
bytes(data, encoding='utf-8')

# 转成 str 类型
str(data, encoding='utf-8')

八、python三大Web主流框架分析对比

8.1 Django

大而全,自带的功能特别特别多,就类似于航空母舰

缺点:有时过于笨重(小项目很多用不到)

8.2 Flask

短小精悍,自带的功能特别少,全都是依赖于第三方组件(模块)

第三方组件特别多 --> 如果把所有的第三方组件加起来,完全可以盖过django

缺点:比较受限于第三方的开发者(可能有bug等)

8.3 Tornado

天生的异步非阻塞框架,速度特别快,能够抗住高并发

​ 可以开发游戏服务器(但开发游戏,还是 C 和C++用的多,执行效率更快)

8.4 手撸三大部分在框架中的情况对比

前面的手撸推导过程,整个框架过程大致可以分为以下三部分

A:socket处理请求的接收与响应的发送

B:路由与视图函数

C:模板语法给动态页面渲染数据

Django

A:用的别人的 wsgiref 模块
B:自带路由与视图函数文件
C:自带一套模板语法

Flask

A:用的别人的werkzeug 模块(基于 wsgiref 封装的)
B:自带路由与视图函数文件
C:用人家的jinja2的模板语法

Tornado

A,B,C全都有自己的实现

 

posted @ 2019-09-22 21:56  www.pu  Views(293)  Comments(0Edit  收藏  举报