go+typescript+graphQL+react构建简书网站(一) 初始化Go后端
学习go已有一段时间,自觉可以做些什么。受社区的启发,便把构建一个属于自己的简书网站当作课业,并决定同时写下此篇,用于给自己厘清思路,发散思维,不至于全在脑子里混沌,一团浆糊,终不成事。
课业规划,均来自社区课程:
《用Go实现一个简书》 https://studygolang.com/topics/9652
建立Go项目
go mod init github.com/unrotten/hello-world-web
新建项目后,在项目目录中,使用go modules初始化项目。
建立目录如下:
cmd/hello-world-web:存放程序入口main.go文件
config:存放配置文件
controller:由于本项目将使用GraphQL API——https://github.com/graphql-go/graphql库实现,故而此目录将用于graphQL的定义
middlewire:中间件
model:结构体定义
resolve:关于graphQL的具体实现
setting:加载配置项
static:前端目录
util:工具包
初始化项目数据库
本项目使用Postgres数据库。
1、文章表
CREATE TYPE article_state as ENUM ('unaudited','online','offline','deleted')CREATE TABLE public.article (id int8 NOT NULL, -- 主键sn varchar(32) NOT NULL, -- 文章序号title varchar(255) NOT NULL, -- 文章标题uid int8 NOT NULL, -- 作者idcover varchar(255) NULL, -- 封面"content" text NOT NULL, -- 内容,markdown格式tags _varchar NULL, -- 文章标签state article_state NOT NULL DEFAULT 'unaudited’::article_state, -- 状态:'unaudited'-未审核,'online'-已上线,'offline'-已下线,'deleted'-已删除created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 创建时间updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 更新时间deleted_at timestamp NOT NULL, -- 删除时间CONSTRAINT article_pkey PRIMARY KEY (id),CONSTRAINT sn UNIQUE (sn));COMMENT ON COLUMN public.article.id IS '主键';COMMENT ON COLUMN public.article.sn IS '文章序号';COMMENT ON COLUMN public.article.title IS '文章标题';COMMENT ON COLUMN public.article.uid IS '作者id';COMMENT ON COLUMN public.article.cover IS '封面';COMMENT ON COLUMN public.article."content" IS '内容,markdown格式';COMMENT ON COLUMN public.article.tags IS '文章标签';COMMENT ON COLUMN public.article.state IS '状态:''unaudited''-未审核,''online''-已上线,''offline''-已下线,''deleted''-已删除';COMMENT ON COLUMN public.article.created_at IS '创建时间';COMMENT ON COLUMN public.article.updated_at IS '更新时间';COMMENT ON COLUMN public.article.deleted_at IS '删除时间';
2、文章扩展表
CREATE TABLE "public"."article_ex" ("aid" int8 NOT NULL,"view_num" int4 NOT NULL DEFAULT 0,"cmt_num" int4 NOT NULL DEFAULT 0,"zan_num" int4 NOT NULL DEFAULT 0,"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"deleted_at" timestamp(6) NOT NULL,PRIMARY KEY ("aid"));COMMENT ON COLUMN "public"."article_ex"."aid" IS '文章ID';COMMENT ON COLUMN "public"."article_ex"."view_num" IS '浏览数';COMMENT ON COLUMN "public"."article_ex"."cmt_num" IS '评论数';COMMENT ON COLUMN "public"."article_ex"."zan_num" IS '点赞数';COMMENT ON COLUMN "public"."article_ex"."created_at" IS '创建时间';COMMENT ON COLUMN "public"."article_ex"."updated_at" IS '更新时间';COMMENT ON COLUMN "public"."article_ex"."deleted_at" IS '删除时间';COMMENT ON TABLE "public"."article_ex" IS '文章扩展表';
3、用户表
CREATE TYPE user_state as ENUM (‘unsign’,’normal,’forbidden’,’freeze’)CREATE TYPE gender as ENUM (‘man’,’woman’,’unknown')CREATE TABLE "public"."user" ("id" int8 NOT NULL,"username" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,"email" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,"password" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,"avatar" varchar(127) COLLATE "pg_catalog"."default" NOT NULL,"gender" "public"."gender" NOT NULL DEFAULT 'unknown'::gender,"introduce" text COLLATE "pg_catalog"."default","state" "public"."user_state" NOT NULL DEFAULT 'unsign'::user_state,"root" bool NOT NULL DEFAULT false,"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"deleted_at" timestamp(6) NOT NULL,PRIMARY KEY ("id"));COMMENT ON COLUMN "public"."user"."id" IS 'ID';COMMENT ON COLUMN "public"."user"."username" IS '用户名';COMMENT ON COLUMN "public"."user"."email" IS '注册邮箱';COMMENT ON COLUMN "public"."user"."password" IS '密码';COMMENT ON COLUMN "public"."user"."avatar" IS '头像';COMMENT ON COLUMN "public"."user"."gender" IS '性别:''man''-男,''woman''-女,''unknown''-保密';COMMENT ON COLUMN "public"."user"."introduce" IS '个人简介';COMMENT ON COLUMN "public"."user"."state" IS '状态:''unsign''-未认证,''normal''-正常,''forbidden''-禁止发言,''freeze''-冻结';COMMENT ON COLUMN "public"."user"."root" IS '是否管理员';COMMENT ON COLUMN "public"."user"."created_at" IS '创建时间';COMMENT ON COLUMN "public"."user"."updated_at" IS '更新时间';COMMENT ON COLUMN "public"."user"."deleted_at" IS '删除时间';
4、用户计数表
CREATE TABLE "public"."user_count" ("uid" int8 NOT NULL,"fans_num" int4 NOT NULL DEFAULT 0,"follow_num" int4 NOT NULL DEFAULT 0,"article_num" int4 NOT NULL DEFAULT 0,"words" int4 NOT NULL DEFAULT 0,"zan_num" int4 NOT NULL DEFAULT 0,"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"deleted_at" timestamp(6) NOT NULL,PRIMARY KEY ("uid"));COMMENT ON COLUMN "public"."user_count"."uid" IS '用户ID';COMMENT ON COLUMN "public"."user_count"."fans_num" IS '粉丝数';COMMENT ON COLUMN "public"."user_count"."follow_num" IS '关注数(关注其他用户)';COMMENT ON COLUMN "public"."user_count"."article_num" IS '文章数';COMMENT ON COLUMN "public"."user_count"."words" IS '字数';COMMENT ON COLUMN "public"."user_count"."zan_num" IS '被赞数';COMMENT ON COLUMN "public"."user_count"."created_at" IS '创建时间';COMMENT ON COLUMN "public"."user_count"."updated_at" IS '更新时间';COMMENT ON COLUMN "public"."user_count"."deleted_at" IS '删除时间';
5、用户关注表
CREATE TABLE "public"."user_follow" ("id" int8 NOT NULL,"uid" int8 NOT NULL,"fuid" int8 NOT NULL,"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"deleted_at" timestamp(6) NOT NULL,PRIMARY KEY ("id"),CONSTRAINT "uq_uid_fuid" UNIQUE ("uid", "fuid"));COMMENT ON COLUMN "public"."user_follow"."id" IS 'ID';COMMENT ON COLUMN "public"."user_follow"."uid" IS '用户ID';COMMENT ON COLUMN "public"."user_follow"."fuid" IS '粉丝ID';COMMENT ON COLUMN "public"."user_follow"."created_at" IS '创建时间';COMMENT ON COLUMN "public"."user_follow"."updated_at" IS '更新时间';COMMENT ON COLUMN "public"."user_follow"."deleted_at" IS '删除时间';COMMENT ON TABLE "public"."user_follow" IS '用户关注表';
6、评论表
CREATE TABLE "public"."comment" ("id" int8 NOT NULL,"aid" int8 NOT NULL,"uid" int8 NOT NULL,"content" text NOT NULL,"zan_num" int4 NOT NULL DEFAULT 0,"floor" int4 NOT NULL DEFAULT 1,"state" "public"."article_state" NOT NULL DEFAULT 'unaudited',"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"deleted_at" timestamp(6) NOT NULL,PRIMARY KEY ("id"),CONSTRAINT "uq_aidfloor" UNIQUE ("aid", "floor"));COMMENT ON COLUMN "public"."comment"."id" IS 'id';COMMENT ON COLUMN "public"."comment"."aid" IS '文章ID';COMMENT ON COLUMN "public"."comment"."uid" IS '评论用户id';COMMENT ON COLUMN "public"."comment"."content" IS '评论内容';COMMENT ON COLUMN "public"."comment"."zan_num" IS '被赞数';COMMENT ON COLUMN "public"."comment"."floor" IS '第几楼';COMMENT ON COLUMN "public"."comment"."state" IS '状态:''unaudited''-未审核,''online''-已上线,''offline''-已下线,''deleted''-已删除';COMMENT ON COLUMN "public"."comment"."created_at" IS '创建时间';COMMENT ON COLUMN "public"."comment"."updated_at" IS '更新时间';COMMENT ON COLUMN "public"."comment"."deleted_at" IS '删除时间';COMMENT ON TABLE "public"."comment" IS '评论表';
7、评论回复表
CREATE TABLE "public"."comment_reply" ("id" int8 NOT NULL,"cid" int8 NOT NULL,"uid" int8 NOT NULL,"content" text NOT NULL,"state" "public"."article_state" NOT NULL DEFAULT 'unaudited'::article_state,"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"deleted_at" timestamp(6) NOT NULL,PRIMARY KEY ("id"));CREATE INDEX "idx_cid" ON "public"."comment_reply" ("cid");COMMENT ON COLUMN "public"."comment_reply"."id" IS 'id';COMMENT ON COLUMN "public"."comment_reply"."cid" IS '评论id';COMMENT ON COLUMN "public"."comment_reply"."uid" IS '回复人id';COMMENT ON COLUMN "public"."comment_reply"."content" IS '回复内容';COMMENT ON COLUMN "public"."comment_reply"."state" IS '状态:''unaudited''-未审核,''online''-已上线,''offline''-已下线,''deleted''-已删除';COMMENT ON COLUMN "public"."comment_reply"."created_at" IS '创建时间';COMMENT ON COLUMN "public"."comment_reply"."updated_at" IS '更新时间';COMMENT ON COLUMN "public"."comment_reply"."deleted_at" IS '删除时间';COMMENT ON TABLE "public"."comment_reply" IS '评论回复表';
8、赞表
create type zan_type as ENUM ('article','comment','reply')CREATE TABLE "public"."zan" ("id" int8 NOT NULL,"uid" int8 NOT NULL,"objtype" "public"."zan_type" NOT NULL DEFAULT 'article',"objid" int8 NOT NULL,"created_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"updated_at" timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,"deleted_at" timestamp(6) NOT NULL,PRIMARY KEY ("id"),CONSTRAINT "uq_u_obj" UNIQUE ("uid", "objtype", "objid"));COMMENT ON COLUMN "public"."zan"."id" IS 'id';COMMENT ON COLUMN "public"."zan"."uid" IS '点赞用户id';COMMENT ON COLUMN "public"."zan"."objtype" IS '被点赞对象:';COMMENT ON COLUMN "public"."zan"."objid" IS '被赞对象id';COMMENT ON COLUMN "public"."zan"."created_at" IS '创建时间';COMMENT ON COLUMN "public"."zan"."updated_at" IS '更新时间';COMMENT ON COLUMN "public"."zan"."deleted_at" IS '删除时间';COMMENT ON TABLE "public"."zan" IS '赞表';
编写Go项目配置
本项目使用https://github.com/spf13/viper库加载配置项。
在hello-world-web项目目录下,执行
go get github.com/spf13/viper
拉取依赖包完成后,在config目录下,新建配置文件config.toml,内容如下:
run_mode = "debug"[]jwt_secret = "20144481"[]port = "8008"[]user = "admin"password = "admin"host = "localhost"port = 5432dbname = "postgres"[]file_path= "/Users/yan/GolandProjects/log/hello-world-web/"level= 0[]host="smtp.gmail.com"port=465email="[email protected]"password="******"
在setting包下,新建setting.go文件:
package settingimport ("fmt""github.com/spf13/viper")var (RunMode stringHttpPort stringMailHost stringMailPort intMailAddr stringMailPwd stringJwtSecret string)func init() {viper.AddConfigPath("config")err := viper.ReadInConfig()if err != nil {panic(fmt.Errorf("读取配置文件失败: %s \n", err))}// 设置默认配置viper.SetDefault("run_mode", "0")viper.SetDefault("http.port", "8008")viper.SetDefault("logger.level", "debug")viper.SetDefault("storage.user", "admin")viper.SetDefault("storage.password", "admin")viper.SetDefault("storage.host", "localhost")viper.SetDefault("storage.port", 5432)viper.SetDefault("storage.dbname", "postgres")// 获取配置信息RunMode = viper.GetString("run_mode")HttpPort = viper.GetString("http.port")MailHost = viper.GetString("mail.host")MailPort = viper.GetInt("mail.port")MailAddr = viper.GetString("mail.email")MailPwd = viper.GetString("mail.password")JwtSecret = viper.GetString("app.jwt_secret")}
首先使用viper.AddConfigPath("config")添加配置文件路径,若需其他配置方法,可以参照如下配置方案:
viper.SetConfigName("config") // name of config file (without extension)viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the nameviper.AddConfigPath("/etc/appname/") // path to look for the config file inviper.AddConfigPath("$HOME/.appname") // call multiple times to add many search pathsviper.AddConfigPath(".") // optionally look for config in the working directoryerr := viper.ReadInConfig() // Find and read the config fileif err != nil { // Handle errors reading the config filepanic(fmt.Errorf("Fatal error config file: %s \n", err))}
读取配置文件后,使用viper.SetDefault()可对相应配置信息设置默认值。
当然viper还有更多用法,譬如动态加载配置信息:
viper.WatchConfig()viper.OnConfigChange(func(e fsnotify.Event) {fmt.Println("Config file changed:", e.Name)})
更多用法可以参考
官方文档 https://github.com/spf13/viper
在本项目中,日志库选择zerolog库,同样是社区课程推荐的日志库。这里只是粗略的将日志按配置好的格式记入指定的文件和标准输出中。同样使用者可以根据自身需求,将不同级别的日志分到不同的文件中,通过
logger := zerolog.New(os.Stderr).With().Timestamp().Logger()
获取指定的日志实例,分别记录不同级别的日志。工具包util目录下编写logger.go文件:
package utilimport ("fmt""github.com/gin-gonic/gin""github.com/rs/zerolog""github.com/spf13/viper""io""os""strings""sync""time")var logOutPut zerolog.ConsoleWritervar (pool sync.Pool)func init() {loggerFile := viper.GetString("logger.file_path")loggerlevel := viper.GetInt("logger.level")// 初始化日志配置zerolog.SetGlobalLevel(zerolog.Level(loggerlevel))if loggerFile == "" {logOutPut = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}} else {file, err := os.Create(loggerFile)if err != nil {panic(fmt.Errorf("打开日志文件[%s]失败 \n", loggerFile))}gin.DefaultWriter = io.MultiWriter(file, os.Stdout)logOutPut = zerolog.ConsoleWriter{Out: io.MultiWriter(file, os.Stdout), TimeFormat: time.RFC3339}}logOutPut.FormatLevel = func(i interface{}) string {return strings.ToUpper(fmt.Sprintf("| %-6s|", i))}logOutPut.FormatMessage = func(i interface{}) string {if i != nil {return fmt.Sprintf("***%s****", i)}return ""}logOutPut.FormatFieldName = func(i interface{}) string {return fmt.Sprintf("%s:", i)}logOutPut.FormatFieldValue = func(i interface{}) string {return strings.ToUpper(fmt.Sprintf("%s", i))}pool = sync.Pool{New: func() interface{} {return zerolog.New(logOutPut).With().Timestamp().Logger()}}}func NewLogger() zerolog.Logger {return pool.Get().(zerolog.Logger)}func PutLogger(logger zerolog.Logger) {pool.Put(logger)}
注意,这里使用了标准库的sync.Pool库,通过指定的Get方法得到日志实例,用完后再调用Put方法放回,已达到反复利用的效果,也可以避免在高并发时需要一次性大量调用new方法。
编写model模块
本项目使用https://github.com/jmoiron/sqlx和https://github.com/unrotten/sqlex库进行数据库操作。
先拉取这两个库
go get -u github.com/jmoiron/sqlxgo get -u github.com/unrotten/sqlex
在模块model下,编写db.go文件:
package modelimport ("fmt""github.com/jmoiron/sqlx"_ "github.com/lib/pq""github.com/sony/sonyflake""github.com/spf13/viper""github.com/unrotten/sqlex""log""time")var (DB *sqlx.DBPSql sqlex.StatementBuilderTypeIdFetcher *sonyflake.Sonyflake)// 初始化数据库连接func init() {// 获取数据库配置信息user := viper.Get("storage.user")password := viper.Get("storage.password")host := viper.Get("storage.host")port := viper.Get("storage.port")dbname := viper.Get("storage.dbname")// 连接数据库psqlInfo := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",host, port, user, password, dbname)DB = sqlx.MustOpen("postgres", psqlInfo)if err := DB.Ping(); err != nil {log.Fatalf("连接数据库失败:%s", err)}// 初始化sql构建器,指定format形式PSql = sqlex.StatementBuilder.PlaceholderFormat(sqlex.Dollar)// 初始化sonyflakest := sonyflake.Settings{StartTime: time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local),}IdFetcher = sonyflake.NewSonyflake(st)}
这里需要注意的是
PSql = sqlex.StatementBuilder.PlaceholderFormat(sqlex.Dollar)
指定了构建sql后所使用的占位符$,后续构建sql都将统一通过PSql进行构建。sqlex库默认情况下,使用?作为占位符。sqlex库fork自https://github.com/Masterminds/squirrel库,主要用于sql的构建。sqlex库在原库的基础上增加了IF用法,可以通过条件判断是否需要构建指定的sql块。譬如绝大多数下,我们使用的where语句,可能需要先判断条件是否满足再决定要不要将sql拼接上去,这里IF用法就简略了if判断手动拼接的用法。另外sqlex库也可以通过RunWith(DB)直接执行将要构建的sql语句。
初始化的IdFetcher使用了sonyflake库,通过雪花算法生成32位的id。
编写graphql.go文件:
此项目使用GraphQL API技术,通过https://github.com/graphql-go/graphql库实现。先拉取该库
go get -u github.com/graphql-go/graphql
在controller目录下编写graphql.go文件:
package controllerimport ("context""github.com/gin-gonic/gin""github.com/graphql-go/graphql""github.com/graphql-go/handler""net/http")var (schema graphql.SchemaqueryType *graphql.ObjectmutationType *graphql.ObjectsubscriptType *graphql.Object)type RequestOptions struct {Query string `json:"query" url:"query" schema:"query"`Variables map[string]interface{} `json:"variables" url:"variables" schema:"variables"`OperationName string `json:"operationName" url:"operationName" schema:"operationName"`}func Register(e *gin.Engine) {queryType = graphql.NewObject(graphql.ObjectConfig{Name: "Query", Fields: graphql.Fields{"test": {Name: "test",Type: graphql.String,Resolve: func(p graphql.ResolveParams) (interface{}, error) {return "test", nil},Description: "test",},}})mutationType = graphql.NewObject(graphql.ObjectConfig{Name: "Mutation", Fields: graphql.Fields{"test": {Name: "test",Type: graphql.String,Resolve: func(p graphql.ResolveParams) (interface{}, error) {return "test", nil},Description: "test",},}})subscriptType = graphql.NewObject(graphql.ObjectConfig{Name: "Subscription", Fields: graphql.Fields{"test": {Name: "test",Type: graphql.String,Resolve: func(p graphql.ResolveParams) (interface{}, error) {return "test", nil},Description: "test",},}})schemaConfig := graphql.SchemaConfig{Query: queryType,Mutation: mutationType,Subscription: subscriptType,}var err errorschema, err = graphql.NewSchema(schemaConfig)if err != nil {panic(err)}h := handler.New(&handler.Config{Schema: &schema,Pretty: true,GraphiQL: true,Playground: false,})router := func(ctx *gin.Context) {h.ContextHandler(context.Background(), ctx.Writer, ctx.Request)}// graphql的web界面,只有admin才能进入e.GET("/graphql", router)e.POST("/graphql", router)e.OPTIONS("/graphql", router)e.GET("/query", query)e.OPTIONS("/query", query)e.POST("/query", query)}func query(ctx *gin.Context) {requestOption := &RequestOptions{}_ = ctx.Bind(requestOption)ctx.Set("operationName", requestOption.OperationName)result := graphql.Do(graphql.Params{Schema: schema,RequestString: requestOption.Query,VariableValues: requestOption.Variables,OperationName: requestOption.OperationName,})ctx.JSON(http.StatusOK, result)}
定义RequestOptions结构体,用于绑定从前端传入的graphql格式的输入。
定义schema,由三个空的Object组成。其中queryType用于查询,对应restful中的get;mutationType用于插入更新,对应restful中的post;subscription是长链接,具体使用时将使用websocket技术实现。
这里定义了两个gin的HandlerFunc,分别是router和query。router主要用于开发阶段对graphql的界面调试,上线后需关闭,query则负责真正和前端进行交互。
编写main.go文件
在cmd/hello-world-web目录下,新建main.go文件:
package mainimport ("context""fmt""github.com/gin-gonic/gin""github.com/unrotten/hello-world-web/setting""github.com/unrotten/hello-world-web/util""net/http""os""os/signal""time")func main() {gin.SetMode(setting.RunMode)engine := gin.New()controller.Register(engine)addr := ":" + setting.HttpPortserver := &http.Server{Addr: addr,Handler: engine,MaxHeaderBytes: 1 << 20,}logger := util.NewLogger()go func() {logger.Info().Msg(fmt.Sprintf("server run on:%s", addr))if err := server.ListenAndServe(); err != nil {logger.Fatal().Caller().Err(err).Msg("server err")}}()quit := make(chan os.Signal)signal.Notify(quit, os.Interrupt)<-quitlogger.Info().Msg("Shutdown Server ...")ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)defer cancel()if err := server.Shutdown(ctx); err != nil {logger.Fatal().Caller().Err(err).Msg("Server Shutdown")}logger.Info().Msg("Server exiting")}
通过graphql.go文件中的Register方法,将路由注册到gin中。
启动后,进入graphql的调试界面,如图所示:
