怎样把一个付费的ETA服务变成三个免费的服务

Estimated time of arrival.

这篇文章是关于我如何不花一分钱用三个免费ETA(预测到达时间estimated time of arrival)服务取代一个的。所有的一切都是基于我在GoDee这个项目中后端开发的经验。GoDee是一个创业项目,他可以提供在线的巴士位置预订服务。关于这个项目你可以在这里查阅更多的信息。

历史

GoDee是一个公共交通服务。在东南亚,GoDee的公共交通相比于摩托车来说更加舒适,比Taxi更加便宜。这个app的系统,可以让用户找到最适合的路线,选择时间,预订座位然后在线付费。GoDee的一个问题就是交通状况很容易影响到用户的体验。用户不想在那边等待并猜测这个巴士什么时候可以到达。所以,为了提供用户体验,我们需要一个服务能够计算巴士的预计到达时间,简称ETA。

从头开发ETA预计大概要一年的时间。所以,为了加速开发的时间,GoDee决定使用Google的Distance Matrix API工具来实现。后来,他们开发了他们自己的Pifia微服务。

Image for post

问题

随着时间的推移,整个商业都在增长,用户的基数也在增加。我们面临的问题就是不断增长的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

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *