golang 项目实战简明指南

darlingtangli · · 414 次点击 · · 开始浏览    
**[原文地址](http://litang.me/post/golang-project-guide/)** ### 开发环境搭建 golang 的开发环境搭建比较简单,由于是编译型语言,写好 golang 源码后,只需要执行 `go build` 就能将源码编译成对应平台(本文中默认为 linux)上的可执行程序。本文不再赘述如何搭建 golang 开发环境,只说明下需要注意的地方。 从官网下载对应平台的 golang 安装包中包括 golang 的编译器、一些工具程序和标准库源码。早期的 golang 版本中,需要设置 `GOROOT` 和 `GOPATH` 两个环境变量。 从 1.8 版开始,`GOPATH` 不再需要显示设置。如果没有显示设置,则 `GOPATH` 的默认值为 `$HOME/go` 。`GOPATH` 可以设置多个目录,但推荐只设置一个或直接使用默认值,多个 `GOPATH` 会造成依赖管理的困难。推荐将 `$GOPATH/bin` 加到 `$PATH` 里,这样通过 `go install` 会安装到 `$GOPATH/bin` 目录的可执行程序可以像系统命令一样直接运行,不用输入完整路径。 从 1.10 版开始, `GOROOT` 也不再需要显示设置了,只需要将安装包中的 bin 目录加到 `$PATH` 里,系统会自动推导出 `GOROOT` 的值。 编辑器根据个人喜好选择,作者主要使用 vim 和 vscode 。[这里](https://studygolang.com/articles/1785)介绍了使用 vim 时需要安装的插件(安装过程可能需要翻墙,YCM 安装比较复杂可以不要,gocode 够用了)。 ### hello world 以下是 golang 版本的 hello world: ```go package main import ( "fmt" ) func main() { fmt.Println("hello world") } ``` golang 安装包自带的 gofmt 能将源码格式化成官方推荐的风格,建议将这个工具整合到编辑器里。 这个简单的程序用 go build 编译出来可执行程序用 ldd 查看发现没有任何动态库依赖,size 也比较大(1.8M ,对等的 C 程序版本只有 7.5K)。实际上这里也体现了 golang 的哲学:直接通过源代码分发软件,所有的代码编到一整个可执行程序里,基本没有动态库依赖(或者只依赖 C/C++ 运行时库和基本的系统库),这也方便了 docker 化(C/C++ 程序员应试能体会动态库依赖有多恶心)。通过 readelf 查看可执行程序会发现代码段和调试信息段占用了比较大的空间,代码段大是因为 golang 的运行时也在里面。调试信息段方便 golang 进程 panic 时会打印详细的进程堆栈及源码信息,这也是为什么 golang 的可执行程序比较大的原因。 ### 命名规范 golang 的标准库提供了 golang 程序命名规范很好的参考标准,命名规范应该尽量和标准库的风格接近,多看下标准库的代码就能体会到 golang 的命名哲学了。 命名在很大程序上也体现了一名程序员的修养,用好的命名写出的代码通常是自注释的,只需要在有复杂的逻辑需要解释的情况下才额外注释。 好的命名应该具有以下特征: * 一致性:见名知义,比如标准库中将对象序列化成字符串的操作名为 `String` ,在你自己的代码里将自定义类型的对象序列化成字符串也应该叫这个名字,并且签名和标准库要一致; * 简明精炼:减少敲键盘的次数; * 精确性:不要使用有歧义的命名。 > Tip: 通常变量的作用域越广,变量的名字应该越长,反之亦然。 golang 中一般使用驼峰命名法,尽量不要使用下划线(基本只在全大写的常量命名中使用)。首字母缩略词应该全部大写,比如 `ServeHTTP` , `IDProcessor` 。 本文中出现的**必须**、 **禁止**是指强烈推荐的 golang 风格的规范,但违反这个规范并不会导致程序编译不过。 #### 常量 全大写或者驼峰命名都可以,全大写的情况下可使用下划线分隔单词: ```go const ( SEEK_SET int = 0 // seek relative to the origin of the file SEEK_CUR int = 1 // seek relative to the current offset SEEK_END int = 2 // seek relative to the end ) const ( MaxInt8 = 1<<7 - 1 MinInt8 = -1 << 7 MaxInt16 = 1<<15 - 1 MinInt16 = -1 << 15 MaxInt32 = 1<<31 - 1 MinInt32 = -1 << 31 MaxInt64 = 1<<63 - 1 MinInt64 = -1 << 63 MaxUint8 = 1<<8 - 1 MaxUint16 = 1<<16 - 1 MaxUint32 = 1<<32 - 1 MaxUint64 = 1<<64 - 1 ) ``` #### 局部变量 通过以下代码片断举例说明局部变量的命名原则: ```go func RuneCount(buffer []byte) int { runeCount := 0 for index := 0; index < len(buffer); { if buffer[index] < RuneSelf { index++ } else { _, size := DecodeRune(buffer[index:]) index += size } runeCount++ } return runeCount } ``` 惯用的变量名应该尽可能短: * 使用 `i` 而不是 `index` * 使用 `r` 而不是 `reader` * 使用 `b` 而不是 `buffer` 这几个字母在 golang 中有约定俗成的含义,使用单字母名字是更 golang 的方式(可能在其他语言的规范中是反例),其他可以举一反三。 变量名中不要有冗余的信息,在函数 `RuneCount` 里,计数器命名就不需再把 `rune` 包含进来了,直接用 `count` 就好了。 在判断 Map 中是否存在某个键值或者接口的转型操作里,通常用 `ok` 来接收判断结果:`v, ok := m[k]`。 上文中的示例代码按照以上原则重构后应该是这个样子: ```go func RuneCount(b []byte) int { count := 0 for i := 0; i < len(b); { if b[i] < RuneSelf { i++ } else { _, n := DecodeRune(b[i:]) i += n } count++ } return count } ``` #### 形参 形参的命名原则和局部变量一致。另外 golang 软件是以源代码形式发布的,形参连同函数签名通常会作为接口文档的一部分,所以形参的命名规范还有以下特点。 如果形参的类型已经能明确说明形参的含义了,形参的名字就可以尽量简短: ```go func AfterFunc(d Duration, f func()) *Timer func Escape(w io.Writer, s []byte) ``` 如果形参类型不能说明形参的含义,形参的命名则应该做到见名知义: ```go func Unix(sec, nsec int64) Time func HasPrefix(s, prefix []byte) bool ``` #### 返回值 跟形参一样,可导出函数的返回值也是接口文档的一部分,所以可导出函数的必须使用命名返回值: ```go func Copy(dst Writer, src Reader) (written int64, err error) func ScanBytes(data []byte, atEOF bool) (advance int, token []byte, err error) ``` #### 接收器(Receivers) 习惯上接收器的命名命名一般是 1 到 2 个字母的接收器类型的缩写: ```go func (b *Buffer) Read(p []byte) (n int, err error) func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) func (r Rectangle) Size() Point ``` 同个类型的不同方法中接收器命名要保持一致,不要在一个方法中叫 `r` ,在另一个方法中又变成了 `rdr` 。 #### 包级导出名 包导出的变量、常量、函数、类型使用时有包名的修饰。这些导出名字里就不再需要包含包名的信息了,所以标准库中 `bytes` 包里的 `Buffer` 不需要叫 `BytesBuffer` 。 #### 接口 只有 1 个方法的接口名通常用方法名加上 `er` 后缀,不引起迷惑的前提下方法名可以使用缩写: ```go type Reader interface { Read(p []byte) (n int, err error) } type Execer interface { Exec(query string, args []Value) (Result, error) } ``` 方法名本身是复合词的情况下,可以酌情调整以符合英文文法: ```go type ByteReader interface { ReadByte() (c byte, err error) } ``` 如果接口有多个方法,则需要选择一个最能精确概括描述接口目的的名词命名(有点难度),但是禁止用多个方法中的某个方法加上 `er` 后缀来命名,否则别人会误解此接口只有一个方法。可以参考标准库这几个接口所包含的方法及接口的命名:`net.Conn`, `http.ResponseWriter`, `io.ReadWriter` 。 `Read`, `Write`, `Close`, `Flush`, `String` 这几个方法在标准库里已经有约定俗成的含义和签名。自定义的接口方法应该要避免使用这几个名字,除非方法的行为确实和标准库这几个接口方法一致,这时候可以使用这些名字,但必须要确保方法的签名和标准库一致。序列化成字符串的方法命名成 `String` 而不是 `ToString` 。 #### 错误 自定义错误类型以 `Error` 作为后缀,采用 `XyzError` 的格式命名: ```go type ExitError struct { ... } ``` 错误值以 `Err` 作为前缀,采用 `ErrXyz` 的格式命名: ```go var ErrFormat = errors.New("image: unknown format") ``` 错误描述全部小写,未尾不需要加结束句点。 #### Getter/Setter struct 的首字母大写的字段是导出字段,可以直接读写不需要 Getter/Setter ,首字母小写的字段是私有字段,必要的情况下可以增加读写私有字段的 Getter/Setter 方法。私有字段首字母变大写即为 Getter 方法名字,不需要加 `Get` 前缀。私有字段首字母变大写加上 `Set` 前缀即为 Setter 方法名字。例如 struct 中名为 `obj` 的私有字段,其 Getter/Setter 方法命名分别为 `Obj`/`SetObj` 。 #### 包 包名使用纯小写、能精确描述包功能且精炼的名词(有点难度),不带下划线,不引起迷惑的前提下可以用缩写,比如标准库的 `strconv` 。如果包名比较复杂出现了多个单词,就应该考虑是不是要分层了,参考标准库的 `crypto/md5`, `net/http/cgi` 等包。包名应该要和包所在目录名一致,比如标准库的 `src/encoding/base64` 目录下,源文件的包名为 `base64` 。避免以下命名: * 和标准库同名 * `util`, `common` 等太过笼统的名字 #### 包路径 包路径的最底层路径名和包名一致: ```go "compress/gzip" // gzip 路径下源文件的的包名也为 gzip ``` 包路径有良好的层级关系但要避免重复罗嗦: ```go "code.google.com/p/goauth2/oauth2" // bad, goath2 和 oauth2 重复罗嗦 ``` 不是所有平台的文件系统都是大小敏感的,包路径名不要有大写字母: ```go "github.com/Masterminds/glide" // bad ``` 在导入包路径时,按照标准库包、第三方库包、项目内部包的顺序导入,各部分用空行隔开: ```go import ( "encoding/json" "strconv" "time" "github.com/golang/protobuf/proto" "github.com/gomodule/redigo/redis" "dc_agent/attr" "dc_agent/dc" ) ``` 禁止使用相对路径导入包: ```go import ( "./attr" // bad ) ``` ### 项目代码布局 开发 golang 库时如何组织项目代码可以参考 golang 的标准库。开发应用程序和开发库在工程实践上还是有点不同。有一些开源项目把所有的代码都放在一个包里 (main) ,项目比较小时还能接受,项目比较大时就难以阅读了。golang 的项目代码布局目前业界也没有一个统一的标准。[这篇文章](https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1)讨论了几种布局方案缺陷,然后提出了一些建议。[这篇文章](http://peter.bourgon.org/go-best-practices-2016/#dependency-management)在此基础上给出了一个可操作的方案,这也是本文推荐的方案。以下以 `xauth` 项目为例说明。 ``` git.yingzhongtong.com/combase/xauth # 项目根目录 ├── cmd # cmd 目录存放可执行文件(binary)代码 │   ├── client # binary: client 不同的可执行程序各自建立目录存放 │   │   └── main.go │   └── xauth # binary: xauth | ├── main.go │   ├── config # 编译当前可执行程序需要的内部库组织成不同包各自建立目录存放 │   │   └── config.go │   ├── handler │   │   └── handler.go │   ├── httpproxy │   │   └── httpproxy.go │   └── zrpcproxy │   └── zrpcproxy.go ├── pkg # pkg 目录存放库代码 │   ├── model # package: model 不同库组织成不同包,各自建一个目录存放 │   │   └── contract.go │   ├── ratelimiter # package: ratelimiter │   │   ├── inmemory.go │   │   ├── inmemory_test.go │   │   ├── ratelimiter.go │   │   ├── redis.go │   │   └── redis_test.go │   └── version # package: version │   └── version.go ├── glide.lock # 项目依赖库文件 ├── glide.yaml ├── Makefile ├── README.md # 项目说明文档 ├── Dockerfile # 用来创建 docker 镜像 └── xauth.yaml # 项目配置 ``` 这种布局特别适合既有可执行程序又有库的复杂项目。主要规范是在项目根目录下建立 `cmd` 和 `pkg` 目录。`cmd` 目录下存放编译可执行文件的代码。通常一个复杂项目可能会有多个可执行程序,每个可执行程序的代码在 `cmd` 目录各建立目录存放。比如 `git.yingzhongtong.com/combase/xauth/cmd/xauth` 下是编译可执行文件 `xauth` 的源码。编译 `xauth` 需要使用的内部库直接在 `git.yingzhongtong.com/combase/xauth/cmd/xauth` 建立目录存放。多个可执行程序都需要用到的公共库应该放到项目根目录下的 `pkg` 目录里。根目录的 `pkg` 目录下每个目录都是一个单独的公共库。 建议项目根目录下放一个 `Makefile` 文件,方便一键编译出所有可执行程序。 总之,这种布局的主要思想是按功能模块划分库,区分私有库和公共库,分别放在不同层级别的目录里。使用这种布局编写代码时,通常可执行程序对应的 main 包一般只有一个 `main.go` 文件,而且这个文件通常代码很少,基本就是把需要用到的库拼到一起。 github 的[这个项目](https://github.com/thockin/go-build-template)提供了这种布局的模板,可以 clone 下来直接使用(有些文件需要适当调整下)。 github 上很多优秀的开源项目也是采用的这种布局,熟悉这种布局也能帮助你更好的阅读这些开源项目。 以上介绍的项目代码布局是开发大型项目时强烈建议的方案。如果是小型项目代码量很少,直接放在一个目录里也是可以接受的。 ### 依赖管理 golang 早期版本中,依赖管理比较简单,依赖的第三方库通过 `go get` 下载到 `GOPATH` 中,编译时会根据 import 的路径去 `GOPATH` 和 `GOROOT` 中查找依赖的库。这种方式虽然简单,但是也有很多缺陷: * 对依赖的第三方库没有版本管理,每次 go get 时都是下载最新的版本,最新的版本可能存在 bug; * 基于域名的第三方库路径可能失效; * 多个项目依赖共同的第三方库时,一个项目更新依赖库会影响其他项目。 golang 从 1.6 版本开始引入了 `vendor` 用来管理第三方库。`vendor` 是项目根目录下的一个特殊目录,`go doc` 会忽略这个目录。编译时会优先从 `vendor` 目录中查找依赖的第三方库,找不到时再去 `GOPATH` 和 `GOROOT` 中查找。 `vendor` 机制解决上述的第 2 个和第 3 个缺陷,因此强烈建议工程实践中将项目的第三方库(所有本项目之外的库,包括开源库及公司级的公共库)全部放到 `vendor` 中管理。使用这种方式, `GOPATH` 存在的意义基本很小了,这也是上文中提到 `GOPATH` 只需要设置 1 个目录或者干脆使用默认值的原因。 `vendor` 机制支持嵌套使用,即 `vendor` 中的第三方库中也可以有 `vendor` 目录,但这样做会导致更复杂的依赖链甚至循环依赖,而且目前也没有完美的解决方案。因此只有在开发可执行程序项目时才需要使用 `vendor` 。开发库时禁止使用 `vendor` 。 `vendor` 机制并没有解决上述的依赖库版本管理问题,并且目前官方也没有提供配套的工具。可以使用开源的第三方工具解决这个问题,推荐 [glide](https://github.com/Masterminds/glide) 或 [godep](https://github.com/tools/godep) 。使用教程参考官方文档,这里就不赘述了。 使用 `vendor` 时要注意,项目中的 `vendor` 目录不要提交到代码仓库中,但是第三方工具生成的依赖库列表文件必须提交,比如 glide 生成的 `glide.lock` 和 `glide.yaml` 。 ### 可执行程序版本管理 有时候生产环境跑的可执行程序可能有问题需要找到对应的源码进行定位。如果发布系统也没有把源码信息和可执行程序关联的话,可能根本找不到可执行程序是哪个版本的源码编译出来的。因此建议在可执行程序中嵌入版本和编译信息,程序启动时可以直接作为启动信息打印。 版本号建议采用通用的 3 级点分字符串形式: `<大版本号>.<小版本号>.<补丁号>`,比如 `0.0.1` 。简单的 2 级也可以。使用 git 的话可以把 git commit SHA (通过 `git rev-parse --short HEAD` 获取)作为 build id 。 ```go package main var ( version string commit string ) func main() { println("demo server version:", version, "commit:", commit) // ... } ``` 以上示例代码中,`version` 和 `commit` 变量可以在源码中硬编码设置。更优雅的方式是在编译脚本(Makefile)里通过环境变量设置: ``` VERSION = "0.0.1" COMMIT = $(shell git rev-parse --short HEAD) all : go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" ``` ### 性能剖析(profiling) 程序的性能通常和使用的范式、算法、语言特性有关。在性能敏感的场景下,需要使用性能剖析工具分析进程的瓶颈所在,进而针对性的优化。golang 自带了性能剖析工具 pprof ,可以方便的剖析 golang 程序的时间/空间运行性能,以下是从某项目中部分代码改编后的示例代码,用来说明 pprof 的使用。直观上似乎函数 `bar` 里有更多的计算,调用函数 `bar` 应该比调用函数 `foo` 占用更多的 CPU 时间,实际情况却并非如此。 ```go // test.go package main import ( "net/http" _ "net/http/pprof" ) func foo() []byte { var buf [1000]byte return buf[:10] } var c int func bar(b []byte) { c++ for i := 0; i < len(b); i++ { b[i] = byte(c*i*i*i + 4*c*i*i + 8*c*i + 12*c) } } func main() { go http.ListenAndServe(":8200", nil) for { b := foo() bar(b) } } ``` 后台程序一般是 HTTP 常驻服务(如果不是 HTTP 服务的话也可以直接在代码里启动一个),import 列表里加上 `_ "net/http/pprof"` 后,程序启动后 golang 运行时就会定时对进程运行状态采样,采样到的数据可能通过 HTTP 接口获取。还有一种方式是使用 `"runtime/pprof"` 包,在需要剖析的程序代码里插入启动采样代码将,采样数据写到本地文件用来分析,具体使用方式参考[这里](https://golang.org/pkg/runtime/pprof/)。原理和第一种方式一样,只是采样数据读取方式不一样。 启用运行时采样后,以下命令通过 HTTP 接口获取一段时间内(5 秒)的采样数据进行分析,然后进入命令行交互模式: ```bash # go tool pprof http://localhost:8200/debug/pprof/profile?seconds=5 (pprof) top Showing nodes accounting for 4990ms, 100% of 4990ms total flat flat% sum% cum cum% 3290ms 65.93% 65.93% 3290ms 65.93% runtime.duffzero 1540ms 30.86% 96.79% 1540ms 30.86% main.bar 110ms 2.20% 99.00% 3400ms 68.14% main.foo (inline) 50ms 1.00% 100% 4990ms 100% main.main 0 0% 100% 4990ms 100% runtime.main ``` 使用 top 命令会打印前 10 个最耗时的调用(top20 打印前20个,依此类推),从输出的信息可以看出大部分 CPU 耗时在 `runtime.duffzero` 调用上。这种命令行方式的输出不是很直观,看不出这个调用的来源是哪里。pprof 也支持可视化输出,不过需要安装 [graphivz](https://www.graphviz.org/) 绘图工具,centos 下可以通过以下命令安装: ``` # sudo yum install graphviz ``` 通过 HTTP 接口采样 5 秒钟的 CPU 性能数据生成 PNG 格式(通过 `-png` 选项开启)的性能剖析图并保存到文件 `cpupprof.png` 里: ``` # go tool pprof -png http://localhost:8200/debug/pprof/profile?seconds=5 > cpupprof.png ``` 生成的性能剖析图如下: ![](https://static.studygolang.com/181105/50f8ceebf10de50fe1bdd56db0c9ef82.png) 从上图可以看出调用函数 `foo` 占用的 CPU 时间要远大于调用函数 `bar` 的(耗时占比越大,表示调用的箭头线段也越粗),并且在函数 `foo` 的耗时主要又耗在调用 `runtime` 的函数 `duffzero` 上。虽然这是 golang 的内置函数,但看名字基本上已经能猜到性能瓶颈出在哪里了,这样就可以进行有针对性的优化。这里不解释为什么调用函数 `foo` 占用的 CPU 时间会远大于调用函数 `bar`的,留给读者思考。 以上这个示例也说明了优化 CPU 性能关键是要找到影响整个系统的瓶颈,对于一个只占系统总耗时 1% 的函数,就算优化 10 倍意义也没什么意义。 大多数情况下 golang 后台应用性能剖析只需要优化 CPU 占用耗时就可以了。 golang 是自带垃圾回收(GC)的语言,由于 GC 的复杂性,和程序员自己管理内存的 C 语言相比,这类语言一般占用内存都比较大。自带 GC 语言很少会有内存泄露问题,不过也有一种特殊场景的内存泄漏:比如往一个全局的切片里不断 append 数据又不自行清理,这种一般是程序有逻辑错误引起的。pprof 也可以在运行时对对象占用内存进行分析: ``` # go tool pprof -png http://localhost:8200/debug/pprof/heap > memused.png ``` 以上命令输出的是对象占用空间的视图,默认只有 512KB 以上的内存分配才会写到内存分析文件里,因此建议在程序开始时加上以下代码让每个内存分配都写到到内存分析文件: ```go func main() { runtime.MemProfileRate = 1 // 修改默认值 512KB 为 1B // ... } ``` 使用 `-inuse_objects` 选项可以把采样对象设成对象数目。内存采样数据是对象占用内存状况的实时快照,不需要像采样 CPU 性能数据那样要让进程跑一段时间。 [这篇文章](https://go101.org/article/memory-leaking.html)介绍了更多 golang 内存泄露的场景,有兴趣可以阅读下。 ### 测试 golang 语言自带了测试工具和相关库,可以很方便的对 golang 程序进行测试。 推荐表驱动测试的方式进行单元测试,golang 标准库中也有很多例子。以下是一个表驱动测试的示例: ```go func TestAdd(t *testing.T) { cases := []struct{ A, B, Expected int }{ // 测试用例表 {1, 1, 2}, {1, -1, 0}, {1, 0, 1}, {0, 0, 0}, } for _, tc := range cases { actual := tc.A + tc.B if actual != expected { t.Errorf( "%d + %d = %d, expected %d", tc.A, tc.B, actual, tc.Expected) } } } ``` 使用表驱动测试可以很方便的增加测试用例测试各种边界条件。[这个工具](https://github.com/cweill/gotests)可以很方便的生成表驱动测试的桩代码。 单元测试一般只需要对包中的导出函数进行测试,非导出函数作为内部实现,除非有比较复杂逻辑,一般不用测试。 [这个视频](https://www.youtube.com/watch?v=yszygk1cpEc)([PPT](https://speakerdeck.com/mitchellh/advanced-testing-with-go))更详细介绍了 golang 测试的最佳实践,值得一看。 ### 总结 本文不是 golang 语法和工具使用的教程,这些内容在网上可以方便找到。本文假设读者已经对 golang 语法有了基本的了解,给了一些使用 golang 进行实际项目开发时的一些建议和方法指导。文中的主题主要是基于作者的实践经验和一些技术博客的总结,不免带有一些个人偏见。另外 golang 也是一门不断演进中的语言(从官方版本发布频率也可以看出来),文中的内容也非一成不变,保持与时俱进应该是 golang 开发者应有的心态。 ### 参考资料 * [https://studygolang.com/articles/1785](https://studygolang.com/articles/1785) * [https://golang.org/doc/effective_go.html](https://golang.org/doc/effective_go.html) * [https://talks.golang.org/2014/names.slide](https://talks.golang.org/2014/names.slide) * [http://peter.bourgon.org/go-best-practices-2016/](http://peter.bourgon.org/go-best-practices-2016/) * [https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1](https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1) * [https://golang.org/pkg/runtime/pprof/](https://golang.org/pkg/runtime/pprof/) * [https://blog.golang.org/profiling-go-programs](https://blog.golang.org/profiling-go-programs)
414 次点击  
加入收藏 微博
2 回复  |  直到 2018-11-05 10:06:53
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传