怎样把一个付费的ETA服务变成三个免费的服务
这篇文章是关于我如何不花一分钱用三个免费ETA(预测到达时间estimated time of arrival)服务取代一个的。所有的一切都是基于我在GoDee这个项目中后端开发的经验。GoDee是一个创业项目,他可以提供在线的巴士位置预订服务。关于这个项目你可以在这里查阅更多的信息。
历史
GoDee是一个公共交通服务。在东南亚,GoDee的公共交通相比于摩托车来说更加舒适,比Taxi更加便宜。这个app的系统,可以让用户找到最适合的路线,选择时间,预订座位然后在线付费。GoDee的一个问题就是交通状况很容易影响到用户的体验。用户不想在那边等待并猜测这个巴士什么时候可以到达。所以,为了提供用户体验,我们需要一个服务能够计算巴士的预计到达时间,简称ETA。
从头开发ETA预计大概要一年的时间。所以,为了加速开发的时间,GoDee决定使用Google的Distance Matrix API工具来实现。后来,他们开发了他们自己的Pifia微服务。
问题
随着时间的推移,整个商业都在增长,用户的基数也在增加。我们面临的问题就是不断增长的Google Distance Matrix API的请求数量。
这个为什么会成为一个问题?
因为这个请求需要耗费金钱,Google API每个月提供了10000个免费的请求,超过之后,每1000个请求就需要付费20刀。而那时候,我们每个月大概有150,000个请求。
我的导师对这件事情非常不满意。他说,我们需要把ETA做个cache,比如30分钟超时的cache。那时候,我们的系统每3秒给Google API发送一个次请求来更新数据。然而,这种基于cache的算法并没有什么效率,因为巴士很容易堵在路上。因此,这个距离可能每10分钟才改变一次。不过,它也能解决一些问题,比如同时有五个用户在查询同样的巴士,那么他们就是同样的请求,cache可以解决这样的问题。
func newCache(cfg config.GdmCacheConfig, pf func(from, to geometry.Coordinate) (durationDistancePair, error)) *Cache { res := Cache{ cacheItems: make(map[string]gdmCacheItem), ttlSec: cfg.CacheItemTTLSec, invalidatePeriodSec: cfg.InvalidationPeriodSec, pfGetP2PDurationAndDistance: pf, } return &res } func (c *Cache) get(from, to geometry.Coordinate) (gdmCacheItem, bool) { c.mut.RLock() defer c.mut.RUnlock() keyStr := geometry.EncodeRawCoordinates([]geometry.Coordinate{from, to}) val, exist := c.cacheItems[keyStr] if exist { return val, exist } itemsWithToEq := make([]gdmCacheItem, 0, len(c.cacheItems)) for _, v := range c.cacheItems { if v.to == to { itemsWithToEq = append(itemsWithToEq, v) } } for _, itwt := range itemsWithToEq { p1 := geometry.Coordinate2Point(from) p2 := geometry.Coordinate2Point(itwt.from) if c.geom.DistancePointToPoint(p1, p2) > 10.0 { continue } return itwt, true } return gdmCacheItem{}, false } func (c *Cache) set(from, to geometry.Coordinate) (gdmCacheItem, error) { keyStr := geometry.EncodeRawCoordinates([]geometry.Coordinate{from, to}) c.mut.Lock() defer c.mut.Unlock() if v, ex := c.cacheItems[keyStr]; ex { return v, nil } resp, err := c.pfGetP2PDurationAndDistance(from, to) if err != nil { return gdmCacheItem{}, err } neuItem := gdmCacheItem{ from: from, to: to, data: durationDistancePair{ dur: resp.dur, distanceMeters: resp.distanceMeters}, invalidationTime: time.Now().Add(time.Duration(c.ttlSec) * time.Second), } c.cacheItems[keyStr] = neuItem return neuItem, nil } func (c *Cache) invalidate() { c.mut.Lock() defer c.mut.Unlock() toDelete := make([]string, 0, len(c.cacheItems)) for k, v := range c.cacheItems { if time.Now().Before(v.invalidationTime) { continue } toDelete = append(toDelete, k) } for _, td := range toDelete { delete(c.cacheItems, td) } } func (c *Cache) run() { ticker := time.NewTicker(time.Duration(c.invalidatePeriodSec) * time.Second) for { select { case <-ticker.C: c.invalidate() } } }
替代服务
cache可以解决一部分问题,但是随着GoDee的用户增长,我们还是会遇到同样的问题 — 查询的请求数目增加了。
我们决定要OSRM来取代Google的API,简单来说,OSRM是一个基于ETA来创建路径的服务(你如果想了解具体的内容,可以参见这里)
OSRM有一个问题:就是他创建路径和计算ETA的时候,没有把交通状况考虑进去。为了解决这个问题,我们开始研究有哪些服务可以提供城市特定区域的交通状况。HERE Traffic提供了我想要的数据。在一些简单调查之后,我写了一个小的代码来每30分钟获取一下交通的信息。然后把交通的信息上传到OSRM,我写了一个小的命令行脚本。
./osrm-contract data.osrm –segment-speed-file updates.csv
具体的信息可以参见这里
数学计算:每个小时,会发两个request到HERE去得到交通的信息,也就是说一天48个request,然后一个月有大概1488(48*31=1488),一年大概就是17520。所以我们可以大概免费使用HERE的请求15年,这足够了。
// everything that these structures mean is described here https://developer.here.com/documentation/traffic/dev_guide/topics/common-acronyms.html type hereResponse struct { RWS []rws `json:"RWS"` } type rws struct { RW []rw `json:"RW"` } type rw struct { FIS []fis `json:"FIS"` } type fis struct { FI []fi `json:"FI"` } type fi struct { TMC tmc `json:"TMC"` CF []cf `json:"CF"` } type tmc struct { PC int `json:"PC"` DE string `json:"DE"` QD string `json:"QD"` LE float64 `json:"LE"` } type cf struct { TY string `json:"TY"` SP float32 `json:"SP"` SU float64 `json:"SU"` FF float64 `json:"FF"` JF float64 `json:"JF"` CN float64 `json:"CN"` } type geocodingResponse struct { Response response `json:"Response"` } type response struct { View []view `json:"View"` } type view struct { Result []result `json:"Result"` } type result struct { MatchLevel string `json:"MatchLevel"` Location location `json:"Location"` } type location struct { DisplayPosition position `json:"DisplayPosition"` } type position struct { Latitude float64 `json:"Latitude"` Longitude float64 `json:"Longitude"` } type osmInfo struct { Waypoints []waypoints `json:"waypoints"` Code string `json:"code"` } type waypoints struct { Nodes []int `json:"nodes"` Hint string `json:"hint"` Distance float64 `json:"distance"` Name string `json:"name"` Location []float64 `json:"location"` } type osmDataTraffic struct { FromOSMID int ToOSMID int TubeSpeed float64 EdgeRate float64 } // CreateTrafficData - function creates a cvs file containing traffic information func CreateTrafficData(h config.TrafficConfig) error { osm := make([]osmDataTraffic, 0) x, y := mercator(h.Lan, h.Lon, h.MapZoom) quadKey := tileXYToQuadKey(x, y, h.MapZoom) trafficInfo, err := getTrafficDataToHereService(quadKey, h.APIKey) if err != nil { return err } for _, t := range trafficInfo.RWS[0].RW { for j := 0; j < len(t.FIS[0].FI)-1; j++ { position, err := getCoordinateByStreetName(t.FIS[0].FI[j].TMC.DE, h.APIKey) if err != nil { logrus.Error(err) continue } osmID, err := requestToGetNodesOSMID(position.Latitude, position.Longitude, h.OSMRAddr) if err != nil { logrus.Error(err) continue } osm = append(osm, osmDataTraffic{ FromOSMID: osmID[0], ToOSMID: osmID[1], TubeSpeed: 0, EdgeRate: t.FIS[0].FI[j].CF[0].SU, }) } } if err := createCSVFile(osm); err != nil { return err } return nil } // http://mathworld.wolfram.com/MercatorProjection.html func mercator(lan, lon float64, z int64) (float64, float64) { latRad := lan * math.Pi / 180 n := math.Pow(2, float64(z)) xTile := n * ((lon + 180) / 360) yTile := n * (1 - (math.Log(math.Tan(latRad)+1/math.Cos(latRad)) / math.Pi)) / 2 return xTile, yTile } // http://mathworld.wolfram.com/MercatorProjection.html func tileXYToQuadKey(xTile, yTile float64, z int64) string { quadKey := "" for i := uint(z); i > 0; i-- { var digit = 0 mask := 1 << (i - 1) if (int(xTile) & mask) != 0 { digit++ } if (int(yTile) & mask) != 0 { digit = digit + 2 } quadKey += fmt.Sprintf("%d", digit) } return quadKey } // requestToGetNodesOSMID - function for getting osm id by coordinates func requestToGetNodesOSMID(lan, lon float64, osrmAddr string) ([]int, error) { osm := osmInfo{} // here it is necessary that at the beginning lon And then lan // WARN only Ho Chi Minh url := fmt.Sprintf("http://%s/nearest/v1/driving/%v,%v", osrmAddr, lon, lan) resp, err := http.Get(url) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("Status code %d", resp.StatusCode) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } err = json.Unmarshal(body, &osm) if err != nil { return nil, err } if len(osm.Waypoints) == 0 { return nil, fmt.Errorf("Nodes are empty, lan: %v, lon: %v", lan, lon) } return osm.Waypoints[0].Nodes, nil } // https://developer.here.com/documentation/geocoder/dev_guide/topics/quick-start-geocode.html // getCoordinateByStreetName - function of the coordinates by street name func getCoordinateByStreetName(streetName, apiKey string) (position, error) { streetName += " Ho Chi Minh" url := fmt.Sprintf("https://geocoder.ls.hereapi.com/6.2/geocode.json?apiKey=%s&searchtext=", apiKey) gr := geocodingResponse{} streetNames := strings.Split(streetName, " ") for _, s := range streetNames { url += s + "+" } resp, err := http.Get(url) if err != nil { return position{}, err } if resp.StatusCode != http.StatusOK { return position{}, fmt.Errorf("Status code %d", resp.StatusCode) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return position{}, err } err = json.Unmarshal(body, &gr) if err != nil { return position{}, err } if len(gr.Response.View) == 0 { return position{}, errors.New("View response empty") } for _, g := range gr.Response.View[0].Result { if g.MatchLevel == "street" { return g.Location.DisplayPosition, nil } } return position{}, fmt.Errorf("street: %s not found", streetName) } func getTrafficDataToHereService(quadKey, apiKey string) (hereResponse, error) { rw := hereResponse{} url := fmt.Sprintf("https://traffic.ls.hereapi.com/traffic/6.2/flow.json?quadkey=%s&apiKey=%s", quadKey, apiKey) resp, err := http.Get(url) if err != nil { return rw, err } if resp.StatusCode != http.StatusOK { return rw, fmt.Errorf("Status code %d", resp.StatusCode) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return rw, err } err = json.Unmarshal(body, &rw) if err != nil { return rw, err } return rw, nil } func createCSVFile(data []osmDataTraffic) error { if err := os.Remove("./traffic/result.csv"); err != nil { logrus.Error(err) } file, err := os.Create("./traffic/result.csv") if err != nil { return err } defer file.Close() writer := csv.NewWriter(file) defer writer.Flush() for _, value := range data { str := createArrayStringByOSMInfo(value) err := writer.Write(str) if err != nil { logrus.Error(err) } } return nil } func createArrayStringByOSMInfo(data osmDataTraffic) []string { var str []string str = append(str, fmt.Sprintf("%v", data.FromOSMID)) str = append(str, fmt.Sprintf("%v", data.ToOSMID)) str = append(str, fmt.Sprintf("%v", data.TubeSpeed)) str = append(str, fmt.Sprintf("%v", data.EdgeRate)) return str }
初步测试显示这个服务工作起来还是不错的,但是这里有一个问题,就是HERE所提供的数据格式有点乱,它不符合OSRM的格式要求。为了让这个信息能够使用,你需要使用另外一个服务,HERE for geocoding然后在加上OSRM(主要用来得到地图上的点)。这样就大概每个月有450,000请求了。后来OSRM被抛弃了,因为这个请求的书面已经超过了免费的限制。我们没有放弃,并且使用了HERE Distance Matrix API来暂时取代了Google Distance Matrix API。HERE的逻辑非常简单:我们发送从点A到点B的坐标,然后得到想要的巴士到达时间:
type response struct { Response matrixResponse `json:"response"` } type matrixResponse struct { Route []matrixRoute `json:"route"` } type matrixRoute struct { Summary summary `json:"summary"` } type summary struct { Distance int `json:"distance"` TrafficTime int `json:"trafficTime"` } func HereDistanceETA() (response, error) { matrixResponse := response{} query := fmt.Sprintf("&waypoint%v=geo!%v,%v", 0, from.Lat, from.Lon) query += fmt.Sprintf("&waypoint%v=geo!%v,%v", 1, to.Lat, to.Lon) query += "&mode=fastest;car;traffic:enabled" url := fmt.Sprintf("https://route.ls.hereapi.com/routing/7.2/calculateroute.json?apiKey=%s", h.hereAPIKey) url += query resp, err := http.Get(url) if err != nil { logrus.WithFields(logrus.Fields{ "url": url, "error": err, }).Error("Get here response failed") return durationDistancePair{}, err } if resp.StatusCode != http.StatusOK { return durationDistancePair{}, fmt.Errorf("Here service, status code %d", resp.StatusCode) } body, err := ioutil.ReadAll(resp.Body) if err != nil { return durationDistancePair{}, err } err = json.Unmarshal(body, &matrixResponse) if err != nil { return durationDistancePair{}, err } if len(matrixResponse.Response.Route) == 0 { return durationDistancePair{}, errors.New("Matrix response empty") } res := durationDistancePair{ dur: time.Duration(matrixResponse.Response.Route[0].Summary.TrafficTime) * time.Second, distanceMeters: matrixResponse.Response.Route[0].Summary.Distance, } return res, nil }
当我们把所有的东西都安装到测试服务器之后,我们收到了从测试者发回来的第一个反馈。他们这个ETA读取的时间不正确。我们开始看这个问题,不停的看log,看log,但是所有的都显示是正确的。我们决定来询问这个问题的更多细节,然后发现假如这个车堵在那边15分钟,这个ETA还是显示同样的时间。我们发现这个可能是因为cache的问题,他存储了开始的时间,并且30分钟没有更新。
我们开始研究这个问题,我们首先检查了网页版本上HERE Distance Matrix API的数据,所有的数据看起来正常,我们得到了同样的ETA。这个问题,我们也用Google地图的服务进行了检查,也是没有问题。这个服务它本身显示了这个ETA。我们向测试者和商业进行了解释,他们接受了。
我们团队的老大建议连接另外一个ETA服务,并且把Google API作为一个备用的选项,然后写一个逻辑来切换服务(当请求超过免费的限额的时候,自动进行切换)
代码像下面这样:
val = getCount() // getting the number of queries used if getMax() <= val { // checking for the limit of free requests for the service used newService = switchService(s) // // if the limit is reached, switch the service return return newService(from, to) // giving the logic of the new service
我们找到了下面的Mapbox的服务,然后连接他,并安装,他还是工作的很不错。所以,最终,我们的ETA变成了:
“Here” — 每个月250,000 个请求
Google — 每个月10,000个请求
Mapbox — 每个月100,000个请求
总结
总是查找可替代的方案,有时这种事情是因为在商业上不想进行付费导致的。作为一个开发者,你在这个服务上花费了很多精力,就需要把它真正地实现。这篇文章,就详细介绍了我们是如何把多个免费的ETA服务集成在一起的故事,仅仅是因为我们并不想去付费。
PS:当然作为一个开发者,我觉得假如这个工具真的很好,那么我们还是应该为之付费的:)。
原文地址 https://blog.maddevs.io/how-to-make-three-paid-eta-services-one-free-6edc6affface
Recent Comments