鸿蒙开发入门之让 httpRequest 支持 Cookie

1,455 阅读5分钟

前言

要说最近前端技术圈什么最🔥,那一定非「遥遥领先」的 Harmony OS 莫属,本着对新技术的追求精神(扶我起来……),咱们也来尝试一下。

思来想去,决定用鸿蒙重写一下之前的Compose 版本的「玩 Android」

问题

对照着官方教程,一开始都挺顺利,直到开始写登录模块。

我们知道,鸿洋大佬的「玩 Android」Api 是用 Cookie 保存登录状态的,而鸿蒙请求网络使用的 httpRequest 不支持 Cookie,我翻遍了官方文档都没找到关于 Cookie 的说明。

我不死心,心想,Cookie 这么常见,应该不止我一个人遇到问题吧,于是在鸿蒙官方开发者论坛搜索 Cookie 关键字,还真有人提问

image01.jpg

于是开心的点了进去,看到版主的回答后,我彻底死心了💔

image02.jpg

其实能够理解官方为什么不提供直接操作 Cookie 的接口,因为这个太“业务”了,况且,Android 官方也没有提供类似的接口,需要开源社区帮助完善。

不过目前鸿蒙的开源库屈指可数,也没有找到网络相关的开源库。

难道 Harmony 版本的「玩 Android」就要折戟于此了吗,或者不提供登录功能?

解决

俗话说得好:只要思想不滑坡,办法总比困难多!既然没有现成的 Cookie 库,那我们就自己来实现一个。

回顾

我们先来复习下 Cookie 的知识

定义

摘自维基百科

HTTP Cookie,简称 Cookie,是浏览网站时由网络服务器创建并由网页浏览器存放在用户计算机或其他设备的小文本文件。

结构

Cookie的基本结构包括

  1. 名称
  2. 取值
  3. 各种属性

属性

一条 Cookie 可能有 DomainPathExpiresMax-AgeSecureHttpOnly 等多种属性,如

HTTP/1.0 200 OK
Set-Cookie: LSID=DQAAAK…Eaem_vYg; Path=/accounts; Expires=Wed, 13 Jan 2021 22:23:01 GMT; Secure; HttpOnly
Set-Cookie: HSID=AYQEVn…DKrdst; Domain=.foo.com; Path=/; Expires=Wed, 13 Jan 2021 22:23:01 GMT; HttpOnly
Set-Cookie: SSID=Ap4P…GTEq; Domain=foo.com; Path=/; Expires=Wed, 13 Jan 2021 22:23:01 GMT; Secure; HttpOnly

工作流程

  1. 客户端请求服务器时,服务器通过响应头中的 Set-Cookie 关键字告诉客户端需要保存的 Cookie 信息
  2. 客户端根据 Cookie 类型保存 Cookie 信息
  3. 下次请求时检查当前 Domain 和 Path 在本地是否有 Cookie 信息,如果有的话,通过请求头中的 Cookie 字段将 Cookie 信息告诉服务器

启发

既然 Cookie 的接收和发送都是通过 Header 实现,那我们也可以从这里入手。

先看接收部分,我们先用 PostMan 发送请求,看下返回的 Cookie,作为对照

image03.png

可以看到,登录信息保存在了 loginUserNametoken_pass

接下来,我们打印下用 httpRequest 发送登录请求的响应头

{
	"content-type": "application/json;charset=UTF-8",
	"date": "Wed, 27 Dec 2023 07:50:12 GMT",
	"server": "Apache-Coyote/1.1",
	"set-cookie": "token_pass_wanandroid_com=******; Domain=wanandroid.com; Expires=Fri, 26-Jan-2024 07:50:12 GMT; Path=/",
	"transfer-encoding": "chunked"
}

好家伙,怎么只有 token_pass,鸿蒙你让我说你什么好,怎么 Cookie 还少了

好在我发现 httpRequest 的 Response 中有一个单独的 Cookie 字段,打印下看看

www.wanandroid.com	FALSE	/	FALSE	1706255412	loginUserName	chaywong
www.wanandroid.com	FALSE	/	FALSE	1706255412	token_pass	xxxxxx
.wanandroid.com	TRUE	/	FALSE	1706255412	loginUserName_wanandroid_com	chaywong
.wanandroid.com	TRUE	/	FALSE	1706255412	token_pass_wanandroid_com	xxxxxx
#HttpOnly_www.wanandroid.com	FALSE	/	TRUE	0	JSESSIONID	CDF02AE9203F644EF8E44F8C444AD3B1

这次倒是基本全了,一个换行表示一条 Cookie,每一行通过空格隔开 Cookie 的属性,可是这每个属性是什么含义啊?

又是一顿翻文档,翻注释,也没找到每个字段的说明,看来只能硬着头皮猜了

先看第一行,wanandroid.com 应该是 Domain,/ 应该是 Path,1706255412 应该是过期时间,loginUserName 这该是 Cookie name,chaywong 是我的用户名,所以这个应该是 Cookie value,剩下的两个 boolean 实在没法猜了,应该是 Secure 和 HttpOnly,但是没办法对应。

好在这两个属性并不重要,不影响主要功能。

有了服务器的 Cookie,我们还要把 Cookie 再发送给服务器,以表明身份,这就更简单了

直接在 httpRequest 的请求头中增加 Cookie 字段,格式为

name1=value1; name2=value2

实践

既然 Cookie 的接收和发送都没问题了,那我们就来实现一个鸿蒙上的 CookieJar!

为了少走弯路,实践部分我们直接参考 Android 上的有名的开源库 PersistentCookieJar,和 OkHttp 配合起来使用 Cookie 非常方便,我在 Compose 版的「玩 Android」 上用的也是这个方案。

首先定义 Cookie 对象,用来保存 Cookie 属性,OkHttp 中已经定义好了,我们直接拿来用

image04.png

转成 TS 版本

/**
 * Cookie 对象,参考 OkHttp 中 Cookie 结构
 */
export default class Cookie {
  name: string = "";
  value: string = "";
  expiresAt: number = 0;
  domain: string = "";
  path: string = "";
  httpOnly: boolean = false;
  persistent: boolean = true;

  matches(url: uri.URI): boolean {
    let domainMatch = Cookie.domainMatch(url.host, this.domain);
    if (!domainMatch) return false;
    if (!Cookie.pathMatch(url, this.path)) return false;
    return true;
  }

  createCookieKey(): string {
    return `https://${this.domain}${this.path}|${this.name}`;
  }

  isExpired(): boolean {
    let nowTime = Date.now() / 1000;
    return this.expiresAt < nowTime;
  }
}

有些属性我们暂时获取不到,可以先忽略。

接下来是解析 httpRequest Response 中的 Cookie 信息

/**
 * httpRequest 返回的 cookie 格式:
 * ```
 * www.wanandroid.com	FALSE	/	FALSE	1706255412	loginUserName	chaywong
 * www.wanandroid.com	FALSE	/	FALSE	1706255412	token_pass	xxxxxx
 * .wanandroid.com	TRUE	/	FALSE	1706255412	loginUserName_wanandroid_com	chaywong
 * .wanandroid.com	TRUE	/	FALSE	1706255412	token_pass_wanandroid_com	xxxxxx
 * #HttpOnly_www.wanandroid.com	FALSE	/	TRUE	0	JSESSIONID	CDF02AE9203F644EF8E44F8C444AD3B1
 * ```
 */
static parseHttpRequestCookies(cookieString: string): Array<Cookie> {
  if (!cookieString) return [];
  let cookies: Array<Cookie> = [];
  let lines = cookieString.split("\r\n");
  lines.forEach((line) => {
    let attrs = line.split("\t");
    if (attrs.length !== 7 || !attrs[0]) return;
    let cookie = new Cookie();
    let domain = attrs[0];
    if (domain.startsWith("#HttpOnly_")) {
      domain = domain.substring("#HttpOnly_".length);
      cookie.httpOnly = true;
    }
    cookie.domain = domain;
    cookie.path = attrs[2];
    cookie.expiresAt = parseInt(attrs[4]);
    cookie.name = attrs[5];
    cookie.value = attrs[6];
    cookies.push(cookie);
  })
  return cookies;
}

接下来是设计 CookieJar,实现 Cookie 的接收、发送功能,这里我们同样借鉴 OkHttp 的 CookieJar 接口,这几个接口刚好满足我们接收、发送、清除 Cookie 的能力。

image05.png

image06.png

转成 TS 版本

export default interface CookieJar {
  init(): Promise<void>;

  saveFromResponse(url: uri.URI, cookies: Array<Cookie>);

  loadForRequest(url: uri.URI): Array<Cookie>;

  clear();
}

增加了一个 init 异步方法主要是为了异步加载 KV 缓存。

接口定义好,接下来就是实现了,这下该参考 PersistentCookieJar

image07.png

这里主要通过内存和 Preference 双重缓存 Cookie 信息,逻辑并不复杂,我们直接实现 TS 版本

export default class PersistentCookieJar implements CookieJar {
  private isInit = false;
  private cache: HashMap<string, Cookie> = new HashMap();
  private persistor: CookiePersistor;

  constructor(context: Context) {
    this.persistor = new KVCookiePersistor(context);
  }

  async init(): Promise<void> {
    if (!this.isInit) {
      let cookies = await this.persistor.loadAll();
      cookies.forEach((cookie) => {
        this.cache.set(cookie.createCookieKey(), cookie);
      })
      this.isInit = true;
    }
    return new Promise<void>((resolve, reject) => {
      resolve();
    })
  }

  saveFromResponse(url: uri.URI, cookies: Cookie[]) {
    cookies.forEach((cookie) => {
      this.cache.set(cookie.createCookieKey(), cookie);
    })
    this.persistor.saveAll(PersistentCookieJar.filterPersistentCookies(cookies));
  }

  private static filterPersistentCookies(cookies: Array<Cookie>): Array<Cookie> {
    return cookies.filter((item) => {
      return item.persistent;
    })
  }

  loadForRequest(url: uri.URI): Cookie[] {
    let cookiesToRemove: Array<Cookie> = [];
    let validCookies: Array<Cookie> = [];

    let iterator: IterableIterator<Cookie> = this.cache.values();
    let result = iterator.next();
    while (!result.done) {
      let currentCookie: Cookie = result.value;
      if (currentCookie.isExpired()) {
        cookiesToRemove.push(currentCookie);
      } else if (currentCookie.matches(url)) {
        validCookies.push(currentCookie);
      }
      result = iterator.next();
    }

    cookiesToRemove.forEach((item) => {
      this.cache.remove(item.createCookieKey());
    })

    this.persistor.removeAll(cookiesToRemove);

    return validCookies;
  }

  clear() {
    this.cache.clear();
    this.persistor.clear();
  }
}

TS 版的 KV 缓存 KVCookiePersistor 的具体实现这里先省略了,后面贴完整代码。

到这里,我们已经实现了鸿蒙版的 CookieJar 了,只需要和 httpRequest 结合起来即可

我抽了一个公共的发送请求的方法,只需要在这里加上 CookieJar 的调用即可。 在请求前查找是否有匹配的 Cookie,如果有就添加到 Header 中,同样的在请求返回后存储服务端返回的 Cookie 信息。

let cookieJar: CookieJar = new PersistentCookieJar(EntryContext.getContext());

/**
 * 通用请求方法
 */
async function requestSync<T>(path: string, method: http.RequestMethod, extraData?: Object): Promise<Response<T>> {
  // 确保 CookieJar 已经初始化
  await cookieJar.init();
  return new Promise<Response<T>>((resolve, reject) => {
    let url = BASE_URL + path;
    let uri = parseUri(url);
    let header = {};
    // 根据 url 加载本地缓存的 Cookie
    let cookies = cookieJar.loadForRequest(uri);
    if (cookies.length > 0) {
      // 将 Cookie 添加到 Header 中
      header["Cookie"] = CookieUtils.cookieHeader(cookies);
    }
    if (method === http.RequestMethod.POST) {
      header["Content-Type"] = "application/x-www-form-urlencoded";
    }
    let httpRequest = http.createHttp();
    httpRequest.request(
      url,
      {
        method: method,
        expectDataType: http.HttpDataType.OBJECT,
        header: header,
        extraData: extraData
      },
      (err, data) => {
        let res = new Response<T>()
        if (!err && data.responseCode === 200) {
          // 请求成功,保存服务端返回的 Cookie
          cookieJar.saveFromResponse(uri, CookieUtils.parseHttpRequestCookies(data.cookies))
          Object.assign(res, data.result)
        } else {
          res.errorCode = data.responseCode
          res.errorMsg = err.message
        }
        resolve(res);
      }
    )
  })
}

/**
 * 登录请求
 */
async login(username: string, password: string): Promise<Response<User>> {
  return requestSync("/user/login", http.RequestMethod.POST, `username=${username}&password=${password}`);
}

运行项目,登录一下,我们的登录状态果然被保存下来了,由于通过 KV 持久化了 Cookie 信息,重启 App 后登录态仍然还在。

完整代码

考虑到方便大家直接使用,原本准备发布到远程仓库,不过后来一想,这个库只是用来临时解决登录态问题,Cookie 的很多属性都未支持,还没达到公开发布的标准,因此先提供代码给需要的同学,也期待能有功能更完善的开源库。

点我直达

总结

本文主要分享在入坑鸿蒙时遇到的 Cookie 问题,通过借鉴 Android 上的 OkHttpPersistentCookieJar,最终实现了鸿蒙版的 PersistentCookieJar,虽然功能不够完善,但已经能够解决问题,期间也被迫复习了一把网络知识。

最后,希望大家看完有所收获。