1.聊天室设计分析

一. 概览

实现 个网络聊天室(群)
功能分析:

  1. 上线下线
  2. 聊天,其他人,自己都可以看到聊天消息
  3. 查询当前聊天室用户名字 who
  4. 可以修改自己名字 rename | Duke
  5. 超时踢出

技术点分析:
1 . sock tcp 编程
2 . map结构 (存储当前用户,map遍历,map删除)
3 . go程,channel
4 . select(超时退出,主动退出)
5 . timer定时器

二、实现基础

第一阶段:
tcp socket,建立多个连接

package mainimport ("fmt""net"
)func main(){// 创建服务器listener,err := net.Listen("tcp",":8088")if err != nil{fmt.Println("net.Listen err:",err)return}fmt.Println("服务器启动成功,监听中...")for {fmt.Println("==>主go程监听中......")// 监听conn,err := listener.Accept()if err != nil{fmt.Println("listener.Accept err:",err)return}// 建立连接fmt.Println("建立连接成功!")// 启动处理业务的go程go handler(conn)}
}// 处理具体业务
func handler(conn net.Conn){for{fmt.Println("启动业务...")// TODO // 代表这里以后再具体实现buf := make([]byte,1024)// 读取客户端发送来的数据cnt,err := conn.Read(buf)if err != nil{fmt.Println("listener.Read err:",err)return}fmt.Println("客户端接收客户端发来的数据为:",string(buf[:cnt-1]),",cnt:",cnt)}}

go run chatroom.go
启动nc

nc下载地址

2、定义User/map结构

type User struct {// 名字name string // 唯一 的 idid string// 管道msg chan string}// 创建一个全局的map结构,用于保存所有的用户
var allUsers = make(map[string]User)

在Handler中调用

// 处理具体业务
func handler(conn net.Conn){for{fmt.Println("启动业务...")
//// 客户端与服务器建立连接的时候,公有ip和port --> 当成user的idclientAddr := conn.RemoteAddr().String()fmt.Println("clientAddr:",clientAddr)// 创建usernewUser := User{id:clientAddr,// id 我们不会修改,这个作为map中的keyname:clientAddr,// 可以修改,会提供rename命令修改,建立连接时,初始值与id相同msg:make(chan string), // 注意需要make空间,否则无法写入数据}// 添加user到map结构allUsers[newUser.id] = newUser
/buf := make([]byte,1024)// 读取客户端发送来的数据cnt,err := conn.Read(buf)if err != nil{fmt.Println("listener.Read err:",err)return}fmt.Println("客户端接收客户端发来的数据为:",string(buf[:cnt-1]),",cnt:",cnt)}

3.定义message管道


创建监听广播go程函数

// 向所有的用户广播消息,启动一个全局唯一的go程
func broadcast(){fmt.Println("广播go程启动成功...")// 1. 从message中读取数据info := <-message // 2. 将数据写入到每一个用户的msg管道中for _,user := range allUsers{user.msg <- info }
}

启动,全局唯一

写入上线数据

当前整体源码

package mainimport ("fmt""net"
)type User struct {// 名字name string // 唯一 的 idid string// 管道msg chan string}// 创建一个全局的map结构,用于保存所有的用户
var allUsers = make(map[string]User)// 定义一个message全局通道,用于接收任何人发送过来消息
var message = make(chan string,10)func main(){// 创建服务器listener,err := net.Listen("tcp",":8087")if err != nil{fmt.Println("net.Listen err:",err)return}// 启动全局唯一的go程,负责监听message通道,写给所有的用户go broadcast()fmt.Println("服务器启动成功,监听中...")for {fmt.Println("==>主go程监听中......")// 监听conn,err := listener.Accept()if err != nil{fmt.Println("listener.Accept err:",err)return}// 建立连接fmt.Println("建立连接成功!")// 启动处理业务的go程go handler(conn)}
}// 处理具体业务
func handler(conn net.Conn){for{fmt.Println("启动业务...")// 客户端与服务器建立连接的时候,公有ip和port --> 当成user的idclientAddr := conn.RemoteAddr().String()fmt.Println("clientAddr:",clientAddr)// 创建usernewUser := User{id:clientAddr,// id 我们不会修改,这个作为map中的keyname:clientAddr,// 可以修改,会提供rename命令修改,建立连接时,初始值与id相同msg:make(chan string,10), // 注意需要make空间,否则无法写入数据}// 添加user到map结构allUsers[newUser.id] = newUser// 向message写入数据,当我用户上线的消息,用于通知所有人(广播)loginInfo := fmt.Sprintf("[%s][%s] ===> |上线了login!!",newUser.id,newUser.name)message <- loginInfobuf := make([]byte,1024)// 读取客户端发送来的数据cnt,err := conn.Read(buf)if err != nil{fmt.Println("listener.Read err:",err)return}fmt.Println("客户端接收客户端发来的数据为:",string(buf[:cnt-1]),",cnt:",cnt)}}// 向所有的用户广播消息,启动一个全局唯一的go程
func broadcast(){fmt.Println("广播go程启动成功...")defer fmt.Println("broadcast 程序退出!")for {// 1. 从message中读取数据fmt.Println("broadcast监听message中...")info := <-message // 2. 将数据写入到每一个用户的msg管道中for _,user := range allUsers{// 如果msg是非缓冲,那么会在这里阻塞user.msg <- info }}
}

4.user监听通道go程

每个用户应该还有一个用来监听自己msg管道的go程,负责将数据返回给客户端

// 每个用户应该还有一个用来监听msg管道的go程,负责将数据返回给客户端
func writeBackToClient(user *User,conn net.Conn){fmt.Printf("user:%s 的go程正在监听自己的msg管道:\n",user.name)for data := range user.msg{fmt.Printf("user:%s 写回给客户端的数据为:%s\n",user.name,data)// Write(b []byte)(n int,err error)_,_ = conn.Write([]byte(data))}
}


当前代码整体为

package mainimport ("fmt""net"
)type User struct {// 名字name string // 唯一 的 idid string// 管道msg chan string}// 创建一个全局的map结构,用于保存所有的用户
var allUsers = make(map[string]User)// 定义一个message全局通道,用于接收任何人发送过来消息
var message = make(chan string,10)func main(){// 创建服务器listener,err := net.Listen("tcp",":8087")if err != nil{fmt.Println("net.Listen err:",err)return}// 启动全局唯一的go程,负责监听message通道,写给所有的用户go broadcast()fmt.Println("服务器启动成功,监听中...")for {fmt.Println("==>主go程监听中......")// 监听conn,err := listener.Accept()if err != nil{fmt.Println("listener.Accept err:",err)return}// 建立连接fmt.Println("建立连接成功!")// 启动处理业务的go程go handler(conn)}
}// 处理具体业务
func handler(conn net.Conn){fmt.Println("启动业务...")// 客户端与服务器建立连接的时候,公有ip和port --> 当成user的idclientAddr := conn.RemoteAddr().String()fmt.Println("clientAddr:",clientAddr)// 创建usernewUser := User{id:clientAddr,// id 我们不会修改,这个作为map中的keyname:clientAddr,// 可以修改,会提供rename命令修改,建立连接时,初始值与id相同msg:make(chan string,10), // 注意需要make空间,否则无法写入数据}// 添加user到map结构allUsers[newUser.id] = newUser// 启动go程,负责将msg的信息返回给客户端go writeBackToClient(&newUser,conn)// 向message写入数据,当我用户上线的消息,用于通知所有人(广播)loginInfo := fmt.Sprintf("[%s][%s] ===> |上线了login!!",newUser.id,newUser.name)message <- loginInfofor{// 具体业务逻辑buf := make([]byte,1024)// 读取客户端发送来的数据cnt,err := conn.Read(buf)if err != nil{fmt.Println("listener.Read err:",err)return}fmt.Println("客户端接收客户端发来的数据为:",string(buf[:cnt-1]),",cnt:",cnt)}}// 向所有的用户广播消息,启动一个全局唯一的go程
func broadcast(){fmt.Println("广播go程启动成功...")defer fmt.Println("broadcast 程序退出!")for {// 1. 从message中读取数据fmt.Println("broadcast监听message中...")info := <-message // 2. 将数据写入到每一个用户的msg管道中for _,user := range allUsers{// 如果msg是非缓冲,那么会在这里阻塞user.msg <- info }}
}// 每个用户应该还有一个用来监听msg管道的go程,负责将数据返回给客户端
func writeBackToClient(user *User,conn net.Conn){fmt.Printf("user:%s 的go程正在监听自己的msg管道:\n",user.name)for data := range user.msg{fmt.Printf("user:%s 写回给客户端的数据为:%s\n",user.name,data)// Write(b []byte)(n int,err error)_,_ = conn.Write([]byte(data))}
}

三、增加功能

  1. 查询用户
    查询命令:who==>将当前所有的登录的用户,展示出来,id,name,返回给当前用户
package mainimport ("fmt""net""strings"
)type User struct {// 名字name string // 唯一 的 idid string// 管道msg chan string}// 创建一个全局的map结构,用于保存所有的用户
var allUsers = make(map[string]User)// 定义一个message全局通道,用于接收任何人发送过来消息
var message = make(chan string,10)func main(){// 创建服务器listener,err := net.Listen("tcp",":8087")if err != nil{fmt.Println("net.Listen err:",err)return}// 启动全局唯一的go程,负责监听message通道,写给所有的用户go broadcast()fmt.Println("服务器启动成功,监听中...")for {fmt.Println("==>主go程监听中......")// 监听conn,err := listener.Accept()if err != nil{fmt.Println("listener.Accept err:",err)return}// 建立连接fmt.Println("建立连接成功!")// 启动处理业务的go程go handler(conn)}
}// 处理具体业务
func handler(conn net.Conn){fmt.Println("启动业务...")// 客户端与服务器建立连接的时候,公有ip和port --> 当成user的idclientAddr := conn.RemoteAddr().String()fmt.Println("clientAddr:",clientAddr)// 创建usernewUser := User{id:clientAddr,// id 我们不会修改,这个作为map中的keyname:clientAddr,// 可以修改,会提供rename命令修改,建立连接时,初始值与id相同msg:make(chan string,10), // 注意需要make空间,否则无法写入数据}// 添加user到map结构allUsers[newUser.id] = newUser// 启动go程,负责将msg的信息返回给客户端go writeBackToClient(&newUser,conn)// 向message写入数据,当我用户上线的消息,用于通知所有人(广播)loginInfo := fmt.Sprintf("[%s][%s] ===> |上线了login!!",newUser.id,newUser.name)message <- loginInfofor{// 具体业务逻辑buf := make([]byte,1024)// 读取客户端发送来的数据cnt,err := conn.Read(buf)if err != nil{fmt.Println("listener.Read err:",err)return}fmt.Println("客户端接收客户端发来的数据为:",string(buf[:cnt-1]),",cnt:",cnt)// -------------业务逻辑处理  开始------------- // 1.查询当前所有的用户 who// a. 先判断接收的数据是不是who  ==》 长度&&字符串userInput := string(buf[:cnt-1])  // 这是用户输入的数据,最后一个是回车,我们去掉他if len(userInput) == 3 && userInput == "who"{// b.遍历allUser这个map(key:userid value:user 本身)。将id和name拼接成一个字符,返回给客户端fmt.Println("用户即将查询所有用户信息!")// 这个切片包含所有的用户信息var userInfos []stringfor _,user := range allUsers{userInfo := fmt.Sprintf("userid:%s,username:%s",user.id,user.name)userInfos = append(userInfos,userInfo)}// 最终写入到通道中,一定是一个字符串r := strings.Join(userInfos,"\n")// 将数据返回到客户端newUser.msg <- r}else{// 如果不是用户输入的命令,只是聊天信息,那么只需要写到广播中即可,由其他的go程常规转发message <- userInput}// -------------业务逻辑处理  结束------------- }}// 向所有的用户广播消息,启动一个全局唯一的go程
func broadcast(){fmt.Println("广播go程启动成功...")defer fmt.Println("broadcast 程序退出!")for {// 1. 从message中读取数据fmt.Println("broadcast监听message中...")info := <-message // 2. 将数据写入到每一个用户的msg管道中for _,user := range allUsers{// 如果msg是非缓冲,那么会在这里阻塞user.msg <- info }}
}// 每个用户应该还有一个用来监听msg管道的go程,负责将数据返回给客户端
func writeBackToClient(user *User,conn net.Conn){fmt.Printf("user:%s 的go程正在监听自己的msg管道:\n",user.name)for data := range user.msg{fmt.Printf("user:%s 写回给客户端的数据为:%s\n",user.name,data)// Write(b []byte)(n int,err error)_,_ = conn.Write([]byte(data))}
}
  1. 重命名
    规则:rename|Duke
    获取数据判断长度7,判断字符是rename
    使用|进行分割,获取|后面的部分,作为名字
    更新用户名字newUser.name = Duke
    通知客户端,更新成功

  2. 主动退出
    每个用户都有自己的watch go程,仅负责监听退出信号

// 启动一个go程,负责监听退出信号,触发后,进行清零工作:delete map,close conn 都在这里处理
func watch(user *User,conn net.Conn,isQuit <-chan bool){fmt.Println("启动监听信号退出的go程...")defer fmt.Println("watch go程退出!")for{select{case <-isQuit:logoutInfo := fmt.Sprintf("%s exit already!",user.name)fmt.Println("删除当前用户:",user.name)delete(allUsers,user.id)message<-logoutInfoconn.Close()return}}
}
在handler中启动go watch,同时传入相应信息:
 // 定义一个退出信号,用来监听client退出var isQuit = make(chan bool)// 启动go程,负责监听退出信号go watch(&newUser,conn,isQuit)

在read之后,通过cnt判断用户退出,向isQuit写入信号:

测试截图

  1. 超时退出
    使用定时器来进行超时管理
    如果60s没有发送任何数据,那么直接将这个链接关闭
<-time.After(60*time.second)

更新watch函数

// 启动一个go程,负责监听退出信号,触发后,进行清零工作:delete map,close conn 都在这里处理
func watch(user *User,conn net.Conn,isQuit,restTimer <-chan bool){fmt.Println("启动监听信号退出的go程...")defer fmt.Println("watch go程退出!")for{select{case <-isQuit:logoutInfo := fmt.Sprintf("%s exit already!\n",user.name)fmt.Println("删除当前用户:",user.name)delete(allUsers,user.id)message<-logoutInfoconn.Close()returncase <-time.After(10*time.Second):logoutInfo := fmt.Sprintf("%s timeout exit elready!\n",user.name)fmt.Println("删除当前用户:",user.name)delete(allUsers,user.id)message<-logoutInfoconn.Close()return case <-restTimer:fmt.Printf("连接%s 重置计数器!\n",user.name)}}
}

创建并传入restTimer管道


效果:

最终代码

package mainimport ("fmt""net""strings""time"
)type User struct {// 名字name string // 唯一 的 idid string// 管道msg chan string}// 创建一个全局的map结构,用于保存所有的用户
var allUsers = make(map[string]User)// 定义一个message全局通道,用于接收任何人发送过来消息
var message = make(chan string,10)func main(){// 创建服务器listener,err := net.Listen("tcp",":8087")if err != nil{fmt.Println("net.Listen err:",err)return}// 启动全局唯一的go程,负责监听message通道,写给所有的用户go broadcast()fmt.Println("服务器启动成功,监听中...")for {fmt.Println("==>主go程监听中......")// 监听conn,err := listener.Accept()if err != nil{fmt.Println("listener.Accept err:",err)return}// 建立连接fmt.Println("建立连接成功!")// 启动处理业务的go程go handler(conn)}
}// 处理具体业务
func handler(conn net.Conn){fmt.Println("启动业务...")// 客户端与服务器建立连接的时候,公有ip和port --> 当成user的idclientAddr := conn.RemoteAddr().String()fmt.Println("clientAddr:",clientAddr)// 创建usernewUser := User{id:clientAddr,// id 我们不会修改,这个作为map中的keyname:clientAddr,// 可以修改,会提供rename命令修改,建立连接时,初始值与id相同msg:make(chan string,10), // 注意需要make空间,否则无法写入数据}// 添加user到map结构allUsers[newUser.id] = newUser// 定义一个退出信号,用来监听client退出var isQuit = make(chan bool)// 创建一个用于重置计数器的管道,用于告知watch函数,当前用户正在输入var restTimer = make(chan bool)// 启动go程,负责监听退出信号go watch(&newUser,conn,isQuit,restTimer)// 启动go程,负责将msg的信息返回给客户端go writeBackToClient(&newUser,conn)// 向message写入数据,当我用户上线的消息,用于通知所有人(广播)loginInfo := fmt.Sprintf("[%s][%s] ===> |上线了login!!\n",newUser.id,newUser.name)message <- loginInfofor{// 具体业务逻辑buf := make([]byte,1024)// 读取客户端发送来的数据cnt,err := conn.Read(buf)if cnt == 0 {fmt.Println("客户端主动关闭ctrl+c,准备退出!")// map 删除,用户 conn  close掉// 服务器还可以主动的退出// 在这里不进行真正的退出动作,而是发出一个退出信号,统一做退出处理,可以使用新的管道来做信号传递isQuit <- true}if err != nil{fmt.Println("listener.Read err:",err)return}fmt.Println("客户端接收客户端发来的数据为:",string(buf[:cnt-1]),",cnt:",cnt)// -------------业务逻辑处理  开始------------- // 1.查询当前所有的用户 who// a. 先判断接收的数据是不是who  ==》 长度&&字符串userInput := string(buf[:cnt-1])  // 这是用户输入的数据,最后一个是回车,我们去掉他if len(userInput) == 3 && userInput == "who"{// b.遍历allUser这个map(key:userid value:user 本身)。将id和name拼接成一个字符,返回给客户端fmt.Println("用户即将查询所有用户信息!")// 这个切片包含所有的用户信息var userInfos []stringfor _,user := range allUsers{userInfo := fmt.Sprintf("userid:%s,username:%s",user.id,user.name)userInfos = append(userInfos,userInfo)}// 最终写入到通道中,一定是一个字符串r := strings.Join(userInfos,"\n")// 将数据返回到客户端newUser.msg <- r}else if len(userInput) > 8 && userInput[:6] == "rename"{// 规则:rename|Duke//     获取数据判断长度7,判断字符是rename//     使用|进行分割,获取|后面的部分,作为名字//     更新用户名字newUser.name = DukenewUser.name = strings.Split(userInput,"|")[1]allUsers[newUser.id] = newUser // 更新map中的user//     通知客户端,更新成功message <- userInput}else{// 如果不是用户输入的命令,只是聊天信息,那么只需要写到广播中即可,由其他的go程常规转发message <- userInput}restTimer <- true// -------------业务逻辑处理  结束------------- }}// 向所有的用户广播消息,启动一个全局唯一的go程
func broadcast(){fmt.Println("广播go程启动成功...")defer fmt.Println("broadcast 程序退出!")for {// 1. 从message中读取数据fmt.Println("broadcast监听message中...")info := <-message // 2. 将数据写入到每一个用户的msg管道中for _,user := range allUsers{// 如果msg是非缓冲,那么会在这里阻塞user.msg <- info }}
}// 每个用户应该还有一个用来监听msg管道的go程,负责将数据返回给客户端
func writeBackToClient(user *User,conn net.Conn){fmt.Printf("user:%s 的go程正在监听自己的msg管道:\n",user.name)for data := range user.msg{fmt.Printf("user:%s 写回给客户端的数据为:%s\n",user.name,data)// Write(b []byte)(n int,err error)_,_ = conn.Write([]byte(data))}
}// 启动一个go程,负责监听退出信号,触发后,进行清零工作:delete map,close conn 都在这里处理
func watch(user *User,conn net.Conn,isQuit,restTimer <-chan bool){fmt.Println("启动监听信号退出的go程...")defer fmt.Println("watch go程退出!")for{select{case <-isQuit:logoutInfo := fmt.Sprintf("%s exit already!\n",user.name)fmt.Println("删除当前用户:",user.name)delete(allUsers,user.id)message<-logoutInfoconn.Close()returncase <-time.After(10*time.Second):logoutInfo := fmt.Sprintf("%s timeout exit elready!\n",user.name)fmt.Println("删除当前用户:",user.name)delete(allUsers,user.id)message<-logoutInfoconn.Close()return case <-restTimer:fmt.Printf("连接%s 重置计数器!\n",user.name)}}
}

这里还有问题就是,上锁问题。记得在操作map的时候加上读锁和写锁
案例

package mainimport("fmt""sync""time"
)var idnames = make(map[int]string)
var lock sync.RwMutex// map不允许同事读写,如果有不同go程同时操作map,需要对map上锁func main(){go func(){for{lock.lock()idnames[0] = "duke"lock.Unlock()}}()go func(){for{lock.Lock()name := idnames[0]fmt.Println("name:",name)lock.Unlock()}}()for{fmt.Println("OVER")time.Sleep(1*time.Second)}
}

感谢大家观看,我们下次见

Golang网络聊天室案例相关推荐

  1. java_OA管理系统(一):Servlet总结案例仿网络聊天室

    因为我们学校的软件联盟要为我们校区开发一个OA系统,为此我将其所需要的一些技术进行汇总,以便web组组员开发所用. 第一篇是关于Servlet的一个汇总案例,算是开个简单的小头. 一.总述 代码来源于 ...

  2. 菜鸟学习笔记:Java提升篇10(网络2——UDP编程、TCPSocket通信、聊天室案例)

    菜鸟学习笔记:Java提升篇10(网络2--UDP编程.TCPSocket通信) UDP编程 TCP编程(Socket通信) 单个客户端的连接 多个客户端的连接(聊天室案例) UDP编程 在上一篇中讲 ...

  3. 【Socket网络编程进阶与实战】-----简易聊天室案例

    前言 本篇博客实现:简易聊天室 聊天室案例: 聊天室数据传输设计 必要条件:客户端.服务器 必要约束:数据传输协议 原理: 服务器监听消息来源,客户端链接服务器并发送消息到服务器.

  4. 视频教程-多人网络聊天室-Unity3D

    多人网络聊天室 广州市码锋网络有限责任公司创始人,从事游戏开发九年,熟练前后端的各种技术,我很乐意将企业中商用的技术分享给你,帮助你解决工作的各种问题. 官剑铭 ¥39.00 立即订阅 扫码下载「CS ...

  5. 小浩的JAVA网络聊天室

    案例:在线聊天室 需求:使用TCP的Socket实现一个聊天室 服务器端:一个线程专门发送消息,一个线程专门接收消息 客户端:一个线程专门发送消息,一个线程专门接收消息 实现:具有 注册 登录 功能的 ...

  6. 视频教程-网络聊天室Java基础版(Socket_Swing编程)仿QQ聊天-Java

    网络聊天室Java基础版(Socket_Swing编程)仿QQ聊天 IT行业资深从业者,7年资深Java高级开发,Java架构师.曾就职银行.电信等行业多家上市公司.担任项目负责人,软件架构师.有丰富 ...

  7. 视频教程-Java基础中国象棋和网络聊天室Swing开发-Java

    Java基础中国象棋和网络聊天室Swing开发 IT行业资深从业者,7年资深Java高级开发,Java架构师.曾就职银行.电信等行业多家上市公司.担任项目负责人,软件架构师.有丰富的高并发.分布式系统 ...

  8. FluorineFx + Flex视频聊天室案例开发----客户端

    上一篇<FluorineFx + Flex视频聊天室案例开发----服务器端>详细的介绍了如何利用FluorineFx开发一个及时通信的视频聊天室服务器处理程序,并通过Web网站来宿主这个 ...

  9. golang实现聊天室(五)

    golang实现聊天室(五) 完成服务端广播消息 server package mainimport ("fmt""log""math/rand&qu ...

最新文章

  1. 谷歌开发者机器学习词汇表:纵览机器学习基本词汇与概念
  2. Selenium2+python自动化70-unittest之跳过用例(skip)
  3. Java-马士兵设计模式学习笔记-观察者模式-读取properties文件改成单例模式
  4. oracle日志文件打开,oracle日志文件和控制文件损坏的恢复
  5. 服务osgi_OSGi –具有服务的简单Hello World
  6. 自适应网页设计/响应式Web设计 (Responsive Web Design)
  7. 3008基于二叉链表的二叉树的遍历(附可能的WA解释)
  8. netty的零拷贝、架构设计、ByteBuf扩容机制详解
  9. R语言中识别和去除重复行
  10. html插入循环图片,javascript – HTML5在带有for循环的画布上绘制图片?
  11. @PropertySource注解获取配置文件值
  12. 郑州大学远程教育学院C语言程序设计题库(一)
  13. ImageNet下载资源(2017年)
  14. 数字图像处理第四版更新内容
  15. 免费电子书下载网站汇总
  16. 【letex编辑输出】pdf文件嵌入字体embedded fonts的问题
  17. 近年来小学计算机课程目录,小学3-6年级信息技术课程目录(苗逢春版)
  18. oracle 数值加减乘除
  19. 基于C#的网站地图制作
  20. 成为域名代理商好不好?域名代理商赚钱吗?

热门文章

  1. 固始计算机学校,固始职业教育中心2021年招生简介
  2. 龙兵汽车4S店系统v1.10.15 汽车销售 汽车营销 汽车小程序
  3. Request failed with status code 404
  4. 新Mac,前端无脑装机
  5. Unity实现拨打电话
  6. 知识点滴 - 什么是AIoT
  7. 【Android】火车票电话订票软件
  8. 【考研英语语法】介词 Preposition
  9. 非广告!在360云盘建立了3个共享群,方便开发者交流共享资源
  10. python数据分析案例,心脏病预测