内容简介:[TOC]本文基于golang 1.11源码进行分析。先演示用法和注意事项,再深入源码分析原理。在golang中,
[TOC]
本文基于golang 1.11源码进行分析。先演示用法和注意事项,再深入源码分析原理。
在golang中, 接口本身也是一种类型,它代表的是一个方法的集合。任何类型只要实现了接口中声明的所有方法,那么该类就实现了该接口 。与其他语言不同, golang并不需要显示声明类型实现了某个接口 ,而是由编译器和runtime进行检查。不用显示什么这点非常棒,这样就无侵入非常方便。
1 使用
1.1 声明
type 接口名 interface { 方法1 方法2 ... 方法n } type 接口名 interface { 已声明接口名1 ... 已声明接口名n }
如果 一个接口不包含任何方法,那么就是一个空接口(empty interface) ,所有类型都符合empty interface的定义,因此 任何类型都能转换成empty interface ,可以看到常常使用empty interface来实现多态,例如之前我们分析过的map源码。
对于接口的命名,一般我们都是以er结尾,例如Writer、Reader等等。
1.2 实现接口
我们看个例子:
package main import ( "fmt" ) type Tester interface { Display() DisplayAppend(string) DisplayAppend2(string) string } type Tester2 interface { DisplayAppend(string) } type Test struct { s string } func (t *Test) Display() { fmt.Printf("Display:%p, %#v\n", t ,t) } func (t Test) DisplayAppend(s string) { t.s += s fmt.Printf("DisplayAppend:%p, %#v\n", &t, t) } func (t *Test) DisplayAppend2 (s string) string { t.s += s fmt.Printf("DisplayAppend2:%p, %#v\n", t, t) return t.s } func TestInterface(t Tester) { t.Display() t.DisplayAppend(" TestInterface") t.DisplayAppend2(" TestInterface") } func TestInterface2(t Tester2) { t.DisplayAppend("TestInterface2") } func main() { var test Test test.s = "aaa" fmt.Printf("%p\n", &test) test.Display() test.DisplayAppend(" raw") TestInterface(&test) //TestInterface(test) //cannot use test (type Test) as type Tester in argument to TestInterface:Test does not implement Tester (Display method has pointer receiver) TestInterface2(&test) TestInterface2(test) }
输出
0xc42000e1e0
Display:0xc42000e1e0, &main.Test{s:"aaa"}
DisplayAppend:0xc42000e200, main.Test{s:"aaa raw"}
Display:0xc42000e1e0, &main.Test{s:"aaa"}
DisplayAppend:0xc42000e230, main.Test{s:"aaa TestInterface"}
DisplayAppend2:0xc42000e1e0, &main.Test{s:"aaa TestInterface"}
DisplayAppend:0xc42000e260, main.Test{s:"aaa TestInterfaceTestInterface2"}
DisplayAppend:0xc42000e290, main.Test{s:"aaa TestInterfaceTestInterface2"}
在这个例子中,我们定义了一个类型Test,Test类型有三个方法,两个方法的接受者是*Test,一个方法的接受者是Test;定义两个接口类型,Tester和Tester2 ,Tester有三个Test中的同名方法,Tester2中只有接受者是Test的同名方法。然后有TestInterface函数,入参是Tester,TestInterface2函数入参是Tester2。
从编译和运行结果可以看到,TestInterface函数入参只能填Test类型,TestInterface2入参既可以是Test也可以是*Test。TestInterface传入Test类型变量test时,编译报错:
cannot use test (type Test1) as type Tester in argument to TestInterface:Test1 does not implement Tester (Display method has pointer receiver)
意思是说test并没有实现Tester的Display接口,因为golang中, 类型T只有接受者是T的方法,语法中T能直接调*T的方法仅仅是语法糖;而类型*T拥有接受者是T和*T的方法 。
1.3 类型判断
传入参数接口的时候,如果我们希望确切知道它具体类型,那么就要用到类型判断了。有两种类型判断方法:
变量.(类型) //判断是不是某个具体类型 switch 变量.(type) //返回具体类型,必须搭配swith语句
talk is cheap,看代码:
package main import ( "fmt" ) type Tester interface { Display() DisplayAppend(string) DisplayAppend2(string) string } type Tester2 interface { DisplayAppend(string) } type Test1 struct { s string } func (t *Test1) Display() { fmt.Printf("Display:%p, %#v\n", t ,t) } func (t Test1) DisplayAppend(s string) { t.s += s fmt.Printf("DisplayAppend:%p, %#v\n", &t, t) } func (t *Test1) DisplayAppend2 (s string) string { t.s += s fmt.Printf("DisplayAppend2:%p, %#v\n", t, t) return t.s } func TestInterface(t Tester) { t.Display() t.DisplayAppend(" TestInterface") t.DisplayAppend2(" TestInterface") } func TestInterface2(t Tester2) { t.DisplayAppend("TestInterface2") } func Printf(t interface{}) { if v, ok := t.(int); ok { v = 2 fmt.Printf("type[%T] %v %v\n", v, v, t) } if v, ok := t.(int32);ok { fmt.Printf("type[%T] %v\n", v, v) } if v, ok := t.(int64); ok { fmt.Printf("type[%T] %v\n", v, v) } if v, ok := t.(Tester2); ok { fmt.Printf("type[%T] %v\n", v, v) } if v, ok := t.(Tester); ok { fmt.Printf("type[%T] %v\n", v, v) } } func Printf2(v interface{}) { fmt.Printf("%p %v\n", &v, v) switch v := v.(type) { case nil: fmt.Printf("type[%T] %v\n", v, v) case int: fmt.Printf("%p %v\n", &v, v) fmt.Printf("type[%T] %v\n", v, v) case int64: fmt.Printf("type[%T] %v\n", v, v) case string: fmt.Printf("type[%T] %v\n", v, v) case Tester: fmt.Printf("tester type[%T] %v\n", v, v) case Tester2: fmt.Printf("tester2 type[%T] %v\n", v, v) default: fmt.Printf("unknow\n") } } func main() { var i int64 = 1 Printf(i) var i2 int = 1 Printf(i2) var test Test1 Printf(test) Printf(&test) fmt.Printf("------------\n") Printf2(i2) Printf2(test) Printf2(&test) }
输出
type[int64] 1
type[int] 2 1
type[main.Test1] {}
main.Test1] &{}
main.Test1] &{}------------
0xc42000e220 1
0xc4200160b8 1
type[int] 1
0xc42000e240 {}
tester2 type[main.Test1] {}
0xc42000e250 &{}
tester type[*main.Test1] &{}
从这里我们可以看出两个点:
- 对于判断接口,只要实现该接口就能匹配上
- 类型判断返回也是按值复制,修改返回的value,不影响原来的值
- 接口的匹配,是严格的匹配,并不是说接口1能转换成接口2他们就能匹配上
golang中,我们经常用类型判断来判断特定的错误。
1.4 接口的值
接口的值简单来说,是由两部分组成的,就是类型和数据,详细的组成会在下面的实现章节中说明。
那么判断两个接口是相等,就是看他们的这两部分是否相等;另外 类型和数据都为nil才代表接口是nil ,eg:
var a interface{} var b interface{} = (*int)(nil) fmt.Println(a == nil, b == nil) //true false
这点很重要,很多人在实现error接口的时候判断错误,下面我们看个例子:
type MyError struct{} func (*MyError) Error() string { return "my error" } func TestError(x int) (int, error) { var err *MyError if x < 0 { err = new(MyError) } return x, err } func main() { var err error _, err = TestError(10) fmt.Println(err == nil) //false }
在x大于0时,TestError中的err是nil,返回的时候,转换成error类型,那么类型就是(*MyError),值是nil,由于类型不是nil,所以最终返回给调用方的总是失败。
2 实现
如前面所说,golang中你不需要声明一个类型实现了那些接口,这带来了方便,但是实现上会比那些需要声明的语言更加复杂。golang的接口检测既有静态部分,也有动态部分。
-
静态部分
对于 具体类型(concrete type,包括自定义类型) -> interface,编译器生成对应的itab放到ELF的.rodata段,后续要获取itab时,直接把指针指向存在.rodata的相关偏移地址即可 。具体实现可以看golang的提交日志CL 20901、CL 20902。
对于 interface->具体类型(concrete type,包括自定义类型),编译器提取相关字段进行比较,并生成值
-
动态部分
在runtime中会有一个全局的hash表,记录了相应type->interface类型转换的itab,进行转换时候,先到hash表中查,如果有就返回成功;如果没有,就检查这两种类型能否转换,能就插入到hash表中返回成功,不能就返回失败。注意这里的hash表不是 go 中的map,而是一个最原始的使用数组的hash表,使用开放地址法来解决冲突。主要是 interface <-> interface(接口赋值给接口、接口转换成另一接口)使用到动态生产itab 。
2.1 结构
interface结构图
2.1.1 接口类型的结构 interfacetype
type interfacetype struct { typ _type pkgpath name mhdr []imethod } // imethod represents a method on an interface type type imethod struct { name nameOff // name of method typ typeOff // .(*FuncType) underneath }
pkgpath记录定义接口的包名
其中的mdhr字段,是一个imethod切片,记录接口中定义的那些函数。
nameOff 和 typeOff 类型是 int32 ,这两个值是链接器负责嵌入的,相对于可执行文件的元信息的偏移量。元信息会在运行期,加载到 runtime.moduledata 结构体中。
2.1.2 接口值的结构 iface eface
为了性能,golang专门分了两种interface,eface和iface,eface就是空接口,iface就是有方法的接口
结构定义分别在两个文件中,runtime2.go:
type iface struct { tab *itab data unsafe.Pointer } type eface struct { _type *_type data unsafe.Pointer } type itab struct { inter *interfacetype _type *_type hash uint32 // copy of _type.hash. Used for type switches. _ [4]byte fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter. }
data字段是用来存储实际数据的, runtime会申请一块新的内存,把数据考到那,然后data指向这块新的内存 。
eface、itab的_tpye字段,是data指向的值的实际类型信息。
iface中tab字段,是一个itab结构,包含了inter接口类型、_type数据类型、hash哈希的方法、fun函数地址占位符。这个hash方法拷贝自_type.hash;fun是一个大小为1的uintptr数组, 当fun[0]为0时,说明_type并没有实现该接口,当有实现接口时,fun存放了第一个接口方法的地址,其他方法一次往下存放,这里就简单用空间换时间,其实方法都在_type字段中能找到,实际在这记录下,每次调用的时候就不用动态查找了 。
2.1.2 全局的itab table
iface.go:
const itabInitSize = 512 // Note: change the formula in the mallocgc call in itabAdd if you change these fields. type itabTableType struct { size uintptr // length of entries array. Always a power of 2. count uintptr // current number of filled entries. entries [itabInitSize]*itab // really [size] large }
可以看出这个全局的itabTable是用数组在存储的
size记录数组的大小,总是2的次幂。
count记录数组中已使用了多少。
entries是一个*itab数组,初始大小是512.
2.2 转换
把一个具体的值,赋值给接口,会调用conv系列函数,例如空接口调用convT2E系列、非空接口调用convT2I系列,为了性能考虑,很多特例的convT2I64、convT2Estring诸如此类,避免了typedmemmove的调用。
func convT2E(t *_type, elem unsafe.Pointer) (e eface) { if raceenabled { raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2E)) } if msanenabled { msanread(elem, t.size) } x := mallocgc(t.size, t, true) // TODO: We allocate a zeroed object only to overwrite it with actual data. // Figure out how to avoid zeroing. Also below in convT2Eslice, convT2I, convT2Islice. typedmemmove(t, x, elem) e._type = t e.data = x return } func convT2I(tab *itab, elem unsafe.Pointer) (i iface) { t := tab._type if raceenabled { raceReadObjectPC(t, elem, getcallerpc(), funcPC(convT2I)) } if msanenabled { msanread(elem, t.size) } x := mallocgc(t.size, t, true) typedmemmove(t, x, elem) i.tab = tab i.data = x return } func convT2I16(tab *itab, val uint16) (i iface) { t := tab._type var x unsafe.Pointer if val == 0 { x = unsafe.Pointer(&zeroVal[0]) } else { x = mallocgc(2, t, false) *(*uint16)(x) = val } i.tab = tab i.data = x return } func convI2I(inter *interfacetype, i iface) (r iface) { tab := i.tab if tab == nil { return } if tab.inter == inter { r.tab = tab r.data = i.data return } r.tab = getitab(inter, tab._type, false) r.data = i.data return }
可以看到:
- 具体类型转空接口,_type字段直接复制源的type;mallocgc一个新内存,把值复制过去,data再指向这块内存。
- 具体类型转非空接口,入参tab是编译器生成的填进去的,接口指向同一个入参tab指向的itab ;mallocgc一个新内存,把值复制过去,data再指向这块内存。
- 对于 接口转接口,itab是调用getitab函数去获取的 ,而不是编译器传入的。
对于那些特定类型的值,如果是零值,那么不会mallocgc一块新内存,data会指向zeroVal[0]。
2.2.1 编译器优化
每次都malloc一块内存,那么性能会很差,因此,对于一些类型,golang的编译器做了优化。 TODO
2.3 获取itab的流程
golang interface的核心逻辑就在这, 在get的时候,不仅仅会从itabTalbe中查找,还可能会创建插入,itabTable使用容量超过75%还会扩容 。下面我们看下代码:
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab { if len(inter.mhdr) == 0 { throw("internal error - misuse of itab") } // easy case if typ.tflag&tflagUncommon == 0 { if canfail { return nil } name := inter.typ.nameOff(inter.mhdr[0].name) panic(&TypeAssertionError{nil, typ, &inter.typ, name.name()}) } var m *itab // First, look in the existing table to see if we can find the itab we need. // This is by far the most common case, so do it without locks. // Use atomic to ensure we see any previous writes done by the thread // that updates the itabTable field (with atomic.Storep in itabAdd). t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable))) if m = t.find(inter, typ); m != nil { goto finish } // Not found. Grab the lock and try again. lock(&itabLock) if m = itabTable.find(inter, typ); m != nil { unlock(&itabLock) goto finish } // Entry doesn't exist yet. Make a new entry & add it. m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys)) m.inter = inter m._type = typ m.init() itabAdd(m) unlock(&itabLock) finish: if m.fun[0] != 0 { return m } if canfail { return nil } // this can only happen if the conversion // was already done once using the , ok form // and we have a cached negative result. // The cached result doesn't record which // interface function was missing, so initialize // the itab again to get the missing function name. panic(&TypeAssertionError{concrete: typ, asserted: &inter.typ, missingMethod: m.init()}) }
流程很简单
- 先用t保存全局itabTable的地址,然后使用t.find去查找,这样是为了防止查找过程中,itabTable被替换导致查找错误。
- 如果没找到,那么就会上锁,然后使用itabTable.find去查找,这样是因为在第一步查找的同时,另外一个协程写入,可能导致实际存在却查找不到,这时上锁避免itabTable被替换,然后直接在itaTable中查找。
- 再没找到,说明确实没有,那么就根据接口类型、数据类型,去生成一个新的itab,然后插入到itabTable中,这里可能会导致hash表扩容,如果数据类型并没有实现接口,那么根据调用方式,该报错报错,该panic panic。
这里我们可以看到申请新的itab空间时,内存空间的大小是unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize,参照前面接受的结构,len(inter.mhdr)就是接口定义的方法数量,因为字段fun是一个大小为1的数组,所以len(inter.mhdr)-1,在fun字段下面其实隐藏了其他方法接口地址。
然后我们再看下上面用到的一些方法的细节
2.3.1 在itabTable中查找itab find
func itabHashFunc(inter *interfacetype, typ *_type) uintptr { // compiler has provided some good hash codes for us. return uintptr(inter.typ.hash ^ typ.hash) } // find finds the given interface/type pair in t. // Returns nil if the given interface/type pair isn't present. func (t *itabTableType) find(inter *interfacetype, typ *_type) *itab { // Implemented using quadratic probing. // Probe sequence is h(i) = h0 + i*(i+1)/2 mod 2^k. // We're guaranteed to hit all table entries using this probe sequence. mask := t.size - 1 h := itabHashFunc(inter, typ) & mask for i := uintptr(1); ; i++ { p := (**itab)(add(unsafe.Pointer(&t.entries), h*sys.PtrSize)) // Use atomic read here so if we see m != nil, we also see // the initializations of the fields of m. // m := *p m := (*itab)(atomic.Loadp(unsafe.Pointer(p))) if m == nil { return nil } if m.inter == inter && m._type == typ { return m } h += I h &= mask } }
从注释我们可以看到,golang使用的开放地址探测法,用的是公式h(i) = h0 + i*(i+1)/2 mod 2^k,h0是根据接口类型和数据类型的hash字段算出来的。以前的版本是额外使用一个link字段去连到下一个slot,那样会有额外的存储,性能也会差写,在1.11中我们看到做了改进,具体是哪个版本开始变的我也不知道。
2.3.2 检查并生成itab init
// init fills in the m.fun array with all the code pointers for // the m.inter/m._type pair. If the type does not implement the interface, // it sets m.fun[0] to 0 and returns the name of an interface function that is missing. // It is ok to call this multiple times on the same m, even concurrently. func (m *itab) init() string { inter := m.inter typ := m._type x := typ.uncommon() // both inter and typ have method sorted by name, // and interface names are unique, // so can iterate over both in lock step; // the loop is O(ni+nt) not O(ni*nt). ni := len(inter.mhdr) nt := int(x.mcount) xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt] j := 0 imethods: for k := 0; k < ni; k++ { i := &inter.mhdr[k] itype := inter.typ.typeOff(i.ityp) name := inter.typ.nameOff(i.name) iname := name.name() ipkg := name.pkgPath() if ipkg == "" { ipkg = inter.pkgpath.name() } for ; j < nt; j++ { t := &xmhdr[j] tname := typ.nameOff(t.name) if typ.typeOff(t.mtyp) == itype && tname.name() == iname { pkgPath := tname.pkgPath() if pkgPath == "" { pkgPath = typ.nameOff(x.pkgpath).name() } if tname.isExported() || pkgPath == ipkg { if m != nil { ifn := typ.textOff(t.ifn) *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*sys.PtrSize)) = ifn } continue imethods } } } // didn't find method m.fun[0] = 0 return iname } m.hash = typ.hash return "" }
这个方法会检查interface和type的方法是否匹配,即type有没有实现interface。假如 interface有n中方法,type有m中方法,那么匹配的时间复杂度是O(n x m),由于interface、type的方法都按字典序排,所以O(n+m)的时间复杂度可以匹配完 。在检测的过程中,匹配上了,依次往fun字段写入type中对应方法的地址。如果 有一个方法没有匹配上,那么就设置fun[0]为0,在外层调用会检查fun[0]==0,即type并没有实现interface 。
这里我们还可以看到golang中continue的特殊用法, 要直接continue到外层的循环中,那么就在那一层的循环上加个标签,然后continue 标签 。
2.3.3 把itab插入到itabTable中 itabAdd
// itabAdd adds the given itab to the itab hash table. // itabLock must be held. func itabAdd(m *itab) { // Bugs can lead to calling this while mallocing is set, // typically because this is called while panicing. // Crash reliably, rather than only when we need to grow // the hash table. if getg().m.mallocing != 0 { throw("malloc deadlock") } t := itabTable if t.count >= 3*(t.size/4) { // 75% load factor // Grow hash table. // t2 = new(itabTableType) + some additional entries // We lie and tell malloc we want pointer-free memory because // all the pointed-to values are not in the heap. t2 := (*itabTableType)(mallocgc((2+2*t.size)*sys.PtrSize, nil, true)) t2.size = t.size * 2 // Copy over entries. // Note: while copying, other threads may look for an itab and // fail to find it. That's ok, they will then try to get the itab lock // and as a consequence wait until this copying is complete. iterate_itabs(t2.add) if t2.count != t.count { throw("mismatched count during itab table copy") } // Publish new hash table. Use an atomic write: see comment in getitab. atomicstorep(unsafe.Pointer(&itabTable), unsafe.Pointer(t2)) // Adopt the new table as our own. t = itabTable // Note: the old table can be GC'ed here. } t.add(m) } // add adds the given itab to itab table t. // itabLock must be held. func (t *itabTableType) add(m *itab) { // See comment in find about the probe sequence. // Insert new itab in the first empty spot in the probe sequence. mask := t.size - 1 h := itabHashFunc(m.inter, m._type) & mask for i := uintptr(1); ; i++ { p := (**itab)(add(unsafe.Pointer(&t.entries), h*sys.PtrSize)) m2 := *p if m2 == m { // A given itab may be used in more than one module // and thanks to the way global symbol resolution works, the // pointed-to itab may already have been inserted into the // global 'hash'. return } if m2 == nil { // Use atomic write here so if a reader sees m, it also // sees the correctly initialized fields of m. // NoWB is ok because m is not in heap memory. // *p = m atomic.StorepNoWB(unsafe.Pointer(p), unsafe.Pointer(m)) t.count++ return } h += I h &= mask } }
可以看到,当hash表使用达到75%或以上时,就会进行扩容,容量是原来的2倍,申请完空间,就会把老表中的数据插入到新的hash表中。然后使itabTable指向新的表,最后把新的itab插入到新表中。
2.4 类型判断
2.4.1 接口转接口
func assertI2I2(inter *interfacetype, i iface) (r iface, b bool) { tab := i.tab if tab == nil { return } if tab.inter != inter { tab = getitab(inter, tab._type, true) if tab == nil { return } } r.tab = tab r.data = i.data b = true return } func assertE2I(inter *interfacetype, e eface) (r iface) { t := e._type if t == nil { // explicit conversions require non-nil interface value. panic(&TypeAssertionError{nil, nil, &inter.typ, ""}) } r.tab = getitab(inter, t, false) r.data = e.data return } func assertE2I2(inter *interfacetype, e eface) (r iface, b bool) { t := e._type if t == nil { return } tab := getitab(inter, t, true) if tab == nil { return } r.tab = tab r.data = e.data b = true return }
首先我们看到有两种用法:
- 返回值是一个时,不能转换就panic。
- 返回值是两个时,第二个返回值标记能否转换成功
此外,data复制的是指针,不会完整拷贝值
2.4.2 接口转具体类型
接口判断是否转换成具体类型,是编译器生成好的代码去做的。我们看个empty interface转换成具体类型的例子:
var EFace interface{} var j int func F4(i int) int{ EFace = I j = EFace.(int) return j } func main() { F4(10) }
我们反汇编看一下
go build -gcflags '-N -l' -o tmp build.go
go tool objdump -s "main.F4" tmp
可以看到汇编代码中有这么一段,我加注释你们就懂了:
MOVQ main.EFace(SB), CX //CX = EFace.typ LEAQ type.*+60128(SB), DX //DX = &type.int CMPQ DX, CX. //if DX == AX
可以看到 empty interface转具体类型,是编译器生成好对比代码,比较具体类型和空接口是不是同一个type,而不是调用某个函数在运行时动态对比 。
然后我们再看下非空接口类型转换:
var tf Tester var t testStruct func F4() int{ t := tf.(testStruct) return t.i } func main() { F4() }
继续反汇编看一下:
MOVQ main.tf(SB), CX // CX = tf.tab(.inter.typ) LEAQ go.itab.main.testStruct,main.Tester(SB), DX // DX = <testStruct,Tester>对应的&itab(.inter.typ) CMPQ DX, CX //
可以看到, 非空接口转具体类型,也是编译器生成的代码,比较是不是同一个itab,而不是调用某个函数在运行时动态对比 。
最后
我还没确定golang程序启动时,是否会把编译期生成的itab插入到全局的hash表中?
还有赋值给interface时,编译优化避免malloc,这里我也不太懂
谁知道可以告诉一下我。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 【剖析 | SOFARPC 框架】系列之 SOFARPC 泛化调用实现剖析
- 剖析 SOFARPC 框架系列之 SOFARPC 泛化调用实现剖析
- RunTime实现原理剖析
- Docker 的实现原理剖析
- SOFAJRaft 线性一致读实现剖析 | SOFAJRaft 实现原理
- 剖析golang map的实现
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
互联网运营实战手册
李春雷 / 人民邮电出版社 / 2017-4 / 49.80元
为什么网络推广做不起来?微信文章也是套路?标题党的背后是什么?把服务器搞瘫痪的活动是怎么玩出来的?社群究竟要如何运营?数据又该如何运营?你会任务分解吗? 《互联网运营实战手册》详细剖析了网站(产品)的运营技巧与实战,涵盖实用的互联网运营方法,是作者从多年的实战中提炼出的运营心得和精华,涉及运营技巧、运营工具和运营思维方法。详细讲解了用户运营、内容运营、新媒体运营、社群运营、活动运营和数据运营......一起来看看 《互联网运营实战手册》 这本书的介绍吧!