IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 网络协议 -> 【Go Web学习笔记】第十五章 Go与RPC -> 正文阅读

[网络协议]【Go Web学习笔记】第十五章 Go与RPC

前言:大家好,以下所有内容都是我学习韩茹老师的教程时所整理的笔记。部分内容有过删改, 推荐大家去看原作者的文档进行学习本文章仅作为个人的学习笔记,后续还会在此基础上不断修改。学习Go Web时应该已经熟悉Go语言基本语法以及计算机网络的相关内容。

学习链接:https://www.chaindesk.cn/witbook/17/253
参考书籍:《Go Web编程》谢孟军

第十五章、RPC

1、什么是RPC

RPC(Remote Procedure Call Protocol)——远程过程调用协议 (补充:RPC是一个协议!),是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。它假定某些传输协议的存在,如TCP或UDP,以便为通信程序之间携带信息数据。通过它可以使函数调用模式网络化。在OSI网络通信模型中,RPC跨越了传输层和应用层。RPC使得开发包括网络分布式多程序在内的应用程序更加容易。

RPC就是想实现函数调用模式的网络化。客户端就像调用本地函数一样,然后客户端把这些参数打包之后通过网络传递到服务端,服务端解包到处理过程中执行,然后执行的结果反馈给客户端。

远程过程调用(Remote Procedure Call,缩写为 RPC)是一个计算机通信协议。 该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员无需额外地为这个交互编程。远程过程调用是构建分布式应用的理论基础,它简单而又广受欢迎。 远程过程调用总是由客户端对服务器发出一个执行若干过程请求,并用客户端提供的参数。执行结果将返回给客户端。

一个通俗的描述是:客户端在不知道调用细节的情况下,调用存在于远程计算机上的某个对象,就像调用本地应用程序中的对象一样。

那么我们至少从这样的描述中挖掘出几个要点:

  • RPC是协议:既然是协议就只是一套规范,那么就需要有人遵循这套规范来进行实现。目前典型的RPC实现包括:Dubbo(Alibaba的分布式服务框架)、Thrift(Facebook)、GRPC(Google)、Hetty等。这里要说明一下,目前技术的发展趋势来看,实现了RPC协议的应用工具往往都会附加其他重要功能,例如Dubbo还包括了服务管理、访问权限管理等功能。
  • 网络协议对其透明:既然RPC的客户端认为自己是在调用本地对象。那么传输层使用的是TCP/UDP还是HTTP协议,又或者是一些其他的网络协议它就不需要关心了。
  • 信息格式对其透明:我们知道在本地应用程序中,对于某个对象的调用需要传递一些参数,并且会返回一个调用结果。至于被调用的对象内部是如何使用这些参数,并计算出处理结果的,调用方是不需要关心的。那么对于远程调用来说,这些参数会以某种信息格式传递给网络上的另外一台计算机,这个信息格式是怎样构成的,调用方是不需要关心的
  • 应该有跨语言能力:为什么这样说呢?因为调用方实际上也不清楚远程服务器的应用程序是使用什么语言运行的。那么对于调用方来说,无论服务器方使用的是什么语言,本次调用都应该成功,并且返回值也应该按照调用方程序语言所能理解的形式进行描述

远程过程调用流程图:

image-20211123165053889

运行时,一次客户机对服务器的RPC调用,其内部操作大致有如下十步:

  • 1.调用客户端句柄;执行传送参数
  • 2.调用本地系统内核发送网络消息
  • 3.消息传送到远程主机
  • 4.服务器句柄得到消息并取得参数
  • 5.执行远程过程
  • 6.执行的过程将结果返回服务器句柄
  • 7.服务器句柄返回结果,调用远程系统内核
  • 8.消息传回本地主机
  • 9.客户句柄由内核接收消息
  • 10.客户接收句柄返回的数据

2、golang中如何实现RPC

在golang中实现RPC非常简单,有封装好的官方库和一些第三方库提供支持。Go RPC可以利用tcp或http来传递数据,可以对要传递的数据使用多种类型的编解码方式。golang官方的net/rpc库使用encoding/gob进行编解码,支持tcphttp数据传输方式,由于其他语言不支持gob编解码方式,所以使用net/rpc库实现的RPC方法没办法进行跨语言调用。

golang官方还提供了net/rpc/jsonrpc库实现RPC方法,JSON RPC 采用JSON进行数据编解码,因而支持跨语言调用。但目前的 jsonrpc 库是基于tcp协议实现的,暂时不支持使用http进行数据传输。

除了golang官方提供的rpc库,还有许多第三方库为在golang中实现RPC提供支持,如:protorpc库,大部分第三方rpc库的实现都是使用protobuf进行数据编解码,根据protobuf声明文件自动生成rpc方法定义与服务注册代码,在golang中可以很方便的进行rpc服务调用。

Go标准包中已经提供了对RPC的支持,而且支持三个级别的RPC:TCP、HTTP、JSONRPC。但Go的RPC包是独一无二的RPC,它和传统的RPC系统不同,它只支持Go开发的服务器与客户端之间的交互,因为在内部,它们采用了Gob来编码。

3、net/rpc 包实现

先介绍Go官方提供的 net/rpc包,它提供了通过网络访问一个对象的方法的能力。服务器需要注册对象, 通过对象的类型名暴露这个服务。注册后这个对象的输出方法就可以远程调用,这个库封装了底层传输的细节,包括序列化。服务器可以注册多个不同类型的对象,但是注册相同类型的多个对象的时候会出错。

Go RPC的函数只有符合下面的条件才能被远程访问,不然会被忽略,详细的要求如下:

  • 函数必须是导出的(首字母大写)
  • 必须有两个导出类型的参数,
  • 第一个参数是接收的参数,第二个参数是返回给客户端的参数,第二个参数必须是指针类型的
  • 函数还要有一个返回值error

所以一个输出方法的格式如下:

func (t *T) MethodName(argType T1, replyType *T2) error

这里的TT1T2能够被encoding/gob序列化。

这个方法的第一个参数代表调用者(client)提供的参数,第二个参数代表要返回给调用者的计算结果,方法的返回值如果不为空, 那么它作为一个字符串返回给调用者。

如果返回error,则reply参数不会返回给调用者。

服务器通过调用ServeConn在一个连接上处理请求,更典型地, 它可以创建一个network listener然后accept请求。对于HTTP listener来说,可以调用 HandleHTTPhttp.Serve

客户端可以调用DialDialHTTP建立连接。 客户端有两个方法调用服务: CallGo,可以同步地或者异步地调用服务。当然,调用的时候,需要把服务名、方法名和参数传递给服务器。异步方法调用Go 通过 Done channel通知调用结果返回。

除非显示的设置codec,否则这个库默认使用包encoding/gob作为序列化框架。

3.1 HTTP RPC

首选介绍一个简单的例子。 这个例子中提供了面积和周长的两个方法。

我们先看服务端:

第一步你需要定义传入参数和返回参数的数据结构:

type Params struct {
    Width, Height int
}

第二步定义一个服务对象,这个服务对象可以很简单, 比如类型是int或者是interface{}, 重要的是它输出的方法。 这里我们定义一个算术类型Rect,它可以是任意类型,也可以是int类型,但是这个值我们在后面方法的实现中也没用到,所以它基本上就起一个辅助的作用。

type Rect struct{}

第三步实现这个类型的两个方法, 面积和周长:

func (r *Rect) Area(p Params, ret *int) error {
    *ret = p.Width * p.Height
    return nil
}

func (r *Rect) Perimeter(p Params, ret *int) error {
    *ret = (p.Width + p.Height) * 2
    return nil
}

目前为止,我们的准备工作已经完成,继续下面的步骤。

第四步实现RPC服务器:

    rect := new(Rect)
    //注册一个rect服务
    rpc.Register(rect)
    //把服务处理绑定到http协议上
    rpc.HandleHTTP()
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal(err)
    }

这里我们生成了一个rect对象,并使用rpc.Register注册这个服务,然后通过HTTP暴露出来。 客户端可以看到服务rect以及它的两个方法Rect.AreaRect.Perimeter

客户端:

最后创建一个客户端,建立客户端和服务器端的连接:

    rpc, err := rpc.DialHTTP("tcp", "127.0.0.1:8080")
    if err != nil {
        log.Fatal(err)
    }

然后客户端就可以进行远程调用了。

    ret := 0
    //调用远程方法
    //注意第三个参数是指针类型
    err2 := rpc.Call("Rect.Area", Params{50, 100}, &ret)
    if err2 != nil {
        log.Fatal(err2)
    }
    fmt.Println(ret)
    err3 := rpc.Call("Rect.Perimeter", Params{50, 100}, &ret)
    if err3 != nil {
        log.Fatal(err3)
    }
    fmt.Println(ret)

或者异步的方式:

divCall := client.Go("Arith.Divide", Params{50, 100}, &ret, nil)
<-divCall.Done  
fmt.Println(ret)

通过上面的调用可以看到参数是我们定义的struct类型,返回值是int类型,在服务端我们把它们当做调用函数的参数的类型,在客户端作为client.Call的第2,3两个参数的类型。客户端最重要的就是这个Call函数,它有3个参数,第1个要调用的函数的名字,第2个是要传递的参数,第3个要返回的参数(注意是指针类型),通过上面的代码例子我们可以发现,使用Go的RPC实现相当的简单,方便。

以上案例完整代码:

服务器端:

package main

import (
    "net/rpc"
    "net/http"
    "log"
)

type Params struct {
    Width, Height int
}
type Rect struct{}

func (r *Rect) Area(p Params, ret *int) error {
    *ret = p.Width * p.Height
    return nil
}

func (r *Rect) Perimeter(p Params, ret *int) error {
    *ret = (p.Width + p.Height) * 2
    return nil
}

func main() {
    rect := new(Rect)
    //注册一个rect服务
    rpc.Register(rect)
    //把服务处理绑定到http协议上
    rpc.HandleHTTP()
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal(err)
    }
}

客户端代码:

package main

import (
    "fmt"
    "log"
    "net/rpc"
)
type Params struct {
    Width, Height int
}

func main() {
    rpc, err := rpc.DialHTTP("tcp", "127.0.0.1:8080")
    if err != nil {
        log.Fatal(err)
    }
    ret := 0
    //调用远程方法
    //注意第三个参数是指针类型
    err2 := rpc.Call("Rect.Area", Params{50, 100}, &ret)
    if err2 != nil {
        log.Fatal(err2)
    }
    fmt.Println(ret)
    err3 := rpc.Call("Rect.Perimeter", Params{50, 100}, &ret)
    if err3 != nil {
        log.Fatal(err3)
    }
    fmt.Println(ret)
}

客户端运行结果如下:

5000
300

3.2 TCP RPC

上面我们实现了基于HTTP协议的RPC,接下来我们要实现基于TCP协议的RPC,服务端的实现代码如下所示:

package main

import (
    "net/rpc"
    "log"
    "net"
)

type Params struct {
    Width, Height int
}
type Rect struct{}

func (r *Rect) Area(p Params, ret *int) error {
    *ret = p.Width * p.Height
    return nil
}

func (r *Rect) Perimeter(p Params, ret *int) error {
    *ret = (p.Width + p.Height) * 2
    return nil
}

func main() {
    rect := new(Rect)
    //注册一个rect服务
    rpc.Register(rect)

    tcpAddr, err :=net.ResolveTCPAddr("tcp",":2001")
    if err != nil {
        log.Fatal(err)
    }

    listener,err := net.ListenTCP("tcp",tcpAddr)
    if err != nil {
        log.Fatal(err)
    }

    for{
        conn,err :=listener.Accept()
        if err != nil {
            continue
        }
        rpc.ServeConn(conn)
    }

}

上面这个代码和http的服务器相比,不同在于: 在此处我们采用了TCP协议,然后需要自己控制连接,当有客户端连接上来后,我们需要把这个连接交给rpc来处理。

如果你留心了,你会发现这它是一个阻塞型的单用户的程序,如果想要实现多并发,那么可以使用goroutine来实现。 下面展现了TCP实现的RPC客户端:

package main

import (
    "fmt"
    "log"
    "net/rpc"
)
type Params struct {
    Width, Height int
}

func main() {
    rpc, err := rpc.Dial("tcp", "127.0.0.1:2001")
    if err != nil {
        log.Fatal(err)
    }
    ret := 0
    //调用远程方法
    //注意第三个参数是指针类型
    err2 := rpc.Call("Rect.Area", Params{50, 100}, &ret)
    if err2 != nil {
        log.Fatal(err2)
    }
    fmt.Println(ret)
    err3 := rpc.Call("Rect.Perimeter", Params{50, 100}, &ret)
    if err3 != nil {
        log.Fatal(err3)
    }
    fmt.Println(ret)
}

客户端运行结果:

5000
300

这个客户端代码和http的客户端代码对比,唯一的区别一个是DialHTTP,一个是Dial(tcp),其他处理一模一样。

4、net/rpc/jsonrpc库

上面的例子我们演示了使用net/rpc实现RPC的过程,但是没办法在其他语言中调用上面例子实现的RPC方法。所以接下来的例子我们演示一下使用net/rpc/jsonrpc库实现RPC方法,此方式实现的RPC方法支持跨语言调用。

服务端示例代码:

package main

import (
    "net/rpc"
    "os"
    "net/rpc/jsonrpc"
    "net"
    "log"
    "fmt"
    "errors"
)

// 算数运算结构体
type Arith struct {
}

// 算数运算请求结构体
type ArithRequest struct {
    A int
    B int
}

// 算数运算响应结构体
type ArithResponse struct {
    Pro int // 乘积
    Quo int // 商
    Rem int // 余数
}

// 乘法运算方法
func (this *Arith) Multiply(req ArithRequest, res *ArithResponse) error {
    res.Pro = req.A * req.B
    return nil
}

// 除法运算方法
func (this *Arith) Divide(req ArithRequest, res *ArithResponse) error {
    if req.B == 0 {
        return errors.New("divide by zero")
    }
    res.Quo = req.A / req.B
    res.Rem = req.A % req.B
    return nil
}

func main() {
    rpc.Register(new(Arith)) // 注册rpc服务

    lis, err := net.Listen("tcp", ":8096")
    if err != nil {
        log.Fatalln("fatal error: ", err)
    }

    fmt.Fprintf(os.Stdout, "%s", "start connection")

    for {
        conn, err := lis.Accept() // 接收客户端连接请求
        if err != nil {
            continue
        }

        go func(conn net.Conn) { // 并发处理客户端请求
            fmt.Fprintf(os.Stdout, "%s", "new client in coming\n")
            jsonrpc.ServeConn(conn)
        }(conn)
    }
}

客户端示例代码:

package main

import (
    "net/rpc/jsonrpc"
    "log"
    "fmt"
)

// 算数运算请求结构体
type ArithRequest struct {
    A int
    B int
}

// 算数运算响应结构体
type ArithResponse struct {
    Pro int // 乘积
    Quo int // 商
    Rem int // 余数
}

