在上篇博客《自我黑客马拉松--从零开始创建一个基于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)
}
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)
}
}
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
}
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)
}
}
最后,笔者思考了一下有哪些不足之处,以遍日后改进,大约如下:
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菜鸟的一些个人想法了。
(完)