小白也能看懂的socks5代理服务器原理及实现 | 青训营

Go语言实战案例(下):SOCKS5代理

实践记录 · 2023/8/6 · 玉米哥

目录

猜数游戏

在线词典
SOCKS5代理

前言

距离上一篇文章Go语言实战案例(中):在线词典发布已经过去了好些天,这篇文章通俗易懂地讲解HTTP通信的一些原理,并实现了一个简易的在线词典,发布前自己对这篇文章的预期比前两篇是要高的,但是最终却表现得平平无奇,只能说还是要多阅读多写作,慢慢来吧。

什么是代理服务器

在上一篇文章中,我们编写的在线词典和翻译服务器进行了信息的交流与沟通。翻译服务器的任务就是提供翻译服务。

基本流程是:

  1. 客户端向翻译服务器发送请求报文
  2. 翻译服务器接收并处理请求报文
  3. 翻译服务器返回响应报文
  4. 客户端对响应报文解析并呈现

那什么是代理服务器呢?你可以将代理服务器看作由两部分组成:代理应用程序和服务器。换句话说,代理服务器就是在一台普通服务器上安装了代理软件。

代理软件的作用就是提供转发功能,接收客户端发送的请求报文,但不着急返回响应报文。而是将该请求报文转发给服务器,然后接收服务器返回的响应报文,到这里还没完,将响应报文转发给客户端。

看到这里,不知道你和我一样有没有下面的疑惑。

疑惑一

客户端准备发布请求报文时,会添加URI和host首部字段,用来明确通信的最终服务器,既然如此,那么如何将请求报文发送给代理服务器呢?

答案:

代理服务器的IP地址可以在网络设置中手动配置,如果使用代理软件,代理软件也可以自行配置。

疑惑二

代理服务器在转发请求报文的时候,不会修改报文内容,那么当最终服务器接收到请求报文时,能够分辨该报文是通过客户端还是代理服务器发送的吗,如果不能分辨,那么最终服务器的响应报文要返回给谁呢?

答案:

请求报文每次经过一台代理服务器时,会在报文中的Via字段添加该代理服务器的名称、版本号、注释等。这样,最终服务器就能知道请求报文经过了哪些代理。

代理服务器的作用

组织内部针对特定网站的访问控制

客户端在准备请求报文时,使用Proxy-Authorization首部字段来提供代理服务器的认证信息,通常,这个值是经过编码的用户名和密码。当代理服务器接收到客户端的请求报文时,会验证这个字段的值,如果验证通过,代理服务器就会转发请求报文到最终服务器,如果验证失败,代理服务器可能会返回错误响应。

利用缓存技术减少网络带宽的流量

假设现在代理服务器接收到了来自最终服务器的响应报文,缓存代理可以将响应报文的副本保存在代理服务器上。

当代理再次接收到对相同资源的请求时,就可以直接将之前缓存的资源作为响应返回,而不去请求最终服务器。减轻了最终服务器的压力。

什么是SOCKS5代理

刚才我们提到过,代理服务器的本质就是服务器上安装了一个代理软件,这个软件具有转发包的功能。而代理软件的实现方式多种多样,SOCKS5代理就是代理协议中的一种。

SOCKS5代理协议规定的主要内容:

  1. 认证支持。SOCKS5支持多种认证方式,包括无需认证,使用用户名和密码认证等多种方式,客户端和代理服务器可以协商选择合适的认证方法。
  2. 通信协议无关性。SOCKS5协议可以应用于各种应用层协议的代理,如HTTP、SMTP、FTP等。
  3. UDP支持。SOCKS5支持用户数据报(UDP)的代理,像是需要实时传输的音视频相关应用。

实现简单的SOCKS5代理服务器

下面我将跟随克纯老师视频中的教程,实现一个简单的SOCKS5代理服务器。

实现一个简单的服务器

我们的目标是先从一个简单服务器开始。

通常,我们自己办公娱乐的电脑充当客户端,在上网的过程中,与各个APP的服务器进行数据传输。

现在,我们需要把自己的电脑变成一台服务器,就需要在自己的电脑上安装一个服务器程序,那么其他电脑就可以通过我们自己的电脑域名和端口号访问我们的服务器。

开始编写一个简易的服务器程序。

go 复制代码
server, err := net.Listen("tcp", "127.0.0.1:8080")
	if err != nil {
		panic(err)
	}

这段代码创建了一个TCP服务器程序,侦听IP地址127.0.0.1和8080端口。

  • net是Go语言标准库中的包,支持多种常用的网络操作。
  • net.Listen函数接受两个参数,在本例中,是tcp127.0.0.1:8080(IP:Port),表明了服务器的类型和监听的IP地址。

如果指明的端口号已被分配,则服务器会启动失败。这时,可以空着端口号,形如127.0.0.1:,程序会自动分配一个可用端口。

紧接着添加下方的代码。

go 复制代码
for {
		client, err := server.Accept()
		if err != nil {
			log.Printf("Accpet failed %v", err)
		}
		go process(client)
	}

server.Accept()函数被调用时,程序会陷入阻塞,直到接收到建立连接的请求时。

连接成功建立后,返回一个新的net.Conn对象,即client变量。该变量就代表某台客户端与该服务器建立的连接。

若连接成功建立,则会使用go关键字调用函数process,来处理连接,这样客户端和服务器可以进行通信了。

go关键字表示创建一个协程,类似于其他语言中线程的概念。这允许服务器可以并发的处理多个客户端的请求。

创建协程后,程序会继续执行,即一次循环结束,另一次循环开始,会再次执行client, err := server.Accept()。在建立一个客户端通信后,等待与其他客户端建立连接,这样就实现了并发。

下面我们看看process函数的实现。在一次客户端和服务器的连接中做了什么。

go 复制代码
func process(client net.Conn) {
	defer client.Close()
	reader := bufio.NewReader(client)
	for {
		b, err := reader.ReadByte()
		if err != nil {
			break
		}
		_, err = client.Write([]byte{b})
		if err != nil {
			break
		}
	}
}

简单来说,该函数持续不断的从客户端读取数据,然后发送回客户端,一直重复该过程直到错误发生或者客户端关闭连接。

下面我们详细分析代码的每一部分。

  • func process(client net.Conn):该函数接受一个net.Conn类型的参数,该参数实际上表示某台具体的客户端与服务器建立的连接。

  • defer client.Close()defer关键字表示,当函数结束时,执行client.Close()语句,即释放与该客户端连接时被占用的资源。

  • reader := bufio.NewReader(client):由于服务器需要从和客户端的网络连接中读取数据,所以我们创建了一个reader。众所周知,我们不仅可以通过键盘读取输入,还可以通过文件读取输入,更可以通过网络连接读取输入。如果你还不知道reader是什么,可以阅读过我的另一篇掘金文章Go语言实战案例(上)。在这篇文章中,我详细介绍了从不同输入源读取输入的方法。

  • b, err := reader.ReadByte():调用readerReadByte函数后,程序会陷入阻塞,等待客户端发送数据。当客户端发送数据完毕后,reader将读取到的数据存入变量b中。

  • _, err = client.Write([]byte{b})client.Write函数接收字节切片作为参数,该参数包含了b中的内容,即客户端发送来的内容。然后,该内容又再次被写入连接。大家可以把这里的write理解为服务器向客户端发送的数据。即服务器接收到客户端发送的内容后,又将其发送回客户端。

一次循环结束后,服务器等待下次客户端继续发送数据,并重复相同的动作。

现在我们运行这个简易的服务器程序。

path/to/go/source/file> go run .

程序启动后,服务器没有任何输出显示。此时服务器程序已经开始运行。

下一步是从客户端与服务器建立连接。按照老师给的提示,我们使用nc命令与服务器建立通信。

不要退出运行服务器程序的命令行窗口。打开新命令行窗口。输入下面的命令。

nc 127.0.0.1 8080

踩坑点:由于我的电脑是windows 10系统,所以需要提前下载netcat(nc),这样才能够执行nc命令,具体下载步骤大家可以自行百度。

下载完成后,解压压缩包,得到一个文件夹,按住shift键后,鼠标右键单击该文件夹,选择复制文件夹路径。然后将该路径添加到系统环境变量中。

执行nc命令前,先查看下载的文件夹当中是nc.exe还是nc64.exe,如果是后者,则需要更改命令为nc64 127.0.0.1 8080

命令若能够正常执行,则客户端与服务器的通信已经建立。此时屏幕不会有任何输出。

输入

hello world

输出

hello world

在客户端输入任意字符串,按下回车键,就会向服务器发送该字符串,服务器收到字符串后,会原封不动返回该字符串。一个简易服务器就此实现。

此时若再打开多个命令行窗口,使用以上命令建立连接,同样可以回显输出的内容。也就是说,我们的服务器支持并发访问。

目前,我们的通信路径是:

客户端 ↔️ 简易服务器

实现SOCKS5代理服务器

现在让我们对简易服务器做出一些小小的改动,使其变成一台SOCKS5代理服务器。我们的最终目标是实现下面的通信路径:

客户端 ↔️ SOCKS5代理服务器 ↔️ 最终服务器

第一阶段:协商阶段

毫无疑问,客户端需要首先和代理服务器建立连接。该阶段被称为协商阶段。需要完成认证。认证究竟是谁认证谁呢?

代理服务器需要认证客户端,只有客户端通过认证,才允许客户端通过代理访问最终服务器。可以将代理服务器理解为看门人。

客户端发送的报文如下。

VER NMTHODS METHODS
1 1 1 to 255
  • VER:协议版本,SOCKS5的值表示为0x05。

  • NMETHODS:客户端支持的认证方法的数量。

  • METHODS:与NMETHODS呼应,NMETHODS的值为多少,METHODS就包含多少个字节,每个字节表示一种认证方法。如00表示不需要认证,02表示用户名/密码认证。

代理服务器收到客户端发送的协商报文后,需要返回报文。

返回报文的内容如下。

VER METHOD
1 1
  • VER:返回协议版本号。

  • METHOD:从客户端支持的认证方法中选一种。在本例中,我们采取无需认证的方法,因此需返回0x00

代码实现如下。由于我们实现代理服务器部分,因此这部分代码应该主要是对客户端发送的报文的解析。

修改process函数。

go 复制代码
func process(client net.Conn) {
	defer client.Close()
	reader := bufio.NewReader(client)
	err := auth(reader, client)
	if err != nil {
		log.Printf("client %v auth failed:%v", client.RemoteAddr(), err)
		return
	}
	log.Println("auth success")
}

回顾上文,当process函数被调用时,客户端与服务器已经建立了连接,进入收发报文阶段。

reader := bufio.NewReader(client):该行创建了一个reader,输入源是客户端发送的报文。若客户端发送了报文,那么reader中会存储报文的内容。

err := auth(reader, client):紧接着,我们使用auth函数来处理这个连接。由于认证阶段需要解析客户端发送的报文,所以我们也传递了reader作为参数。

添加auth函数如下。

go 复制代码
func auth(reader *bufio.Reader, conn net.Conn) (err error) {
	// +----+----------+----------+
	// |VER | NMETHODS | METHODS  |
	// +----+----------+----------+
	// | 1  |    1     | 1 to 255 |
	// +----+----------+----------+
	ver, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read ver failed:%v", err)
	}
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}
	methodSize, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read methodSize failed%v", err)
	}
	method := make([]byte, methodSize)
	_, err = io.ReadFull(reader, method)
	if err != nil {
		return fmt.Errorf("read method failed%v", err)
	}
	log.Println("ver", ver, "method", method)
	// +----+--------+
	// |VER | METHOD |
	// +----+--------+
	// | 1  |   1    |
	// +----+--------+
	_, err = conn.Write([]byte{socks5Ver, 0x00})
	if err != nil {
		return fmt.Errorf("write failed%v", err)
	}
	return nil
}

读取第一个字节:协议版本号

该代码块从reader取出第一个字节,即协议版本号,如果协议版本号等于SOCKS5的协议版本号,则继续处理。若读取发送错误或者客户端发送的版本号不是SOCKS5,则返回错误信息。

go 复制代码
ver, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read ver failed:%v", err)
	}
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", ver)
	}

socks5Ver是全局常量,定义如下。const socks5Ver = 0x05

读取第二个字节:客户端支持的认证方法的数量

该代码块从reader中继续读取下一个字节,即客户端支持的认证方法数量。若读取成功,则继续。若读取失败,则返回错误信息。

