GSP644

概览
12 年前,Lily 创办了 Pet Theory 连锁宠物医院。
Pet Theory 目前给客户开具 DOCX 格式的账单,但许多客户抱怨打不开这类账单。
为了提高客户满意度,Lily 要求 IT 部门的 Patrick 研究替代方案来改善当前的情形。
Pet Theory 的运维团队只有一个人,因而非常希望能够找到一种不需要大量持续维护的经济实惠型解决方案。在分析了各种处理方案后,Patrick 决定使用 Cloud Run。
Cloud Run 采用无服务器设计,它将基础设施管理任务剥离出来,让您可全力专注于构建应用,而不必担心开销的问题。作为 Google 无服务器产品,它能够缩减至零,也就是说不使用时不会产生费用。它还允许您使用基于容器的自定义二进制软件包,这使得构建一致且隔离的制品变得可行。
在本实验中,您将在 Cloud Run 上构建一个用于转换 PDF 的 Web 应用,该应用会自动将存储在 Cloud Storage 中的文件转换为 PDF 并存储在单独的文件夹中。
架构
下图概述了您将使用的服务,以及这些服务之间的关系:

目标
在本实验中,您将学习如何完成以下操作:
- 将 Node JS 应用转换为容器。
- 使用 Google Cloud Build 构建容器。
- 创建一个将文件转换为云中的 PDF 文件的 Cloud Run 服务。
- 将事件处理与 Cloud Storage 结合使用
前提条件
本实验是中级实验,假设您熟悉 Cloud 控制台和 shell 环境。具备一些与使用 Firebase 相关的经验也会有帮助,但不作硬性要求。在完成本实验之前,建议您先完成以下 Google Cloud Skills Boost 实验:
准备就绪后,请向下滚动页面,并按下方步骤来设置实验室环境。
设置和要求
点击“开始实验”按钮前的注意事项
请阅读以下说明。实验是计时的,并且您无法暂停实验。计时器在您点击开始实验后即开始计时,显示 Google Cloud 资源可供您使用多长时间。
此实操实验可让您在真实的云环境中开展实验活动,免受模拟或演示环境的局限。为此,我们会向您提供新的临时凭据,您可以在该实验的规定时间内通过此凭据登录和访问 Google Cloud。
为完成此实验,您需要:
- 能够使用标准的互联网浏览器(建议使用 Chrome 浏览器)。
注意:请使用无痕模式(推荐)或无痕浏览器窗口运行此实验。这可以避免您的个人账号与学生账号之间发生冲突,这种冲突可能导致您的个人账号产生额外费用。
注意:请仅使用学生账号完成本实验。如果您使用其他 Google Cloud 账号,则可能会向该账号收取费用。
如何开始实验并登录 Google Cloud 控制台
-
点击开始实验按钮。如果该实验需要付费,系统会打开一个对话框供您选择支付方式。左侧是“实验详细信息”窗格,其中包含以下各项:
- “打开 Google Cloud 控制台”按钮
- 剩余时间
- 进行该实验时必须使用的临时凭据
- 帮助您逐步完成本实验所需的其他信息(如果需要)
-
点击打开 Google Cloud 控制台(如果您使用的是 Chrome 浏览器,请右键点击并选择在无痕式窗口中打开链接)。
该实验会启动资源并打开另一个标签页,显示“登录”页面。
提示:将这些标签页安排在不同的窗口中,并排显示。
注意:如果您看见选择账号对话框,请点击使用其他账号。
-
如有必要,请复制下方的用户名,然后将其粘贴到登录对话框中。
{{{user_0.username | "<用户名>"}}}
您也可以在“实验详细信息”窗格中找到“用户名”。
-
点击下一步。
-
复制下面的密码,然后将其粘贴到欢迎对话框中。
{{{user_0.password | "<密码>"}}}
您也可以在“实验详细信息”窗格中找到“密码”。
-
点击下一步。
重要提示:您必须使用实验提供的凭据。请勿使用您的 Google Cloud 账号凭据。
注意:在本实验中使用您自己的 Google Cloud 账号可能会产生额外费用。
-
继续在后续页面中点击以完成相应操作:
- 接受条款及条件。
- 由于这是临时账号,请勿添加账号恢复选项或双重验证。
- 请勿注册免费试用。
片刻之后,系统会在此标签页中打开 Google Cloud 控制台。
注意:如需访问 Google Cloud 产品和服务,请点击导航菜单,或在搜索字段中输入服务或产品的名称。
激活 Cloud Shell
Cloud Shell 是一种装有开发者工具的虚拟机。它提供了一个永久性的 5GB 主目录,并且在 Google Cloud 上运行。Cloud Shell 提供可用于访问您的 Google Cloud 资源的命令行工具。
-
点击 Google Cloud 控制台顶部的激活 Cloud Shell
。
-
在弹出的窗口中执行以下操作:
- 继续完成 Cloud Shell 信息窗口中的设置。
- 授权 Cloud Shell 使用您的凭据进行 Google Cloud API 调用。
如果您连接成功,即表示您已通过身份验证,且项目 ID 会被设为您的 Project_ID 。输出内容中有一行说明了此会话的 Project_ID:
Your Cloud Platform project in this session is set to {{{project_0.project_id | "PROJECT_ID"}}}
gcloud
是 Google Cloud 的命令行工具。它已预先安装在 Cloud Shell 上,且支持 Tab 自动补全功能。
- (可选)您可以通过此命令列出活跃账号名称:
gcloud auth list
- 点击授权。
输出:
ACTIVE: *
ACCOUNT: {{{user_0.username | "ACCOUNT"}}}
To set the active account, run:
$ gcloud config set account `ACCOUNT`
- (可选)您可以通过此命令列出项目 ID:
gcloud config list project
输出:
[core]
project = {{{project_0.project_id | "PROJECT_ID"}}}
注意:如需查看在 Google Cloud 中使用 gcloud
的完整文档,请参阅 gcloud CLI 概览指南。
任务 1. 理解任务
Pet Theory 希望将账单转换为 PDF,以便客户能够可靠地打开账单。团队希望自动完成转换,以尽量减少办公室经理 Lisa 的工作量。
Pet Theory 的计算机顾问 Ruby 收到了 IT 部门 Patrick 的一封邮件...

Patrick,IT 管理员
|
Ruby,您好!
我做了一些调研,发现 LibreOffice 能够将许多不同的文件格式转换为 PDF。
是否有可能在云端运行 LibreOffice,从而免去维护服务器的工作?
Patrick
|
Ruby,软件顾问
|
Patrick,您好!
我想我已经找到办法了。
我刚刚在 YouTube 上观看了 Next 19 大会中关于 Cloud Run 的一段精彩视频。看起来我们可以使用 Cloud Run 在无服务器环境中运行 LibreOffice,不需要维护服务器!
我会发一些资源过来帮助您进行设置。
Ruby
|
帮助 Patrick 设置并部署 Cloud Run。
任务 2. 启用 Cloud Run API
-
打开导航菜单 (
),依次点击 API 和服务 > 库。在搜索栏中输入“Cloud Run”,然后从结果列表中选择 Cloud Run Admin API。
-
点击启用,然后点击浏览器中的返回按钮两次。现在,您的控制台应如下图所示:

任务 3. 部署一个简单的 Cloud Run 服务
Ruby 开发了一个 Cloud Run 原型,希望 Patrick 将其部署到 Google Cloud 上。现在需要帮助 Patrick 为 Pet Theory 搭建 PDF Cloud Run 服务。
-
打开一个新的 Cloud Shell 会话,并运行以下命令来克隆 Pet Theory 代码库:
git clone https://github.com/rosera/pet-theory.git
-
然后将当前工作目录改为 lab03:
cd pet-theory/lab03
-
使用 Cloud Shell 代码编辑器或您惯用的文本编辑器修改 package.json
。在“scripts”部分,如下所示添加 "start": "node index.js",
:
...
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
-
现在,在 Cloud Shell 中运行以下命令来安装转换脚本将使用的软件包:
npm install express
npm install body-parser
npm install child_process
npm install @google-cloud/storage
-
现在打开 lab03/index.js
文件并查看代码。
该应用将部署为接受 HTTP POST 的 Cloud Run 服务。
如果 POST 请求是有关所上传文件的 Pub/Sub 通知,则服务会将文件详细信息写入日志。否则,服务将仅返回字符串“OK”。
-
查看名为 lab03/Dockerfile
的文件。
上述文件称为清单,它为 Docker 命令提供了构建映像的配方。每行都以一个命令开头,该命令告诉 Docker 如何处理以下信息:
- 第一个列表指示基础映像应使用节点作为要创建的映像的模板。
- 最后一行指示要执行的命令,在本例中指的是“npm start”。
-
要构建并部署 REST API,请使用 Google Cloud Build。运行以下命令以启动构建流程:
gcloud builds submit \
--tag gcr.io/$GOOGLE_CLOUD_PROJECT/pdf-converter
该命令使用您的代码构建一个容器并将其放入您项目的 Artifact Registry 中。
-
返回 Cloud 控制台,打开导航菜单,然后选择 Artifact Registry > 映像。您应该看到您的容器托管:

验证您已完成的任务
点击检查我的进度,验证您是否完成了上述任务。
构建一个简单的 REST API
-
返回代码编辑器标签页,在 Cloud Shell 中运行以下命令来部署您的应用:
gcloud run deploy pdf-converter \
--image gcr.io/$GOOGLE_CLOUD_PROJECT/pdf-converter \
--platform managed \
--region {{{ project_0.default_region | Region }}} \
--no-allow-unauthenticated \
--max-instances=1
-
部署完成后,您会看到如下消息:
Service [pdf-converter] revision [pdf-converter-00001] has been deployed and is serving 100 percent of traffic at https://pdf-converter-[hash].a.run.app
-
为应用创建环境变量 $SERVICE_URL
,这样您就可以轻松访问它了:
SERVICE_URL=$(gcloud beta run services describe pdf-converter --platform managed --region {{{ project_0.default_region | Lab Region }}} --format="value(status.url)")
echo $SERVICE_URL
验证您已完成的任务
点击检查我的进度,验证您是否完成了上述任务。
为 Cloud Run 创建修订版本
-
向您的新服务发出匿名的 POST 请求:
curl -X POST $SERVICE_URL
注意:
这会导致系统提示一条错误消息,指出“Your client does not have permission to get the URL”(您的客户端没有权限,无法获取网址)。这是正常的,因为您不会希望用户匿名调用该服务。
-
现在尝试以授权用户身份调用该服务:
curl -X POST -H "Authorization: Bearer $(gcloud auth print-identity-token)" $SERVICE_URL
如果您收到响应 "OK"
,则表示您已成功部署 Cloud Run 服务。太棒了!
任务 4. 上传新文件时触发该 Cloud Run 服务
现在该 Cloud Run 服务已成功部署,Ruby 希望 Patrick 为要转换的数据创建一个暂存区。当文件已上传并等待处理时,Cloud Storage 存储桶将使用事件触发器通知该应用。
-
运行以下命令,在 Cloud Storage 中为上传的文档创建一个存储桶:
gsutil mb gs://$GOOGLE_CLOUD_PROJECT-upload
-
为已处理的 PDF 创建另一个存储桶:
gsutil mb gs://$GOOGLE_CLOUD_PROJECT-processed
-
现在返回“Cloud 控制台”标签页,打开导航菜单,选择 Cloud Storage。验证是否已创建这两个存储桶(还会有平台使用的其他存储桶)。
验证您已完成的任务
点击检查我的进度,验证您是否完成了上述任务。
创建两个 Cloud Storage 存储桶
-
在 Cloud Shell 中运行以下命令,指示 Cloud Storage 在每当有新文件上传到文档存储桶后,立即发送 Pub/Sub 通知:
gsutil notification create -t new-doc -f json -e OBJECT_FINALIZE gs://$GOOGLE_CLOUD_PROJECT-upload
此类通知的主题带有“new-doc”标签。
验证您已完成的任务
点击检查我的进度,验证您是否完成了上述任务。
创建一个 Pub/Sub 主题以处理来自存储桶的通知
-
然后创建一个新的服务账号,供 Pub/Sub 用来触发 Cloud Run 服务:
gcloud iam service-accounts create pubsub-cloud-run-invoker --display-name "PubSub Cloud Run Invoker"
-
授予新服务账号调用 PDF 转换器服务的权限:
gcloud beta run services add-iam-policy-binding pdf-converter \
--member=serviceAccount:pubsub-cloud-run-invoker@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
--role=roles/run.invoker \
--platform managed \
--region {{{ project_0.default_region | Lab Region }}}
-
运行以下命令来查找您的项目编号:
gcloud projects list --filter="PROJECT_ID={{{ project_0.project_id | PROJECT_ID }}}"
您将在下一个命令中使用项目编号的值。
PROJECT_ID: {{{ project_0.project_id | PROJECT_ID }}}
NAME: {{{ project_0.project_id | PROJECT_ID }}}
PROJECT_NUMBER: 103480415252
-
创建一个 PROJECT_NUMBER
环境变量
PROJECT_NUMBER=$(gcloud projects list --filter="PROJECT_ID={{{ project_0.project_id | PROJECT_ID }}}" --format=json | jq -r .[0].projectNumber)
-
最后,创建一个 Pub/Sub 订阅,以便在每当有消息发布到主题“new-doc”时,运行 PDF 转换器。
gcloud beta pubsub subscriptions create pdf-conv-sub \
--topic new-doc \
--push-endpoint=$SERVICE_URL \
--push-auth-service-account=pubsub-cloud-run-invoker@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com
验证您已完成的任务
点击检查我的进度,验证您是否完成了上述任务。
创建 Pub/Sub 订阅
任务 5. 检验文件上传到 Cloud Storage 后是否会触发 Cloud Run 服务
为了验证应用是否以预期的方式运行,Ruby 让 Patrick 将一些测试数据上传到指定的存储桶,然后检查 Cloud Logging。
-
将一些测试文件复制到您的上传存储桶中:
gsutil -m cp gs://spls/gsp644/* gs://$GOOGLE_CLOUD_PROJECT-upload
-
上传完成后,返回“Cloud 控制台”标签页,打开导航菜单,然后从“操作”部分下方选择 Logging。
-
在资源下拉菜单中,选择 Cloud Run 修订版本作为过滤条件,再点击应用。然后点击运行查询。
-
在查询结果中,找到以 file:
开头的日志条目,然后点击该条目。它显示了新文件上传后,Pub/Sub 发送到您的 Cloud Run 服务的文件数据的转储。
-
您能在这个对象中找到所上传文件的名称吗?

注意:
如果您没有看到任何以“file”开头的日志条目,请尝试点击页面底部附近的“load newer logs”(加载较新的日志)按钮。
-
现在返回代码编辑器标签页,在 Cloud Shell 中运行以下命令,通过删除 upload
目录中的文件来清理该目录:
gsutil -m rm gs://$GOOGLE_CLOUD_PROJECT-upload/*
任务 6. 容器
Patrick 需要将积压的账单转换为 PDF,以便所有客户都可以打开账单。他给 Ruby 发了一封电子邮件来寻求帮助...

Patrick,IT 管理员
|
Ruby,您好!
根据您的调研结果,我想我们可以自动执行这个过程,也可以开始使用 PDF 作为账单格式。
我昨天花了一点时间写解决方案的代码,并且构建了一个 Node.js 脚本来完成我们需要的功能。您能看一下吗?
Patrick
|
Patrick 给 Ruby 发送了他编写的基于文件生成 PDF 的代码片段:
const {promisify} = require('util');
const exec = promisify(require('child_process').exec);
const cmd = 'libreoffice --headless --convert-to pdf --outdir ' +
`/tmp "/tmp/${fileName}"`;
const { stdout, stderr } = await exec(cmd);
if (stderr) {
throw stderr;
}
Ruby 回复了 Patrick...

Ruby,软件顾问
|
Patrick,您好!
Cloud Run 使用容器,因此我们需要以这种格式提供应用。接下来,我们需要为应用创建一个 Dockerfile 清单。
您的代码使用 LibreOffice。您能把安装这个软件的命令发给我吗?我需要把它包含在容器中。
Ruby
|

Patrick,IT 管理员
|
Ruby,您好!
太棒了!下面是我在办公室的服务器上安装 LibreOffice 时通常使用的命令:
apt-get update -y && apt-get install -y libreoffice && apt-get clean
如果您还需要其他任何信息,请告诉我。
Patrick
|
构建容器需要集成多个组件:

更新清单
识别出所有文件后,现在可以创建清单了。帮助 Ruby 设置和部署容器。
LibreOffice 的软件包之前未包含在容器中,现在需要添加。
Patrick 之前提供了他用来构建应用的命令,Ruby 会将这些命令作为 RUN
命令添加到 Dockerfile 中。
-
打开 Dockerfile
清单并添加命令行 RUN apt-get update -y && apt-get install -y libreoffice && apt-get clean
,如下所示:
FROM {{{ project_0.startup_script.node_version | NODE_VERSION }}}
RUN apt-get update -y \
&& apt-get install -y libreoffice \
&& apt-get clean
WORKDIR /usr/src/app
COPY package.json package*.json ./
RUN npm install --only=production
COPY . .
CMD [ "npm", "start" ]
部署新版本的 pdf-conversion 服务
-
打开 index.js
文件,在文件顶部添加以下软件包要求:
const {promisify} = require('util');
const {Storage} = require('@google-cloud/storage');
const exec = promisify(require('child_process').exec);
const storage = new Storage();
-
将 app.post('/', async (req, res)
替换为以下代码:
app.post('/', async (req, res) => {
try {
const file = decodeBase64Json(req.body.message.data);
await downloadFile(file.bucket, file.name);
const pdfFileName = await convertFile(file.name);
await uploadFile(process.env.PDF_BUCKET, pdfFileName);
await deleteFile(file.bucket, file.name);
}
catch (ex) {
console.log(`Error: ${ex}`);
}
res.set('Content-Type', 'text/plain');
res.send('\n\nOK\n\n');
})
-
现在将处理 LibreOffice 文档的以下代码添加到文件底部:
async function downloadFile(bucketName, fileName) {
const options = {destination: `/tmp/${fileName}`};
await storage.bucket(bucketName).file(fileName).download(options);
}
async function convertFile(fileName) {
const cmd = 'libreoffice --headless --convert-to pdf --outdir /tmp ' +
`"/tmp/${fileName}"`;
console.log(cmd);
const { stdout, stderr } = await exec(cmd);
if (stderr) {
throw stderr;
}
console.log(stdout);
pdfFileName = fileName.replace(/\.\w+$/, '.pdf');
return pdfFileName;
}
async function deleteFile(bucketName, fileName) {
await storage.bucket(bucketName).file(fileName).delete();
}
async function uploadFile(bucketName, fileName) {
await storage.bucket(bucketName).upload(`/tmp/${fileName}`);
}
-
确保您的 index.js
文件如下所示:
注意:
为避免任何格式错误,建议您使用这里的示例代码替换 index.js
文件中的所有代码。
const {promisify} = require('util');
const {Storage} = require('@google-cloud/storage');
const exec = promisify(require('child_process').exec);
const storage = new Storage();
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log('Listening on port', port);
});
app.post('/', async (req, res) => {
try {
const file = decodeBase64Json(req.body.message.data);
await downloadFile(file.bucket, file.name);
const pdfFileName = await convertFile(file.name);
await uploadFile(process.env.PDF_BUCKET, pdfFileName);
await deleteFile(file.bucket, file.name);
}
catch (ex) {
console.log(`Error: ${ex}`);
}
res.set('Content-Type', 'text/plain');
res.send('\n\nOK\n\n');
})
function decodeBase64Json(data) {
return JSON.parse(Buffer.from(data, 'base64').toString());
}
async function downloadFile(bucketName, fileName) {
const options = {destination: `/tmp/${fileName}`};
await storage.bucket(bucketName).file(fileName).download(options);
}
async function convertFile(fileName) {
const cmd = 'libreoffice --headless --convert-to pdf --outdir /tmp ' +
`"/tmp/${fileName}"`;
console.log(cmd);
const { stdout, stderr } = await exec(cmd);
if (stderr) {
throw stderr;
}
console.log(stdout);
pdfFileName = fileName.replace(/\.\w+$/, '.pdf');
return pdfFileName;
}
async function deleteFile(bucketName, fileName) {
await storage.bucket(bucketName).file(fileName).delete();
}
async function uploadFile(bucketName, fileName) {
await storage.bucket(bucketName).upload(`/tmp/${fileName}`);
}
const file = decodeBase64Json(req.body.message.data);
await downloadFile(file.bucket, file.name);
const pdfFileName = await convertFile(file.name);
await uploadFile(process.env.PDF_BUCKET, pdfFileName);
await deleteFile(file.bucket, file.name);
每当有文件上传时,都会触发此服务。它执行以下任务(上面每一行代码表示执行一个任务):
- 从 Pub/Sub 通知中提取文件详细信息。
- 将文件从 Cloud Storage 下载到本地硬盘。这实际上不是物理磁盘,而是一段表现得像磁盘的虚拟内存。
- 将下载的文件转换为 PDF。
- 将 PDF 文件上传到 Cloud Storage。环境变量
process.env.PDF_BUCKET
包含 PDF 要写入的 Cloud Storage 存储桶的名称。您将在部署下面的服务时为该变量指定一个值。
- 从 Cloud Storage 中删除原始文件。
index.js
的余下部分实现了此顶级代码调用的函数。
现在可以部署该服务并设置 PDF_BUCKET
环境变量了。为 LibreOffice 提供 2GB 的 RAM 也是一个不错的主意(请参阅带有 --memory
选项的那一行)。
-
运行以下命令来构建容器:
gcloud builds submit \
--tag gcr.io/$GOOGLE_CLOUD_PROJECT/pdf-converter
注意:
如果您收到一条弹出消息,提示您启用 Cloud Build API,请输入 Y
验证您已完成的任务
点击检查我的进度,验证您是否完成了上述任务。
创建另一个针对 REST API 的版本
-
现在部署最新版本的应用:
gcloud run deploy pdf-converter \
--image gcr.io/$GOOGLE_CLOUD_PROJECT/pdf-converter \
--platform managed \
--region {{{ project_0.default_region | Lab Region }}} \
--memory=2Gi \
--no-allow-unauthenticated \
--max-instances=1 \
--set-env-vars PDF_BUCKET=$GOOGLE_CLOUD_PROJECT-processed
由于容器中现在包括了 LibreOffice,因此这次构建将比上一次构建需要更长的时间。可以趁此机会起身活动活动。
点击检查我的进度以验证是否完成了以下目标:
创建一个新的修订版本
任务 7. 测试 pdf-conversion 服务
-
部署命令完成后,请运行以下命令,确保服务已正确部署:
curl -X POST -H "Authorization: Bearer $(gcloud auth print-identity-token)" $SERVICE_URL
-
如果您收到响应 "OK"
,则表示您已成功部署更新了的 Cloud Run 服务。LibreOffice 可以将许多文件类型转换为 PDF:DOCX、XLSX、JPG、PNG、GIF 等。
-
运行以下命令来上传一些示例文件:
gsutil -m cp gs://spls/gsp644/* gs://$GOOGLE_CLOUD_PROJECT-upload
-
返回 Cloud 控制台,打开导航菜单,选择 Cloud Storage。打开 -upload
存储桶并点击刷新按钮几次,查看这些文件在转换为 PDF 后是如何逐一删除的。
-
然后点击左侧菜单中的存储桶,再点击名称以“-processed”结尾的存储桶。该存储桶应包含所有文件的 PDF 版本。请随意打开 PDF 文件,检查它们是否已正确转换:
注意:
如果您在 -processed
存储桶中没有看到所有已转换的 PDF 文件,请重新运行此命令。
恭喜!
Pet Theory 现在有了一个用于将积压的旧文件转换为 PDF 的系统。
只需将旧文件上传到“upload”存储桶,pdf-converter 服务就会将它们转换为 PDF 并写入“processed”存储桶。
请接着探索无服务器 Cloud Run 开发课程,继续您的无服务器之旅。
您将阅读一个虚构的业务场景并协助角色制定无服务器迁移计划。
Google Cloud 培训和认证
…可帮助您充分利用 Google Cloud 技术。我们的课程会讲解各项技能与最佳实践,可帮助您迅速上手使用并继续学习更深入的知识。我们提供从基础到高级的全方位培训,并有点播、直播和虚拟三种方式选择,让您可以按照自己的日程安排学习时间。各项认证可以帮助您核实并证明您在 Google Cloud 技术方面的技能与专业知识。
上次更新手册的时间:2024 年 5 月 28 日
上次测试实验的时间:2024 年 5 月 28 日
版权所有 2025 Google LLC 保留所有权利。Google 和 Google 徽标是 Google LLC 的商标。其他所有公司名和产品名可能是其各自相关公司的商标。