Go语言基础学习笔记

1.go语言复合类型:结构体,数组,切片和map。

2.go语言中,传递数组是纯粹的值拷贝,对于元素类型长度过大或者元素个数较多的数组,如果直接以数组类型传递到函数中会有不小的性能损耗。

3.map对value的类型没有严格限制,但是对key的类型有严格要求:key的类型应该严格定义了作为”==“和”!=“两个操作符的操作数时的行为,因为函数,map, 切片不能作为map的key类型。

4.map类型不支持”零值可用“,未显式赋初值的map类型变量的零值为nil。对于处于零值状态的map变量进行操作将会导致运行时panic。

5.和切片一样,map也是引用类型,将map类型变量作为函数参数传入不会有很大的性能损耗,并且在函数内部对map的修改,在函数外部也是可见的。

6.可以借助内置函数delete从map中删除数据。即便要删除的数据在map中不存在也不会导致panic

7.go运行时在初始化map迭代器时对起始位置做了随机处理,因此不要依赖遍历map所得到的元素顺序,同一map多次遍历,遍历的元素次序并不相同。

8.充当map描述符角色的hmap自身是有状态的(hmap.flags),且对状态的读写并没有并发保护,因此map实列不是并发写安全的,不支持并发读写。如果对map实例进行并发读写,程序运行时会发生panic。

9.go 1.9版本引入了支持并发读写的sync.Map类型,可以用来在并发读写的场景下替换map。另外考虑到map会自动扩容,map中的数据元素的value位置可能在这一过程中发生变化,因此go不允许获取map中value的地址,这个约束在编译期间就生效。

10.string类型的数据是不可变的。一旦声明了一个string类型的标识符,无论是变量还是常量,该标识符所指代的数据在整个程序的生命周期内便无法更改。

11.go string类型支持”零值可用“。支持+/+=字符串拼接操作。并支持各种比较关系操作符:==,!=,>=,<=,>和<。

12.由于go的字符串是不可变的,因此如果两个字符串的长度不同,那么无需比较具体数据内容即可判断两个字符串不相等。如果长度相同,则要判断指针是否指向同一块底层存储数据。如果相同,则两个字符串是等价的。如果不同,则需要进一步比较实际的数据内容。

13.将string类型通过方法/函数参数传入也不会有太多的损耗,因为传入的仅仅是一个”描述符“,而不是真正的字符串数据。

14.go语言字符串构造方法如下:使用+/+=拼接、使用fmt.Sprintf、使用strings.Join、使用strings.Builder、使用bytes.Builder

①在能预估字符串长度的情况下,使用初始化的strings.Builder连接字符串效率最高。

②strings.Join连接字符串的性能最稳定。如果输入的多个字符串是以[]string承载,那么使用strings.Join。

③使用操作符连接字符串的方法最直观自然,在编译器知晓连接字符串的个数的情况下使用这种方法可以得到编译器的优化处理。

④如果有多种不同类型的变量构建特定格式的字符串,最合适的方法是fmt.Sprintf。

15.无论是string转slice还是slice转string,转换都是要付出代价的,这些代价的根源在于string是不可变的,运行时要为转换后的类型分配新内存。

16.slice类型是不可比较的,而strig类型是可比较的,因此在日常go编码中,我们会经常遇到将slice临时转换为string的情况。Go编译器为这种场景提供了优化——不再为string开辟一块内存,而是直接使用slice的底层存储。当然这样使用有个前提:在原slice被修改后,这个string就不能再被使用了。因此这样的优化针对一下几个特定的场景:

①string(b)用在map类型的key中。

②string(b)用在字符串连接语句中。

③string(b)用在字符串比较中。

17.Go编译器对用在for-range循环中的string到[]byte的转换也有优化处理,他不会为[]byte进行额外的内存分配,而是直接使用string的底层数据。

18.switch/select语句中表达式的求值属于惰性求值,在需要进行求值时才会对表达式进行求值。这样做的目的是降低程序消耗,对性能提升有一定的帮助。

19.go语言中的select为我们提供了一种在多个channel间实现“多路复用”的机制,是编写go语言并发程序最常用的并发原语之一。select-case语句中表达式求值顺序:

①select执行开始时,首先所有的case表达式都会被按出现的先后顺序求值一遍。但是,位于case等号左边的从channel接收数据的表达式不会被求值。

②如果选择要执行的是一个从channel接收数据的case,那么该case等号左边的表达式在接收前才会被求值。

20.每个if、for和switch语句均被视为位于其自己的隐式代码块中;switch和select语句中的每一个子句都被视为一个隐式代码块(变量延申范围到代码块内部)。

21.go语言的控制结构全面继承了C语言的语法并进行了一些创新:

①仅保留for一种循环控制语句。

②为break和continue增加后接label的可选能力。

③switch的case语句执行完毕后,不会像c语言那样继续执行下一个case,除非显式使用fallthrough。

④switch的case语句支持表达式列表。

⑤增加type switch,让类型信息也可以作为分支选择的条件。

⑥增加针对channel通信的switch-case语句——select-case。

22.在for range循环中,参与迭代的是range表达式的副本。for i, v := range s {...}, 参与迭代的并不是真正的数组s,而是数组s的副本,也就是在循环语句中对数组s的操作只会影响range后边的s(数组s的副本s'),而不会影响原有数组s。如果想要参与迭代的副本取的是原数组s的元素,可以用s的切片s[:]或者s的指针&s。range表达式的复制模式实际上会带来性能消耗,而当range表达式为数组指针或者切片时,这个消耗要小得多。range后面的其他表达式类型,如string、map、channel,for range依旧会复制副本。

①string作为range表达式的类型时,由于string在go运行时内部表示为struct {*byte, len},并且string本身是不可变的,因此其和切片作为range表达式的消耗差不多。

②当map类型作为range表达式得类型时,map在go运行时内部表示为一个hmap的描述符结构指针,因此该描述符的指针也指向同一个hmap描述符,这样range对map副本的操作即为对原map的操作。然而,for range无法保证每次迭代的元素次序是一致的(go运行时在初始化map迭代器时对起始位置做了随机处理)。同时,如果在迭代过程中对map进行修改,那么修改的结果是否会影响后续迭代也是不确定的。

③channel类型作为range表达式的类型时,channel在go运行时内部表示为一个channel描述符的指针,因此channel描述符的指针也指向原channel。当channel作为range表达式类型时,for range最终以阻塞读的方式阻塞在channel表达式上,即便是带缓冲的

channel亦是如此:当channel中无数据时,for range也会阻塞在channel上,直到channel关闭。

23.for range语句中,range后面接受的表达式的类型可以是数组、指向数组的指针、切片、字符串、map和channel(至少需要有只读权限)。

24.一个包内、分布在多个文件中的多个init函数,先被传递给go编译器的源文件中的init函数先被执行,同一个源文件中的多个init函数按声明顺序依次执行。但go语言的惯例告诉我们:不要依赖init函数的执行顺序。

25.init函数的执行顺位排在其所在包的包级别变量之后。

26.go语言函数可以像普通类型值那样被创建和使用:

①正常创建

②在函数内创建

③存储到变量中

④作为参数传入函数

⑤作为返回值从函数返回

⑥函数还可以放入数组、切片或map等结构中,可以像其他类型值一样赋值给interface{},甚至可以建立元素为函数的channel

27.函子:函子本身是一个容器类型,以go语言为例,这个容器可以是切片,map甚至是channel。该容器类型需要实现一个方法,该方法接受一个函数类型参数,并在容器的每个元素上应用这个函数,得到一个新函子,原函子容器内部的元素值不受影响。

28.在go中,只有在函数和方法内部才能使用defer;defer关键字后面只能接函数或者方法,这些函数被称为deferred函数。defer将他们注册到其所在goroutine用于存放deferred函数的栈数据结构中,这些deferred函数将在执行defer的函数退出前按后进先出(LIFO)的顺序调度执行。无论是执行到函数体尾部返回,还是在某个错误处理分支显式调用return返回,抑或出现panic,已经存储到deferred函数栈中的函数都会被调度执行。

29.defer的常见用法:

-释放资源;

-拦截panic,并按需对panic进行处理,可以尝试从panic中恢复。这也是go语言中唯一的从panic中恢复的方法。也可以触发一个新的panic并为新的panic传一个新的error值(defer虽然可以拦截绝大部分的panic,但无法拦截并恢复运行时以外的致命问题);3.修改函数的具名返回值;

-输出调试信息;

-还原变量旧值。

关于defer的几个关键问题:

①对于自定义的函数或方法,defer可以给予无条件的支持,但对于有返回值的自定义函数或方法,返回值会在deferred函数被调度执行的时候自动丢弃。

②go语言内置函数:append cap close complex copy delete imag len make new panic print println real recover中,

append, cap, len, make, new等内置函数不能直接作为deferred函数,但是可以使用一个包裹它的匿名函数来间接满足要求。

③deferred函数是在注册到deferred函数栈的时候进行求值的。

30.go方法特点:

①方法名的首字母是否大写决定了该方法是不是导出方法

②方法定义要与类型定义放在同一个包内,故不能为原生类型(如int、float64、map等)定义方法;同理,不能横跨go包为其他包内的类型定义方法

③每个方法只能有一个receiver参数,不支持多receiver参数列表或变长receiver参数

④一个方法只能绑定一个基类型,go语言不支持同时绑定多个类型的方法

⑤receiver参数的基类型本身不能时指针类型或接口类型

31.go方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数。

32. go方法receiver参数类型有T、*T两种(func (t T)Method() {}或者func (t *T)Method() {})。当receiver参数类型为T时,go函数采用的是值复制传递,函数体中的t是T类型实例的一个副本,这样在Method函数中对参数t所作的任何修改都只会影响副本,而不会影响到原T类型实例;当receiver参数类型为*T时,函数体中的t是T类型实例的地址,这样在Method函数中对参数t所作的任何修改都反应到原T类型实例;因此:如果要对类型实例进行修改,那么receiver类型选择指针类型;如果对类型实例没有修改要求,那么receiver类型既可以是指针类型也可以是值类型。如果类型size较大,以值形式传入会导致较大损耗,这时选择指针作为receiver类型会好一些。另一个重要因素是类型是否要实现某个接口类型,而一个类型是否实现了某个接口取决于其方法集合。

33.自定义类型与接口之间的实现关系是松耦合的,这也是go语言的一个创新:如果某个自定义类型T的方法集合是某个接口类型的方法集合的超集,那么就说类型T实现了该接口。并且类型T的变量可以被赋值给该接口类型的变量,即方法集合决定接口实现。

34.方法集合是go中一个重要概念,在为接口类型变量赋值、使用结构体嵌入/接口嵌入、类型别名和方法表达式等时都会用到方法集合,它像胶水一样将自定义类型与接口隐式地黏结在一起。

35.要判断一个自定义类型是否实现了某接口类型,我们首先要识别出自定义类型的方法集合和接口类型的方法集合。

36.对于非接口类型的自定义类型T,其方法集合由所有receiver为T类型的方法集合组成;而类型*T的方法集合则包含所有receiver为T和*T类型的方法。

37.go的设计哲学之一是偏好组合,go支持用组合的思想来实现一些面向对象领域经典的机制,比如继承。而具体的方法就是利用类型嵌入。与接口类型和结构体类型相关的类型嵌入有三种组合:在接口类型中嵌入接口类型、在结构体类型中嵌入接口类型及在结构体类型中嵌入结构体类型。

①go语言惯例,接口类型中仅包含少量方法,并且常常仅有一个方法。通过接口类型中嵌入其他接口类型可以实现接口的组合,这也是go语言中基于已有接口类型构建新接口类型的惯用法

②在结构体类型中嵌入接口类型后,该结构体类型的方法集合中将包含被嵌入接口类型的方法集合。在嵌入了其他接口类型的结构体类型的实例在调用方法时,go优先选择结构体自身实现的方法,如果结构体自身并未实现该方法,那么将查找结构体中嵌入的接口类型的方法集合中是否有该方法,如果有,则提升为结构体的方法。如果结构体类型嵌入了多个接口类型且这些接口类型的方法集合存在交集,那么go编译器将报错,因此要尽量避免在结构体类型中嵌入方法集合有交集的多个接口类型

③结构体类型在嵌入某个接口类型的同时,也实现了这个接口。这一特性在单元测试中极为有用

④在结构体类型中嵌入结构体类型,可以实现“继承”。外部结构体类型T可以“继承”嵌入结构体类型的所有方法,如:type T1 struct {}; type T2 struct {}; type T struct {T1 *T2}。无论是T类型变量实例还是*T类型变量实例,都可以调用所有“继承”的方法。但是T和*T类型的方法集合是有区别的:

T类型的方法集合=T1的方法集合+*T2的方法集合

*T类型的方法集合=*T1的方法集合+*T2的方法集合

39.go语言支持基于已有的类型创建新类型。已有的类型被称为underlying类型,新类型被称为defined类型。新创建的类型与原有的类型是完全不同的类型。基于接口类型创建的defined类型与原接口类型的方法集合是一致的,基于自定义非接口类型创建的defined类型并没有“继承”原类型的方法集合——新的defined类型的方法集合是空的。所以即便原类型实现了某些接口,基于其创建的defined类型也没有“继承”这一隐式关联。新defined类型要想实现那些接口,仍需实现接口的所有方法。

40.go语言类型别名与原类型几乎是等价的。类型别名和原类型都拥有完全相同的方法集合,无论原类型是接口类型还是非接口类型。

41.接受“...T”类型形式参数的函数被称为变长参数函数。一个变长参数函数只能有一个“...T”类型形式参数,并且该形式参数必须位于函数参数中的最后一个形式参数。变长参数函数的“...T”类型形式参数在函数体内呈现为[]T类型的变量,在函数外部,“...T”类型形式参数可匹配和接受的实参类型有两种:多个T类型变量或t...(t为[]T类型变量)。对于变长参数函数,传入参数类型只能选择一种,要么多个T类型变量,要么t...(t为[]T类型变量)。不能混用,否则编译会报错。使用变长参数函数是最容易出现的一个问题是实参与形参不匹配,但是有一个例外,那就是go内置的append函数,它支持将字符串附加到一个字节切片s后面:s = append(s "string"...)。(形参是[]byte类型,但是传入string类型也可以。自己编写的函数是不可能实现这种功能的。)

42.string类型的变量可以直接赋值给interface{}类型变量,但是[]string类型变量并不能直接赋值给[]interface{}类型变量。

43.接口是go中唯一“动静兼备”的语言特性。接口的静态特性:接口类型变量具有静态特性,支持在编译阶段的类型检查;接口的动态特性:接口类型变量兼具动态类型,即在运行时存储在接口类型变量中的值的真实类型,接口类型变量在程序运行时可以被赋值为不同的动态类型变量,从而支持运行时多态。

44.go语言中结构体类型和接口类型的区别:结构体类型是一种自定义的数据类型,它由一组字段组成,每个字段可以是不同的数据类型。结构体类型可以定义方法,用于操作结构体中的字段。接口类型是一种抽象类型,它定义了一组方法,但并不实现这些方法。接口类型可以被任何类型实现,只要实现了接口中定义的方法,就可以被视为实现了该接口。接口类型可以用于实现多态性,使得不同类型的对象可以以相同的方式被处理。因此,结构体类型和接口类型的主要区别在于,结构体类型是一种具体的数据类型,而接口类型是一种抽象的类型。结构体类型用于表示具体的数据结构,而接口类型用于表示行为。

45.装箱是编程语言领域一个基础概念,一般是指把值类型转换成引用类型。在Go语言中,将任意类型赋值给一个接口类型变量都是装箱操作。经过装箱后,原变量和箱内的数据没有任何关系,即便原变量值改变,箱内数据也不会改变,除非是指针类型。装箱是一个有性能损耗的操作。

46.go语言中尽量定义小接口,小接口的优点:

-接口越小,抽象程度越高,被接纳度越高;

-易于实现和测试;

-锲约职责单一,易于复用组合。

47.go语言中一切皆组合,组合方式主要有两种。垂直组合(类型组合):go语言主要通过类型嵌入机制实现垂直组合,进而实现方法实现的复用、接口定义重用等;水平组合:通常go程序以接口类型变量作为程序水平组合的连接点。接口时水平组合的关键,他就好比程序肌体上的关节,给予连接关节的两个部分多多个部分各自自由活动的能力,而整体又实现了某种功能。而通过接口进行水平组合的一种常见模式是使用接受接口类型参数的函数或方法。以下是以接口为连接点的水平组合的几种形式:

①基本形式:水平组合的基本形式是接受接口类型参数的函数或方法

func FunctionName(param InterfaceType)

②包裹函数:接受接口类型参数,并返回与其参数类型相同的返回值,即返回接口类型值。通过包裹函数可以实现对输入数据的过滤、装饰、变换等操作,并将结果再次返回给调用者

func WrapperFunction(param InterfaceType) InterfaceType

③适配器函数类型

④中间件

48.传统编程语言中,并发设计多以操作系统线程作为承载模块的基本执行单元,由操作系统执行调度。操作系统线程的创建、销毁及线程间上下文切换的代价较大,且线程间通信原语复杂。go语言中,实现了goroutine这一由go运行时负责调度的用户层轻量级线程为并发设计提供原生支持。goroutine相比传统操作系统线程具有如下优势:

①资源占用小,每个goroutine初始栈大小仅为2kb

②由Go运行时而不是操作系统调度,goroutine上下文切换代价小

③语言原生支持:由go关键字接函数或方法创建,函数或方法返回即表示goroutine退出,开发体验更佳

④语言内置channel作为goroutine间通信原语,为并发设计提供强大支撑。创建一个channel, make(chan TYPE {, NUM}),

TYPE指的是channel中传输的数据类型,第二个参数是可选的,指的是channel的容量大小。向channel传入数据,

CHAN <- DATA, CHAN指的是目的channel即收集数据的一方,DATA则是要传的数据。从channel读取数据,DATA := <-CHAN,和向channel传入数据相反,在数据输送箭头的右侧的是channel。

49.goroutine调度器:将goroutine按照一定算法放到CPU上执行的程序

goroutine调度模型:GPM

G:goroutine在go运行时中的抽象模型。代表goroutine,存储了goroutine的执行栈信息、goroutine状态及goroutine函数信息,G对象是可以重复使用的。

P:G和M之间的一个中间层。P的数量决定了系统内最大可并行的G数量(P数量 < CPU核数)

M: 对操作系统线程(被视为”物理CPU“)的一种抽象,代表这真正的计算资源

G想要运行起来,首先需要分配一个P,即进入P的本地运行队列中(local runq),P又和M绑定,P和M绑定后P的本地队列中的G才能运行起来。P和M的关系类似Linux操作系统调度层面用户线程与内核线程的对应关系:多对多。

Go调度器实现了抢占式调度,调度到死循环后不会一直执行下去,即G不会一直占用分配给它的P和M,位于P的本地队列中的其他G可以得到调度。

50.go并发原语:

①goroutine:Go运行时调度的基本执行单元

②channel:用于goroutine之间的通信和同步,可以像变量一样被初始化,传递或赋值

③select:用于应对多路输入/输出,可以让goroutine同时协调处理多个channel操作。

51.go常见并发模型:

①创建模式:使用go关键字+函数/方法创建goroutine

②退出模式:大多数情况下无需考虑对goroutine的退出进行控制,goroutine执行函数返回,即意味着goroutine退出。但一些常驻的后台服务器程序可能会对goroutine的退出有着优雅的要求。退出模式又分分离模式、join模式,notify-and-wait模式。

③管道模式:多个数据处理环节,每个数据处理环节都由一组功能相同的goroutine完成,每个数据处理环节的goroutine都要从数据输入channel获取上一个数据处理环节生产的数据,然后对这些数据进行处理并将处理后的结果通过数据输出channel发往下一个环节。

④超时和取消模式:超时模式在超时返回后,已经创建的goroutine可能依然处于等待状态,没有返回,也没有被回收,依然占用资源。这种情况下一般使用Go的context包来实现取消模式

52.一组goroutine的退出总体有两种情况,一种是并发退出,这种退出方式各个goroutine的退出先后次序对数据处理无影响。一种是串行退出,即各个goroutine的退出是按照一定的次序进行的,次序错误可能导致程序状态错误或混乱。

53.Go并发原语channel:

①无缓冲channel

无缓冲channel兼具通信和同步特性,在并发程序中应用颇为广泛。可以通过不带有capacity参数的内置make函数创建一个可用的无缓冲channel:c := make(chan T) // T为channel中元素的类型。由于无缓冲channel的运行时层实现不带有缓冲区,因此对无缓冲channel的接收和发送操作是同步的,即对于同一个无缓冲channel,只有在对其进行接收操作的goroutine和对其进行发送操作的goroutine都存在的情况下,通信才能进行,否则单方面的操作会让对应的goroutine陷入阻塞状态。对于无缓冲channel的操作时许有以下两点:1.发送动作一定发生在接收动作完成之前;2.接收动作一定发生在发送动作完成之前无缓冲channel的使用

用做信号传递:1.一对一通知信号:无缓冲channel常被用于两个goroutine之间一对一地传递通知信号。2.一对多通知信号:有些时候,无缓冲channel还被用于实现一对多的信号通知机制,这样的信号通知机制常被用于协调多个goroutine一起工作。

用于替代锁机制:无缓冲channel具有同步特性,这让它在某些场合可以替代锁,从而使程序更加清晰,可读性更好。

②带缓冲channel

与无缓冲channel不同,带缓冲channel可以通过带capacity参数的内置make函数创建:

c := make(chan T, capacity) // T为channel中元素的类型,capacity为带缓冲channel的缓冲区容量

由于带缓冲channel的运行时层实现带有缓冲区,因此对带缓冲channel的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收无需阻塞等待)。也就是说,对一个带缓冲的channel,在缓冲区无数据或有数据但未满的情况下,对其进行发送操作的goroutine不会阻塞;在缓冲区已满的情况下,对其进行发送操作的goroutine会阻塞;在缓冲区为空的情况下对其进行接收操作的goroutine亦会阻塞。

用作消息队列:1.无论是单收单发还是多收多发,带缓冲channel的收发性能都要好于无缓冲channel 2.对于带缓冲channel,选择适当容量会在一定程度上提升收发性能

用作计数信号量:go并发设计的一个惯用法是将带缓冲channel用作计数信号量(counting semaphore)。带缓冲channel中的当前数据个数代表的是当前同时处于活跃状态的goroutine的数量,而带缓冲channel的容量代表允许同时处于活跃状态的goroutine的最大数量。一个发往带缓冲channel的发送操作表示获取一个信号量槽位,而一个来自带缓冲channel的接收操作则表示释放一个信号量槽位。

54.len(channel):len是Go语言原生内置的函数,它可以接收数组、切片、map、字符串或channel类型的参数,并返回对于类型的

长度——整型值。以len(s)为例:

如果s是字符串类型,len(s)返回的是字符串的字节数

如果s是[n]T或*[n]T类型,len(s)返回的是数组的长度

如果s是[]T类型,len(s)返回的是切片当前的长度

如果s是map[K]T类型,len(s)返回的是map中一定义key的个数

如果s是chan T类型,len(s)返回值有两种情况:1.当s是无缓冲channel时,len(s)总是返回0. 2.当s是带缓冲channel时,len(s)返回的是当前channel中未被读取的元素个数

