你好,我是轩脉刃。

从标准库开始搭建,到框架核心的替换,到相关功能的完善,我们已经完整地将Golang的Web框架hade打造出来了。我们一起回顾一下,从最初的net/http开始,我们不断构建自己的框架,根据“一切皆服务”的思想,打造了12个服务以及15个命令行工具。这些服务和工具都是围绕实际的业务开发需求而设计的。

今天我们就来用自己搭好的框架开发一个具体的应用,不过开发什么比较好呢。之前准备的是写一个类论坛的网站,但是课程更新到这里,我突然有了一个更好的新想法。

在框架的使用过程中,你一定会有各种各样的疑问,使用上的或者代码理解上的,而这些疑问一定会很希望有一个类似知乎那样的地方可以进行答疑,所以这次,我们就为hade框架打造一个类似知乎这样的问答网站。

这个问答网站的源代码,我们另外开启一个开源项目 https://github.com/gohade/bbs 来存放,并且从零开始一步步演示如何使用hade框架。后续如果你也想试试自己对hade框架的掌握程度,也欢迎一起参与来共建这个项目。

由于一个网站是由前端和后端协同完成的,前端我们使用的是Vue框架,后端使用的是自己的hade框架。之前也提过,前端Vue的内容是一门很大的课程,我们会介绍一下重点的部分,以不影响对整体网站开发逻辑的理解为主。不过所有的代码都可以在刚才的BBS开源项目中找到,方便你比对和学习。

下面开始我们的实战之旅吧。

需求设计

相信你肯定逛过各种各样的论坛类网站,这样的网站可以设计得很复杂,可以有用户模块、问答模块、会员模块、积分模块、排行榜模块、广场模块等等。但是对于一个以提出问题和回答问题为主的网站,它最核心的其实就只有两个模块:用户模块和问答模块。

用户模块

一个网站最基本的是用户模块,毕竟一个使用者在网站中的各种行为,比如提出问题、回答问题等都需要有一个用户身份。作为一个最基本的模块,用户模块负责的功能有注册和登录两个部分:

用户注册功能负责为网站创建一个新的用户。但是并不是随意用一个用户名密码就可以创建用户,为了安全起见,注册的时候,用户还需要输入注册的邮箱。

所以,整个用户注册流程分为两个过程:

  1. 预注册过程。用户在浏览器输入用户名、密码、邮箱,向网站发送注册请求;而网站收到注册请求之后,往用户的邮箱中发送一个邮件,这个邮件中包含一个验证链接。
  2. 注册验证过程。用户进入邮箱,打开邮件中的验证链接;这个验证链接会往网站中发送一个验证请求;网站在后台验证这个请求的合理性,然后在浏览器中引导用户进入下一步登录的过程。

用户注册时序图如下:

在用户完成注册之后,每次使用网站需要进行一次用户登录的过程。

登录的过程大致是,用户在登录页面输入用户名密码,向后端发送登录请求。后端接收到带着一个token的登录请求。

这个token在前端会种到cookie中,后续所有发送给后端的请求都会带上这个token信息,而后端的所有请求都会验证一下这个cookie中的token,只有验证token确实是登录颁发的,才会通过请求,执行所有后续的请求;如果验证失败,则要求用户重新登录。

整个登录时序图如下:

完成了用户注册和用户登录,基本上用户模块的功能也就完成了。我们看问答模块怎么设计。

问答模块

问答模块负责用户提问和回答,它包含的页面比较多:

我们大致描述一下用户在问答模块的具体表现,更多细节在具体实现的时候再讨论。

用户A通过用户模块的登录页面登录进入网站,先进入的是问题列表页,在问题列表页他可以做两件事情。一是阅读问题列表,对于有兴趣的问题直接点击进入问题详情页,进行回答。另外,他也可以点击右上角的“我要提问”来进入问题编写页,提交问题,并且点击提交,他提出的问题在问题列表页就可以展示出来了。

问题列表页最终展示图如下:
图片

前端准备知识

