在上篇博客《自我黑客马拉松--从零开始创建一个基于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) } }
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菜鸟的一些个人想法了。
(完)
有疑问加站长微信联系(非本文作者)