用户注册实践 | 青训营

131 阅读7分钟

确定需求和目标

需求

新用户注册时提供用户名,密码即可,用户名需要保证唯一。创建成功后返回用户 id 和权限token

请求参数

参数类型备注
usernamestring注册用户名,最长32个字符
passwordstring密码,最长32个字符

返回响应

参数类型备注
status_codeinteger状态码,0-成功,其他值-失败
status_msgstring返回状态描述
user_idinteger用户id
tokenstring用户鉴权token

设计数据库表结构

创建 user

定义表的字段和属性idusernamepasswordtoken

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `username` varchar(255) NOT NULL,
  `password` varchar(255) NOT NULL,
  `token` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `name_password_index` (username,`password`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表\n';

编写接口代码

  1. 根据要求,先写好请求和响应两个结构体

    type RegisterRequest struct {
    	Username string `json:"username"`
    	Password string `json:"password"`
    }
    
    type RegisterResponse struct {
    	StatusCode int    `json:"status_code"`
    	StatusMsg  string `json:"status_msg"`
    	UserID     int64  `json:"user_id"`
    	Token      string `json:"token"`
    }
    
  2. 建立数据库连接

    1. 首先,需要安装 database/sqlmysql 驱动包。可以通过以下命令来安装:
    go get -u database/sql
    go get -u github.com/go-sql-driver/mysql
    
    1. 接下来在代码中创建数据库连接:
    dsn := "username:password@tcp(hostname:port)/databasename"
    db, err := sql.Open("mysql", dsn) 
    if err != nil {
    	http.Error(writer, err.Error(), http.StatusInternalServerError)
    	return
    }
    defer db.Close()
    
    // 检查是否连接成功
    err = db.Ping()
    if err != nil {
    	http.Error(writer, err.Error(), http.StatusInternalServerError)
    	return
    }
    

    sql.Open() 函数用于打开数据库连接,第一个参数是数据库类型 "mysql",第二个参数是连接数据源的字符串。注意要使用 defer db.Close() 来确保在代码执行完毕后关闭数据库连接。db.Ping()可以用来检验数据库是否连接成功, 避免因为数据库连接失败造成的问题

  3. 处理请求数据

    1. 读取请求数据, 核心是ioutil.ReadAll() 函数

      body, err := ioutil.ReadAll(request.Body)
      if err != nil {
      	http.Error(writer, err.Error(), http.StatusBadRequest)
      	return
      }
      

      首先ioutil.ReadAll() 函数接受一个实现了 io.Reader 接口的参数request.Body,并将其中的数据全部读取到字节切片中

      然后将读取到的结果赋值给了变量body, 以便进行后续处理,例如解析 JSON 或表单数据

      为了检查是否读取主体数据时发生了错误, 可以将错误信息作为 HTTP 响应的内容返回给客户端,并设置响应的状态码为http.StatusBadRequest(400), 并用return结束函数

    2. 解析请求数据, 核心是json.Unmarshal() 函数

      var requestData RegisterRequest
      err = json.Unmarshal(body, &requestData)
      if err != nil {
          http.Error(writer, err.Error(), http.StatusBadRequest)
          return
      }
      

      首先我们定义一个名为 requestData 的变量,其类型为 RegisterRequest, 用于存储从请求主体解析出的数据

      然后调用 json.Unmarshal() 函数,将请求主体中的 JSON 数据解析到 requestData 变量中。body 是包含请求主体数据的字节切片,&requestData 是对 requestData 变量的指针,这样可以将解析后的数据直接存储到 requestData

      如果解析过程中出现错误,则表示请求数据的格式不符合预期或无法解析为 RegisterRequest 结构体, 然后将错误信息作为 HTTP 响应的内容返回给客户端,并设置响应的状态码为 http.StatusBadRequest(400), 并用return结束函数

  4. 查询数据库检查用户名是否已存在

    query := `
    	SELECT COUNT(*) FROM user WHERE username = ?
    `
    var count int
    err = db.QueryRow(query, requestData.Username).Scan(&count)
    if err != nil {
    	http.Error(writer, err.Error(), http.StatusInternalServerError)
    	return
    }
    
    if count > 0 {
    	http.Error(writer, "用户名已存在", http.StatusBadRequest)
    	return
    }
    

    首先调用 QueryRow() 方法来查询表 user 中与给定用户名匹配的行的数量, 并将结果存储到 count 变量中。Scan(&count) 方法将查询结果扫描并赋值给 count 变量。

    如果出现查询错误,将错误信息作为 HTTP 响应的内容返回给客户端,并设置响应的状态码为 http.StatusInternalServerError(500), 并用return结束函数

    然后检查变量 count 的值是否大于 0,即数据库中是否已存在与给定用户名匹配的记录。如果存在匹配的记录,返回一个 HTTP 响应给客户端,内容为 "用户名已存在",状态码为 http.StatusBadRequest(400)。

  5. 将注册信息插入数据库表

    token := generateToken()
    insertQuery := `
    	INSERT INTO user (username, password, token)
    	VALUES (?, ?, ?)
    `
    result, err := db.Exec(insertQuery, requestData.Username, requestData.Password, token)
    if err != nil {
        http.Error(writer, err.Error(), http.StatusInternalServerError)
        return
    }
    

    首先调用 generateToken() 的函数,生成令牌值, 然后 调用 Exec() 方法来执行 SQL 插入操作, 将用户名、密码和令牌插入到名为 user 的数据库表中

    然后检查插入操作是否出现错误。如果有错误发生,表示插入操作无法正常执行。如果出现插入错误,将错误信息作为 HTTP 响应的内容返回给客户端,并设置响应的状态码为 http.StatusInternalServerError(500)

  6. 获取刚插入记录的主键id的值

    userID, err := result.LastInsertId()
    if err != nil {
        http.Error(writer, err.Error(), http.StatusInternalServerError)
        return
    }
    

    调用 Result 对象的 LastInsertId() 方法,以获取刚插入记录的自增主键的值并作为id。

    如果出现错误,将错误信息作为 HTTP 响应的内容返回给客户端,并设置响应的状态码为 http.StatusInternalServerError(500)

  7. 处理响应数据

    1. 构建响应数据

      responseData := RegisterResponse{
          StatusCode: http.StatusOK,
          StatusMsg:  "注册成功!",
          UserID:     userID,
          Token:      token,
      }
      

      定义了一个名为 responseData 的变量,其类型为 RegisterResponse, 将相应的值赋予这些字段

    2. 将响应数据转换为 JSON 格式

      responseJSON, err := json.Marshal(responseData)
      if err != nil {
          http.Error(writer, err.Error(), http.StatusInternalServerError)
          return
      }
      

      使用 json.Marshal() 函数将 responseData 转换为 JSON 格式的字节切片。如果转换过程中出现错误,我们会返回一个 HTTP 错误响应。

    3. 设置响应头部

      writer.Header().Set("Content-Type", "application/json")
      

      设置响应头部的 "Content-Type""application/json",表明响应数据的类型为 JSON

    4. 发送响应数据

      writer.WriteHeader(http.StatusOK)
      writer.Write(responseJSON)
      

      通过调用 writer.WriteHeader(http.StatusOK) 设置响应的状态码为 http.StatusOK(200 OK)。

      最后,我们使用 writer.Write() 方法发送 JSON 格式的响应数据。

测试和调试

使用单元测试发送模拟请求

func TestRegisterHandler(t *testing.T) {
	// 创建一个用于测试的临时数据库
	db, err := sql.Open("mysql", "root:123456@tcp(localhost:3306)/tiktok")
	if err != nil {
		t.Errorf("Failed to connect to the database: %v", err)
	}
	defer db.Close()

	// 检查数据库连接状态
	if err := db.Ping(); err != nil {
		t.Errorf("Failed to ping the database: %v", err)
	}

	// 创建一个模拟的 HTTP 请求和响应
	reqBody := RegisterRequest{
		Username: "test_username",
		Password: "test_password",
	}
	jsonData, _ := json.Marshal(reqBody)
	req := httptest.NewRequest("POST", "/douyin/user/register/?username=your_username&password=your_password", bytes.NewBuffer(jsonData))
	req.Header.Set("Content-Type", "application/json")

	recorder := httptest.NewRecorder()

	// 调用被测试的处理函数
	RegisterHandler(recorder, req)

	// 检查响应状态码
	if recorder.Code != http.StatusOK {
		t.Errorf("Expected status code %d, but got %d", http.StatusOK, recorder.Code)
	}

	// 从响应中读取内容
	body, _ := ioutil.ReadAll(recorder.Body)

	// 解析响应数据
	var resp RegisterResponse
	if err := json.Unmarshal(body, &resp); err != nil {
		t.Errorf("Failed to parse response body: %v", err)
	}

	// 检查响应内容
	if resp.UserID == 0 {
		t.Error("User ID is not set in the response")
	}

	if resp.Token == "" {
		t.Error("Token is not set in the response")
	}
}

开发过程中的问题和解决方案

通过运行以上测试, 得到了以下问题:

  1. sql: unknown driver "mysql" (forgotten import?) 表示没有正确导入 MySQL 驱动程序。搜索后应该在代码开头添加 _ "github.com/go-sql-driver/mysql" 来导入该驱动程序,但是仍然出错。由于注意到这是个代码仓库,因此我将这个仓库下载下来,并放入了config包中,修改路径后成功解决问题。
  2. Failed to parse response body: invalid character 's' looking for beginning of value 表示解析响应体时遇到了无效的字符。这是因为问题1的存在导致的,解决后成功修复这个问题。
  3. User ID is not set in the responseToken is not set in the response 表示响应数据中的用户ID和令牌没有正确设置。这同样是因为问题1的存在导致的,解决后成功修复这个问题。

修修补补

后面写用户登录接口时注意到,接收请求和进行响应的代码是可以重复利用的,于是重构了HandleRequestHandleResponse函数,如下:

userRequest.go

package controller

import (
	"encoding/json"
	"io/ioutil"
	"net/http"
)

type Request struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

func HandleRequest(writer http.ResponseWriter, request *http.Request) Request {
	var requestData Request
	// 检查请求方法是否为POST
	if request.Method != http.MethodPost {
		http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed)
		return requestData
	}

	// 读取请求体数据
	requestBody, err := ioutil.ReadAll(request.Body)
	defer request.Body.Close()
	if err != nil {
		http.Error(writer, err.Error(), http.StatusInternalServerError)
		return requestData
	}

	// 解析JSON数据
	err = json.Unmarshal(requestBody, &requestData)
	if err != nil {
		http.Error(writer, err.Error(), http.StatusBadRequest)
		return requestData
	}

	return requestData
}

userResponse.go

package controller

import (
	"encoding/json"
	"net/http"
)

type Response struct {
	StatusCode int    `json:"status_code"`
	StatusMsg  string `json:"status_msg,omitempty"`
	UserID     int64  `json:"user_id,omitempty"`
	Token      string `json:"token,omitempty"`
}

func HandleResponse(userID int64, token string, writer http.ResponseWriter, StatusMsg string) {
	// 构造响应数据
	responseData := Response{
		StatusCode: http.StatusOK,
		StatusMsg:  StatusMsg,
		UserID:     userID,
		Token:      token,
	}

	// 将响应转换为JSON并写入响应体中
	responseJSON, err := json.Marshal(responseData)
	if err != nil {
		http.Error(writer, err.Error(), http.StatusInternalServerError)
		return
	}

	// 设置响应头部
	writer.Header().Set("Content-Type", "application/json")

	// 发送响应数据
	writer.WriteHeader(http.StatusOK)
	writer.Write(responseJSON)
}

这样,可以减少冗余,提高代码的可维护性和可读性,如果组员的其它请求同样适用,可以直接使用而无需重复造轮子。