忙完暑研之后有些想补上之前的游记,毕竟今年因为疫情的关系也去不了什么地方,就回忆一下之前的旅程吧。

游记里肯定要插入地图,但似乎几种选择都不太理想:

  • Apple Maps 的截图:好看,但不可交互。
  • Google Maps:能看,但国内无法访问。
  • 国内的一些地图:丑,且国外地图数据不完全。

苦恼之时,发现 Apple 在 WWDC 18 时已经推出了用于在网页中嵌入 Apple Maps 的框架:MapKit JS。


更新:MapKit JS 暂不支持中国大陆访问(尽管简介页上的 demo 是可以的)。


需求与注意事项

首先,不像嵌入 Google Maps 那么简单,使用 MapKit JS 是需要与开发者账户进行认证的,也就是说你需要加入每年 99 USD 的 Apple Developer Program

其次,虽然使用 MapKit JS 不需要支付额外费用,但是是有 quota 限制的:

  • 250,000 map views per day
  • 25,000 service calls per day

其中「service call」应该指的是规划路线之类的 request(待考证)。这个量对于个人博客而言显然绰绰有余了。

相应地,Apple 提供了 MapKit JS Dashboard 用于监控服务用量。

准备工作

在部署 MapKit JS 前,首先要做好认证的准备工作。

认证材料

认证材料的介绍在 WWDC 18 的这个 session 中已经讲得很详细了,以下简要介绍一下其中的部分内容。

认证所需要的材料有三种:

  • Maps Identifier (Maps ID):顾名思义,是嵌入的地图的标识符。Apple 给出的建议是为每个域名创建一个,当然不嫌麻烦的话创建多个也可以。同时也 MapKit JS Dashboard 里的使用情况也是按照这个 ID 来分类的。
  • MapKit JS Key:一个 PKCS #8 私钥文件,与一个 Maps ID 相对应,同时文件名中有一个十位的 Key ID 用于标识这个私钥。这个私钥用于签名下面即将提到的 token。
  • MapKit JS Token:一个直接用于调用 Apple Maps API 的 token,采用 JSON Web Token (JWT) 标准,有有效期限制。

前面提到 MapKit JS 的用量是有限制的,而这一用量的计算便是通过 token 识别开发者身份完成的。如果他人窃取了你的 token 来用,对应的用量也会算在你的账户里。为了解决这一安全问题,token 按照有效期分为两种:

  • Short lived tokens:建议的有效期为30分钟,需要在服务器上部署接口以更新 token。很安全,但对于个人博客来说有些麻烦了。
  • Long lived tokens:有效期可以设为很长,适合在静态网站上部署。就是它了。

生成 token

一个 JWT 由 header,payload,signature 三个部分组成,详细的介绍可以参考这里

能够对 JWT 进行签名的库在这里列出了很多,这里使用 Python 库jwcrypto进行签名,使用pip3 install jwcrypto就可以安装了。

首先是 header 部分:

header = {
  "alg": "ES256",
  "typ": "JWT",
  "kid": "K1K1K1K1K1" # Key ID
}

其中alg必须设为"ES256",也就是 ES256 椭圆曲线数字签名算法。

typ必须为"JWT"

kid是 Key ID,也就是私钥文件名中那一串十位的编号。Apple 的服务器应该是用这一项来获取对应的公钥以验证签名。

然后是 payload:

current_time = time.time() # Seconds since epoch
duration = 31536000 # Seconds in one calendar year
payload = {
  "iss": "XTID1XTID1", # Xcode Team ID
  "iat": current_time,
  "exp": current_time + duration,
  "origin": "https://blog.weiheng.me" # Domain name
}

这里iss是发行者,需要填上你的 Xcode Team ID(在这里查看)。

iatexp分别是发行时间和过期时间,均为 Unix epoch 算起的秒数,这里将exp设为iat的一年后,也就是说这个 token 生成后有一年的有效期。

origin是对地图所嵌入的网站域名的限制,有了这一项就可以防止其他人盗取你的 token 在自己的网站上使用了。

有了 header 和 payload,最后只需要构建 JWT 并用私钥签名就可以了。完整代码如下:

#!/bin/python3
from jwcrypto import jwt, jwk
import time

# Construct header
header = {
  "alg": "ES256",
  "typ": "JWT",
  "kid": "K1K1K1K1K1" # Key ID
}

# Construct payload
current_time = time.time() # Seconds since epoch
duration = 31536000 # Seconds in one calendar year
payload = {
  "iss": "XTID1XTID1", # Xcode Team ID
  "iat": current_time,
  "exp": current_time + duration,
  "origin": "https://blog.weiheng.me" # Domain name
}

# Load key file
key_file = open("AuthKey_K1K1K1K1K1.p8", "rb") # Path to private key file
key = jwk.JWK.from_pem(key_file.read())

# Create and sign token
token = jwt.JWT(header=header, claims=payload)
token.make_signed_token(key)

# Print signed token
print(token.serialize())

在 Ghost 中部署 MapKit JS

MapKit JS 的简介中提供了几个有代表性的示例和其源码,拿来参考非常合适。 Developer Documentation 里有更详细的文档。

然而,这几个示例全部采用的是 short lived token,而之前生成的是 long lived token。

适配很简单,只需要将认证过程中的

mapkit.init({
    authorizationCallback: function(done) {
        var xhr = new XMLHttpRequest();
        xhr.open("GET", "/services/jwt");
        xhr.addEventListener("load", function() {
            done(this.responseText);
        });
        xhr.send();
    }
});

改为

mapkit.init({
    authorizationCallback: function(done) {
        done("your-token-here");
    }
});

以直接通过done提交 token 就可以了。

接下来就可以在 Ghost 的文章中使用 MapKit JS 插入 Apple Maps 了。这里以简介中的「Embed」这个例子作为示例。

  • 在 Code injection 的 header 中添加如下代码。其中<script>用于加载mapkit.js,准确来讲是 MapKit JS 5的最新版本。
<script src="https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js"></script>
        
<style>
#map {
    width: 100%;
    height: 600px;
    margin: 2em 0;
}
</style>
  • 接下来在正文中添加一个 HTML block,输入<div id="map"></div>,也就是一个用于显示地图的<div>。这个 block 会在 Ghost 编辑器中显示为空白。
  • 再添加一个 HTML block,加入以下的 JavaScript 片段以加载地图。这个 block 会在 Ghost 编辑器中显示为Embedded JavaScript

再添加一个 HTML block,加入以下的 JavaScript 片段以加载地图:

<script>
    mapkit.init({
        authorizationCallback: function(done) {
            done("your-token-here");
        }
    });
    
    var Cupertino = new mapkit.CoordinateRegion(
        new mapkit.Coordinate(37.3316850890998, -122.030067374026),
        new mapkit.CoordinateSpan(0.167647972, 0.354985255)
    );
    var map = new mapkit.Map("map");
    map.region = Cupertino;
</script>

预览文章,检查地图是否加载成功。

如果只有地图网格,则 token 配置有误。如果一切顺利的话,效果如下: