一、步骤1:添加用于加速分发的加速域名
请根据 添加加速域名 指引添加加速域名,例如:www.example.com 且源站配置为 Android APK 母包 所在的对象存储 COS 自定义域名,详情可参见 存储桶切换自定义域名 。配置如下:
注意:说明:
注意:该域名将用于访问下载 APK 安装包。
二、步骤2:创建用于触发渠道信息写入的边缘函数
1. 根据 函数管理 指引创建一个边缘函数,将如下代码复制到函数代码内。
注意:说明:
注意:本函数已更新,新增对单 Range 请求的支持,适配多线程下载器、断点续传等分段下载场景。此前 Range 请求会直接透传回源、不写入渠道信息,更新后即可正常写入。
const CUSTOM_BLOCK_VALUE_LENGTH = 10240;const APK_SIGNING_BLOCK_MAGIC_LENGTH = 16;const APK_SIGNING_BLOCK_OFFSET_LENGTH = 8;const APK_COMMENT_LENGTH = 512;class EdgePack { totalSize; signVersion; centralDirectoryOffset; customBlockValueStart; customBlockValueEnd; rangeRelativeOffset; customInfo; constructor() { /** @type {number} */ this.totalSize = null; this.signVersion = null; this.centralDirectoryOffset = null; this.customBlockValueStart = null; this.customBlockValueEnd = null; this.rangeRelativeOffset = null; /** @type {string} */ this.customInfo = null; /** @type {string} */ this.customName = null; /** @type {number[]} */ this.customRange = null; /** @type {boolean} */ this.invalidRange = false; // 标记是否为非法 Range } async handle(event) { const { request } = event; const headers = new Headers(request.headers); const modifiedRequest = new Request(request, { headers }); if (!this.checkRequest(modifiedRequest)) { // 如果检测到非法 Range 格式,直接返回 416 if (this.invalidRange) { const responseHeaders = new Headers(); // 此时还未获取到 totalSize,无法设置 Content-Range // responseHeaders.set("Content-Range", `bytes */0`); return event.respondWith( new Response("", { status: 416, statusText: "Range Not Satisfiable", headers: responseHeaders, }) ); } // 其他情况 passthrough return; } let response = null; try { const headHeaders = new Headers(modifiedRequest.headers); // 删除 Range 头,获取正确的 Content-Length headHeaders.delete("Range"); const headRequest = new Request(modifiedRequest.url, { method: "HEAD", headers: headHeaders, }); response = await fetch(headRequest, { redirect: "manual" }); } catch (err) { const error = { code: "FETCH_ORIGIN_ERROR", message: err?.message, }; response = new Response(JSON.stringify(error), { status: 590, }); } if (!this.checkResponse(response)) { // 如果是非法 Range(格式错误或范围错误),返回 416 状态码 if (this.invalidRange && this.totalSize !== null) { const headers = new Headers(); headers.set("Content-Range", `bytes */${this.totalSize}`); return event.respondWith( new Response("", { status: 416, statusText: "Range Not Satisfiable", headers: headers, }) ); } return event.respondWith(response); } if (!!this.customName) { response.headers.set( "Content-Disposition", `attachment; filename="${this.customName}"` ); } // 处理单一范围的 Range 请求 if (this.customRange) { const [rangeStart, rangeEnd] = this.customRange; let actualStart = rangeStart; let actualEnd = rangeEnd; let contentLength = this.totalSize; if (rangeStart === null && rangeEnd !== null) { // bytes=-suffix 格式:最后 suffix 个字节 actualStart = Math.max(0, this.totalSize – rangeEnd); actualEnd = this.totalSize – 1; contentLength = Math.min(rangeEnd, this.totalSize); } else if (rangeEnd !== null) { // bytes=start-end 格式 contentLength = rangeEnd – rangeStart + 1; actualEnd = rangeEnd; } else if (rangeStart !== null) { // bytes=start- 格式 actualEnd = this.totalSize – 1; contentLength = this.totalSize – rangeStart; } response.headers.set( "Content-Range", `bytes ${actualStart}-${actualEnd}/${this.totalSize}` ); response.headers.set("Content-Length", contentLength.toString()); response.headers.set("Accept-Ranges", "bytes"); response = new Response(null, { status: 206, statusText: "Partial Content", headers: response.headers, }); } // 处理非 Range 请求 else { // 非 Range 请求也应该声明支持 Range response.headers.set("Accept-Ranges", "bytes"); } const streamResponse = new Response( await this.combineStreams(modifiedRequest), response ); return event.respondWith(streamResponse); } getRelativeOffset(response) { const start = this.customBlockValueStart; const end = this.customBlockValueEnd; const range = response.headers.get("Content-Range"); if (!range) return start; const match = range.match(/bytes\\s*(\\d*)-(\\d*)/i); if (!match || match?.length < 2) { return start; } const rangeStart = +match[1]; const rangeEnd = +match[2]; if (rangeEnd < start || rangeStart > end) { return null; } return start – rangeStart; } checkRequest(request) { if (request.method !== "GET") { // 非 GET 请求,使用 passthrough 回源 return false; } if (request.headers.has("Range")) { const range = request.headers.get("Range"); // 检查是否为多 Range 请求 if (range.includes(",")) { // Multi-Range 请求,使用 passthrough 回源 console.log("Multi-Range detected, using passthrough"); return false; } // 单 Range 请求 else { // 使用更精确的正则:分别匹配 bytes=start-end 和 bytes=-suffix // 捕获组: (1)start (2)end 用于 start-end 格式 // (3)suffix 用于 -suffix 格式 const match = range.match(/bytes=(?:(\\d+)-(\\d*)|-(\\d+))$/i); if (match) { // 处理三种 Range 格式: // 1. bytes=start-end: 指定范围 // 2. bytes=start-: 从 start 到文件末尾 // 3. bytes=-suffix: 最后 suffix 个字节 // 检查是否为 bytes=-suffix 格式(第3个捕获组有值) if (match[3] !== undefined) { // bytes=-suffix 格式:最后 suffix 个字节 const suffix = +match[3]; // bytes=-0 是无效的范围请求,应该返回 416 if (suffix === 0) { console.warn("Invalid Range: bytes=-0 is not satisfiable"); this.invalidRange = true; return false; } this.customRange = [null, suffix]; } else { // bytes=start-end 或 bytes=start- 格式 const start = match[1] ? +match[1] : null; const end = match[2] && match[2] !== "" ? +match[2] : null; // 检测 start > end 的非法情况 if (start !== null && end !== null && start > end) { console.warn("Invalid Range: start > end:", range); this.invalidRange = true; return false; } this.customRange = [start, end]; } } else { console.warn("Invalid Range format:", range); this.invalidRange = true; return false; } } } const { pathname, searchParams } = new URL(request.url); // 获取渠道信息参数,存放在 comment 参数中 const comment = searchParams?.get("comment"); // 非 APK 文件,或是没有渠道信息参数,使用 passthrough 回源 if (!pathname.endsWith(".apk") || !comment) { return false; } this.customInfo = comment; const filename = searchParams?.get("filename"); if (filename) { this.customName = filename; } return true; } /** * @param {number} fileSize * @returns {boolean} */ validateRange(fileSize) { // 验证单 Range if (!this.customRange) { return true; } const [rangeStart, rangeEnd] = this.customRange; // 处理 bytes=-suffix 格式 if (rangeStart === null && rangeEnd !== null) { // suffix 超出文件大小,修正为文件大小 if (rangeEnd > fileSize) { this.customRange = [null, fileSize]; return true; } return true; } // 处理 bytes=start-end 和 bytes=start- 格式 if (rangeStart !== null) { // start 超出文件大小范围,标记为非法并返回 false if (rangeStart >= fileSize) { this.invalidRange = true; return false; } // start 在范围内,但 end 超出范围,修正为 start- if (rangeEnd !== null && rangeEnd >= fileSize) { this.customRange = [rangeStart, null]; return true; } } return true; } checkResponse(response) { if (response.status !== 200 && response.status !== 206) { return false; } const contentLength = response.headers.get("Content-Length"); if (response.body === null || contentLength === null) { return false; } this.totalSize = Number(contentLength); if (this.customRange && !this.validateRange(this.totalSize)) { // Range 请求超出范围,标记 invalidRange 并返回 false // invalidRange 已在 validateRange() 中设置 return false; } const cosOffsetHeader = response.headers.get("x-cos-meta-edgepack-offset"); const cosTypeHeader = response.headers.get("x-cos-meta-edgepack-type"); if (!cosOffsetHeader || !cosTypeHeader) { return false; } this.signVersion = cosTypeHeader; this.centralDirectoryOffset = Number(cosOffsetHeader); if (this.signVersion === "v1") { this.customBlockValueStart = this.totalSize – APK_COMMENT_LENGTH; this.customBlockValueEnd = this.totalSize; } else { this.customBlockValueStart = this.centralDirectoryOffset – CUSTOM_BLOCK_VALUE_LENGTH – APK_SIGNING_BLOCK_MAGIC_LENGTH – APK_SIGNING_BLOCK_OFFSET_LENGTH; this.customBlockValueEnd = this.centralDirectoryOffset; } this.rangeRelativeOffset = this.getRelativeOffset(response); if (this.rangeRelativeOffset === null) { return false; } return true; } async combineStreams(request) { // 处理单 Range 或完整请求 let contentLength = this.totalSize; if (this.customRange) { const [rangeStart, rangeEnd] = this.customRange; if (rangeStart === null && rangeEnd !== null) { // bytes=-suffix 格式:最后 suffix 个字节 contentLength = Math.min(rangeEnd, this.totalSize); } else if (rangeEnd !== null) { // bytes=start-end 格式 contentLength = rangeEnd – rangeStart + 1; } else if (rangeStart !== null) { // bytes=start- 格式 contentLength = this.totalSize – rangeStart; } } const { readable, writable } = new FixedLengthStream(contentLength); this.handleStream(request, writable); return readable; } /** * 写入指定范围的数据 * 使用 pipeTo 优化性能,适用于单 Range 请求 * @private */ async _writeRangeData(request, writable, actualStart, actualEnd) { const comment = this.customInfo; const encoder = new TextEncoder(); const section = encoder.encode(comment); // Header 部分:签名块之前的数据 if (actualStart < this.customBlockValueStart) { const apkHeader = await this.apkHeaderStream( request, actualStart, actualEnd ); if (apkHeader !== null) { try { await apkHeader.pipeTo(writable, { preventClose: true, }); } catch (e) { console.error("HEADER_STREAM_ERROR: ", e); } } } // Body 部分:签名块(渠道信息注入区域) if ( actualStart < this.customBlockValueEnd && (actualEnd === null || actualEnd >= this.customBlockValueStart) ) { const apkBody = await this.apkBodyStream( request, section, actualStart, actualEnd ); if (apkBody !== null) { const apkBodyStream = apkBody.stream(); try { await apkBodyStream.pipeTo(writable, { preventClose: true, }); } catch (e) { console.error("BODY_STREAM_ERROR: ", e); } } } // Tail 部分:签名块之后的数据 if (actualEnd === null || actualEnd >= this.customBlockValueEnd) { const apkTail = await this.apkTailStream( request, actualStart, actualEnd ); if (apkTail !== null) { try { await apkTail.pipeTo(writable, { preventClose: true, }); } catch (e) { console.error("TAIL_STREAM_ERROR: ", e); } } } } async handleStream(request, writable) { try { const [rangeStart, rangeEnd] = this.customRange || [0, null]; let actualStart = rangeStart; let actualEnd = rangeEnd; if (rangeStart === null && rangeEnd !== null) { actualStart = Math.max(0, this.totalSize – rangeEnd); actualEnd = this.totalSize – 1; } await this._writeRangeData(request, writable, actualStart, actualEnd); } catch (err) { console.error("HANDLE_STREAM_ERROR: ", err); } finally { let writer = writable.getWriter(); writer.close(); writer.releaseLock(); } } async apkHeaderStream(request, actualStart, actualEnd) { const headers = new Headers(request.headers); let headerStart = 0; let headerEnd = this.customBlockValueStart – 1; if (actualStart > 0) { headerStart = Math.max(headerStart, actualStart); } if (actualEnd !== null) { headerEnd = Math.min(headerEnd, actualEnd); } if (headerStart > headerEnd) { return null; } headers.set("Range", `bytes=${headerStart}-${headerEnd}`); // 获取签名块之前的部分 const headResponse = await fetch(request, { headers: headers, }); return headResponse.body; } async apkBodyStream( request, section = null, actualStart = 0, actualEnd = null ) { const headers = new Headers(request.headers); let bodyStart = this.customBlockValueStart; let bodyEnd = this.customBlockValueEnd – 1; if (actualStart > this.customBlockValueStart) { bodyStart = Math.max(bodyStart, actualStart); } if (actualEnd !== null) { bodyEnd = Math.min(bodyEnd, actualEnd); } if (bodyStart > bodyEnd) { return null; } headers.set("Range", `bytes=${bodyStart}-${bodyEnd}`); const middleResponse = await fetch(request, { headers: headers, }); const reader = middleResponse.body.getReader(); let outputBuffers = []; try { let handledBytes = bodyStart; while (true) { const result = await reader.read(); if (result.done) { console.log("APK_BODY_STREAM_DONE"); break; } const startByteOffset = handledBytes; const buffer = result.value; handledBytes += buffer.byteLength; // 渠道信息在签名块中的绝对位置 const channelAbsoluteStart = this.customBlockValueStart; const channelAbsoluteEnd = channelAbsoluteStart + section.byteLength; // 计算当前 buffer 中需要替换的范围 const min = Math.max(startByteOffset, channelAbsoluteStart); const max = Math.min(channelAbsoluteEnd, handledBytes); if (min < max) { const bufferStart = min – startByteOffset; const sectionStart = min – channelAbsoluteStart; const sectionEnd = max – channelAbsoluteStart; const replacement = section.subarray(sectionStart, sectionEnd); new Uint8Array(buffer).set(replacement, bufferStart); } outputBuffers.push(buffer); } } catch (err) { console.error("APK_BODY_STREAM_ERROR: ", err); } return new Blob(outputBuffers); } async apkTailStream(request, actualStart, actualEnd) { const headers = new Headers(request.headers); let tailStart = this.customBlockValueEnd; let tailEnd = this.totalSize – 1; if (actualStart > this.customBlockValueEnd) { tailStart = Math.max(tailStart, actualStart); } if (actualEnd !== null) { tailEnd = Math.min(tailEnd, actualEnd); } if (tailStart > tailEnd) { return null; } headers.set("Range", `bytes=${tailStart}-${tailEnd}`); const tailResponse = await fetch(request, { headers: headers, }); return tailResponse.body; }}async function handleEvent(event) { const edgepack = new EdgePack(); await edgepack.handle(event);}addEventListener("fetch", handleEvent);
2. 完成部署函数后,根据 函数管理 指引配置触发规则,其 HOST 值为 步骤1 创建的加速域名:
3. 单击确定,即可完成触发规则的创建。用户访问域名 www.example.com 且文件后缀为apk时,即可触发边缘函数进行动态打包。
三、步骤3:验证 EdgeOne 边缘函数是否已将渠道信息写入到 Android APK 包
在浏览器中输入带有渠道信息的 URL,例如:http://www.example.com/v2_src.apk?comment=test 即可触发边缘函数,动态地将渠道信息注入到指定位置,其中 comment 为你在 创建用于触发渠道信息写入的边缘函数 中定义的渠道参数。以 v2-VasDolly 方式为例,可以使用 VasDolly 多渠道打包工具来读取动态注入的渠道信息: