A Cool HTTP Feature for Simple Real-Time Updates

本文翻译自: https://morsecodist.io/blog/http-simple-realtime-updates

用于实现简单实时更新的超酷HTTP特性

核心内容:就是If-Modified-SinceLast-Modified这两个HTTP头字段。

最近我一直在开发一个针对缅因州波特兰市公共交通的实时追踪网络应用程序,这成了我目前的心头好。以后我可能还会写更多关于它的博客文章,但今天我想先分享我刚学到的一个小知识。有这么个情况,我刚冒出一个想法,结果就发现HTTP规范里早就有了比我想法更好的实现方式。HTTP里藏着好多超酷却又容易被忽视的功能,所以我觉得必须得分享一下。

遇到的问题

我用一种叫GTFS的规范,从公共交通服务系统获取实时数据,然后展示给用户。GTFS能让交通管理部门以标准化的方式,把静态和实时数据提供给第三方应用。这个规范特别有意思,能讲的内容很多,但就这篇文章而言,大家只需要知道我可以通过定期访问一个端点来读取公交系统的状态信息就行。这个端点返回的响应里还会带一个时间戳,能让我知道公交系统上次更新状态的时间。我接入的这个公交服务系统每5秒就会更新一次数据。我希望这些数据能在我的网页上实时更新。

我的应用有两个展示实时数据的页面,一个是显示公交车位置的地图页面,另一个是显示预计到达时间的页面。公交车位置数据很简单,占用资源也少,所以我先用这个数据来测试解决方案,之后再应用到预计到达时间的数据处理上。

简单轮询

在前端实现实时更新,最简单的办法就是按照一定的时间间隔去向后端请求数据,然后更新应用。我用的是React和Next.js,代码大概长这样:

function VehiclePositions() {
  const [vehicles, setVehicles] = useState<VehiclePosition[]>([]);
  useEffect(() => {
    const interval = setInterval(async () => {
      const resp = await fetch("/api/vehicle-positions");
      if (!resp.ok) return;
      setVehicles(vehiclePositions);
    }, 30_000); // 每30秒请求一次
    return () => clearInterval(interval);
  }, []);
  return (
    <>
      {vehicles.map(({ vehicleId, position }) => (
        <Marker
          key={vehicleId}
          position={position}
        />
      ))}
    </>
  );
}

这种方法效果不错,而且特别简单。但轮询有个问题,要是想让数据保持最新,就得频繁调用API。在这个例子里,我每30秒请求一次数据,这就意味着用户可能要等整整30秒才能看到新信息。更糟糕的是,公交系统每5秒才更新一次数据,如果30秒的请求时间没赶上最新数据,拿到的可能就是35秒前的旧数据了。要是我想让数据和服务器的差距最多只有1秒,那API的请求次数就得变成原来的30倍。

这么频繁地轮询还会导致React频繁重新渲染页面。我倒是可以在新数据和旧数据深度比较后,发现有变化再调用setVehicles,但这就增加了客户端的工作量。

服务器发送事件

服务器发送事件(Server-Sent Events)解决了轮询的问题,它通过和服务器保持一个持久连接,让服务器在有更新时通过这个连接把数据推送给客户端。这对我的应用场景来说很理想,虽然最后我没用这个方案,但它可能确实是处理这类问题的“最佳”方式。

我在本地用服务器发送事件实现了公交车位置数据的更新。后端实现起来确实更复杂一些,但前端就简单多了,而且运行效果很好。我把应用部署到DigitalOcean应用平台上进行测试。刚开始看起来一切正常,但过了一会儿,数据更新就停止了。

DigitalOcean应用平台是无服务器架构,不支持持久化状态。我知道服务器发送事件能处理重连问题,所以一开始觉得应该没问题。结果还是不行,后来我研究了一下才发现,这个平台不太支持这种方式。

我知道肯定有人会说,我不该用这种无服务器架构的东西,“我只需要”一台裸金属服务器和sqlite数据库就行了。但我真不想去管理服务器,这只是我的一个业余项目,我就喜欢把代码推到github上,让应用自动部署、自动运行,不用我操心。

把无状态的应用变成有状态的可不是小事。部署的时候要考虑很多新问题,后端代码也会变得更复杂,更容易出错。公交车位置数据还算简单,但要是用在到达时间预测上,会更复杂。虽然有很多应用适合做状态持久化,但要是能避免,生活也会轻松不少。就我的情况而言,数据更新量小且简单,只是一些对Redis的操作,其实提高简单轮询的频率也能满足需求。所以对我来说,服务器发送事件不太值得。

巧用If-Modified-SinceLast-Modified优化轮询

服务器发送事件的方案失败后,我有点沮丧,就想:要是只能用轮询,怎么才能让它尽可能高效呢?后来我就了解到了If-Modified-SinceLast-Modified这两个HTTP头字段。

获取公交车位置数据的请求特别轻量级。我有一个后台任务,会定期从公交服务系统获取数据,然后把我需要的响应内容直接存到Redis里,所以我的端点只需要读取Redis里的键值,再直接返回就行,后端都不用解析数据。所以每次轮询的开销其实并不大,代码如下:

export async function GET(req: Request) {
  const response = await getModel().getVehiclePositionsRaw();
  return new Response(response, {
    headers: {
      "Content-Type": "application/json",
    },
  });
}

因为请求轻量级,频繁轮询最开始遇到的最大问题是React频繁重新渲染页面。我只想在数据真正有变化的时候才重新渲染。虽然可以在前端比较数据是否相同,但这需要深度遍历整个对象。这倒也不是不行,但我还能找到更好的办法。公交服务系统给我公交车位置数据的时候,会很贴心地告诉我这些数据的最后更新时间。所以我有一个时间戳,不用查看数据内容,就能知道数据有没有变化。一开始,我把这个时间戳和位置数据一起存到Redis里,然后用它来决定什么时候重新渲染页面。代码如下:

function VehiclePositions() {
  const [vehicles, setVehicles] = useState<VehiclePosition[]>([]);
  const lastUpdatedRef = useRef(0);  // 初始值设为0,这样第一次获取的数据肯定是更新的
  useEffect(() => {
    const interval = setInterval(async () => {
      const resp = await fetch("/api/vehicle-positions");
      if (!resp.ok) return;
      
      const { lastUpdated, vehiclePositions } = await resp.json();
      // 如果更新时间比上一次的旧或者相同,就不做任何操作
      if (lastUpdated <= lastUpdatedRef.current) return;
      setVehicles(vehiclePositions);
    }, 1_000); // 每1秒请求一次
    return () => clearInterval(interval);
  }, []);
  return (
    <>
      {vehicles.map(({ vehicleId, position }) => (
        <Marker
          key={vehicleId}
          position={position}
        />
      ))}
    </>
  );
}

这个方法效果很好,说实话可能已经够用了,但我还是觉得能做得更好。虽然每次轮询开销不大,但大部分时候还是得把数据传输过来、解析,结果很多时候因为数据没变化又用不上,就浪费了。所以我就想:客户端已经有当前的时间戳了,如果它能把这个时间戳发给服务器,服务器只在数据比这个时间戳更新的时候才返回数据,那会怎么样呢?

我一开始想把时间戳放在URL的查询参数里,每次都返回200状态码,只是有时候返回的内容为空,我可以通过判断返回内容的长度来决定是否解析数据并更新界面。这个方法能实现功能,但总感觉不太对。于是我把我的想法告诉了ChatGPT,问有没有更规范的做法。我不想把这篇文章写成关于ChatGPT的,但我觉得还是值得一提,因为我认为大语言模型最大的优势之一,就是当你不知道某个东西叫什么的时候,它能帮你找到相关信息。ChatGPT给我推荐了If-Modified-SinceLast-Modified这两个HTTP头字段,然后我就去看了相关文档。

从概念上讲,这两个头字段和我用查询参数的思路差别不大,但它们是标准化的,而且浏览器会自动处理。我不用把lastModified时间戳和车辆数据放在一起返回,而是可以把它作为Last-Modified头字段放在响应里。不用返回空的200响应,我可以返回304: Not Modified响应,这个状态码就是专门为这种情况设计的。不用自己存储时间戳再通过自定义查询参数发送,浏览器会帮我把时间戳放在If-Modified-Since头字段里发送给服务器。

和我自定义的方法相比,这种方式各方面都更好。浏览器会自动处理这些操作,还能把车辆数据和用于判断是否更新的元数据更清晰地分开。这和服务器发送事件有点像,因为服务器可以通过发送新的时间戳来决定客户端是否更新,而且只有在数据有更新的时候才发送数据。另外,因为时间戳直接来自公交服务系统,我只是传递它,不用担心时钟不同步的问题,因为我从来不用和自己生成的时间戳进行比较。

把这些整合起来,我就把时间戳的检查从客户端移到了服务器端。现在我的代码比之前更简单了。它不仅避免了页面重新渲染,还减少了调用端点的大部分工作,节省了带宽。如果没有更新,我还能用这个时间戳提前结束后台更新任务。当然,对于公交车位置数据来说,本来要做的工作也不多,但对于到达时间预测数据,这能节省更多资源。

客户端代码如下:

function VehiclePositions() {
  const [vehicles, setVehicles] = useState<VehiclePosition[]>([]);
  useEffect(() => {
    const interval = setInterval(async () => {
      const resp = await fetch("/api/vehicle-positions");
      if (!resp.ok) return;
      if (resp.status === 304) return; // 没有新数据
      
      const vehiclePositions = await resp.json();
      setVehicles(vehiclePositions);
    }, 1_000);
    return () => clearInterval(interval);
  }, []);
  return (
    <>
      {vehicles.map(({ vehicleId, position }) => (
        <Marker
          key={vehicleId}
          position={position}
        />
      ))}
    </>
  );
}

服务器端代码如下:

export async function GET(req: Request) {
  const currentUpdatedAt = await getModel().getVehiclePositionsUpdatedAt();
  // 检查请求中是否有"If-Modified-Since"头字段
  const ifModifiedSince = req.headers.get("if-modified-since");
  const clientDate = ifModifiedSince && new Date(ifModifiedSince);

  // 如果服务器的最后更新时间不比客户端的新,返回304
  if (currentUpdatedAt && clientDate && currentUpdatedAt <= clientDate) {
    return new Response(null, { status: 304 });
  }

  const response = await getModel().getVehiclePositionsRaw();
  return new Response(response, {
    headers: {
      "Content-Type": "application/json",
      "Last-Modified": currentUpdatedAt?.toUTCString() || "",
    },
  });
}

Digital Ocean又出问题了

可惜,我的完美解决方案还是出了岔子。原来,Digital Ocean应用平台把我的应用放在了Cloudflare的CDN后面,而这个CDN被配置成会去除If-Modified-Since头字段。我试了各种缓存控制方法都没用。这太让人郁闷了,之前我还为用这个平台沾沾自喜呢。幸运的是,和服务器发送事件的问题不同,这个问题很好解决。我只需要用自定义的If-Modified-Since头字段,模拟浏览器的操作就行。下面就是我的“补救方法”:

const dumbFetchMap = new Map<string, string>();

/**
 * dumbFetch把"if-modified-since"头字段放到一个自定义头字段里,
 * 因为DigitalOcean应用平台的Cloudflare配置会去除"if-modified-since"头字段,这太不合理了。
 */
export async function dumbFetch(
  input: RequestInfo,
  init?: RequestInit
): Promise<Response> {
  const headers = new Headers(init?.headers);

  const prevLastModified = dumbFetchMap.get(input.toString());
  if (prevLastModified) {
    headers.append("x-if-modified-since", prevLastModified);
  }

  const resp = await fetch(input, { ...init, headers });
  const lastModified = resp.headers.get("last-modified");
  if (lastModified) {
    dumbFetchMap.set(input.toString(), lastModified);
  }
  return resp;
}

/**
 * dumbIfModifiedSince从请求中获取"if-modified-since"头字段并返回。
 * 它还会检查自定义的"x-if-modified-since"头字段,
 * 因为DigitalOcean应用平台的Cloudflare配置会去除"if-modified-since"头字段,这太不合理了。
 */
export function dumbIfModifiedSince(req: Request): string | null {
  return (
    req.headers.get("if-modified-since") ||
    req.headers.get("x-if-modified-since")
  );
}

dumbFetch代替fetch,就能避开这个问题,同时还能保持和fetch一样的逻辑。这个方法有点难看,但至少不好的部分被封装起来了。要是你用的平台支持HTTP规范,那就可以正常使用原生的功能。

最后终于成功了!服务器每次获取新数据(每5秒一次),我这边就能同步获取到(为了便于阅读,数据有删减):
image

我对这个解决方案还挺满意的,而且也学到了很多东西。但我还是对DigitalOcean应用平台很失望,它让HTTP里这么重要的一个功能没法正常使用。这个头字段虽然有点小众,但我试过把它换成ETag,还是不行,在我看来这是个大问题。要是有人知道有哪些平台满足以下条件:

  1. 支持持久化的后台任务(我不用vercel就是因为他们的定时任务每分钟最多运行一次)
  2. 支持自动持续部署
  3. 能自动处理HTTPS
  4. 能自动管理CDN
  5. 提供Redis服务
  6. 不用我自己维护服务器
  7. 每月费用大概39美元
  8. 额外加分项:支持Terraform/声明式定义

我洗耳恭听。我对fly.io挺感兴趣的,但还得再研究研究他们的运作方式。我感觉我差不多要像在日常工作里那样,用AWS或者GCP了。

posted @ 2025-05-12 18:36  talentzemin  阅读(38)  评论(0)    收藏  举报