Go语言学习

274 阅读16分钟

1、Go语言介绍

Go语言优势

  • 可直接编译成机器码,不依赖其他库,部署简单
  • 静态类型语言,但是有动态语言的感觉,开发效率高。静态语言可以在编译期检查出很多问题
  • 语言层面支持并发
  • 内置runtime,支持垃圾回收
  • 丰富的标准库

Go适合做什么?

  • 服务器变成
  • 分布式系统
  • 网络编程
  • 内存数据库
  • 云平台

环境搭建

安装go、安装IDE

go中文在线文档:studygolang.com/pkgdoc

代码结构分析

// go语言以包作为管理单位。程序必须有一个main包
package main
// 导入依赖包。导入的包必须使用,否则报错
import "fmt"
// 入口函数
func main() { // 左括号必须和函数名同行
    // 变量声明了必须要使用,否则报错
    var a int 
    fmt.Println("hello,", a) // go语言语句结尾没有分号
}

命令:

go build hello.go 编译代码,生成可执行程序
go run hello 运行可执行程序 go run hello.go 不生成可执行程序,直接运行

2、基本类型

2.1 变量

变量声明格式:var 变量名 变量类型

(1)变量先声明,再初始化

var a int
a = 10
// 一次定义多个变量
var b,c int

(2)变量声明的同时直接初始化

var b int = 10

(3)自动推导类型。必须初始化,通过初始化的值确定类型

c := 30  // 先声明变量c,再给c赋值
fmt.Printf("c type is %T", c)  // %T打印变量所属的类型。 Printf:格式化输出

(4)多重赋值

a, b := 10, 20
a, b = b, a // 交换两个变量的值

(5)匿名变量

a, b := 10, 20
var tmp int
// _是匿名变量,代表丢弃数据不处理。匿名变量配合函数返回值使用才有优势。go的函数可以有多个返回值,想丢弃某个返回值时可以使用_
tmp, _ = a, b

(6)多个变量定义

// 简化方法
var {
    a int
    b float64
}
// 自动推导类型
var {
    a = 10
    b = 3.14
}

2.2 常量

常量声明格式:const 变量名 变量类型 = 变量值 常量在运行期间不可以改变。

(1)常量声明

// 常量声明时必须赋值
const a int = 10
// 自动推导类型,不需要使用:=
const b = 11.1

(2)多个常量定义

// 简化方法
const {
    a int = 10
    b float64 = 3.14
}
// 自动推导类型
const {
    a = 10
    b = 3.14
}

(3) 枚举 iota

iota是常量自动生成器,用于给常量赋值

const {
    // iota:常量自动生成器,每个一行,自动加1
    a = iota  // 0
    b = iota  // 1
    c = iota  // 2
}
// iota遇到const,重置为0
const d = iota  // 0

// 可以只写一个iota
const {
    a1 = iota  // 0
    b1         // 1
    c1         // 2
}

// 如果是同一行,值一样
const {
    a2 = iota  // 0
    b2, b3, b4 = iota, iota, iota  // 三个值都是1
    c2 = iota  // 2
}

2.3 基础数据类型

Go语言内置的数据类型:

类型名称长度(字节)零值说明
bool布尔1falsetrue/false。不可用数字代表
byte字节10uint8别名
rune字符40专用于存储unicode编码,等价于unit32
int,uint4或80
int8,uint810
int16,uint1620
int32,uint3240
int64,uint6480
float3240.0精确到小数点后7位
float6480.0精确到小数点后15位
complex64复数8
complex128复数16
uintptr整型4或8足以存储指针的unit32或unit64整数
string""utf8字符串

(1)bool

// 初始值 false
var a bool
a = true
b := false

(2)浮点型

// 初始值 false
var a float32
a = 3.14

// 自动推导类型,类型默认是float64
b := 3.14

(3)类型转换

  • 不兼容的类型无法转换 ( go语言的bool和整型不兼容)
  • 兼容的类型,在做类型转换时,必须强制转换,不能隐式转换。
  • 类型转换时,可以从低精度转成高精度;也可以从高精度转成低精度,转换时,编译不会报错,但是转换的结果会按照溢出处理,导致结果可能不正确。
var a int32 = 100
// 必须强制转换
var f folat32 = float32(a)
// 低精度向高精度转换,也需要强制转换
var b int64 = int64(a)

fmt.Printf("a=%v, f=%v, b=%v", a, f, b)  // %v会自动做类型匹配

基本数据类型转string类型

  • (1)fmt.Sprintf()
var a int32 = 100
var s string
s = fmt.Sprintf("%d", a)
  • (2) 使用strconv包的函数
import "strconv"

var a int32 = 100
var s string
// func FormatInt(i int64, base int) string
// 返回i的base进制的字符串表示。base 必须在2到36之间,结果中会使用小写字母'a'到'z'表示大于10的数字。
s = strconv.FormatInt(int64(a), 10)  
// Itoa是FormatInt(i, 10) 的简写
s = strconv.Itoa(int(a))

strconv中还有很多函数,可以从studygolang.com/pkgdoc 学习具体使用。 image.png

string类型转基本数据类型

string转基本数据类型,要保证string能转成有效数据,如果string是“hello”,则会报错,转成默认值

  • (1) 使用strconv包的函数
import "strconv"

var s string = "true"
var b bool
b,_ = strconv.ParseBool(s)

image.png

2.4 类型别名

多用于结构体定义

// 给int64起个别名叫bigint
type bigint int64

var bigint a  // 等价于int64 a

type {
    char byte
    double float64
}

2.5 值类型和引用类型

  • 值类型:

(1)基本数据类型、string、struct、数组

(2)值类型的变量直接存储值,通常在栈中分配

  • 引用类型:

(1)指针、slice切片、map、chan管道、interface接口 (2)引用类型存储的是地址,地址指向的空间存储的才是真正的值,通常在堆上分配内存(内存逃逸),当没有变量引用这个地址时,会被GC

2.6 运算符

++和--,只有后置,没有前置

2.7 标识符

  • 字母、数字、下划线。不能以数字开头。区分大小写。
  • 包名和文件夹名尽量保持一致
  • 变量、函数、常量名,采用驼峰
  • 如果变量、函数、常量名的首字母大写,则代表可以被其他包访问。首字母小写,则只能在本包中使用

3、复杂数据类型

3.1 指针

获取变量a的地址:&a

指针变量的定义:var ptr *int

获取指针指向的值:*ptr

var a int = 10
var ptr *int
ptr = &a  // ptr指向a
*ptr = 10 // 通过指针修改a的值

3.2 数组

  • 数组定义: var a [6]int。 长度是数组类型的一部分。
  • 数组中保存的是同一种数据类型的数据。
  • 数组是值类型。在值传递时会进行拷贝。如果想传递原数组,需要使用指针传递。
  • 数组创建后,如果没有赋值,有默认值。

(1)数组初始化方式

var arr1 [3]int = [3]int{1,2,3}
var arr2 = [3]int{1,2,3}         // 使用类型推导
var arr3 = [...]int{1,2,3}       // 数组的大小自动计算
var arr4 = [...]int{1:100, 2:200, 0:300}   // 指定下标

(2)数组遍历

  • 常规遍历 for
  • for-range遍历。 写法:for index,value := range arr1

3.3 切片slice

  • 切片是引用类型,是对数组的引用
  • 切片定义:var a []int。不用写长度
  • 切片是动态数组,其长度可变。slice底层是个结构体,包含指向数组的指针(切片首元素的地址)、切片长度、切片容量。
  • 切片的使用和数组类似。
  • 可以对切片继续做切片。

切片创建

(1)创建slice,指向已经创建好的数组

var arr1 [5]int = [5]int{1,2,3,4,5}
slice1 := arr1[1:3]     // 下标[1,3)的数据。start省略代表从0开始取;end省略代表取到数组最后一个

fmt.Println(slice1)
fmt.Println(len(slice1))   // 切片的长度
fmt.Println(cap(slice1))   // 切片的容量

(2)通过make创建切片

  • var 切片名 []type = make([]type, len, cap)
  • type:数据类型。len:切片长度。cap:容量,可选。
  • 通过make创建的数组是由slice底层维护,对外不可见
var slice2 []int = make([]int, 5, 10)

(3)定义切片,直接指定具体的数组

var slice3 []string = []string{"a","b","c"}

切片遍历

与数组相同

切片使用

(1)append 对切片进行动态追加元素

  • append的本质是对数组扩容。
  • append底层会先创建一个新的数组,然后将老数组中的元素拷贝过去,再将切片的指针指向新数组。老的数组如果没有引用,会被GC。
var slice3 []string = []string{"a","b","c"}
slice4 := append(slice3, "d", "e")  // slice3不变

// 可以追加切片
slice4 = append(slice4, slice3...)

(2)copy 切片拷贝

  • copy(dstSlice, srcSlice)。两个切片的数据空间是独立的。dst的长度比src大或者小都不会报错,以dst为准。
  • 只能拷贝切片,不能拷贝数组 (3)对string做切片 string底层是byte数组,所以也可以对string做切片。

3.4 结构体 struct

4、流程控制

4.1 选择

(1)if

// 语法
if 条件表达式 {
    代码
}

// 允许在if中直接定义一个变量
if age:=20; age>18 {

} else if age>10 {
} else {
}

(2)switch

  • case中可以有多个表达式,逗号分隔
  • case里不需要break,匹配到了自动退出
  • default不是必须的
  • 表达式可以是常量、变量、有返回值的函数
  • case和switch的表达式的值得数据类型必须一样,否则编译报错
  • switch后面可以不带表达式,类似于if else使用
  • switch穿透:fallthrough。在case后面写fallthrough,则会继续执行下一个case的语句块。只能穿透一层
  • type switch:可以用于判断接口的变量的实际的数据类型,用法:x.(type)
// 语法
switch 表达式 {
    case 表达式1, 表达式2,... :
        语句块1
    case 表达式3, 表达式4,... :
        语句块2
    default :
        语句块
}

// switch后面可以不带表达式,类似于if else使用
age :=10;
switch {
    case age == 10 :
        fmt.Pringln("age = 10")
    case age == 20 :
        fmt.Pringln("age = 20")
    default :
        fmt.Pringln("mismatch")
}

// switch穿透,下面代码会输出"age = 10"和"age = 20"
age :=10;
switch {
    case age == 10 :
        fmt.Pringln("age = 10")
        fallthrough
    case age == 20 :
        fmt.Pringln("age = 20")
    default :
        fmt.Pringln("mismatch")
}

// type switch
var x interface() // 空接口
var y = 10.0
x = y
switch i := x.(type) {
    case nil :
        fmt.Printf("x的类型是%T", i)
    case float64 :
        fmt.Println("x的类型是float64")   // 匹配这条case
}

4.2 循环

(1)for循环

// 语法
for 循环变量初始化; 循环条件; 循环变量迭代 {
    循环体
}

// 循环变量初始化和循环变量迭代可以不放在for后面
for 循环条件 {
    循环体
}

i := 1
for i < 10 {
    i ++
}

// 等价于 for ;; , 死循环,通常配合break使用
for {
    循环体
}

// 使用for-range,可以方便遍历字符串和数组。
// for-range是按照字符方式遍历的,对于汉字也可以遍历成功。
// 而传统的for循环用len(str)这种方式是按照字节遍历的,对汉字处理不正确,需要将str转换成切片[]rune即可

str := "hello"
for index, value := range str {
    fmt.Printf("index:%d, value:%c \n", index, value)
}

(2)while的实现

go语言中没有while和do...while。可以用for实现while的效果

// 语法
for {
    if 循环条件表达式 {
        break
    }
    循环体
    循环变量迭代
}

4.3 跳转

(1)break

break用于for、switch中

多重嵌套里,break可以配合标签使用,指明要跳出哪层循环

label1 :
for i :=0; i < 10; i ++ {
    label2:
    for j :=0; j < 10; j ++ {
        if j == 2 {
            break label1  // 跳出label1的for循环
        }
    }
}

(2)continue

多重嵌套里,continue可以配合标签使用,指明要跳到哪层循环

label1 :
for i :=0; i < 10; i ++ {
    label2:
    for j :=0; j < 10; j ++ {
        if j == 2 {
            continue label1  // 跳到label1的for循环
        }
    }
}

(3)goto

  • goto可以无条件地跳转到指定代码行
  • goto通常与条件表达式配合使用,用于实现条件转移、退出循环等
  • 不建议使用goto
// 语法
goto label1
fmt.Println("1")
label1:
fmt.Println("2")

(4)return

用在函数和方法中

5、函数

5.1 函数介绍