func main() {
    conn, err := jsonrpc.Dial("tcp", "127.0.0.1:8096")
    if err != nil {
        log.Fatalln("dailing error: ", err)
    }

    req := ArithRequest{9, 2}
    var res ArithResponse

    err = conn.Call("Arith.Multiply", req, &res) // 乘法运算
    if err != nil {
        log.Fatalln("arith error: ", err)
    }
    fmt.Printf("%d * %d = %d\n", req.A, req.B, res.Pro)

    err = conn.Call("Arith.Divide", req, &res)
    if err != nil {
        log.Fatalln("arith error: ", err)
    }
    fmt.Printf("%d / %d, quo is %d, rem is %d\n", req.A, req.B, res.Quo, res.Rem)
}

客户端运行结果:

9 * 2 = 18
9 / 2, quo is 4, rem is 1

5、总结

5.1 有了HTTP为何还有RPC?

论复杂度,RPC框架肯定是高于简单的HTTP接口的。但毋庸置疑,HTTP接口由于受限于HTTP协议,需要带HTTP请求头,导致传输起来效率或者说安全性不如RPC。

下面展示了一个请求头,无用数据过多,响应头类似

GET /search/suggest/initial_page/ HTTP/1.1
Host    www.toutiao.com
Content-Type    application/x-www-form-urlencoded
Accept-Encoding gzip, deflate
Cookie  CNZZDATA1259612802=569328305-1527816505-https%253A%252F%252Fwww.baidu.com%252F%7C1527821905; WEATHER_CITY=%E5%8C%97%E4%BA%AC; __tasessionId=i33ntavtt1527822487203; tt_webid=6561930712081466884; UM_distinctid=163b91509b88ff-0e1ee05ec87bae-3f636c4f-13c680-163b91509b96aa; _ga=GA1.2.195827442.1493809988; sso_login_status=0; tt_webid=6561930712081466884; uuid="w:b5453e80f63342d1afe07a5d3c3360f9"
Connection  keep-alive
Proxy-Connection    keep-alive
Accept  text/javascript, text/html, application/xml, text/xml, */*
User-Agent  Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1 Safari/605.1.15
Referer https://www.toutiao.com/
Accept-Language zh-cn
X-Requested-With    XMLHttpRequest

http接口是在接口不多、系统与系统交互较少的情况下;优点就是简单、直接、开发方便。利用现成的http协议进行传输。但是如果是一个大型的网站,内部子系统较多、接口非常多的情况下,RPC框架的好处就显示出来了,首先就是长链接,不必每次通信都要像http 一样去3次握手什么的,减少了网络开销;其次就是RPC框架一般都有注册中心,有丰富的监控管理;发布、下线接口、动态扩展等,对调用方来说是无感知、统 一化的操作。

HTTP与RPC存在重大不同的是:请求是使用具有标准语义的通用的接口定向到资源的,这些语义能够被中间组件和提供服务的来源机器进行解释。结果是使得一个应用支持分层的转换和间接层,并且独立于消息的来源。

与之相比较,RPC的机制是根据语言的API来定义的,而不是根据基于网络的应用来定义的。

事实上,对于http,也可以作为RPC框架的通信层协议和实现。 只不过,对于大部分企业成熟的RPC框架,使用thrift等工具可以实现二进制传输,相比HTTP的文本传输无疑大大提高了传输效率; HTTP通常使用的json,一个需要用户序列化/反序列化,性能和复杂度较高。相比之下,Thrift等工具,使用了成熟的代码生成技术,将通信接口的文件生成了对应语言的代码接口,实现了远程调用接近于本地方法的调用。另外无论是网络传输编码、解码,还是传输内容大小还是网络开销都想比HTTP有较大的优势。

一般rpc框架包括:服务查找,负载均衡,服务降级、熔断,下游路由配置,数据格式约定,链接维护等几个方面。

5.2 TCP、HTTP与Socket区别

5.2.1 TCP连接

手机能够使用联网功能是因为手机底层实现了TCP/IP协议,可以使手机终端通过无线网络建立TCP连接。TCP协议可以对上层网络提供接口,使上层网络数据的传输建立在“无差别”的网络之上。

建立起一个TCP连接需要经过“三次握手”:(补充:翻一翻计算机网络的相关内容)

第一次握手:客户端发送syn包(syn=j)到服务器,并进入SYN_SEND状态,等待服务器确认;

第二次握手:服务器收到syn包,必须确认客户的SYN(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态;

第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手。

握手过程中传送的包里不包含数据,三次握手完毕后,客户端与服务器才正式开始传送数据。理想状态下,TCP连接一旦建立,在通信双方中的任何一方主动关闭连接之前,TCP 连接都将被一直保持下去。断开连接时服务器和客户端均可以主动发起断开TCP连接的请求,断开过程需要经过“四次握手”(过程就不细写了,就是服务器和客户端交互,最终确定断开)

5.2.2 HTTP连接

HTTP协议即超文本传送协议(Hypertext Transfer Protocol ),是Web联网的基础,也是手机联网常用的协议之一,HTTP协议是建立在TCP协议之上的一种应用。

**http 为短连接:**客户端发送请求都需要服务器端回送响应。请求结束后,主动释放链接,因此为短连接。要保持客户端程序的在线状态,需要不断地向服务器发起连接请求。通常的 做法是即时不需要获得任何数据,客户端也保持每隔一段固定的时间向服务器发送一次“保持连接”的请求,服务器在收到该请求后对客户端进行回复,表明知道客 户端“在线”。若服务器长时间无法收到客户端的请求,则认为客户端“下线”,若客户端长时间无法收到服务器的回复,则认为网络已经断开。

TCP是底层通讯协议,定义的是数据传输和连接方式的规范 HTTP是应用层协议,定义的是传输数据的内容的规范。 http与tcp不是对等的两种协议,http是可以基于tcp传输的协议。

5.2.3 Socket连接

**Socket为长连接:**Socket 是对 TCP/IP 协议的封装,Socket 只是个接口不是协议,通过 Socket 我们去使用 TCP/IP 协议,除了 TCP,也可以使用 UDP 协议来传递数据。

Socket也称作套接字,套接字是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元。它是网络通信过程中端点的抽象表示。建立Socket连接至少需要一对套接字,其中一个运行于客户端,称为ClientSocket ,另一个运行于服务器端,称为ServerSocket 。

套接字之间的连接过程分为三个步骤:服务器监听,客户端请求,连接确认。

服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态,等待客户端的连接请求。

客户端请求:指客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。

连接确认:当服务器端套接字监听到或者说接收到客户端套接字的连接请求时,就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发给客户 端,一旦客户端确认了此描述,双方就正式建立连接。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

5.2.4 总结

HTTP连接使用的是"请求-响应"方式,不仅在请求时才建立连接,而且客户端向服务器端请求后,服务器才返回数据。

创建 Socket 连接的时候,可以指定传输层协议,可以是 TCP 或者 UDP。通常情况下Socket 连接就是 TCP 连接,因此 Socket 连接一旦建立,通讯双方开始互发数据内容,直到双方断开连接。

很多情况下,都是需要服务器端向客户端主动推送数据,保持客户端与服务端的实时同步。

若双方是 Socket 连接,可以由服务器直接向客户端发送数据。

若双方是 HTTP 连接,则服务器需要等客户端发送请求后,才能将数据回传给客户端。

因此,客户端定时向服务器端发送请求,不仅可以保持在线,同时也询问服务器是否有新数据,如果有就将数据传给客户端。

  网络协议 最新文章
使用Easyswoole 搭建简单的Websoket服务
常见的数据通信方式有哪些?
Openssl 1024bit RSA算法---公私钥获取和处
HTTPS协议的密钥交换流程
《小白WEB安全入门》03. 漏洞篇
HttpRunner4.x 安装与使用
2021-07-04
手写RPC学习笔记
K8S高可用版本部署
mySQL计算IP地址范围
上一篇文章      下一篇文章      查看所有文章
加:2021-11-24 08:19:34  更:2021-11-24 08:20:27 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/26 7:17:09-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码