55.对没有初始化的channel(nil channel)进行读写操作会发送阻塞, 对于已经关闭(close)的channel进行读操作,从这个

channel获取到的数据是该channel对应类型的零值

56.Go语言中读写锁和互斥锁的使用场景:读写锁适合在具有一定并发量且读多写少的的场合。在大量并发读的情况下,多个

goroutine可以同时持有读锁,从而减少锁竞争中等待的时间,而互斥锁即便是读请求,同一时刻也只有有一个goroutine持有锁,其他goroutine只能阻塞在加锁操作上等待。

57.atomic包提供了两大类原子操作接口:一类是针对整型变量的,包括有符号整型,无符号整型以及对应的指针类型;一类是针对自定义类型的。

58.利用原子操作的无锁并发写的性能随着并发量增大几乎保持恒定。利用原子操作的无锁并发读的性能随着并发量增大持续提升。

59.在标准库中,Go提供了构造错误值的两种基本方法:errors.New和fmt.Errorf。如果错误处理方需要错误值提供更多的错误上下文,我们需要通过自定义错误类型的方式构造错误值来提供更多的错误上下文信息。由于错误值均通过error接口变量统一呈现,要得到底层错误类型携带的错误上下文信息,错误处理方需要使用Go提供的类型断言机制(type assertion)或类型选择机制(type switch)。

Go语言基础及实战 文章被收录于专栏

Go语言学习笔记、语法知识、技术要点和个人理解及实战

全部评论
感谢大佬分享
点赞 回复 分享
发布于 2023-05-29 09:39 上海
map的随机处理很费劲啊,怎么解决?
点赞 回复 分享
发布于 2023-05-29 09:57 河北

相关推荐

评论
点赞
11
分享
牛客网
牛客企业服务