1. 程式人生 > >GeoHash在LBS的應用,看完這篇就什麼都懂了

GeoHash在LBS的應用,看完這篇就什麼都懂了

今天在做專案時,遇到這麼一個小小場景:對於使用者的一條行為資料資訊,我需要通過他的地理座標實時的得到他所在地附近商圈資訊,並且給他打上相關標籤以方便向他實時推送廣告。問題是:如何根據使用者的地理座標獲得他附近的商圈資訊呢?怎樣控制獲得商圈資訊的地理座標範圍呢? 怎樣更精確的獲得附近商圈的資訊呢?
這裡有一個很關鍵的GeoHash演算法解決了這些問題,下面帶著這三個問題來閱讀這篇文章,你就會收穫很多。
在這之前,先給大家介紹一下GeoHash演算法

1、GeoHash將二維的經緯度轉換成字串,比如下圖展示了北京9個區域的GeoHash字串,分別是WX4ER,WX4G2、WX4G3等等,每一個字串代表了某一矩形區域。也就是說,這個矩形區域內所有的點(經緯度座標)都共享相同的GeoHash字串,這樣既可以保護隱私(只表示大概區域位置而不是具體的點),又比較容易做快取,比如左上角這個區域內的使用者不斷髮送位置資訊請求餐館資料,由於這些使用者的GeoHash字串都是WX4ER,所以可以把WX4ER當作key,把該區域的餐館資訊當作value來進行快取,而如果不使用GeoHash的話,由於區域內的使用者傳來的經緯度是各不相同的,很難做快取。

在這裡插入圖片描述

2、字串越長,表示的範圍越精確。如圖所示,5位的編碼能表示10平方千米範圍的矩形區域,而6位編碼能表示更精細的區域(約0.34平方千米)

在這裡插入圖片描述

3、字串相似的表示距離相近(特殊情況後文闡述),這樣可以利用字串的字首匹配來查詢附近的POI資訊。如下兩個圖所示,一個在城區,一個在郊區,城區的GeoHash字串之間比較相似,郊區的字串之間也比較相似,而城區和郊區的GeoHash字串
相似程度要低些。

在這裡插入圖片描述

通過上面的介紹我們知道了GeoHash就是一種將經緯度轉換成字串的方法,並且使得在大部分情況下,字串字首匹配越多的距離越近,回到我們的案例,根據所在位置查詢來查詢附近餐館時,只需要將所在位置經緯度轉換成GeoHash字串,並與各個餐館的GeoHash字串進行字首匹配,匹配越多的距離越近。

現在相信你對GeoHash演算法已經有了一些瞭解,下面我們通過程式碼通過地理座標獲取它的商圈資訊
當然在這之前你需要在百度地圖開放平臺申請金鑰,然後申請應用獲取AK和SK的資訊,個人就可以申請,並且每天免費有0.6萬次的配額上限,具體的申請過程這裡就不再贅述,下面來看這段用scala來寫的獲取商圈的程式碼

package com.utils
import java.io.UnsupportedEncodingException
import java.net.URLEncoder
import java.security.NoSuchAlgorithmException
import java.util

import com.google.gson.{JsonObject, JsonParser}
import org.apache.commons.httpclient.HttpClient
import org.apache.commons.httpclient.methods.GetMethod
import org.apache.commons.lang3.StringUtils
/**
  * 請求百度LBS(位置服務),解析經緯度對應的商圈資訊
  *
  */
object BaiduLBSHandler {
  /**
    * 對外提供的解析經緯度對應的商圈資訊
    *
    * @param lng 經度
    * @param lat 緯度
    */
  def parseBusinessTagBy(lng: String, lat: String) = {
    var business: String = ""
    val requestParams = requetParams(lng, lat)
    val requestURL = "http://api.map.baidu.com/geocoder/v2/?" + requestParams
    // 使用HttpClient 模擬瀏覽器傳送請求
    val httpClient = new HttpClient()
    val getMethod = new GetMethod(requestURL)
    val statusCode = httpClient.executeMethod(getMethod)
    if (statusCode == 200) { // HTTP.OK
      val response = getMethod.getResponseBodyAsString

      // 判斷是否是合法的json字元換
      var str = response.replaceAll("renderReverse&&renderReverse\\(", "")
      if (!response.startsWith("{")) {
        str = str.substring(0, str.length - 1)
      }

      // 解析這個json字串,取出business節點資料
      val returnData = new JsonParser().parse(str).getAsJsonObject

      // 伺服器返回來的json資料,status表示伺服器是否正常(0)處理了我的請求
      val status = returnData.get("status").getAsInt
      if (status == 0) {
        val resultObject = returnData.getAsJsonObject("result")
        business = resultObject.get("business").getAsString.replaceAll(",", ";")

        // 判斷business是否為空,如果為空,接著解析改座標點附近的標籤資訊pois
        if (StringUtils.isEmpty(business)) {
          val pois = resultObject.getAsJsonArray("pois")
          var tagSet = Set[String]()
          for (i <- 0 until pois.size()) {
            val elemObject: JsonObject = pois.get(i).getAsJsonObject
            val tag = elemObject.get("tag").getAsString
            if (StringUtils.isNotEmpty(tag)) tagSet += tag
          }
          business = tagSet.mkString(";")
        }
      }
    }
    business
  }

  private def requetParams(lng: String, lat: String) = {

    //3eWFUfbFLTMopRpY1xk9BRD3iFdxo3r4
    //rvGCL2H2iEScXwNgZvGplcyRsnDC2x9j
     //   ak , sk
    val list  ="y3sWrdIWEjAMMti4i4klRtZzzRDPgwl7,82pKQOUGkjGcuESghMndR00PmQwQSxIS"

    val Array(ak, sk) = list.split(",")

    // 計算sn跟引數對出現順序有關,get請求請使用LinkedHashMap儲存<key,value>,
    // 該方法根據key的插入順序排序;post請使用TreeMap儲存<key,value>,
    // 該方法會自動將key按照字母a-z順序排序。所以get請求可自定義引數順序(sn引數必須在最後)傳送請求,
    // 但是post請求必須按照字母a-z順序填充body(sn引數必須在最後)。
    // 以get請求為例:http://api.map.baidu.com/geocoder/v2/?address=百度大廈&output=json&ak=yourak,
    // paramsMap中先放入address,再放output,然後放ak,放入順序必須跟get請求中對應引數的出現順序保持一致。
    val paramsMap = new util.LinkedHashMap[String, String]();
    paramsMap.put("callback", "renderReverse")
    //        paramsMap.put("location", "39.343424,116.452987")
    paramsMap.put("location", lat.concat(",").concat(lng))
    paramsMap.put("output", "json")
    paramsMap.put("pois", "1")
    paramsMap.put("ak", ak)

    // 請求的引數
    val paramsStr = toQueryString(paramsMap)

    // 生成SN
    val wholeStr = new String("/geocoder/v2/?" + paramsStr + sk)
    val tempStr = URLEncoder.encode(wholeStr, "UTF-8")
    val sn = MD5(tempStr)

    paramsStr + "&sn=" + sn
  }
  // 對Map內所有value作utf8編碼,拼接返回結果
  @throws[UnsupportedEncodingException]
  private def toQueryString(data: util.LinkedHashMap[String, String]): String = {
    val queryString = new StringBuffer
    import scala.collection.JavaConversions._
    for (pair <- data.entrySet) {
      queryString.append(pair.getKey + "=")
      queryString.append(URLEncoder.encode(pair.getValue.asInstanceOf[String], "UTF-8") + "&")
    }
    if (queryString.length > 0) queryString.deleteCharAt(queryString.length - 1)
    queryString.toString
  }

  // 來自stackoverflow的MD5計算方法,呼叫了MessageDigest庫函式,並把byte陣列結果轉換成16進位制
  private def MD5(md5: String): String = {
    try {
      val md = java.security.MessageDigest.getInstance("MD5")
      val array = md.digest(md5.getBytes)
      val sb = new StringBuffer
      var i = 0
      while ( {
        i < array.length
      }) {
        sb.append(Integer.toHexString((array(i) & 0xFF) | 0x100).substring(1, 3))

        {
          i += 1
          i
        }
      }
      return sb.toString
    } catch {
      case e: NoSuchAlgorithmException =>

    }
    null
  }
}

下面的是通過呼叫上面的方法傳入地理座標,獲取到相關商圈,其實也可以把獲取到的商圈資訊儲存在redis(程式碼中已註釋掉)中,由於百度地圖提供的介面是按條收費的,這樣我們就可以直接從redis中讀取商圈資訊,從而減少開發成本。當然嘍,這種方式也是有弊端的,比如說,使用者的某個地理座標附近的商圈位置或資訊發生了變化,而redis中的資料卻沒有改變,這樣就導致使用者獲得了錯誤的資訊,這裡其實也是有解決方案的,對redis中的資料用expire進行過期時間處理,根據地理資訊每過一段時間清除redis中的資料,然後使用者獲得新的商圈資訊。個人認為這樣可行,有誤的地方或者有更好的見解,歡迎大家踴躍指出

package com.Tag
import ch.hsr.geohash.GeoHash
import com.utils.{BaiduLBSHandler}
import org.apache.spark.sql.SQLContext
import org.apache.spark.{SparkConf, SparkContext}
object ExtractLongandLat2Business {
  def main(args: Array[String]): Unit = {
    if(args.length!=1){
      println("argument is wrong!!")
      sys.exit()
    }
    val Array(inputPath) = args
    val conf = new SparkConf().setMaster("local[*]")
      .setAppName(s"${this.getClass.getSimpleName}")
      .set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
    val sc = new SparkContext(conf)
    val sqlContext = new SQLContext(sc)
    sqlContext.setConf("spark.sql.parquet.compression.codec","snappy")

    sqlContext.read.parquet(inputPath).select("lat","long").filter(
      """
        |cast(long as double) >=73 and cast(long as double) <=136 and
        |cast(lat as double) >=3 and cast(lat as double) <=54
      """.stripMargin).distinct()
      //還可以把獲得的商圈資訊存放在redis中,減少成本
      //      .foreachPartition(t=>{
      //        val jedis = JedisConnectionPool.getConnection()
      //        t.foreach(t=>{
      //          val long = t.getAs[String]("long")
      //          val lat = t.getAs[String]("lat")
      //          //通過百度的逆地址解析,獲取到商圈資訊
      //          val geoHashs = GeoHash.geoHashStringWithCharacterPrecision(long.toDouble,lat.toDouble,8)
      //          //進行sn驗證
      //          val business = BaiduLBSHandler.parseBusinessTagBy(long,lat)
      //          jedis.set(geoHashs,business)
      //        })
      //        jedis.close()
      //      })
      .map(t=>{
      val long =t.getAs[String]("long")
      val lat = t.getAs[String]("lat")
      //8代表返回geoHash的值為8位   字串的長度越長,獲得的地理位置越精確
      val geoHash = GeoHash.geoHashStringWithCharacterPrecision(lat.toDouble,long.toDouble,8)
      val b = BaiduLBSHandler.parseBusinessTagBy(long,lat)
      (geoHash,b)
    }).foreach(println)
  }
}

看到這裡,相信篇頭的三個問題大家都迎刃而解。好了,今天的分享就到這裡,希望大家看完都各有收穫!!