现在我们搞清楚了要完成的两大模块大致有哪些需求。不过不管是什么模块,都离开不了前端页面的编写,如何编写,我们使用的是Vue框架,所以一些后续会用到的基础知识再统一梳理一遍,主要讲 vue、webpack、element-UI、vue-router、vuex、axios 这六个包。

vue

Vue框架在实现前后端一体化的时候简单介绍过,是尤雨溪的大作,在GitHub上有189k之多的star。

Vue框架目前是国内用得最多的Web前端框架了,没有之一。据我所见,不论是大厂还是小厂,Vue已经成为了必备的前端开发框架。这个使用率一部分归功于尤大在国内的不断推广,更大一部分要归功于Vue自身的优雅设计和便捷使用。

Vue的使用方式非常简单,首先在npm的包管理工具package.json中引入它:

"vue": "^2.5.16",

然后我们在首页模板index.html中标记好一个ID为app的元素:

<body style="margin: 0px;">
  <div id="app"></div>
</body>

接着在页面对应的Vue组件js main.js 中引入Vue的包,并且创建一个Vue对象,把它绑定到ID为app的元素中:

import Vue from 'vue' // 引入vue项目
import App from './App.vue' //引入App组件
...

// 创建vue对象
new Vue({
  el: '#app',  // 绑定id为app的元素
  ...
  render: h => h(App) // 将App组建渲染在这个元素中
})

在Vue中有个很重要的概念,组件,比如上面代码的App.vue就是App组件。什么是组件呢?就是一个包含HTML、JS、CSS的独立展示单元,包含三个部分:

<template>
...
</template>

<script>
...
</script>

<style>
...
</style>

template 中编写输出的HTML信息,当然其中可能有一些诸如用户名这类的数据信息;这类数据信息是通过 script 中的脚本来获取的,所以Vue组件中的script 编写的是数据,以及获取数据的方法。最后style 里面编写的就是HTML展示的样式信息。

webpack

前面我们提到了首页模版index.html、页面对应的Vue组件js main.js、Vue组件App.vue,这些都是开发过程中的文件,最终生成的文件其实只有三种HTML、JS、CSS。也就是说,前面定义的各种开发文件,有的可能是我们最终的文件,有的可能并不是,所以这里要有一个将开发文件编译成为最终的HTML、JS、CSS文件的过程,这个过程就是webpack

webpack本质也是一个npm包,你同样可以在package.json 中引入:

"webpack": "^2.4.1",

webpack配置项都存在根目录下的webpack.config.js中,你可以在这个配置文件中配置所有需要打包的配置信息。比如入口js:

module.exports = (options = {}) => ({
  entry: {
    index: './src/main.js'
  },
  ...

入口的HTML模版:

plugins: [
  ...
  new HtmlWebpackPlugin({
    template: 'src/index.html'
  })
],

又比如编译后输出的目录和文件名:

module.exports = (options = {}) => ({
  ...
  output: {
    path: resolve(__dirname, 'dist'), // 输出目录
    filename: options.dev ? '[name].js' : '[name].js?[chunkhash]', // 输出文件名
    ...
  },

又或者如何阅读vue文件:

module: {
  rules: [{
      test: /\.vue$/,
      use: ['vue-loader']
    },

当然webpack还有许许多多的配置项等你挖掘,所有的配置信息和示例都可以在官网上看到。

element-UI

有了Vue,也有了打包Vue成为HTML、JS、CSS的打包工具webpack之后,其实我们已经可以编写Vue来生成页面了。但是对于后端甚至前端工程师来说,写出有功能的界面简单,但是如何写出“漂亮”的页面,又成了摆在面前的一道难题了。

有没有一个UI组件库,我们一旦引入并且使用这个组件库之后,就能很快捷方便地完成一个“漂亮”的页面呢?

element-UI就是这么一套组件库,它是由饿了么公司开发并且免费开源出来的项目,目前也是国内最火的一套UI组件库,提供了非常多的UI组件,我们可以很方便地使用这些组件库构建自己想要的样式。

比如要使用element-UI的一个卡片组件,来生成类似这样的卡片样式:

图片

首先同样要引入element-UI:

"element-ui": "^2.15.6",

在入口js、main.js中使用Vue.use将ElementUI引入到Vue中:

import Vue from 'vue' // 引入vue项目
import ElementUI from 'element-ui'  // 引入element-ui组件
import 'element-ui/lib/theme-chalk/index.css' // 引入element-ui的css样式
...

Vue.use(ElementUI) // 可以在vue的任何组件中使用elemnt-ui

...

接着在需要使用卡片样式的页面Vue文件的template中直接使用el-card 标签:

<template>
  ...
  <el-card v-for="i in count" class="box-card" shadow="hover">
    <div slot="header" class="clearfix">
      <span>问题标题{{i}}</span>
    </div>
    <div class="text item">
      这个是问题的具体内容,显示前200个字...
    </div>
    <div class="bottom clearfix">
        <time class="time">2021-10-10 10:10:10 | jianfengye  | 10 回答</time>
        <el-button type="text" class="button">去看看</el-button>
    </div>
  </el-card>
  ...
</template>

这样编译出来的HTML文件就会有对应的样式了。

vue-router

解决了打包和样式问题,在Vue开发中我们遇到各种各样的需求,第一个遇到的就是根据URL展示不同的页面。比如问题列表页和问题详情页,它们的头部都是一样的,但是中间部分不一样,这个应该怎么处理呢?

我们当然可以每个页面写一个同样的头部,但是更好的办法是头部固定,让中间部分不固定,根据路由来选择不同Vue组件进行加载。这种“根据路由加载不同Vue组件”的技术就叫做vue-router。

vue-router首先也是要在pacakge.json引入:

"vue-router": "^3.5.3",

然后和element-UI一样,通过Vue引入这个router组件:

import Vue from 'vue'
import Router from 'vue-router'
...

Vue.use(Router)

这样,在需要根据路由加载不同组件的地方,就可以使用 router-view 标签来占位一个组件位置:

<template>
  <el-container>
    ...
    <el-main>
        <router-view></router-view>
    </el-main>
  </el-container>
</template>

而占位的组件具体使用哪个,则是通过创建一个Router对象,并且把Router对象设置进入Vue实例的router字段中实现的:

import ViewLogin from '../views/login/index'

export const constantRoutes = [
    {
        path: '/login',
        component: ViewLogin,
        hidden: true
    },
    ...
]

const createRouter = () => new Router({
    routes: constantRoutes
})

const router = createRouter()

new Vue({
  el: '#app',  // 绑定id为app的元素
  router: router, // router字段
  ...
})

Vue的组件渲染的时候,遇到占位的 router-view 标签,就会去 constantRoutes 中根据URL进行寻找。

vuex

Vue的组件和组件之间经常需要传递各种信息,比如用户信息。那么vuex 就提供了统一管理这些信息的模块,在这个模块中,我们可以把要存储的内容都放在里面,当页面跳转的时候,只需要操作这个公共模块的内容即可。

它的使用方式首先也是一样,需要先在package.json 中引入vuex:

"vuex": "^3.6.2"

其次创建一个vue.Store来创建一个store实例保存相应内容,这里的modules是一个key为string、value 为一个vuex对象的映射:

const store = new Vuex.Store({
  modules,
  getters
})

在入口的main.js中,我们将vuex创建的store实例,作为Vue的store字段,传递进入:

// 创建vue对象
new Vue({
  el: '#app',  // 绑定id为app的元素
  ...
  store: store, // store设置
  ...
})

之后在使用的时候,对应的所有存储状态就可以使用this.$store访问到:

const Counter = {
  template: `<div>{{ count }}</div>`,
  computed: {
    count () {
      return this.$store.state.count // 访问存储状态
    }
  }
}

axios

前端一定是需要和后端进行交互的,而axios库就是负责前端给后端发送请求的。axios的使用非常简单。首先同样需要在package.json中引入:

"axios": "^0.24.0",

其实这一步axios的引入就已经完成了,它的使用并不依赖于Vue实例的任何字段。所以它其实是完全可以脱离Vue使用的。

之后在具体使用它组件的地方,直接import调用它的接口了。

import axios from 'axios'

// Send a POST request
axios({
  method: 'post',
  url: '/user/12345',
  data: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  }
});

但是在使用它的时候,我们一般会习惯做一个封装。

一方面,axios的名字还是比较绕口,我们将它的名字封装成为request,会更适合阅读。另一个更重要的,我们不希望直接调用axios,在这个封装中,对所有的请求参数和返回值都可以做一个注入操作。比如统一设置请求超时时间,又比如在返回值中,一旦返回状态不是200,也就是返回了异常,我们就在页面上打印出一个错误信息。

所以在src/utils/request.js中进行一下封装:

import axios from 'axios'
import { Message } from 'element-ui'



// 创建一个axios
const service = axios.create({
    withCredentials: true, // 请求携带cookie
    timeout: 10000 // 统一设置超时
})

// response中统一做处理
service.interceptors.response.use(
    response => {
        // 判断http status是否为200
        if (response.status !== 200) {
            Message({
                message: "请求错误",
                type: 'error',
                duration: 5 * 1000
            })
        }
    },
    error => {
        ...
    }
)



export default service

而在具体使用的时候,直接import这个request.js就可以了:

import request from '../../utils/request'

submitForm: function(e) {
  ...
  request({
    url: '/user/register',
    method: 'post',
    params: this.form
  }).then(function (response) {
    ...
  })
}

好,vue、webpack、element-ui、vue-router、vuex、axios 这些组件基本上是我们这次开发页面会用到的了。不过前端的组件非常多且杂,后续开发过程中如果还有遇到,我们再详细说明。

框架搭建

首先我们要做的事情是使用hade框架搭建一个新项目。先使用命令:

go install github.com/gohade/hade@latest

安装go命令到我们的$GOPATH/bin 目录下。

然后使用 hade new 命令创建一个BBS的项目:

图片

前端框架

我们开始搭建前端,前端需要引入Vue、webpack、element-UI。不过其实我们并不需要一个个引入,element-UI有一个现成的集成了这三者的脚手架项目 element-starter,直接使用这个项目是最快的方式了。

怎么直接使用这个项目呢?我们做两个步骤:

  1. 将BBS项目中原先的前端文件直接删除
  2. 将element-starter 全部复制到BBS项目中

BBS项目中原先的前端文件包含 build 目录、docs 目录、src 目录、package.json、package-lock.json 等,下图红色箭头所示:

图片

删除之后,将 element-starter 项目直接复制过来,就完成了前端框架的迁移。

图片

我们再来规划一下前端源码 src 目录:

图片

index.html 是我们前面说的页面模版,而main.js 是前面说的前端js入口,这个入口加载的App.vue 就是我们的入口组件,vendor.js 存放一些需要import的第三方组件。

看这六个目录:

当然这套目录结构并不是固定的,只是在前端Vue的开源届,大家都认为这种目录算是一种最佳实践。很多项目都遵照这种目录结构划分,比如这次我们使用的element-UI。

具体的路由设计,有必要说明一下。前面说了路由vue-router是根据URL来占位展示不同的组件的。但是,我们这里是需要设计一个双层vue-router的。看一下这三个页面,登录页面、问题列表页、问题创建页:

图片

图片

图片

你可以看到,登录页面和下面两个页面整体布局是不一样的,而下面两个页面的头部header是一样的,body部分不一样。我们需要设计一套布局结构来同时支持这三种布局,所以就用到了二级的vue-router。

具体代码你可以参考GitHub上BBS的geekbang/30分支。这里大致说一下逻辑,我们的路由设计为两级路由和一级路由同时存在。在src/router/index.js中:

export const constantRoutes = [
    {
        path: '/login',
        component: ViewLogin,
        hidden: true
    },
    {
        path: '/',
        redirect: '/',
        component: ViewContainer,
        children: [
            {
                path: '',
                component: ViewList
            },
            {
                path: 'create',
                component: ViewCreate
            },
            ...
        ]
    },
]

在入口vue组件App.vue中,我们使用第一层vue-router:

<template>
  <div id="app">
    <router-view></router-view>
  </div>
</template>

而对于问题列表页和问题创建页,它们会先去加载Container组件;在Container组件src/views/layout/container.vue中我们使用第二层vue-router:

<template>
  <el-container>
    <el-header>
      <bbs-header></bbs-header>
    </el-header>
    <el-main>
        <router-view></router-view>
    </el-main>
  </el-container>
</template>

这样前端两层路由就能满足不同的页面布局需求了。

后端框架

前端框架搭建差不多了,我们开始后端的框架搭建。

目录结构已经在hade框架中定义好了,所有的逻辑代码都存放在 app 目录下。

图片

可以看到app/module/和/app/provider/下都有两个文件夹user和qa,估计你有点疑惑为什么要这么分层。

首先基于一切皆服务的逻辑,我们的服务层可以拆分为两个服务,用户服务和问答服务。按照这里需求的划分,分为两个服务的粒度是正好的,每个服务的基本服务接口都能控制在10个左右。当然在一些更大规模的项目中,是有可能分为更细粒度的,比如分为三个用户服务、问题服务、回答服务。这里的服务划分其实就是业务架构师的工作了,基本准则是让每个服务复杂度可控。

在这里,我们分为两个用户服务和问答服务,每个服务的复杂度基本上都在可控范围内。

下面就创建服务。回忆创建服务的三步骤,服务协议,服务提供者,服务实现。这里我们使用之前为hade框架提供过的快速创建服务工具: ./hade provider new 快速创建两个服务user 和 qa。

图片

图片

我们会将所有的user或者qa的模型都封装在这两个服务(service provider)中。

那么app/modules下的user 和 qa的模块负责什么呢?这两个模块目前就简化为负责定义接口、定义返回对象、定义服务对象和返回对象的转换,我们分别设计api.go、dto.go、mapper.go 三个文件负责做这个事情。

这种开发模型就是hade建议的标准模型。还记得第12章的课后问题让你思考如何设计业务模块的分层模型么?这个就是我的答案。

service provider层将领域对象封装成一个个的服务;再上面一层是mapper层,将服务结构转化为业务输出的结构;最后经过DTO的数据结构过滤和组织,通过API层输出。这种业务分层和结构设计的基本思想,是面向领域驱动的设计思想DDD,也是《企业应用架构模式》一书中推荐的。这种设计模型如图所示:

官方说明文档中也具体说明了。

这里还有一个小优化点:一个模块会定义多个接口,你可以把所有接口都放在一个api.go文件中,但是这样一个文件就特别冗长了。所以我建议将每个接口定义一个文件,以api_xxx.go 命名。

最终对于一个业务模块,比如user,我们会定义两个目录,一个是app/module/user,负责接口设计和返回对象设计,另外一个是app/provider/user,负责实质的业务服务抽象。

图片

到这里,我们的前后端框架搭建完成了。当然前端部分,可能还有很多细节点,不过今天我们已经把搭建框架的关键点和难点都详细说明了,具体的实现你可以参照GitHub上的BBS项目的geekbang/30分支比对查看。

这里也列一下本节课的框架搭建结构:

图片

小结

这一节课我们进入了实战部分,真正使用hade框架来开发一个类知乎的问答网站。从需求开始设计分析了两个核心的模块,用户模块和问答模块,并且搭建了前端和后端的框架。这节课的内容比较多且杂,特别是前端的准备知识,一个纯后端工程师需要花费一些时间理解,如果你希望更多了解这些前端知识,网上相关资料也很多。

不过回看今天的后端开发,我们使用了 ./hade new./hade provider new 两个命令,这些都是之前实现的hade命令行工具,你是不是切实感受到它们提升了不少开发效率?

思考题

分析需求的时候,我们画了用户注册和登录的时序图来理解注册/登录逻辑,是不是一下子就简明扼要地说清楚了。其实在实际工作中,使用时序图来解释复杂交互或者服务间调用的逻辑是我们非常必要的技能了。

这节课的思考题希望你去了解一下时序图的元素和绘制方法,然后对照着文中注册和登录的时序图找出以下几个对应元素,检验一下自己的学习效果:

欢迎在留言区分享你的学习笔记。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。下节课我们继续开发用户模块。