// 语法
func 函数名(形参列表) (返回值列表) {
    函数体
    return 返回值列表
}
  • go可以返回多个值
  • 如果调用函数时,想要忽略某个返回值,可以用_代替,表示占位忽略
  • 如果只有一个返回值,函数定义时的返回值可以不加括号
  • 形参分为值传递和引用传递
  • go语言不支持传统的函数重载,go使用另一种方法实现重载的效果
  • 函数也是一种数据类型,可以赋值给一个变量,然后通过变量调用函数
  • 函数也可以作为形参,传给另一个函数
  • go可以自定义数据类型,语法: type 自定义数据类型名 数据类型, 相当于给数据类型取了别名。go认为这是两个数据类型,在使用时需要进行类型转换。可以给函数自定义数据类型,简化调用
  • 支持对函数返回值命名,这样就不用关注返回值的顺序了
  • go函数的形参支持可变参数。可变参数其实是切片slice,可以通过下标访问每个参数的值
// 返回多个值
func getSumAndSub(n1 float64, n2 float64) (float64, float64){
    sum := n1 + n2
    sub := n1 - n2
    return sum, sub
}

// 对返回值命名
func getSumAndSub2(n1 float64, n2 float64) (sum float64, sub float64){
    sum = n1 + n2
    sub = n1 - n2
    return
}
// 使用引用传递修改基本数据类型的值
func add (n1 *int) {
    *n1 = *n1 +1
}

func main {
    num := 10
    add(&num)
    fmt.Println(num)  // num的值是11
}
// 函数也是一种数据类型,可以赋值给一个变量,然后通过变量调用函数
func getSum(n1 float64, n2 float64) float64{
    sum := n1 + n2
    return sum
}

// 函数也可以作为形参,传给另一个函数
func getSum2(funcvar func(float64,float64) float64) float64{
    return funcvar(n1, n2)
}

func main {
    a := getSum     // a是函数类型的变量
    res := a(10,20) // 等价于 res := getSum(10, 20)
    
    res2 := getSum2(getSum, 10, 20)
}
// 给函数自定义数据类型,简化调用
type myFuncType func(int,int) int

func getSum(n1 int, n2 int) int{
    sum := n1 + n2
    return sum
}

// 形参中直接使用自定义数据类型,简化
func getSum2(funcvar myFuncType) int{
    return funcvar(n1, n2)
}
// go函数的形参支持可变参数。可变参数其实是切片slice,可以通过下标访问每个参数的值
func getSum(n1 int, args... int) sum int{
    sum = n1
    for(i := 0; i < len(args), i++) {
        sum += n[i]
    }
    return
}

init函数

  • 每一个源文件中可以有一个init函数,在main函数前执行,由go框架自动调用,可以做一些初始化的工作
  • 执行顺序:全局变量 -> init() -> main()

image.png

匿名函数

没有名字的函数。

(1) 在定义匿名函数时直接调用,这种方式匿名函数只能调用一次

func main() {
   res := func(n1 int, n2 int) int {
       return n1 + n2
   }(1, 2)
}

(2)将匿名函数赋值给函数变量,然后通过变量调用函数,可以多次调用

func main() {
   a := func(n1 int, n2 int) int {
       return n1 + n2
   }
   
   res := a(10, 20)
}

(3)全局匿名函数:如果将匿名函数赋值给全局变量,则叫全局命名函数,可以在整个程序中使用

var Fun1 = func(n1 int, n2 int) int {
       return n1 + n2
}

func main() {
   res := Fun1(10, 20)
}

闭包

闭包:函数与其相关的环境组成的整体。 简单理解:闭包是类,函数是操作,变量是字段。

// 返回了匿名函数,匿名函数和函数外的变量n构成了个整体,即闭包
func AddUpper() func (int) int {
    var n int = 10
    return func (x int) int {
        n = n + x
        return n
    }
}

func main() {
   // f只初始化了一次
   f := AddUpper()
   res := f(1) // 11
   res = f(2)  // 13
   res = f(3)  // 16
}

defer 延时机制

  • 当go执行到defer时,不会立即执行defer的语句,而是将defer后的语句压入一个栈中。当函数执行完后,从栈中弹出语句并执行。
  • defer后面的语句入栈时,也会将相关的变量拷贝一份放到栈中
  • defer最主要的价值在于,函数执行完毕后,可以及时释放函数创建的资源

image.png

func open() {
    connect = openConnect()
    defer connect.close()
    // 使用connect的其他代码
}

5.2 包

  • go是按照包来管理函数和变量的
  • 一个包中的函数和变量要开放给其他包使用,则必须定义成首字母大写
  • 使用其他包的函数和变量的格式:包名.函数名
  • 可以给包取别名,取了别名之后原始的包名就不能用了
  • 同一个包下的函数名和变量名不能重复
  • 如果需要编译成可执行文件,则需要将这个包声明成main包
  • 编译后会自动生成一个pkg目录,里面放的是.a的库文件
// 给包取别名
import {
   "fmt"
   util "model/util/"  // 别名。  包路径是从GO_PATH/src开始的
}

编译成可执行文件的方法:

// go build main包的地址
go build hellodemo/main   // 路径是从GO_PATH/src开始的。 生成的可执行文件默认跟go文件名一致,放在当前目录下

go build -o bin/hello.exe hellodemo/main   // 指定生成的可执行文件的路径和文件名

5.3 错误处理

  • go不支持传统的try catch,而是通过defer、panic、recover来处理。
  • go可以抛出一个panic异常,然后在defer中通过recover捕获异常,然后进行处理
  • 自定义错误:errors.New()和panic内置函数。errors.New(msg)会返回一个error类型的错误,然后通过panic内置函数将错误抛出去
// 通过defer、panic、recover进行错误处理
func divide() {
    defer func() {
        err := recover() // 内置函数,可以捕获异常
        if err != nil {
            fmt.Println("error="err)
        }
    }()
    
    10 / 0       // 此处会抛出panic
}
// 自定义错误
func readFile() (err error) {
    err = errors.New("read file error")  // 返回错误
    return
}

func test() {
    err := readFile
    if err != nil {
        panic(err) // 抛出异常
    }
}

6、方法

golang的方法是作用在指定的数据类型上。

方法的定义

  • receiver type:表示方法与type这个类型进行绑定。type可以是结构体,也可以是其他自定义类型。
  • receiver是type类型的一个变量(实例)
func (receiver type) methodName (参数列表) (返回值列表) {
    方法体
    return 返回值
}
type A struct {
    Num int
}

// func后指定数据类型,表示其是该数据类型的方法
func (a A) test() {
    fmt.Println(a,Num)
}

// 方法也可以有入参和出参
func (a A) getSum(n1 int, n2 int) int {
    return n1 + n2
}

func main() {
    var a A
    a.test   // 调用方法
}

方法的调用机制

方法的调用和函数一样,不一样的地方在于,会将变量作为方法的实参传给方法。如果变量是值类型,则进行值拷贝;如果变量是引用类型,则进行引用拷贝。

  • 结构体是值类型,在方法调用时,是值拷贝
  • 如果希望方法调用时,修改结构体本身的值,可以使用结构体指针的方式来处理
  • 如果一个方法实现了String()方法,那么fmt.Println()会自动调用这个String()方法
type Circle struct {
    Radius int
}

// 值传递
func (circle Circle) area1() float64{
    return 3.14 * circle.Radius * circle.Radius
}

// 引用传递
func (circle *Circle) area2() float64{
    // 下面这两种写法等价。因为编译器优化,省略了*
    // return 3.14 * (*circle).Radius * (*circle).Radius
    return 3.14 * circle.Radius * circle.Radius
}

func main() {
    var a Circle{10}
    res := a.area1   // 值传递。如果area1里面修改了Radius,a的Radius不会变化
    
    var b Circle{20}
    // 下面这两种写法等价,因为编译器优化,省略了&
    // res2 := (&b).area2     // 引用传递
    res2 := b.area2     // 引用传递。如果area2里面修改了Radius,b的Radius会变化
}

方法和函数的区别

  • 调用方式不一样。变量.方法名(入参)
  • 对于普通函数,接收者是值类型时,必须将值类型的数据传给它,不能传指针类型;反之亦然。
  • 对于方法,接收者是值类型时,可以使用值类型也可以使用指针类型调用;反之亦然。因为编译器做了优化。最终起决定作用的是方法定义是和值类型还是指针类型绑定的。

反射

基本介绍

反射可以在运行时动态获取变量的各种信息。反射的包叫“reflect”。

image.png 反射中有两个重要的函数:

  • relect.TypeOf(变量名):获取变量的类型
  • reflect.ValueOf(变量名):获取变量的值。

变量、interface{}、Value之间可以互相转换

image.png

import "reflect"