go 复制代码
methodSize, err := reader.ReadByte()
	if err != nil {
		return fmt.Errorf("read methodSize failed%v", err)
	}

读取剩下的字节:客户端支持的每一种认证方法

到现在,reader中剩下的字节序列就表示支持的每一种认证方法。

首先初始化一个字节切片method,大小为methodSize,即上一步读取的客户端一共支持的认证方法的数量。

使用io.ReadFull函数,该函数接收两个参数,第一个参数是读取源,即reader。第二个参数是method,即将读取到的内容填充到method中。

io.ReadFull函数有一个特性,第二个参数有多大,就从reader中读取对应的字节,填充满第二个参数。在本例中,method的大小是通过reader中剩余的字节数目来初始化的。也就是说读取完毕后,reader中的内容(协议版本号、客户端支持的认证方法的数量、每种认证方法)恰好都被读取完。

若一切正常,则会打印日志,包含版本号以及客户端支持的每种认证方法。

go 复制代码
method := make([]byte, methodSize)
	_, err = io.ReadFull(reader, method)
	if err != nil {
		return fmt.Errorf("read method failed%v", err)
	}
	log.Println("ver", ver, "method", method)

选择认证方式并返回报文

服务器对协商报文解析后,需要在客户端支持的认证方式中选择一种,并返回报文。

conn代表此前客户端与服务器建立的连接。使用Write函数写入字节流,内容包括协议版本号和选中的认证方法。可以将Write函数理解为服务器向客户端返回的报文内容。

go 复制代码
_, err = conn.Write([]byte{socks5Ver, 0x00})
	if err != nil {
		return fmt.Errorf("write failed%v", err)
	}

到目前为止,协商阶段结束。此时建立的连接如下:

客户端 ↔️ SOCKS5代理服务器 ↔️ 最终服务器

此时我们启动该服务器程序验证一下。程序启动后,没有任何输出。此时代理服务器已经开始运行。

保持代理服务器程序处于运行状态,再打开新命令行充当客户端,输入以下命令。

curl --socks5 127.0.0.1:8080 -v http://www.qq.com

这是一条curl命令,完整含义是:与http://www.qq.com建立连接,需要使用socks5代理,代理服务器的IP地址是127.0.0.1,端口是8080。

聪明的你可以猜一下输出什么?

客户端输出

go 复制代码
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
* SOCKS5 connect to IPv4 221.198.70.47:80 (locally resolved)
* Recv failure: Connection was aborted
* SOCKS4: Failed receiving SOCKS5 connect request ack: Failure when receiving data from the peer
* Closing connection 0
curl: (97) Recv failure: Connection was aborted

可以看到连接分为两步进行,第一阶段是客户端与代理服务器建立连接,该阶段成功。第二阶段是代理服务器与最终服务器建立连接,该阶段失败。原因很简单,在我们编写的代理服务器程序中,只包含与客户端协商的步骤,并未包含与最终服务器建立连接的步骤。

服务器程序输出

打开一直运行的服务器程序,可以看到有下面输出。

go 复制代码
2023/08/05 19:51:49 ver 5 method [0 1]
2023/08/05 19:51:49 auth success

这两行输出分别对应代码中打印日志的两行。也从侧面印证了第一阶段是成功了。

第二阶段:请求阶段

协商成功后,客户端向代理服务器发送请求,指出要通过代理访问的IP地址或域名。

客户端发送的请求报文如下。

VER CMD RSV ATYP DST.ADDR DST.PORT
1 1 0X00 1 Variable 2
  • VER:协议版本,SOCKS5的值表示为0x05。

  • CMD:0x01表示CONNECT请求。

  • RSV:保留字段,值为0x00。

  • ATYP:目标地址类型。DST.ADDR的数据对应这个类型。

    • 0x01表示IPv4地址,DST.ADDR为4个字节。
    • 0x03表示域名,DST.ADDR是一个可变长度的域名。DST.ADDR的第一个字节指出域名的长度。
  • DST.ADDR:一个可变长度的值。

  • PORT:目标端口,固定两个字节。(电脑中端口的范围是0-65535,即两个字节所能表示的范围)

与协商阶段类似,接下来服务器程序需要依次读出每个字段的值并做校验,在这里不再赘述。

go 复制代码
buff := make([]byte, 4)
	_, err = io.ReadFull(reader, buff)
	if err != nil {
		return fmt.Errorf("read header failed:%w", err)
	}
	ver, cmd, atyp := buff[0], buff[1], buff[3]
	if ver != socks5Ver {
		return fmt.Errorf("not supported ver:%v", err)
	}
	if cmd != cmdBind {
		return fmt.Errorf("not supported cmd:%v", err)
	}
	addr := ""
	switch atyp {
	case atypeIPV4:
		_, err = io.ReadFull(reader, buff)
		if err != nil {
			return fmt.Errorf("read atyp failed:%w", err)
		}
		addr = fmt.Sprintf("%d.%d.%d.%d", buff[0], buff[1], buff[2], buff[3])
	case atypeHOST:
		hostSize, err := reader.ReadByte()
		if err != nil {
			return fmt.Errorf("read hostSize failed:%w", err)
		}
		host := make([]byte, hostSize)
		_, err = io.ReadFull(reader, host)
		if err != nil {
			return fmt.Errorf("read host failed:%w", err)
		}
		addr = string(host)
	case atypeIPV6:
		return errors.New("IPv6: not supported yet")
	default:
		return errors.New("invalid atype")
	}
	_, err = io.ReadFull(reader, buff[:2])
	if err != nil {
		return fmt.Errorf("read port failed:%w", err)
	}
	port := binary.BigEndian.Uint16(buff[:2])

	log.Println("dial", addr, port)

代码中需要注意的地方。

  • 前四个字段都是1个字节,因此我们声明了要给四字节的字节切片,一次性读取reader中前四个字节的内容,并作出处理。

  • 读取端口号时,我们使用buff[:2]复用buff变量。

  • port := binary.BigEndian.Uint16(buff[:2])。端口号是2字节表示的非负整数。该行代码表示将2字节的端口号按照大端序转换为整数。

如果你学过计算机组成原理,可能就会知道这是为什么了。举一个简单的例子。整数65534 可用二进制表示为1111 1111 1111 11101111 1111 的部分属于高位端,1111 1110的部分属于低位端。

计算机中的内存由存储单元组成。存储单元的最小单位就是字节。你可以将存储单元理解成一个个小房子,每个小房子可以容纳8位。我们对内存单元依次进行编号,就形成了内存地址。比如0x01表示1号内存单元,0x02表示2号内存单元。

现在假设我们需要将65534 存储到内存单元中,很显然我们需要两个连续的内存单元,如地址为0x01、0x02的内存单元。问题来了。我们是将65534的高位端先放入0x01、低位端放入0x02,还是将低位端先放入0x01、高位端放入0x02呢?这就形成了存储数字的不同规则。先放置高位端就叫做大端序(big endian),先放置低位端就叫做小端序(little endian)。

代理服务器处理完请求之后,按照socks5协议的规定,需要返回报文确认。

返回的报文字段如下。

VER REP RSV ATYP BND.ADDR BND.PORT
1 1 0X00 1 Variable 2
  • VER: socks版本,这里为0x05

  • REP: Relay field,内容取值如下 X'00' succeeded

  • RSV: 保留字段

  • ATYPE: 地址类型

  • BND.ADDR: 服务绑定的地址

  • BND.PORT: 服务绑定的端口DST.PORT

go 复制代码
_, err = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
	if err != nil {
		return fmt.Errorf("write failed:%w", err)
	}
	return nil

调用connWrite函数,准备好以上报文内容,并由代理服务器发送回客户端。这样请求就被确认了。

再次运行代理服务器。同样地,没有任何输出内容,代理服务器已经开始运行。

保持代理服务器处于运行状态。打开新的命令行窗口

客户端输入

curl --socks5 127.0.0.1:8080 -v http://www.qq.com

客户端输出

