深入浅出Web开发

Web开发

Web开发,通俗一点来说就是做网站。学术一点来说,是做B/S架构的软件,B指Browser浏览器,S指服务端Server。

浏览器(HTML、JavaScript) <—> HTTP协议 <—> 服务器(PHP、Python、Java...)

用户打开网址,服务器返回HTML,浏览器解析HTMl图形化展示给用户内容。
用户提交表单,经过HTTP协议传输,服务收到来自用户的数据,数据交给应用程序处理,返回给用户结果。

移动互联网时代,这个架构也许是这样。

APP(ios,安卓) <—> HTTP协议 <—> 服务器(PHP、Python、Java...)

B(Browser)变成了C(Client),也叫C/S架构。

现在的Web开发,按照这个架构HTTP协议的两头分为前端、后端,本文我们侧重后端。

HTTP协议

我们先从架构中的网络部分开始,即HTTP协议rfc2616

HTTP协议中定义了两个角色客户端和服务端:

  • 客户端,发起请求的实体
  • 服务端,响应请求的实体

在B/S架构中,客户端便是我们的浏览器,服务端是我们服务器软件。

常见的浏览器有:Chrome、Firefox、IE等。
常见的服务器软件有:Apache2、Nginx等。

这些软件已经帮我们实现了,Web体系中面向网络的,网络编程部分工作,比如建立连接、解析HTTP报文、提取数据等。

应用网关协议

在Web后端中,服务器软件档在了前面,处理网络连接和数据接收解析。

我们开发业务系统,其实工作在这些Web服务器的后面,业务系统与Web服务器交互。

Browser <—HTTP协议—> Web Server <——> 业务系统(PHP,Python,Java...)

我们先看一个普通的程序

def get_user_info(user_id):
	user = db.query("select * from user where id=?",user_id)
	return {
		"result": user
	}

问:我们返回的结果如何交付给Web服务器?Web服务器如何执行这段Python代码?

答:应用网关协议!

Browser <—HTTP协议—> Web Server <—应用网关协议—> 业务系统(PHP,Python,Java...)

应用网关协议定义了业务系统如何与Web服务器交互,比如Web服务器如何将用户传来的参数传给业务系统,业务系统如何交付输出给Web服务器。

目前为止不同Web服务器,编程语言出现了很多应用网关协议,比如CGI、FastCGI、Python的WSGI,Java的Servlet等等。

我们以最古老和简单的CGI(Common Gateway Interface)为例rfc3875

CGI的工作原理非常简单,业务系统是一个简单的可执行二进制或脚本文件,当请求到来,Web服务器fork子进程执行这个可执行程序,请求数据通过Stdin和环境变量传入,然后接受子进程执行结果的Stdout,最后将结果装入HTTP响应报文返回给客户端。

Browser <—HTTP协议—> Web Server <—CGI—> exec(get_user.py, env,stdin, stdout)

将我们上面的程序封装成CGI Web程序大概这样子。

def get_user_info(user_id):
	user = db.query("select * from user where id=?",user_id)
	return {
		"result": user
	}

# 从环境变量获取请求参数
query_string = get_env("QUERY_STRING")

uid = get_args_from_query(query_string)

# 执行业务逻辑查找用户
user = get_user_info(uid)

# 输出用户数据到stdout
import json
print ("Content-type:application/json")
print ()
print(json.dumps({
     "result":user
 }))

在Ubuntu下以Apache2和Python为例,做一个CGI的例子。

为什么不用Nginx?因为Nginx默认模块不支持CGI了,写本文时候也想用Nginx,发现怪麻烦的。

启用CGI模块

/etc/apache2/mods-enabled
ln -s ../mods-available/cgid.load .
ln -s ../mods-available/cgid.conf .

配置Vhost和CGI脚本路径

# 基于default修改
<VirtualHost *:80>
        ServerAdmin webmaster@localhost
        DocumentRoot /home/www/play

        ErrorLog ${APACHE_LOG_DIR}/error.log
        CustomLog ${APACHE_LOG_DIR}/access.log combined

<IfModule mod_alias.c> # 添加cgi模块
        <IfModule mod_cgi.c>
                Define ENABLE_USR_LIB_CGI_BIN
        </IfModule>

        <IfModule mod_cgid.c>
                Define ENABLE_USR_LIB_CGI_BIN
        </IfModule>

        <IfDefine ENABLE_USR_LIB_CGI_BIN>
                ScriptAlias /cgi-bin/ /home/www/play/cgi-bin/ # cgi脚本路径
                <Directory "/home/www/play/cgi-bin">
                        AllowOverride None
                        Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch
                        Require all granted
                        AddHandler cgi-script .cgi .py .sh # 配置cgi脚本后缀
                </Directory>
        </IfDefine>
</IfModule>

</VirtualHost>

CGI脚本/home/www/play/cgi-bin/index.py

#!/compiler/miniconda3/bin/python

import json
print ("Content-type:application/json")
print ()
print(json.dumps({
    "result":"hello cgi"
}))

访问测试

curl -v http://127.0.0.1/cgi-bin/index.py

< HTTP/1.1 200 OK
< Date: Sun, 29 Aug 2021 03:45:33 GMT
< Server: Apache/2.4.29 (Ubuntu)
< Transfer-Encoding: chunked
< Content-Type: application/json
<
{"result": "hello cgi"}

Web框架

掌握了Web程序整个工作流程,学习Web框架便会轻松很多。框架应该是加速我们实现业务系统的工具,而不是什么难学的技术。

在上面例子中我们,手动处理了很多输入与输出

# 从环境变量获取请求参数
query_string = get_env("QUERY_STRING")

...

# 输出用户数据到stdout
import json
print ("Content-type:application/json")
print ()
print(json.dumps({
     "result":user
 }))

假设现在有一个框架think,也许代码便写成了这样

from think import controller

@controller
def get_user_info(user_id):
	user = db.query("select * from user where id=?",user_id)
	return {
		"result": user
	}

框架帮我们处理了所有对接CGI协议的工作,实际上,目前流行的框架也确实工作在应用网关协议这一层。

总结

画一个点技能的路线图:

Browser  <—    HTTP协议  —>     Web Server         <—        应用网关协议          —>     业务系统(PHP,Python,Java...)
    |                   |                              |                                     |                                              |
 (前端)    (网络协议、路由、DNS)    (操作系统、网络编程 、框架设计、编程语言特性、数据库、中间件、并行/并发、分布式...)
阅读全文

详解HTTP协议

协议内容

HTTP(超文本传输协议)是一种应用层协议,基于TCP/IP协议族。它是一种文本协议,通过文本形式进行数据交换,即传输的TCP报文消息内容是特定格式的文本。

消息格式

HTTP消息分为请求(Request)和响应(Response)两种类型,其格式均符合以下通用格式:

HTTP-message = Request | Response
generic-message = start-line *(message-header CRLF) CRLF [message-body]

其中,消息头部包括General Header、Request Header和Response Header三类,消息体可选,用于携带与请求或响应相关的实体数据。

消息头(Message Header)

格式为

field-name:[field-value]

包括General Header、Request Header和Response Header和Entity Header。

消息体(Message Body)

消息体(Message Body/Entity Body)是可选的,用于携带与请求或响应相关的实体数据。

通用头部(General Header)

通用头部是指在请求和响应消息中都可以使用的头部信息,不会针对特定的资源或实体进行设置,具体字段如:

Cache-Control, Connection, Date, Pragma, Trailer, Transfer-Encoding, Upgrade, Via, Warning等。

实体头部(Entity Header)

实体头部是只能在请求或响应的实体部分中使用的头部。实体是指请求或响应的正文部分,也就是消息体,实体头部包含有关该实体的元数据,如内容长度、内容编码方式,具体字段如:

Allow,Content-Encoding,Content-Language,Content-Length,Content-Location,Content-MD5,Content-Range,Content-Type,Expires,Last-Modified,extension-header等。

请求(Request)

请求(Request)由请求行(Request-Line)、请求头部(Request Header)和消息体(Message Body)三部分构成,其中请求行由请求方法(Method)、请求URI(Request-URI)和HTTP版本(HTTP-Version)组成。

Request = Request-Line * ( ( general-header | request-header | entity-header ) CRLF ) CRLF [ message-body ]
Request-Line = Method SP Request-URI SP HTTP-Version CRLF

HTTP协议定义了多种请求方法,常见的包括:

OPTIONS, GET, HEAD, POST, PUT, DELETE, TRANCE, CONNECT等。

请求URI可以是绝对地址(absolute URI)、相对地址(abs_path)或者授权机构(authority)。

请求头部(Request Header)

请求头部(Request Header)包含了请求相关的属性和特性信息,具体字段如:

Accept, Accept-Charset, Accept-Encoding, Accept-Language, Authorization, Expect, Form, Host,If-match, If-Modified-Since, If-None-Match, If-Range, If-Unmodified-Since, Max-Forwards, Proxy-Authorization,Range, Referer, TE, User-Agent等

响应(Response)

响应(Response)由状态行(Status-Line)、响应头部(Response Header)和消息体(Message Body)三部分构成,其中状态行由HTTP版本(HTTP-Version)、状态码(Status-Code)和原因短语(Reason-Phrase)组成。

Response = Status-Line * ( ( general-header | response-header | entity-header ) CRLF ) CRLF [ message-body ]
Status-Line = HTTP-Version SP Status-Code SP Reason-Pharse CRLF

HTTP状态码共分为5大类,包括1XX信息类、2XX成功类、3XX重定向类、4XX客户端错误类和5XX服务端错误类。响应头部以及消息体的信息与请求头部和消息体的内容相似。

响应头部(Response Header)

响应头部(Response Header)包含了响应相关的属性和特性信息,具体字段如:

Accept-Ranges, Age, Etag, Location, Proxy-Authenticate, Retry-After, Server, Vary, WWW-Authenticate等。

HTTP服务器实现

HTTP协议是一种文本协议,实现HTTP服务器,按照协议格式正确处理文本即可。

下面是一个使用Python的socket和多线程,实现的简单HTTP服务器SimpleHTTPServer,支持GET请求,找不到资源返回404,没有实现的方法返回501。

import socket
from multiprocessing.dummy import Pool as ThreadPool
import traceback
import logging
import os


class Server(object):

    SERVER_STRING = b"Server: SimpleHttpd/1.0.0\r\n"

    def __init__(self, host, port, worker_count=4):
        self._host = host
        self._port = port
        self._listen_fd = None
        self._worker_count = worker_count
        self._worker_pool = ThreadPool(worker_count)
        self._logger = logging.getLogger("simple.httpd")
        self._logger.setLevel(logging.DEBUG)
        self._logger.addHandler(logging.StreamHandler())

    def run(self):
        # 初始化 socket,绑定地址并开始监听
        self._listen_fd = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._listen_fd.bind((self._host, self._port))
        self._listen_fd.listen(self._worker_count)
        try:
            while True:
                # 接受连接请求,并将处理任务交给线程池
                conn, addr = self._listen_fd.accept()
                self._worker_pool.apply_async(self.accept_request, (conn, addr,))
        except Exception as e:
            traceback.print_exc()
        finally:
            # 关闭监听 socket
            self._listen_fd.close()

    def accept_request(self, conn: socket.socket, addr):
        try:
            # 解析请求并根据请求方法处理
            method, path, http_version, req_headers, req_body = self.recv_request(conn)
            if method == "GET":
                code = self.try_file(conn, path)
            elif method == "POST":
                code = self.unimplemented(conn)
            else:
                code = self.unimplemented(conn)

            # 记录访问日志
            self._logger.info("{}:{} {} {} {} {}".format(addr[0], addr[1], http_version, method, path, code))
        except Exception as e:
            traceback.print_exc()
        finally:
            # 关闭连接
            conn.close()

    def try_file(self, conn: socket.socket, path: str):
        # 尝试读取静态文件
        here = os.path.abspath(os.path.dirname(__file__))
        target = os.path.join(here, "www", path.strip("/"))
        if not os.path.isfile(target):
            return self.not_found(conn)
        with open(target, "rb") as target_file:
            data = target_file.read()
            conn.sendall(b"HTTP/1.0 200 OK\r\n")
            conn.sendall(self.SERVER_STRING)
            if ".html" in path:
                conn.sendall(b"Content-Type: text/html\r\n")
            else:
                conn.sendall(b"Content-Type: application/octetstream\r\n")
            conn.sendall(bytes("Content-Length: {}\r\n".format(len(data)), "utf-8"))
            conn.sendall(b"\r\n")
            conn.sendall(data)
            return 200

    def not_found(self, conn: socket.socket):
        # 处理404错误
        html = "<html>"
        html += "<head><title>Not Found</title></head>"
        html += "<body>Not Found</body>"
        html += "</html>"
        html = bytes(html, "utf-8")
        conn.sendall(b"HTTP/1.0 404 Not Found\r\n")
        conn.sendall(self.SERVER_STRING)
        conn.sendall(b"Content-Type: text/html\r\n")
        conn.sendall(b"Content-Encoding: utf-8\r\n")
        conn.sendall(bytes("Content-Length: {}\r\n".format(len(html)), "utf-8"))
        conn.sendall(b"\r\n")
        conn.sendall(html)
        return 404

    def unimplemented(self, conn: socket.socket):
        # 处理501错误
        html = "<html>"
        html += "<head><title>Method Not Implemented</title></head>"
        html += "<body>HTTP request method not supported</body>"
        html += "</html>"
        html = bytes(html, "utf-8")
        conn.sendall(b"HTTP/1.0 501 Method Not Implemented\r\n")
        conn.sendall(self.SERVER_STRING)
        conn.sendall(b"Content-Type: text/html\r\n")
        conn.sendall(b"Content-Encoding: utf-8\r\n")
        conn.sendall(bytes("Content-Length: {}\r\n".format(len(html)), "utf-8"))
        conn.sendall(b"\r\n")
        conn.sendall(html)
        return 501

    def recv_request(self, conn: socket.socket):
        # 读取请求行
        line = b''
        while not line.endswith(b'\r\n'):
            data = conn.recv(1)
            if not data:
                raise ConnectionError('Connection closed unexpectedly')
            line += data
        method, path, version = line.strip().decode().split(' ', 2)

        # 读取请求头
        headers = {}
        while True:
            line = b''
            while not line.endswith(b'\r\n'):
                data = conn.recv(1)
                if not data:
                    raise ConnectionError('Connection closed unexpectedly')
                line += data
            if line == b'\r\n':
                break
            key, value = line.strip().decode().split(': ', 1)
            headers[key] = value

        # 读取请求体
        content_length = int(headers.get('Content-Length', '0'))
        if content_length > 0:
            body = conn.recv(content_length)
        else:
            body = None

        # 返回请求行、请求头和请求体
        return method, path, version, headers, body


if __name__ == "__main__":
    server = Server("0.0.0.0", 3000)
    server.run()
阅读全文

内存管理

write code

任何类型的指针变量都可以用来存储内存地址,通常用u_char *表示一个内存地址。

(void*)-1是将有符号整数-1强制转换为指向void类型的指针,这个指针的值为-1,它通常用于表示一个无效的指针或错误指针。

malloc

标准C库中的malloc函数维护了一张内存块链表(或者堆)来管理动态内存分配和释放。当程序调用malloc函数申请内存时,malloc会在链表中查找大小合适的空闲内存块,如果找到则使用该内存块;否则就会向操作系统请求更多的内存,并把这个新申请的内存块加入到链表中,以备后续使用。在这个过程中,malloc会把每个内存块的大小等信息记录下来,以便在后续的内存管理操作中使用。

free

调用free函数时,C库会根据该指针,找到相应的内存块,并把它标记为可用状态,以便下次又有其他程序需要使用该内存时可以再次被分配出去。因此,在使用free函数时,我们必须确保传入的指针是通过malloc或者realloc函数申请得到的,否则可能会发生内存泄漏或者非法操作的情况。

syscall

#include<unistd.h>

void *sbrk(intptr_t increment);
int brk(void *addr);

sbrk函数返回指向新分配内存的起始地址的指针,并且自动将堆末地址增加increment字节,brk函数将堆末指针移动到addr处。两个函数都返回0表示操作成功,返回-1表示失败并设置errno变量。

#include <unistd.h>

int main() {
    void* ptr1 = sbrk(10 * sizeof(int)); // 分配10个整型变量的内存空间
    if (ptr1 == (void*) -1) {
        // 内存分配失败
        return -1;
    }

    void* ptr2 = sbrk(20 * sizeof(int)); // 再次分配20个整型变量的内存空间
    if (ptr2 == (void*) -1) {
        // 内存分配失败
        brk(ptr1); // 释放ptr1之前分配的内存
        return -1;
    }

    // 使用ptr1和ptr2进行一些操作

    brk(ptr2); // 释放ptr2之前分配的内存
    brk(ptr1); // 释放ptr1之前分配的内存

    return 0;
}

malloc会调用brk/sbrk来增加堆区大小以满足分配需求,分配/回收完成进程的堆顶指针(brk指针)将被更新,指向新的堆顶位置。

kernel

即使调用brk()sbrk()函数移动了进程数据段的位置,Linux内核也不会立即为进程分配新的物理内存。Linux内核只有在进程需要访问这些新分配的虚拟地址时,才会将虚拟地址映射到物理内存。

在进程需要访问某个虚拟地址时,Linux内核首先会检查该虚拟地址是否已经被映射到物理内存,如果没有则会进行页面缺失(page fault)处理。这时,Linux内核会评估进程请求的内存块大小,并查找可用的物理内存块来满足进程的请求。如果有足够的可用物理内存,则Linux内核会将该内存块映射到进程的虚拟地址空间中,并允许进程对其进行读写操作。

阅读全文

函数指针

在 C 语言中,可以通过以下方式定义函数指针:

返回值类型 (*指针变量名)(参数列表)

其中,指针变量名 是自定义的指针变量名称, 返回值类型 表示该函数返回值的类型, 参数列表 表示该函数接受的参数类型及参数个数。

例如,假设我们有一个函数 add,它接受两个整数作为参数,返回它们的和。那么我们可以定义一个指向该函数的指针类型和对应的指针变量,如下所示:

int add(int a, int b)
{
    return a + b;
}

int (*p_add)(int, int);   // 定义函数指针类型和指针变量
p_add = &add;             // 将指针指向 add 函数

int result = (*p_add)(2, 3);  // 调用 p_add 指向的函数

在上述代码中,p_add 是一个指向函数的指针变量,它被定义为指向接受两个整数参数、返回整数类型的函数。通过将其指向 add 函数,并使用 (*p_add)(2, 3) 的方式调用函数,就可以得到 2 + 3 = 5 的结果。

除了上述方法外,还可以使用 typedef 来简化函数指针类型的定义。例如,可以使用以下方式定义一个函数指针类型 ADD_FUNC,并使用它来定义指针变量和调用函数:

typedef int (*ADD_FUNC)(int, int);  // 定义函数指针类型

int add(int a, int b)
{
    return a + b;
}

int main()
{
    ADD_FUNC p_add = &add;           // 定义指针变量
    int result = p_add(2, 3);        // 调用指针指向的函数
    return 0;
}

这种方式可以让代码更加简洁易读,提高可维护性。

阅读全文

宏与条件编译

C语言宏是一种类似于函数的预处理器指令,用于在代码中定义和使用常量、函数以及重复代码块。它们通过在代码中使用#define指令来定义,可以接受参数并生成文本替换。在编译时,预处理器会将所有宏都展开为它们的定义,然后进行编译。

条件编译

C语言中的条件编译指令包括 #if#ifdef#ifndef#elif#endif。这些指令可以根据预定义宏、常量或表达式的值来判断是否编译指定的代码块。

  • #if 指令用于条件编译,可以根据预定义的宏、常量或表达式的值来判断是否编译指定的代码块。其语法格式为:#if 常量表达式
  • #ifdef 指令用于检查某个宏是否已经被定义。如果该宏已经被定义,则编译指定的代码块。其语法格式为:#ifdef 宏名
  • #ifndef 指令与 #ifdef 相反,用于检查某个宏是否未被定义。如果该宏未被定义,则编译指定的代码块。其语法格式为:#ifndef 宏名
  • #elif 指令用于在多个条件之间进行选择。如果前面的条件不成立,则继续判断下一个条件。其语法格式为:#elif 常量表达式
  • #endif 指令用于结束条件编译的代码块。其语法格式为:#endif

在条件编译中,通常使用 #define 指令定义宏,并将它们传递给编译器。例如:

#define DEBUG 1     // 定义一个名为 DEBUG 的宏,值为1
#ifdef DEBUG
    printf("Debugging is enabled.\n");
#else
    printf("Debugging is disabled.\n");
#endif

上述代码中,如果 DEBUG 宏已经被定义,则输出 “Debugging is enabled.";否则输出 “Debugging is disabled."。

带参数的宏

带参数的宏是一种宏定义,可以接受一个或多个参数,并在使用该宏时将这些参数替换成指定的值。带参数的宏通常用于简化复杂的表达式,增强代码可读性和可维护性。

带参数的宏的基本语法格式为:

#define 宏名(参数列表) 替换文本

其中,宏名是自定义的宏名称,参数列表是用括号括起来的一组宏参数,多个参数之间用逗号分隔。替换文本是宏定义中要替换成的文本内容,可以包含参数名和其他字符。

例如,下面是一个简单的带参数的宏定义:

#define MIN(a, b) ((a) < (b) ? (a) : (b))

这个宏接受两个参数 ab,并比较它们的大小,返回最小值。在使用该宏时,可以像调用函数一样传入实际参数:

int x = 10, y = 20, min;
min = MIN(x, y);   // 返回10,等价于 min = ((x) < (y) ? (x) : (y));

当宏定义中涉及运算优先级时,加括号是非常重要的。以下是一个简单的例子,其中宏 MAX 比较两个值并返回其中较大的那个值:

#define MAX(a, b) (((a) > (b)) ? (a) : (b))

在这个宏定义中,使用了条件运算符 ?: 和比较运算符 >,而条件运算符的优先级高于比较运算符。为确保在使用此宏时不会出现错误结果,需要在所有操作符周围添加括号。

例如,假设有以下代码:

int x = 2, y = 3;
int z = MAX(x + 1, y - 1);

如果没有使用括号,则该代码将展开为:

int z = x + 1 > y - 1 ? x + 1 : y - 1;

由于条件运算符 ?: 的优先级高于比较运算符 >,因此 x + 1 和 y - 1 将首先被比较,然后根据比较结果选择一个分支,而 x + 1 和 y - 1 的加法和减法运算将被忽略。这可能导致得到错误的结果。

但是,在使用宏定义中添加括号之后,该代码将展开为:

int z = ((x + 1) > (y - 1)) ? (x + 1) : (y - 1);

这确保了加法和减法运算首先被执行,并且条件运算符按照预期的方式为其提供参数。

在编写宏定义时,正确地设置运算符的优先级和添加括号是至关重要的,以确保宏在使用时不会导致错误的结果。

阅读全文