怎样把一个付费的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