// 方法的入参是空接口
func reflectTest(b interface{}){
    // 获取reflect.Type
    rType := reflect.TypeOf(b)
    // 获取reflect.Value
    rVal := reflect.ValueOf(b)
    // 获取变量真正的值
    num1 := rVal.Int()
    
    // 将rVal转成空接口
    iV := rVal.Interface()
    // 通过断言,将iV转成需要的类型
    num2 := iV.(int)
    
}
  • Kind:变量的类别,是个常量。
  • Type:变量的类型。比如结构体Student,Kind是struct,Type是包名.Student
  • 通过反射修改变量的值,必须传入指针类型才能实际进行修改,引用传递。
// 通过反射修改变量的值
func reflectTest(b interface{}){
    // 获取reflect.Value
    rVal := reflect.ValueOf(b)
    // 修改变量的值。需要使用rVal.Elem()获取指针指向的实际类型的Value
    rVal.Elem().SetInt(20)
}

func main() {
    num := 10
    // 传入地址
    reflectTest(&num)
    fmt.Printf("num的值是%v", num)  // num的值被修改成了20
}

通过反射获取结构体的信息

type Student struct{
    name string `json:"studentName"`
    score int `json:"studentScore"`
}

func (s Student) Print() {
    fmt.Printf("用户名是%v", s.Name)
}
func (s Student) getSum(n1 int, n2 int) int {
    return n1 + n2
}



func reflectTest(b interface{}){
    // 获取reflect.Value
    rType := reflect.TypeOf(b)
    rVal := reflect.ValueOf(b)
    kd := val.Kind()   // 获取变量的类别,这里kd是struct
    if kd != reflect.Struct {
        return // 不是结构体,直接返回
    }
    
    num1 := rVal.NumField()  // 结构体中有几个字段
    
    // 遍历字段
    for i := 0; i < num1; i++ {
        // 获取第i个字段的Value
        rVal.Field(i)  
        // 拿到字段的标签
        tag := rType.Field(i).Tag.Get("json")
        if tag != "" {
            fmt.Printf("标签是%v", tag)
        }
    }
    
    
    num2 := rVal.NumMethod()  // 结构体中有几个方法
    
     // 获取第i个方法,并调用。 根据方法名的ascii码排序的
    var params []reflect.Value
    params = append(params, reflect.ValueOf(10))
    params = append(params, reflect.ValueOf(20))
    res := rVal.method(0).call(params) // 第0个方法是getSum(),入参是[]Value 切片
    fmt.Printf("getSum方法的结果是%v", res[0].Int())   // 返回的res是个[]Value 切片
    
    rVal.method(1).call(nil)  // 第1个方法是Print(),无参传入nil
}

func main() {
    num := 10
    // 传入地址
    reflectTest(&num)
    fmt.Printf("num的值是%v", num)  // num的值被修改成了20
}

网络编程

net包

// 服务端
package main
import (
    "fmt",
    "net"
)

// 处理一个客户端的连接
func process(conn net.Conn){
    defer conn.Close()   // 延时关闭连接
    
    for{
        // 创建一个切片
        buf := make([]byte, 1024)
        // 从连接中读取数据
        // 如果客户端没有发送数据,则Read阻塞等待
        n, err := conn.Read(buf) 
        if err != nil {
            fmt.Println("server read error,", err)
        }else{
            // 打印客户端发送的消息
            fmt.Print(string(buf[:n]))  // 切片转成string
        }
    }
}

func main() {
    // 监听
    listen, err := net.Listen("tcp", "0.0.0.0:8888")   
    if err != nil {
        return   // 监听时出现错误
    }
    
    // 延时关闭连接
    defer listen.close()
    
    for {
        conn, err := listen.Accept()
        if err != nil {
            fmt.Println("accept error,", err)
            continue
        }
        fmt.Println("client address:", conn.RemoteAddr().String())
        // 启动协程,处理一个客户端的连接
        go process(conn)
    }
}
// 客户端
package main
import (
    "fmt",
    "bufio"
)

func main() {
    // 连接服务端
    conn, err := net.Dial("tcp", "0.0.0.0:8888")   
    if err != nil {
        return   // 连接服务端失败
    }
    
    reader := bufio.NewReader(os.StdIn)   // 标准输入
    line, err := reader.ReadString('\n')  //从终端读取一行数据
    if err != nil {
        fmt.Println("read string error,", err)
        return 
    }
    // 向服务端发送数据
    n, err := conn.Write([]byte(line))   // n代表发送的字节数
    if err != nil {
        fmt.Println("conn write error,", err)
    }
}