再次自我黑客马拉松--不用第三方库实现一个基于golang的web service

在上篇博客《自我黑客马拉松--从零开始创建一个基于Go语言的Web Service》中,笔者从零开始接触Go语言,挑战了一下自我,实现了一个web service. 不过这里有一个问题,在上次的实现中,用了一些第三方的库,比如beego框架和go-simplejson. 从工程的角度来说,利用框架等第三方库的优点是,很多地方的编码变得简单,代码量较少;但是缺点是:一、对golang本身built-in的库如net/http和encoding/json都还了解得很不够;二、一旦第三方的库出了问题,查找问题和修正bug都比较麻烦。所以这次,笔者打算再自我挑战一下,不用任何第三方库只用golang自带的库来把上次的4个API实现一遍,另外还要加上单元测试!


这次的目录结构比较简单,所有文件全放一个文件夹下了。鉴于上次的package叫做cityweather,那么这次的package就叫做cityweather2吧!(真是不会起名字啊)

对requirement不熟悉的朋友,还是请看上篇博客里的介绍吧。

这次一共有4个文件:main.go, controller.go, model.go 和 model_test.go,详情如下:

1. main.go

package main

import (
    "net/http"
)


func main() {    
    http.HandleFunc("/", topHandler)
    http.ListenAndServe(":8080", nil)
}

2. controller.go

package main

import (
    "io"
    "fmt"
    "net/http"
    "strings"
    "encoding/json"
    
    _ "github.com/mattn/go-sqlite3"
)

// POST /location
func postLocationHandler(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()
    body := make([]byte, r.ContentLength)
    r.Body.Read(body)
    
    var city CityName
    err := json.Unmarshal(body, &city)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        io.WriteString(w, err.Error())
        return
    }

    status, err := AddOneCity(city.Name)
    
    w.WriteHeader(status)
    if err != nil {
        io.WriteString(w, err.Error())
    }
}


// GET /location
func getAllLocationsHandler(w http.ResponseWriter, r *http.Request) {
    cities, respCode, err := GetAllCities()
    w.WriteHeader(respCode)
    
    if err == nil {
        citiesStr := "["
        for i, city := range cities {
            if i > 0 {
                citiesStr += (", " + city)
            } else {
                citiesStr += city
            }
        }
        citiesStr += "]"
        
        io.WriteString(w, citiesStr)
    } else {
      io.WriteString(w, err.Error())
    }
}


// DELETE /location/{name}
func deleteCityHandler(w http.ResponseWriter, r *http.Request, city string) {
    respCode, err := DeleteOneCity(city)
    
    w.WriteHeader(respCode)
    if err != nil {
        io.WriteString(w, err.Error())
    }
}


// GET /location/{name}
func getCityWeatherHandler(w http.ResponseWriter, r *http.Request, city string) {
    result, respCode, err := GetOneCityWeather(city)
    resp, err := json.Marshal(result)
    
    w.WriteHeader(respCode)
    if err == nil {
        w.Write(resp)
    } else {
        io.WriteString(w, err.Error())
    }
}


func topHandler(w http.ResponseWriter, r *http.Request) {
    items := strings.Split(r.URL.Path, "/")
    
    if (len(items) > 4 || len(items) <=1) {
        w.WriteHeader(http.StatusNotFound)
        fmt.Fprintf(w, "404 Not Found: %s", r.URL.Path)
        return
    }
    
    loc := "location"
    firstPlace := strings.ToLower(items[1])
    
    if firstPlace == loc {
        if (r.Method == http.MethodPost && len(items) == 2) {  // POST /location
            postLocationHandler(w, r)
            
        } else if (r.Method == http.MethodGet && (len(items) == 2 || (len(items) == 3 && items[2] == ""))) {  // GET /location
            getAllLocationsHandler(w, r)
            
        } else if (r.Method == http.MethodGet && (len(items) == 3 || (len(items) == 4 && items[3] == ""))) {  // GET /location/{name}
            getCityWeatherHandler(w, r, items[2])
        
        } else if (r.Method == http.MethodDelete && len(items) == 3) {  // DELETE /location/{name}
            deleteCityHandler(w, r, items[2])
        
        } else {
            w.WriteHeader(http.StatusNotFound)
            fmt.Fprintf(w, "404 Not Found: %s", r.URL.Path)
        }
    } else {
        w.WriteHeader(http.StatusNotFound)
            fmt.Fprintf(w, "404 Not Found: %s", r.URL.Path)
    }
}


3. model.go

package main 

import (
    "os"
    "fmt"
    "time"
    "regexp"
    "net/http"
    "io/ioutil"
    "database/sql"
    "encoding/json"
)

const weatherTable string = "city_weather"
const timeOutSeconds int64 = 3600
const OpenWeatherURL string = "http://api.openweathermap.org/data/2.5/weather"
const AppID string = "xxxxxxxxxxxxxxxxxxxxxxx"

var gopath string
var dbpath string

type CityName struct {  // for Unmarshal HTTP Request Body
    Name    string
}

type CityWeather struct {   // for Database
    Id          int64   // primary key, auto increment
    Name        string  // city name, UNIQUE
    Main        string  // main in weather
    Description string  // description in weather
    Icon        string  // icon in weather
    Wid         int64   // id in weather
    TimeStamp   int64   // timestamp when updating
}

type WeatherReport struct {
    Id      int64       `json:"id"`
    Main    string      `json:"main"`
    Description string  `json:"description"`
    Icon    string      `json:"icon"`
}

type ReportResult struct {  // for HTTP Response
    Weather    []WeatherReport  `json:"weather"`
}


func checkErr(err error) {
    if err != nil {
        panic(err)
    }
}


func init() {
    InitializeDatabase()
}


func InitializeDatabase() {
    gopath = os.Getenv("GOPATH")
    dbpath = gopath + "/bin/weather.db"
    
    db, err := sql.Open("sqlite3", dbpath)
    defer db.Close()
    checkErr(err)

    createTable := fmt.Sprintf("CREATE TABLE IF NOT EXISTS `%s` (`id` integer NOT NULL PRIMARY KEY AUTOINCREMENT, `name` varchar(255) NOT NULL DEFAULT ''  UNIQUE, `main` varchar(255) NOT NULL DEFAULT '' , `description` varchar(255) NOT NULL DEFAULT '' , `icon` varchar(255) NOT NULL DEFAULT '' , `wid` integer NOT NULL DEFAULT 0 , `time_stamp` integer NOT NULL DEFAULT 0);", weatherTable)
    
    _, err = db.Exec(createTable)
    checkErr(err)
}


// For "POST /location"
func AddOneCity(city string) (respCode int, err error) {
    db, err := sql.Open("sqlite3", dbpath)
    defer db.Close()
    if err != nil {
        return http.StatusInternalServerError, err
    }

    queryStr := fmt.Sprintf("SELECT name FROM %s WHERE name=?", weatherTable)    
    tmpName := ""
    db.QueryRow(queryStr, city).Scan(&tmpName)
    
    if tmpName != "" {    // result set is not empty
       respCode = http.StatusConflict   // 409
    } else {
        insertStr := fmt.Sprintf("INSERT INTO %s (`name`, `wid`, `time_stamp`) values (?, ?, ?)", weatherTable)

        stmt, err := db.Prepare(insertStr)
        if err != nil {
            return http.StatusInternalServerError, err
        } 

        _, err = stmt.Exec(city, -1, 0)
        if err != nil {
            return http.StatusInternalServerError, err
        } 

        respCode = http.StatusCreated   // 201
    }
    
    return respCode, err
}


// GET /location
func GetAllCities() (allCities []string, respCode int, err error) {
    allCities = []string{}
    
    db, err := sql.Open("sqlite3", dbpath)
    defer db.Close()
    if err != nil {
        return allCities, http.StatusInternalServerError, err
    }
    
    queryStr := fmt.Sprintf("SELECT name FROM %s", weatherTable)
    rows, err := db.Query(queryStr)
    if err != nil {
        return allCities, http.StatusInternalServerError, err
    }
    
    for rows.Next() {
        var cityName string
        err = rows.Scan(&cityName)
        if err != nil {
            return allCities, http.StatusInternalServerError, err
        }
        
        allCities = append(allCities, cityName)
    }
    
    return allCities, http.StatusOK, err
}


// DELETE /location/{name}
func DeleteOneCity(city string) (respCode int, err error) {
    db, err := sql.Open("sqlite3", dbpath)
    defer db.Close()
    if err != nil {
        return http.StatusInternalServerError, err
    } 
    
    execStr := fmt.Sprintf("DELETE FROM %s WHERE name=?", weatherTable)
    stmt, err := db.Prepare(execStr)
    if err != nil {
        return http.StatusInternalServerError, err
    } 
    _, err = stmt.Exec(city)
    if err != nil {
        return http.StatusInternalServerError, err
    } 
    
    return http.StatusOK, err
}


// GET /location/{name}
func GetOneCityWeather(city string) (result *ReportResult, respCode int, err error) {
    cw := new(CityWeather)
    result = new(ReportResult)

    db, err := sql.Open("sqlite3", dbpath)
    defer db.Close()
    if err != nil {
        return result, http.StatusInternalServerError, err
    }
    
    // Get data of the specified city from Database
    cw.Id = 0
    queryStr := fmt.Sprintf("SELECT id, name, main, description, icon, wid, time_stamp FROM %s WHERE name=?", weatherTable)    
    db.QueryRow(queryStr, city).Scan(&cw.Id, &cw.Name, &cw.Main, &cw.Description, &cw.Icon, &cw.Wid, &cw.TimeStamp)
    
    if cw.Id == 0 {
        return result, http.StatusNotFound, nil
    }
    
    currentTime := time.Now().UTC().UnixNano()
    passedSeconds := (currentTime - cw.TimeStamp) / 1e9
    
    if passedSeconds > timeOutSeconds {  // If older than one hour or the first get, need to update database
        client := &http.Client{}
        url := fmt.Sprintf("%s?q=%s&APPID=%s", OpenWeatherURL, city, AppID)
        reqest, err := http.NewRequest("GET", url, nil)
        if err != nil {
            return result, http.StatusServiceUnavailable, err    // 503
        }
        
        response, err := client.Do(reqest)
        defer response.Body.Close()
        
        if err != nil {
            return result, http.StatusServiceUnavailable, err   // 503
        } else {   // Get Response from openweather!!
            body, err := ioutil.ReadAll(response.Body)
            if err != nil {
                return result, http.StatusInternalServerError, err  // 500
            }
            
            bodyStr := string(body)
            
            // get "weather" part as string
            reg := regexp.MustCompile(`"weather":(\[.+\])`)
            ws := (reg.FindStringSubmatch(bodyStr))[1]
            
            // convert "weather" string to bytes
            tmpBytes := make([]byte, len(ws))
            copy(tmpBytes[:], ws)
            
            // Unmarshal the bytes to ReportResult.Weather
            var rcds []WeatherReport
            json.Unmarshal(tmpBytes, &rcds)
            result.Weather = rcds
            
            // update cw
            cw.Wid         = rcds[0].Id
            cw.Main        = rcds[0].Main
            cw.Description = rcds[0].Description
            cw.Icon        = rcds[0].Icon
            cw.TimeStamp   = currentTime

            // Update Database
            updateStr := fmt.Sprintf("UPDATE %s SET wid=?, main=?, description=?, icon=?, time_stamp=? WHERE name=?", weatherTable)
            stmt, err := db.Prepare(updateStr)
            if err != nil {
                return result, http.StatusInternalServerError, err
            }

            _, err = stmt.Exec(cw.Wid, cw.Main, cw.Description, cw.Icon, cw.TimeStamp, city)
            if err != nil {
                return result, http.StatusInternalServerError, err
            }
        }
    } else {    // If shorter than timeOutSeconds, get the data from Database
        var item WeatherReport
        item.Id          = cw.Wid
        item.Main        = cw.Main
        item.Icon        = cw.Icon
        item.Description = cw.Description
        
        result.Weather = []WeatherReport{item}
    }
    
    return result, http.StatusOK, nil
}

4. model_test.go

package main

import (
    "testing"
    "net/http"
)

const sampleCityName string = "Shanghai"


func reportFailure(t *testing.T, respCode int, err error) {
    if respCode != http.StatusOK || err != nil {
        t.Errorf("Test Faield: respCode = %d, err = %v", respCode, err)
    }
}

func Test_DeleteOneCity(t *testing.T) {
    respCode, err := DeleteOneCity(sampleCityName)
    reportFailure(t, respCode, err)
}

func Test_AddOneCity(t *testing.T) {
    respCode, err := AddOneCity(sampleCityName)
    if respCode != http.StatusCreated || err != nil {   // 201
        t.Errorf("Test Failed when adding %s for the first time: respCode = %d, err = %v", sampleCityName, respCode, err)
    }
    
    respCode, err = AddOneCity(sampleCityName)
    if respCode != http.StatusConflict || err != nil {   // 409
        t.Errorf("Test Failed when adding %s for the second time: respCode = %d, err = %v", sampleCityName, respCode, err)
    }
}


func Test_GetAllCities(t *testing.T) {
    allCities, respCode, err := GetAllCities()
    reportFailure(t, respCode, err)
    
    found := false
    for _,v := range(allCities) {
        if v == sampleCityName {
            found = true
            break
        }
    }
    if found == false {
        t.Errorf("Test Faield due to no expected city")
    }
}


func Test_GetOneCityWeather(t *testing.T) {
    result, respCode, err := GetOneCityWeather(sampleCityName)
    reportFailure(t, respCode, err)
    
    if result == nil || result.Weather == nil || len(result.Weather) == 0 {
        t.Errorf("Test Failed: returned result = %v", result)
    }
}

对了,run test的时候只要在该文件夹下跑一句"go test -v"即可。当然,如果跑“go test -cover”,那么就可以看到代码覆盖率了。

最后,笔者思考了一下有哪些不足之处,以遍日后改进,大约如下:

1. 在做model.go的单元测试时,没有去mock数据库的行为。那么应该怎么做呢?笔者没有仔细去研究了,大约是可以利用这个第三方的库吧:https://github.com/DATA-DOG/go-sqlmock

2. 没有写controller.go的单元测试。该怎么写呢?首先那么些个controller函数最后都是写到web上的,但其实它们调用的是一个接口 -- http.ResponseWriter,所以,我们只要fake几个http.Request作为输入参数,再mock这个http.ResponseWriter接口,将其原本写入到web的数据写入到另一个地方(文件或channel?),再从这个地方将数据取出来和期望值做对比,应该就可以实现了。

以上是笔者作为一个golang菜鸟的一些个人想法了。

(完)

你可能感兴趣的:(Go)