zoukankan      html  css  js  c++  java
  • 实现Tcp服务器需要考虑哪些方面

    目录

    基本Tcp服务器

    • Server模块:服务器配置信息、启动、停止、运行、添加路由、路由管理器、链接管理器等。
    • 链接属性配置模块:启动链接、停止链接、获取链接对象等。
    • 消息封装:消息id、长度、内容、及消息的封包与解包等。
    • 基础路由模块:提供路由接口及基类,开启服务前必须继承基类实现重写基类中的方法再注册。
    • 全局配置模块:配置服务器的信息,使用前必须在根目录下配置json文件。
    • 多路由模块:使服务器根据数据不同类型做不同业务,提供路由注册、添加、与删除。
    • 读写分离:服务端读写分离。
    • 任务队列+工作池:服务端默认开启工作池,工作池中协程的数量与任务队列数量相同,任务队列为缓冲信道。
    • 链接管理:提供链接管理功能,链接的添加、删除、查找、清理等。

    简单使用

    1.配置json文件,名字为server.json。

    {
      "Name":"Demo_Server",
      "Version": "tcp4",
      "Host":"127.0.0.1",
      "TcpPort":"7777",
      "MaxPackSize":"4096",
      "MaxConn":"5000"
    }

    2.服务端继承重写路由基类方法,业务方法。

    3.配置封包与解包。

    package pack
    
    import (
        "bytes"
        "encoding/binary"
        "fmt"
        "sync"
    )
    
    type Message struct {
        Msglen uint32
    
        Msgid uint32
    
        Msgdata []byte
    }
    
    
    //封包函数
    func Pack(len uint32,id uint32,data []byte)([]byte,error) {
        var bufferPool = sync.Pool{
            New:func() interface{}{
                return new(bytes.Buffer)
            },
        }
        //获取一个存放bytes的缓冲区,存储字节序列
        dataBuff := bufferPool.Get().(*bytes.Buffer)
        //将数据长度写入字节流
        err :=  binary.Write(dataBuff,binary.LittleEndian,len)
        checkerr(err)
        //将id写入字节流
        err = binary.Write(dataBuff,binary.LittleEndian,id)
        checkerr(err)
        //将数据内容写入字节流
        err = binary.Write(dataBuff,binary.LittleEndian,data)
        checkerr(err)
        return dataBuff.Bytes(),nil
    
    }
    //解包函数
    func Unpack(data []byte)(*Message,error){
        //这里可以不需要额外创建一个数据缓冲
        //创建一个io。Reader
        boolBuffer := bytes.NewReader(data)
        msg := &Message{}
        //读取数据长度和id
        err := binary.Read(boolBuffer, binary.LittleEndian, &msg.Msglen)
        checkerr(err)
        err = binary.Read(boolBuffer, binary.LittleEndian, &msg.Msgid)
        checkerr(err)
        //数据包限制
        //if
        //
        //}
        return msg,nil
    }
    
    func checkerr(err error){
        if err != nil{
            fmt.Println("数据写入与读取失败")
        }
    }
    封包与解包

    4.创建服务端句柄。

    5.注册路由。

    6.运行服务器。

    服务端示例

    MyDemo/tcptest/pack:为封包与解包目录
    Zinx_Server/znet为服务源码目录
    package main
    
    import (
    	"MyDemo/tcptest/pack"
    	"Zinx_Server/znet"
    	"fmt"
    )
    
    type PingRouter struct{
    	znet.BaseRouter
    }
    
    //Test Handle
    func (this *PingRouter)Handle(request znet.IRequest){
    	var id uint32 =0
    	s := "hello client,i am Handle"
    	//将字符串转为字节切片
    	data :=  []byte(s)
    	//对数据进行封包
    	data,_ = pack.Pack(uint32(len(data)),id,data)
    	_,err := request.GetConnecttion().GetTcpConnecttion().Write(data)
    	if err != nil {
    		fmt.Println("call back  ping error")
    	}
    }
    
    func main(){
    	//创建Server句柄
    	Server := znet.NerServer()
    	//注册路由1:客户端数据类型为1,则由服务端注册的此条路由处理
    	//所有注册的类都应该继承BaseRouter类并重写,而BaseRouter类则实现了IRouter接口。
    	Server.AddRouter(1,&PingRouter{})
    	//注册路由2:客户端数据类型为2,则由服务端注册的此条路由处理
    	//Server.AddRouter(2,&test{})
    	Server.Run()
    }

    客户端示例:

    package main
    
    import (
    	"MyDemo/tcptest/pack"
    	"fmt"
    	"net"
    	"time"
    )
    
    func main() {
    	//1.创建链接远程链接服务器,得到一个conn链接
    	conn, err := net.Dial("tcp", "127.0.0.1:7777")
    	if err != nil {
    		fmt.Println("client start err,exit!")
    		return
    	}
    	i := 1
    	var id uint32 = 1
    	for {
    		//2.调用链接Write写数据
    		s := "Hello Server ,i am clent2"
    		//将字符串转为字节切片
    		data :=  []byte(s)
    		//对数据进行封包
    		data,_ = pack.Pack(uint32(len(data)),id,data)
    		_, err := conn.Write(data)
    		if err != nil {
    			fmt.Println("write conn err", err)
    			return
    		}
    
    		buf := make([]byte, 512)
    		_,err = conn.Read(buf)
    		//解包tcp数据
    		msg,_ := pack.Unpack(buf)
    		fmt.Printf("recv msg id:%d
    ", msg.Msgid)
    		fmt.Printf("recv msg len:%d
    ", msg.Msglen)
    		fmt.Printf("recv buf:%s
    ",string(buf[8:8+msg.Msglen]))
    		if err != nil {
    			fmt.Println("read buf err")
    			return
    		}
    		//fmt.Printf("Server call back:%s,cnt = %d
    ", buf, cnt)
    		i++
    		time.Sleep(5 *time.Second)
    	}
    }

    服务端运行:

    源码

    文件目录

     server.go

    package znet
    
    import (
        "fmt"
        "net"
    )
    type IServer interface {
        //启动
        Start()
        //停止
        Stop()
        //运行
        Run()
        //路由功能:给当前服务注册一个路由方法,供客户端链接处理使用
        AddRouter(index uint32,router IRouter)
        //得到路由管理器
        GetRoutermanger()IRoutermanger
        //得到链接管理器
        GetConnManager()IConnManager
    }
    
    //IServer接口的实现,定义一个Server的服务模块
    type Server struct{
        //服务器的名称
        Name string
        //IP版本
        IPversion string
        //服务器的IP
        IP string
        //服务器的端口
        Port string
        //最大链接数
        MaxConn int64
        //路由管理器
        Routermange IRoutermanger
        //链接管理器
        ConnManager IConnManager
        //工作池
        Pool *WorkerPool
    }
    
    //启动服务器
    func (s *Server)Start(){
            s.Pool.StartWorkPool()
            //在这里使用go协程让服务端阻塞接收客户端的消息为异步。
            //1.获取一个tcp的addr
            addr, err := net.ResolveTCPAddr(s.IPversion, fmt.Sprintf("%s:%s", s.IP, s.Port))
            if err != nil {
                fmt.Println("resolve tcp addr error:", err)
                return
            }
            //2.监听服务器的地址
            listenner, err := net.ListenTCP(s.IPversion, addr)
            if err != nil {
                fmt.Printf("listen:%s err:%s", s.IPversion, err)
                return
            }
            fmt.Println("Start Zinx serversucc:", s.Name)
    
            var cid uint32
            cid = 0
            go func() {
            //3.阻塞的等待客户端链接,触处理客户端链接业务
            for {
                //如果有客户端链接过来,阻塞会返回
                conn, err := listenner.AcceptTCP()
                if err != nil {
                    fmt.Println("Accept err", err)
                    continue
                }
                //判断是否超过最大链接数
                if int64(s.ConnManager.GetConnLen()) >= s.MaxConn{
                    err := conn.Close()
                    if err != nil{
                        fmt.Println("拒绝用户失败!")
                    }
                }else{
                    //将每个客户端链接与服务端路由模块进行绑定
                    //c:封装每个客户端链接信息,绑定链接、id、及服务器的路由管理器
                    Conn := NewConnection(s,conn,cid,s.Routermange)
                    cid++
                    //启动链接进行业务处理
                    s.Pool.Put(Conn)
                }
            }
        }()
    
    
    }
    //停止服务器
    func (s *Server)Stop(){
        s.ConnManager.ClearConn()
        //将服务器的资源、状态或者已经开辟的链接信息回收
    
    }
    //运行服务器
    func (s *Server)Run(){
        //启动Server的服务功能
        s.Start()
    
        //做一些启动服务器之后的业务//
    
        //阻塞状态
        select {}
    }
    //添加路由功能
    func (s *Server)AddRouter(index uint32,router IRouter){
        s.Routermange.AddRouter(index,router)
        fmt.Println("Add Router Succ!!!")
    }
    
    func (s *Server)GetRoutermanger()IRoutermanger{
        return s.Routermange
    }
    
    func (s *Server)GetConnManager()IConnManager{
        return s.ConnManager
    }
    
    //初始化Server模块方法
    func NerServer() *Server{
        config := NewConfig()
        s := &Server{
            Name :config.Name,
            IPversion:config.Version,
            IP:config.Host,
            Port:config.TcpPort,
            MaxConn:config.MaxConn,
            Routermange:NewRoutermange(),
            ConnManager:NewConnManger(),
            Pool:NewWorkerPool(2,10),
        }
        return s
    }
    View Code

    connection.go

    package znet
    
    import (
        "fmt"
        "net"
    )
    
    type IConnection interface {
        //启动链接:让当前链接开始工作
        Start()
        //停止链接:结束当前链接的工作
        Stop()
        //获取当前链接的绑定socket conn
        GetTcpConnecttion() *net.TCPConn
        //获取当前链接模块的ID
        GetConnID() uint32
        //获取远程客户端tpc的状态、ip、port
        GetRemoteAddr() net.Addr
        //发送数据、将数据发送给远程的客户端
        Send(data []byte) error
    }
    //链接模块
    type Connection struct {
        //当前链接绑定的服务端
        Server IServer
        //当前链接的socket tcp套接字
        Conn *net.TCPConn
        //链接的ID
        ConnID uint32
    
        //当前的链接状态
        ConnStatus bool
    
        //告知当前链接已经退出的/停止的channel
        ExitChan chan bool
    
        //路由管理器
        Routermange IRoutermanger
    
        //读协程与写协程之间数据信道
        done chan IRequest
    }
    
    
    func (c *Connection)StartReader() {
        go c.Reader()
        go c.Write()
    }
    
    //
    func (c *Connection)Reader() {
        fmt.Println("Reader Goroutine is running")
        defer fmt.Println("connID=",c.ConnID,"Reader is exit,remote addr is",c.GetRemoteAddr().String())
        for{
            buf := make([]byte,1024)
            _,err := c.Conn.Read(buf)
            if err != nil{
                fmt.Printf("recv buf err %s
    ",err)
                c.ExitChan <-true //通知Write,客户端断开链接
                break
            }
            c.ExitChan <- false //如果客户端没有断开链接,向信道发送信息,继续执行解包
            //将数据解包,得到结构体对象
            // msg:封装数据长度、数据id(类型)、数据内容
            msg := NewMessage(buf)
            //将当前链接信息和请求数据封装
            req :=NewRequest(c,msg)
            //将解包后的数据传入信道,数据传入信道后会也会阻塞,直到信道中的数据被取走
            c.done <- req
        }
    }
    //
    func (c *Connection)Write(){
        fmt.Println("Writer Goroutine is running")
        for{
            //信道阻塞,只有从信道中接收到信息才能往下执行
            status := <- c.ExitChan
            if status{
                c.Stop()
                break
            } else{
                req := <-c.done
                //执行注册的路由方法,每次将req传入
                go func(request IRequest){
                    //获取数据类型,查询路由表执行不同的路由
                    id := req.Getdata().Getmsgid()
                    c.Routermange.IndexRouter(id).Handle(request)
                }(req)
            }
        }
    }
    
    //启动链接:让当前链接开始准备工作
    func (c *Connection)Start(){
        fmt.Println("Start Conn,ConnID:",c.ConnID)
        //TODO 启动从当前链接写数据的业务
        go c.StartReader()
    }
    //停止链接:结束当前链接的工作
    func (c *Connection)Stop(){
        fmt.Println("Stop Conn,ConnID:",c.ConnID)
        if c.ConnStatus == true{
            return
        }
        //设置状态
        c.ConnStatus = false
        //关闭链接
        c.Conn.Close()
        c.Server.GetConnManager().Remove(c.ConnID)
        //回收资源
        close(c.ExitChan)
        close(c.done)
    }
    //获取当前链接的绑定socket conn
    func (c *Connection)GetTcpConnecttion() *net.TCPConn{
        return c.Conn
    }
    //获取当前链接模块的ID
    func (c *Connection)GetConnID() uint32{
        return c.ConnID
    }
    //获取远程客户端tpc的状态、ip、port
    func (c *Connection)GetRemoteAddr() net.Addr{
        return c.Conn.RemoteAddr()
    }
    //发送数据、将数据发送给远程的客户端
    func (c *Connection)Send(data []byte) error{
        _,err := c.Conn.Write([]byte(fmt.Sprintf("Hello")))
        return  err
    }
    
    //此方法用来初始化每一个客户端链接
    func NewConnection(Server IServer,conn *net.TCPConn,ConnID uint32,Routermange IRoutermanger)*Connection{
        c := & Connection{
            Server:Server,
            Conn:conn,
            ConnID:ConnID,
            ConnStatus:false,
            Routermange:Routermange,
            ExitChan:make(chan bool,1),
            done:make(chan IRequest),
        }
        Server.GetConnManager().Add(c.ConnID,c)
        return c
    }
    View Code

    connectmager.go

    package znet
    
    import (
        "errors"
        "sync"
    )
    
    type IConnManager interface {
        Add(connID uint32,conn IConnection)
        Remove(connID uint32)
        GetConn(connID uint32)(IConnection,error)
        GetConnLen() int
        ClearConn()
    }
    type ConnManger struct {
        ConnMangerMap   map[uint32]IConnection
        connLock  *sync.RWMutex
    }
    //添加
    func (M *ConnManger)Add(connID uint32,conn IConnection){
        M.connLock.Lock() //加写锁
        defer M.connLock.Unlock()//解写锁
        M.ConnMangerMap[connID] = conn
    
    }
    //删除
    func (M *ConnManger)Remove(connID uint32){
        M.connLock.Lock() //加写锁
        defer M.connLock.Unlock()//解写锁
        delete(M.ConnMangerMap,connID)
    }
    //得到链接
    func (M *ConnManger)GetConn(connID uint32)(IConnection,error){
        M.connLock.RLock() //加读锁
        defer M.connLock.RUnlock()//解读锁
        if conn,ok := M.ConnMangerMap[connID];ok{
            return conn,nil
        }else{
            return nil,errors.New("connnection not Found!")
        }
    
    }
    //得到链接个数
    func (M *ConnManger)GetConnLen()int{
        return len(M.ConnMangerMap)
    }
    
    func (M *ConnManger)ClearConn(){
        M.connLock.Lock() //加写锁
        defer M.connLock.Unlock()//解写锁
        for ConnID,conn := range M.ConnMangerMap{
            //停止链接
            conn.Stop()
            //删除链接
            delete(M.ConnMangerMap,ConnID)
        }
    }
    
    func NewConnManger() *ConnManger{
        return &ConnManger{
            make(map[uint32]IConnection),
            new(sync.RWMutex),
        }
    }
    View Code

    datapack.go

    package znet
    
    import (
        "bytes"
        "encoding/binary"
        "fmt"
    )
    
    type IDatapack interface {
        //获取包的头长度方法
        GetHead() uint32
        //封包
        Pack(msg IMessage)([]byte,error)
        //解包
        Unpack(data []byte)(Message,error)
    }
    
    type Datapack struct {}
    
    func (dp *Datapack)GetHead() uint32 {
        //4字节长度+4字节长度
        return 8
    
    }
    
    func (dp *Datapack)Pack(msg IMessage)([]byte,error) {
        //创建一个存放bytes的缓冲
        dataBuff := bytes.NewBuffer([]byte{})
        //将数据长度写入字节流
        err :=  binary.Write(dataBuff,binary.LittleEndian,msg.Getmsglen())
        checkerr(err)
        //将id写入字节流
        err = binary.Write(dataBuff,binary.LittleEndian,msg.Getmsgid())
        checkerr(err)
        //将数据内容写入字节流
        err = binary.Write(dataBuff,binary.LittleEndian,msg.Getmsg())
        checkerr(err)
        return dataBuff.Bytes(),nil
    
    }
    func (dp *Datapack)Unpack(data []byte)(*Message,error){
        //这里可以不需要额外创建一个数据缓冲
        //创建一个io。Reader
        boolBuffer := bytes.NewReader(data)
        msg := &Message{}
        //读取数据长度和id
        err := binary.Read(boolBuffer, binary.LittleEndian, &msg.Msglen)
        checkerr(err)
        err = binary.Read(boolBuffer, binary.LittleEndian, &msg.Msgid)
        checkerr(err)
        //数据包限制
        //if
        //
        //}
        return msg,nil
    }
    
    func NewDatapack()*Datapack{
        return &Datapack{}
    }
    
    func checkerr(err error){
        if err != nil{
            fmt.Println("数据写入与读取失败")
        }
    }
    View Code

    message.go

    package znet
    
    import (
        "fmt"
    )
    
    type IMessage interface {
        //获取消息的内容
        Getmsg() []byte
        //获取消息的长度
        Getmsglen() uint32
        //获取消息的ID
        Getmsgid() uint32
        //设置消息的ID
        Setmsgid(id uint32)
        //设置消息的内容
        Setmsg(data []byte)
    }
    
    type Message struct {
        //TLV格式数据
        //消息长度
        Msglen uint32
        //消息ID
        Msgid uint32
        //消息内容
        data []byte
    }
    
    func (M *Message)Getmsg() []byte{
        return M.data
    }
    func (M *Message)Getmsglen() uint32{
        return M.Msglen
    }
    func (M *Message)Getmsgid() uint32{
        return M.Msgid
    }
    func (M *Message)Setmsgid(id uint32){
        M.Msgid = id
    }
    func (M *Message)Setmsg(data []byte){
        M.data = data
    }
    
    func NewMessage(data []byte)*Message{
        pack := NewDatapack()
        msg,_ := pack.Unpack(data)
        fmt.Printf("recv buf:%s
    ",string(data[8:8+msg.Msglen]))
        msg.data = data[8:8+msg.Msglen]
        return msg
    }
    View Code

    request.go

    package znet
    
    
    type IRequest interface {
        //得到请求数据封装
        Getdata() IMessage
        //得到当前链接封装
        GetConnecttion() IConnection
    }
    
    type Request struct {
        //已经和客户端建立链接对象
         conn IConnection
         //客户端请求数据
         Msg IMessage
    }
    
    //得到请求数据
    func (r *Request)Getdata()IMessage{
        return r.Msg
    }
    //得到当前链接
    func (r *Request)GetConnecttion() IConnection{
        return r.conn
    }
    //初始化Request对象
    func NewRequest(conn IConnection,Msg IMessage) *Request{
        r := &Request{
            conn:conn,
            Msg:Msg,
        }
        return r
    }
    View Code

    router.go

    package znet
    
    
    type IRouter interface {
        //Hook机制:其主要思想是提前在可能增加功能的地方埋好(预设)一个钩子,这个钩子并没有实际的意义,当我们需要重新修改或者增加这个地方的逻辑的时候,把扩展的类或者方法挂载到这个点即可。
        //在处理conn业务之前的钩子方法Hook
        PreHandle(request IRequest)
        //在处理conn业务主方法Hook
        Handle(request IRequest)
        //在处理conn业务之后的钩子方法Hook
        PostHandle(request IRequest)
    }
    
    //基类实现所有接口方法,但不是不具体写死,然后子类继承基类,重写基类方法
    type BaseRouter struct{}
    //在处理conn业务之前的钩子方法Hook
    func (br *BaseRouter)PreHandle(request IRequest){}
    //在处理conn业务主方法Hook
    func (br *BaseRouter)Handle(request IRequest){}
    //在处理conn业务之后的钩子方法Hook
    func (br *BaseRouter)PostHandle(request IRequest){}
    View Code

    routermange.go

    package znet
    
    type IRoutermanger interface {
        //返回索引对应的路由
        IndexRouter(index uint32)IRouter
        //添加路由
        AddRouter(index uint32, router IRouter)
        ////删除路由
        DeleteRouter(index uint32)
    }
    
    type Routermange struct {
        //路由映射
        Routermap map[uint32]IRouter
        //负责worker取任务的消息队列
    
        //业务工作worker池的worker数量
    
    
    }
    //返回索引对应的路由,服务端通过消息绑定的ID(类型),可以执行对应不同的注册路由函数
    func (R * Routermange)IndexRouter(index uint32)IRouter{
        return R.Routermap[index]
    }
    //添加路由
    func (R * Routermange)AddRouter(index uint32,router IRouter){
        R.Routermap[index] = router
    }
    ////删除路由
    func (R * Routermange)DeleteRouter(index uint32){
        delete(R.Routermap,index)
    }
    //初始化路由
    func NewRoutermange()*Routermange{
        return &Routermange{
            make(map[uint32]IRouter),
        }
    }
    View Code

    config.go

    package znet
    
    import (
        "encoding/json"
        "fmt"
        "os"
        "strconv"
    )
    
    type Config struct {
        Name  string
        Version string
        Host   string
        TcpPort string
        MaxPackSize int64
        MaxConn   int64
    
    }
    
    func (c *Config)setname(Name string){
        c.Name = Name
    }
    func (c *Config)setVersion(Version string){
        c.Version = Version
    }
    
    func (c *Config)setHost(Host string){
        c.Host = Host
    }
    
    func (c *Config)setTcpPort(TcpPort string){
        c.TcpPort = TcpPort
    }
    func (c *Config)setMaxConn(MaxConn   int64){
        c.MaxConn = MaxConn
    }
    
    func (c *Config)setMaxPackSize(MaxPackSize   int64){
        c.MaxPackSize = MaxPackSize
    }
    
    
    func NewConfig() *Config {
        //打开json文件
        srcFile,err := os.Open("D:/Go/test/src/Zinx_Server/znet/server.json")
        if err != nil{
            fmt.Println("文件打开失败,err=",err)
            return nil
        }
        defer srcFile.Close()
        //创建接收数据的map类型数据
        datamap := make(map[string]string)
        //创建解码器
        decoder := json.NewDecoder(srcFile)
        //解码
        err = decoder.Decode(&datamap)
        if err != nil{
            fmt.Println("解码失败,err:",err)
            return nil
        }
        MaxConn,err := strconv.ParseInt(datamap["MaxConn"], 10, 64)
        MaxPackSize,err := strconv.ParseInt(datamap["MaxPackSize"],10,64)
        c := &Config{
            datamap["Name"],
            datamap["Version"],
            datamap["Host"],
            datamap["TcpPort"],
            MaxPackSize,
            MaxConn,
        }
        return c
    }
    View Code

    WorkerPool.go

    package znet
    
    
    
    
    //限制
    //1.worker工作池的任务队列的最大值
    //2.任务队列中任务的最大数量
    //协程池
    type WorkerPool struct {
        cap            int
        tasksSize   int
        TaskQueue []chan IConnection //信道集合
    
    }
    
    //启动一个worker工作池,开启工作池只能发生一次
    func (W *WorkerPool)StartWorkPool(){
        //根据任务队列的大小,分别开启worker,每个worker用go来承载,每一个worker对应一个任务队列
        for i:=0;i<W.cap;i++{
            //为每个worker开辟缓冲信道(任务队列)
            W.TaskQueue[i] = make(chan IConnection,W.tasksSize)
            //启动worker,阻塞等待任务从channel中到来
            go W.StartOneWorker(i,W.TaskQueue[i])
        }
    }
    
    func (W *WorkerPool)StartOneWorker(id int,taskqueue chan IConnection){
    
        for{
            select {
            case request :=<- taskqueue:
                //如果有消息过来,则处理业务
                request.Start()
            default:
                continue
            }
        }
    }
    func (W *WorkerPool)Put(Connection IConnection){
        index :=  Connection.GetConnID()%uint32(W.cap)
        W.TaskQueue[index] <- Connection
    }
    
    func  NewWorkerPool(cap int,len int)*WorkerPool{
        return &WorkerPool{
            cap:cap,
            tasksSize:len,
            TaskQueue:make([]chan IConnection,cap),
        }
    }
    View Code
  • 相关阅读:
    结构化思考力
    对象序列化解析
    单元测试的规范
    关于CefSharp的坎坷之路
    构建基于Chromium的应用程序
    C# 面向切面编程(AOP)--监控日志记录方案
    CefSharp 中断点 已达到中断点
    用批处理命令实现激活office2016
    Winform开发中的困境及解决方案
    VUE+Element 前端应用开发框架功能介绍
  • 原文地址:https://www.cnblogs.com/-wenli/p/12346819.html
Copyright © 2011-2022 走看看