go 复制代码
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
* SOCKS5 connect to IPv4 221.198.70.47:80 (locally resolved)
* SOCKS5 request granted.
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: www.qq.com
> User-Agent: curl/8.0.1
> Accept: */*
>
* Recv failure: Connection was aborted
* Closing connection 0
curl: (56) Recv failure: Connection was aborted

服务器程序输出

go 复制代码
2023/08/06 11:25:13 ver 5 method [0 1]
2023/08/06 11:25:13 dial 221.198.70.47 80

日志的第二行证明了请求阶段已经成功。

现阶段我们完成的路径如下:

客户端 ↔️ SOCKS5代理服务器 ↔️ 最终服务器

客户端和代理服务器已经建立了扎实的连接,下一步,我们需要让代理服务器去请求最终服务器,从而实现数据的双向传输。

第三阶段:Relay阶段

与最终服务器建立TCP连接

当代理服务器获得最终服务器的IP地址和端口之后,就与之建立TCP连接,代码如下。

go 复制代码
dest, err := net.Dial("tcp", fmt.Sprintf("%v:%v", addr, port))
	if err != nil {
		return fmt.Errorf("dial dst failed:%w", err)
	}
	defer dest.Close()
	log.Println("dial", addr, port)

net包的Dial函数用于建立连接,第一个参数是连接类型,第二个参数是IP地址和端口。连接成功建立后,用dest变量表示,并打印日志。

实现数据的双向传输

我们创建了两个goroutine,第一个数据传输的方向是readerdest,表示从客户端到最终服务器。第二个数据传输的方向是destconn,表示从最终服务器到客户端。

请注意,这时2个goroutine和主进程同时运行,但是主进程运行地更快,在这样的情况下,双向数据传输还没有完成,该函数就被关闭了。

因此,我们在两个goroutine的结尾添加cancel函数,当数据交换完成时,也就是cancel函数被调用的时候,ctx.Done函数被执行,主进程才继续往下执行。这样就确保了双向的数据传输完成后才进行下一步。

go 复制代码
ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	go func() {
		_, _ = io.Copy(dest, reader)
		cancel()
	}()
	go func() {
		_, _ = io.Copy(conn, dest)
		cancel()
	}()

	<-ctx.Done()

开启服务器程序,再一次检验输入和输出。

客户端输入

curl --socks5 127.0.0.1:8080 -v http://www.qq.com

客户端输出

go 复制代码
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
* SOCKS5 connect to IPv4 221.198.70.47:80 (locally resolved)
* SOCKS5 request granted.
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: www.qq.com
> User-Agent: curl/8.0.1
> Accept: */*
>
< HTTP/1.1 302 Moved Temporarily
< Server: stgw
< Date: Sun, 06 Aug 2023 03:47:23 GMT
< Content-Type: text/html
< Content-Length: 137
< Connection: keep-alive
< Location: https://www.qq.com/
<
<html>
<head><title>302 Found</title></head>
<body>
<center><h1>302 Found</h1></center>
<hr><center>stgw</center>
</body>
</html>
* Connection #0 to host 127.0.0.1 left intact

代理服务器输出

go 复制代码
2023/08/06 11:47:23 ver 5 method [0 1]
2023/08/06 11:47:23 dial 221.198.70.47 80

现在我们可以看到请求报文和响应报文的内容了。证明代理服务器运行正常。

下一步

希望看完这篇文章,你能对socks5代理服务器有了更为深刻的理解,并能够实现简易的代理服务器。

码字不易,如果您看到了这里,听我说谢谢你😀

如果您觉得本文还不错,请留下小小的赞😀

如果您有感而发,请留下宝贵的评论😀

相关推荐
CallBack8 个月前
Typora+PicGo+阿里云OSS搭建个人图床,纵享丝滑!
前端·青训营笔记
Taonce1 年前
站在Android开发者的角度认识MQTT - 源码篇
android·青训营笔记
AB_IN1 年前
打开抖音会发生什么 | 青训营
青训营笔记
monster1231 年前
结营感受(go) | 青训营
青训营笔记
翼同学1 年前
实践记录:使用Bcrypt进行密码安全性保护和验证 | 青训营
青训营笔记
hu1hu_1 年前
Git 的正确使用姿势与最佳实践(1) | 青训营
青训营笔记
星曈1 年前
详解前端框架中的设计模式 | 青训营
青训营笔记
tuxiaobei1 年前
文件上传漏洞 Upload-lab 实践(中)| 青训营
青训营笔记
yibao1 年前
高质量编程与性能调优实战 | 青训营
青训营笔记
小金先生SG1 年前
阿里云对象存储OSS使用| 青训营
青训营笔记