Golang 的特点
- 自带 GC
- 静态编译
- 语法简洁,天然支持并发,拥有同步并发的 channel 类型
- 源码以".go"结尾
- 函数、变量、常量、自定义类型、包(package)的命名遵循以下原则
- 首字符可以是任意的 Unicode 字符或下划线
- 剩余字符可以是 Unicode 字符、下划线或数字
- 字符长度不作限制
- 大小写敏感
- private:声明在函数内部,是函数的本地值
- protect:声明在函数外部,是对当前包可见(包内所有的".go"文件都可见)的全局值
- public:声明在函数外部且首字母大写,是对所有包都可见的全局值
- 四种声明方式
- var (声明变量)
- const (声明常量)
- type (声明类型)
- func (声明函数)
- Go 工程中主要包含以下三个目录
- src:源代码文件
- pkg:包文件
- bin:相关 bin 文件
Golang 的类型和函数
- 值类型
- 引用类型
- slice:切片
- map:映射
- chan:管道
- init 函数
- 用于包(package)的初始化
- 每个包(package)中可以拥有多个 init 函数
- 包(package)的每个源文件也可以拥有多个 init 函数
- 同一个包(package)中,多个 init 函数的执行顺序没有明确定义
- 不同包(package)的 init 函数,按照包(package)导入的依赖关系决定该初始化函数的执行顺序
- init 函数不能被其他函数所调用,是在 main 函数执行之前自动被调用
- main 函数
- 主函数:func main() {}
- init 函数和 main 函数
- 两个函数在定义时都不能有任何参数或返回值,且由程序自行调用
- init 函数可以应用于任意包中,且可以重复定义
- main 函数只能应用于 main 包中,且只能定义一个
- 同一个 go 文件中,init 函数的调用顺序从上至下
- 同一个 package 中不同的文件,按文件名字符串比较,从小到大顺序调用各个文件中 init 函数
- 不同的 package,如果不相互依赖,按 main 包中"先 import 后调用"的顺序调用其包中的 init 函数;如果存在依赖,则先调用最早被依赖的 package 中的 init 函数,最后再调用 main 函数
- 特例:如果在 init 函数中使用了 println() 或 print(),这两个函数在真正执行过程中不会按顺序执行,官方只推荐在测试环境中使用
Golang 的常用命令
- go env:打印环境信息
- go run:编译并运行源码文件
- go get:从远程版本库中安装 Go 包及其依赖项
- go build:编译源码文件
- go install:将本地源码包编译并安装到工作空间的对应位置
- go clean:删除执行其他命令时产生的文件和目录
- go doc:查看 Go 文档
- go test:进行代码测试
- go list:列出代码包的信息
- go fix:将指定代码包的所有 Go 源码文件中的旧版本代码修正为新版本
- go vet:检查 Go 源码中静态错误的简单工具
- go tool pprof:Go 的一个性能分析工具,支持标准的性能分析格式,可以帮助开发者识别程序中的性能瓶颈,从而可以优化代码以提升程序性能
Golang 中的占位符"_"
- Go 语言中的特殊标识符,用于忽略结果。例如结果可能返回某个值或两个值,但我们实际上不需要这个值或只需要一个(Go 中如果定义了变量却不使用,编译器会报错)
- import 导入包时,该包下的文件里的所有 init 函数都会被执行。如果不需要导入整个包,而仅是希望执行 init 函数,可以使用
import _ 包路径
,这样仅调用 init 函数,且无法再通过包名来调用包中的其他函数
Golang 中的变量与常量
- 变量
- 声明后才能使用,同一作用域中不可重复声明,且声明后必须使用
var 变量名 变量类型
,行尾无需分号,支持批量声明var ()
- 变量声明时,会自动对变量对应的内存区域做初始化操作,变量会被初始化成变量类型的默认值(整形、浮点型变量的默认值为 0,字符串变量的默认值为空字符串,布尔值变量的默认值为 false,切片/函数/指针变量的默认值为 nil),也可在声明时指定初始值
- 变量类型可以省略,由编译器根据等号右边的值自行推导变量类型并完成初始化,支持
:=
的短变量声明方式(短变量声明方式不可用于函数外) - 使用占位符"_"声明匿名变量,匿名变量不占用命名空间,不会分配内存,匿名变量之间不存在重复声明
- 常量
- 类似于变量声明,用 const 表示
- iota
- Go 中的常量计数器,只能用于常量表达式中
- 在 const 关键字出现时将被重置为 0
Golang 中的数据类型
- 布尔值
- 以 bool 类型进行声明,默认值为 false
- 不允许将整形强转为布尔型
- 无法参与数值运算,也无法与其他类型进行转换
- 字符串
- uint8(byte):代表 ASCII 码中的一个字符
- rune(int32):代表一个 UTF-8 字符,用于处理中文、日文或其他复合字符
- 字符串底层是一个 byte 数组,所以字符串的长度是 byte 字节的长度;因为在 UTF-8 编码下一个中文汉字由 3 ~ 4 个字节组成,所以不能按字节去遍历,rune 类型用来表示 UTF-8 字符,它由一个或多个 byte 组成
- 字符串不能直接修改,如果需要修改字符串,需要先将其转换为 []byte 或 []rune,再转换为 string,两种方式都会重新分配内存并复制字节数组(Go 中只有强制类型转换,没有隐式类型转换)
func changeString() { str1 := "hello world" // 强制类型转换 byteStr1 := []byte(s1) byteStr1[0] = 'H' fmt.Println(string(byteStr1)) str2 := "你好世界" runeStr2 := []rune(str2) runeStr2[0] = '您' fmt.Println(string(runeStr2)) }
- 数组
var a [len]int
,数组长度必须是常量,且是数组类型的组成部分,一旦定义不可改变,var a [5]int 和 var a [10]int 是不同的类型- 值类型,赋值和传参会复制整个数组而不是指针,改变副本的值不会影响到本体的值
- 支持
==
、!=
,因为内存总是被初始化过的 - 有指针数组([n] *T)和数组指针(*[n] T)
// 全局 var arr0 [5]int = [5]int{1, 2, 3} var arr1 = [5]int{1, 2, 3, 4, 5} var arr2 = [...]int{1, 2, 3, 4, 5, 6} // 长度为5的字符串数组, 其中定义了索引3和4的元素值 var str = [5]string{3: "hello", 4: "world"} var arr3 [5][3]int // 第二维度不可用"..." var arr4 [2][3]int = [...][3]int{{1, 2, 3}, {7, 8 ,9}} // 局部 // 未初始化的元素值为0 a := [3]int{1, 2} // 初始化定义元素值并确定数组长度 b := [...]int{1, 2, 3, 4} c := [5]int{2: 100, 4: 200} d := [...]struct{ name string age uint8 }{ // 可省略元素类型 {"user1", 10}, } e := [2][3]int{{1, 2, 3}, {4, 5, 6}} f := [...][2]int{{1, 1}, {2, 2}, {3, 3}}
- 切片
var 变量名 []类型
,比如var str []string
- 切片(slice)并不是数组或者数组指针,它通过内部指针和相关属性引用数组片段,以实现变长
- 切片是数组的一个引用,因此是引用类型,但其自身是结构体,值拷贝传递
- 切片的长度可以改变,它是一个可变的数组
- 切片的遍历方式和数组一致,一样可以用 len() 求长度,读写操作不能超过该限制
- cap() 可以求切片的最大扩张容量,不能超过数组限制,即 0 <= len(slice) <= len(array),array 表示 slice 引用的数组
- 如果 slice == nil,那么 len、cap 的结果都为 0
- 如果超过 slice.cap 的限制,就会重新分配底层数组,通常以 2 倍的容量重新进行分配。在进行大批量添加数据时,建议一次性分配足够大的空间,以减少内存分配和数据复制的开销,或者初始化足够长的 len 属性,或者改用索引号进行操作,及时释放不再使用的 slice 对象,避免持有过期数组,造成 GC 回收异常
var s1 []int s2 := []int{} // make() var s3 []int = make([]int, 0) var s4 []int = make([]int, 0, 0) s5 := []int{1, 2, 3} // 通过make()创建切片 // len表示切片中元素的个数, cap表示切片的长度(切片可以容纳的最大元素数量) // 可省略cap, 默认cap == len var slice []type = make([]type, len) slice := make([]type, len) slice := make([]type, len, cap) // 从数组切片, 左闭右开 arr := [5]int{1, 2, 3, 4, 5} var s6 []int s6 = arr[1:4] // 通过初始化表达式构造, 可使用索引值指定 s7 := []int{0, 1, 2, 3, 4, 6: 666} // 元素类型为[]T data := [][]int{ []int{1, 2, 3}, []int{4, 5, 6}, } // append追加, 向slice尾部追加新元素并返回新的slice对象 var a = []int{1, 2, 3} var b = []int{4, 5, 6} c := append(a, b...) d := append(c, 7, 8, 9)
- map
map[KeyType]ValueType
,默认初始值为 nil,需要使用 make() 来分配内存make(map[KeyType]ValueType, [cap])
- 使用 range() 进行遍历,遍历的顺序和添加键值对的顺序无关
scoreMap := make(map[string]int, 8) scoreMap["xiaoming"] = 100 userInfo := map[string]string{ "name": "xiaoming", "address": "xxx", } // 判断是否存在某个键 value, ok := map[key] // map的有序输出 map1 := make(map[int]string, 5) map1[1] = "xiaoming" map2[2] = "xiaohong" slice1 := []int{} for k, _ := range map1 { slice1 = append(slice1, k) } sort.Ints(slice1) for i := 0; i < len(map1); i++ { fmt.Println(map1[slice1[i]]) }
Golang 中的指针
- 指针不能参与偏移和运算,是安全指针,&(取地址),*(根据地址取值)
ptr := &v
,v 代表被取地址的变量,类型为 T;ptr 用于接收地址的变量,类型为 *T,也称为 T 的指针类型,* 代表指针- 在对普通变量使用 & 操作符取地址后会获得这个变量的指针,然后可以对指针使用 * 操作,即指针取值
- 对变量进行取地址(&)操作,可以获得这个变量的指针变量
- 指针变量的值是指针地址
- 对指针变量进行取值(*)操作,可以获得指针变量指向的原变量的值
- 当一个指针被定义后没有分配到任何变量时,它的值为 nil,即为空指针
- Go 中对于引用类型的变量,在使用时不仅需要声明,还需要为它分配对应的内存空间,否则我们无法存储值;而值类型的声明不需要分配,因为它们在声明的时候就已经默认分配好了内存空间;使用 new 或 make 来分配内存
func new(Type) *Type
,Type 表示类型,new 函数只接受一个参数;*Type 表示类型指针,new 函数返回一个指向该类型内存地址的指针,并且该指针对应的值为该类型的零值func make(t Type, Size ...IntegerType) Type
,make 只用于 slice、map、chan 的内存创建,并且返回的类型就是这三种类型本身,而不是它们的指针类型(因为这三种类型本身就是引用类型,所以就没必要再返回它们的指针了)
a := 10
// 取变量a的地址, 将指针保存到b中
b := &a
// 指针取值, 根据指针去内存取值
c := *b
// 空指针
var p *string
// *d == 0
d := new(int)
// *e == false
e := new(bool)
// 声明指针变量f并初始化
var f *int
f = new(int)
*f = 10
// 声明map类型的变量g
var g map[string]int
g = make(map[string]int, 10)
g["age"] = 10
Golang 中的结构体
- 自定义类型
type MyInt int
,通过 type 关键字的定义,定义一个具有 int 特性的新类型 MyInt
- 类型别名
type byte = uint8
、type rune = int32
- 结构体
type 类型名 struct { 字段名 字段类型 }
,类型名是标识自定义结构体的名称,在同一个包内不可重复;结构体中的字段名必须唯一;相同类型的字段可以写在同一行- 结构体实例化后才会真正地分配内存,才能使用结构体中的字段,结构体本身也是一种类型,声明结构体类型
var 结构体示例 结构体类型
- 结构体允许其成员字段在声明时没有字段名而只有类型,称为匿名字段;匿名字段默认采用类型名作为字段名,因为结构体字段名称唯一,所以同一个结构体中同种类型的匿名字段唯一
- 一个结构体中可以嵌套另一个结构体或结构体指针;当访问结构体成员时,优先会在结构体中查找该字段,如果找不到,再去匿名结构体中查找;嵌套结构体内部可能存在同名字段,所以在使用时需要指定具体的内嵌结构体的字段;可以通过嵌套匿名结构体来实现继承
- 结构体中字段名称大小写敏感,大写开头表示可公开访问,小写开头则表示私有(仅在定义当前结构体的包中可访问)
key1:"value1" key2:"value2"
,结构体标签 Tag,是结构体的元信息,可以在运行时通过反射机制读取;结构体标签必须严格遵守键值对规范,中间不可存在空格
// 自定义类型
type MyInt int
// 类型别名
type CurInt = int
func main() {
var a MyInt
var b CurInt
// a的类型为main.MyInt, 表示main包下定义的MyInt类型, MyInt类型只会在代码中存在, 在编译完成时并不会有MyInt类型
fmt.Printf("type of a:%T\n", a)
// b的类型为int
fmt.Printf("type of b:%T\n", b)
}
type person struct {
name, address string
age int8
}
// 匿名结构体
var user struct{Name string; Age int}
// 指针类型结构体, 通过new关键字对结构体进行实例化, 得到结构体的地址
var p2 = new(person)
// 使用&对结构体进行取地址相当于new实例化
// p3.name = "小明"等于(*p3).name = "小明"
p3 := &person{}
// 结构体键值对初始化, 没有指定初始值的字段默认为该字段类型的零值
p4 := person{
name: "xiaohong",
age: 10,
}
// 简写初始化结构体
// 必须初始化结构体中所有的字段
// 初始值的填充顺序必须与字段在结构体中的声明顺序保持一致
// 不能和键值对初始化方式混用
p5 := &person{
"xiaoming",
"sz",
10,
}
// 结构体标签
type Student struct {
// 通过指定tag实现json序列化该字段时的key, 即输出为{"id":1}
ID int `json:"id"`
}
Golang 中的方法及其接收者
- Go 中的方法(method)是一种作用于特定类型变量的函数,这种特定类型变量成为接收者(Receiver),类似于其他语言中的 this 或 self
func (接收者变量 接收者类型) 方法名(参数列表) (返回参数) {}
,接收者变量的命名官方建议取接收者类型名的第一个小写字母;接收者类型和参数类似,可以是指针类型和非指针类型;方法名、参数列表、返回参数的格式与函数定义相同- 方法与函数的区别在于,函数不属于任何类型,而方法只属于特定的类型;对于普通函数,接收者为值类型时,不能用指针类型的数据直接传递,反之亦然;对于方法,接收者为值类型时,依然可以直接用指针类型的变量调用方法,反之亦然
- 所有给定类型的方法属于该类型的方法集;类型 T 方法集包含全部 receiver T 方法;类型 *T 方法集包含全部 receiver T + *T 方法;如果类型 S 中包含匿名字段 T,则 S 和 *S 方法集包含 T 方法;如果类型 S 包含匿名字段 *T,则 S 和 *S 方法集包含 T + *T 方法;嵌入 T 或 *T,*S 方法集总是包含 T + *T 方法
- 指针类型的接收者
- 需要修改接收者中的值
- 接收者是拷贝代价比较大的大对象
- 保证一致性,如果有某个方法使用了指针接收者,那么其他的方法也应该使用指针接收者
- 接收者的类型可以是任意类型,也可以为自定义类型添加方法,但是不能给其他包的类型定义方法
type Person struct {
name string
age int8
}
// 构造函数
func NewPerson(name string, age int8) *Person {
return &Person{
name: name,
age: age,
}
}
// Person的方法
func (p Person) Dream() {
fmt.Println("%s想暴富\n", p.name)
}
// 指针类型的接收者, 调用方法时修改接收者指针的任意成员变量, 在方法结束后修改仍有效
func (p *Person) SetAge(newAge int8) {
p.age = newAge
}
// 值类型的接收者, 代码运行时会将接收者的值复制一份, 修改操作只是针对的副本, 无法修改接收者变量本身
func (p Person) SetName(newName string) {
p.name = newName
}
func main() {
p1 := NewPerson("xiaoming", 10)
p1.Dream()
}
Golang 中的条件语句
- if
- 可省略括号
- 不支持三目运算
- switch
- 分支表达式可以是任意类型,不局限于常量
- 可省略 break,默认会自动终止
- 匹配成功后不会再自动向下执行其他 case,而是直接跳出整个 switch,可以使用 fallthrough 强制执行后面的 case
- select
- 类似于 switch,但 select 会随机执行一个可运行的 case;如果都不可运行,优先执行 default 子句,同时程序的执行会从 select 语句后的语句中恢复;如果没有 default 子句,则将阻塞至有 case 可以运行为止
- 每个 case 必须是一个通信操作(channel),要么是发送,要么是接收
- 用途:超时判断、协程退出、判断 channel 是否阻塞
- for … range
- range 会复制对象
- for 可以遍历 array 和 slice、key 为整形递增的 map、string;for … range 除了可以完成所有 for 可以完成的事情,还可以遍历 key 为 string 类型的 map 并同时获取 key 和 value、遍历 channel
- Goto、Break、Continue
- 循环控制语句,可以配合标签(label)使用;标签名区分大小写,定义后必须使用
- goto 语句可以无条件地转移到程序中指定的行,它使程序能够从一个地方跳转到另一个地方,而不需要经过中间的代码
- break 语句可以终止循环吗,它使程序能够从一个循环中跳出,而不再执行该循环余下的代码
- continue 语句用于跳过当前循环余下的代码,并直接开始下一次
// 这段代码使用goto语句创建了一个循环, 它会打印出从0开始的10个数字(0-9), 它会首先打印i的值, 然后i的值加1, 如果i的值小于10, 它会跳回到Loop, 重复上述步骤 func main() { i := 0 Loop: fmt.Println(i) i++ if i < 10 { goto Loop } }
Golang 中的 defer 和 recover
- defer
- 关键字,用于延迟函数的执行,直到函数返回前;用于确保函数可以在 return 前完成一些清理操作,如:关闭文件、释放资源等
- 多个 defer 语句,按先进后出的方式执行,后面的语句会依赖前面的资源,因此如果前面的资源先释放了,那么后面的语句就无法再执行了
- defer 语句中的变量,在 defer 声明时就决定了
- recover
- Go 没有结构化异常,使用 panic 抛出错误,使用 recover 捕获错误
- 如果函数中存在 panic 语句,则会终止其后要执行的代码;如果函数中存在 defer 语句,则按 defer 的逆序执行
- 使用 recover 处理 panic,defer 必须放在 panic 之前定义,并且 recover 只能在 defer 调用的函数中才有效
- recover 处理异常后,函数不会恢复到 panic 的位置,而是恢复到 defer 之后的位置
func test() { defer func() { if err := recover(); err != nil { println(err.(string)) } }() panic("panic error") } // 实现类似try...catch的异常处理 func Try(fun func(), handler func(interface{})) { defer func() { if err := recover(); err != nil { handler(err) } }() fun() } func main() { Try(func() { panic("panic error") }, func(err interface{}) { fmt.Println(err) }) }
Golang 中的接口
- Go 中的接口是一种虚拟类型,它不定义任何变量,它只是一系列方法的声明,这些方法构成了接口的签名。接口定义了一组方法,但没有实现它们,它只是定义某种行为的契约。如果类型实现了接口定义的所有方法(指具有相同名称、参数列表不包括参数名,以及返回值),那么它就实现了这个接口
- 接口提供了一种结构化编程的方法,将复杂的程序结构化,使其易于维护和扩展。接口的使用允许开发者定义类型的行为,而不需要实现它
- 接口也可以用于实现多态,它允许我们通过抽象层次来定义程序,而不用考虑实际实现细节。它使程序更具灵活性,可以从不同的实现中选择最合适的实现
- 对象赋值给接口时,会发生拷贝,而接口内部存储的是指向这个副本的指针,既无法修改副本的状态,也无法获取指针
- 只有当接口存储的类型和对象都为 nil 时,接口才等于 nil
- 一个类型可以实现多个接口
- 接口命名习惯以 er 结尾
- 当方法名首字母大写且接口类型名首字母大写时,该方法可被接口所在包之外的代码访问
- 注意
值接收者实现接口
和指针接收者实现接口
的区别:使用值接收者实现接口后,结构体和结构体指针类型的变量都可以直接赋值给该接口变量;使用指针接收者实现接口后,不可以接收结构体类型的变量 - 空接口
var x interface{}
是指没有定义任何方法的接口,因此任何类型都实现了空接口;空接口类型的变量可以存储任意类型的变量
Golang 中的 goroutine 和 GMP 调度
- goroutine
- 在调用函数前加上关键字 go,就能开启一个 goroutine,函数与 goroutine 的对应关系为 1:N
- 注意在程序启动时会自动为 main 函数创建一个默认的 goroutine,当 main 函数返回的时候该 goroutine 就结束了,并且其内部启动的其他 goroutine 也会一同结束
- goroutine 的调度是随机的
- GMP
- GMP 是 Go 语言运行时(runtime)层面的实现,是 Go 自己实现的一套调度系统,区别于操作系统调度 OS 线程
- G 很好理解,即 goroutine,里面除了存放本 goroutine 的信息外,还存放了与所在 P 的绑定等信息
- P 是管理着一组 goroutine 的队列,P 里面会存储当前 goroutine 运行的上下文环境(函数指针、堆栈地址以及地址边界),P 会对自己管理的 goroutine 队列做一些调度(比如把占用 CPU 时间较长的 goroutine 暂停,运行后续的 goroutine 等等),当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了则会去其他的 P 队列里抢任务
- M(machine)是 Go 运行时(runtime)对操作系统内核线程的虚拟,M 与内核线程一般是一对一映射的关系,一个 groutine 最终是要放到 M 上执行的
- P 与 M一般也是一对一的映射关系,P 管理着一组 G 挂载在 M 上运行,当一个 G 长久阻塞在一个 M 上时,runtime 会新建一个 M,阻塞 G 所在的 P 会把其他的 G 挂载在新建的 M 上,当旧的 G 阻塞完成或者认为其已经死掉时,再回收旧的 M
- P 的个数可以通过
runtime.GOMAXPROCS
设定(最大 256,Go 1.5 版本之后默认为物理线程数), 在并发量大的时候会增加一些 P 和 M,但不会太多,因为切换太频繁会得不偿失 - 单从线程调度而言,Go 相比其他语言的优势在于 OS 线程是由 OS 内核来调度的,而 goroutine 则是由 Go 运行时(runtime)自己的调度器来调度的,这个调度器使用一个称为 m:n 调度的技术(复用/调度 m 个 goroutine 到 n 个 OS 线程上)。 其一大特点是 goroutine 的调度是在用户态下完成的,不会涉及到内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池,不直接调用系统的 malloc 函数(除非内存池需要改变),成本比调度 OS 线程低很多;另一方面是它充分利用了多核的硬件资源,近似地把若干个 goroutine 均分在物理线程上,再加上本身 goroutine 的超轻量特性(OS 线程一般都有固定的栈内存(通常为 2 MB),一个 goroutine 在其生命周期开始时只有很小的栈(典型情况下为 2 KB),goroutine 的栈不是固定的,它可以按需进行增缩,goroutine 的栈大小限制可以达到 1 GB,所以在 Go 语言中一次性创建十万左右的 goroutine 也是可以的),从而保证了调度方面的性能
- https://www.topgoer.cn/docs/golang/chapter09-11
Golang 中的 channel
- 用于在不同 goroutine 之间进行同步和交换数据,它可以视为一种管道,在管道的一端可以放入数据,在另一端可以取出数据,遵循先进先出的规则;channel 中只能存储特定类型的数据,而且必须在使用前先建立,否则会出现 panic 错误;channel 可以是双向的,允许 goroutine 之间双方发送和接收数据,也可以是单向的,只允许其中一个 goroutine 发送或者接收数据
- channel 也是一种阻塞的数据结构,当 goroutine 将数据写入 channel 时,直到另一个 goroutine 将数据读出来,这个 goroutine 才会继续执行
var 变量 chan 元素类型
,空值为 nil;声明通道必须使用 make 函数初始化空间后才能使用make(chan 元素类型, [缓冲大小])
,缓冲大小可选;无缓冲的通道是阻塞型通道,必须有接收才能发送;有缓冲的通道在缓冲大小填充满后进入阻塞状态- 关闭已经关闭了的 channel 也会引发 panic
参考资料
- https://www.topgoer.cn/docs/golang/golang-1ccjbpfstsfi1
- https://www.yuque.com/aceld
- https://www.bilibili.com/video/BV1gf4y1r79E/?spm_id_from=333.880.my_history.page.click&vd_source=934085b00c3c6e4153bf6e8a7a80beb8
- https://juejin.cn/post/6844904122450182151