当前位置:   article > 正文

在Azure上大规模部署模型——第2部分:部署和扩展PyTorch模型_fastapi deploy torch model

fastapi deploy torch model

目录

入门

注册模型

创建模型推理代码

构建FastAPI服务代码

使用Python和FastAPI发布应用服务

更新FastAPI的应用服务配置

授予应用服务应用程序权限

测试服务

检查应用服务日志

使用Azure机器学习和托管终结点

创建在线端点代码

配置在线端点

创建托管在线端点

测试在线端点

本地部署

删除Azure资源

下一步


这是3部分系列文章的第2部分,演示如何获取使用各种Python AI框架构建的AI模型,并使用Azure ML托管终结点部署和扩展它们。在这篇文章中,PyTorch模型被训练来识别手写数字。我们将FastAPI与应用服务一起使用,然后使用Azure机器学习在线端点。

机器学习(ML)通常需要大量的处理能力。尽管您的Python ML项目可能超出您当前计算机的能力,但您可以使用Azure运行几乎任何规模的ML工作负载。

在这个由三部分组成的系列的第一篇文章中,我们发布了一个XGBoost模型,该模型经过训练可以从著名的MNIST数据集中识别手写数字。我们将Azure App ServiceFlask一起使用,然后使用机器学习在线端点。在本教程中,我们将使用上一个系列中的另一个模型。该模型要求更高,但允许我们探索更高级的场景。

我们的目标是为机器学习模型的实时推理创建自定义Rest API服务。我们将首先使用PythonFastAPIAzure应用服务发布PyTorch模型。然后,我们将使用相同的模型创建一个在线端点,这是一个相对较新的Azure机器学习功能,目前仍处于预览阶段。

我们使用Azure CLI编写易于理解和可重复的脚本,我们可以存储这些脚本并与我们的其余代码一起进行版本控制。在专用的GitHub存储库中找到示例代码、脚本、模型和一些测试图像。

入门

要遵循本文的示例,您需要Visual Studio Code、最新的Python版本(3.7+)和用于Python包管理的Conda。如果您没有其他偏好,请从MinicondaPython 3.9开始。

安装Conda后,创建并激活一个新环境:

  1. $ conda create -n azureml python=3.9
  2. $ conda activate azureml

除了PythonConda,我们将在2.15.0 或更高版本中使用带有机器学习扩展Azure命令行工具:

$ az extension add -n ml -y

最后但同样重要的是,如果您还没有Azure帐户,请注册一个免费的Azure帐户,并享受数百美元的积分和访问各种服务。

准备好所有这些资源后,登录您的订阅。

$ az login

然后,设置以下环境变量以在脚本中使用:

  1. export AZURE_SUBSCRIPTION="<your-subscription-id>"
  2. export RESOURCE_GROUP="azureml-rg"
  3. export AML_WORKSPACE="demo-ws"
  4. export LOCATION="westeurope"

如果您没有遵循上一个系列中的示例,您还需要创建一个Azure机器学习工作区:

  1. $ az ml workspace create --name $AML_WORKSPACE --subscription $AZURE_SUBSCRIPTION
  2. --resource-group $RESOURCE_GROUP --location $LOCATION

我们已准备好开始准备模型以进行部署。

注册模型

因为我们的模型很小,我们可以将它与我们的应用程序代码捆绑并部署,但这不是最佳实践。现实生活中的模型可能非常大,因此我们应该能够独立于代码对模型进行版本控制。我们将使用Azure机器学习工作区的内置模型注册表。

在上一个系列的第二篇文章中,我们已经将模型保存到了注册表中。如果您还没有它,只需运行以下命令:

  1. $ az ml model create --name "mnist-pt-model" --local-path "./mnist.pt_model"
  2. --subscription $AZURE_SUBSCRIPTION --resource-group $RESOURCE_GROUP
  3. --workspace-name $AML_WORKSPACE

创建模型推理代码

与上一篇文章一样,我们的Rest API服务的核心部分是加载模型并对图像数据运行推理的代码。我们将代码存储在inference_model.py文件中。

文件以import开头:

  1. import numpy as np
  2. from PIL import Image
  3. import torch
  4. from torch.nn import functional as F
  5. from torch import nn

接下来,我们的模型需要一个class。它必须与我们用于训练的代码相同。

  1. class NetMNIST(nn.Module):
  2. def __init__(self):
  3. super().__init__()
  4. self.conv1 = nn.Conv2d(1, 10, kernel_size=5)
  5. self.conv2 = nn.Conv2d(10, 20, kernel_size=5)
  6. self.fc1 = nn.Linear(320, 50)
  7. self.fc2 = nn.Linear(50, 10)
  8. def forward(self, x):
  9. x = F.max_pool2d(F.relu(self.conv1(x)), (2,2))
  10. x = F.max_pool2d(F.dropout(F.relu(self.conv2(x)), p=0.2), (2,2))
  11. x = x.view(-1, 320)
  12. x = F.relu(self.fc1(x))
  13. x = F.dropout(x, p=0.2, training=self.training)
  14. x = self.fc2(x)
  15. return F.log_softmax(x, dim=1)

现在,我们可以添加我们的InferenceModel类:

  1. class InferenceModel():
  2. def __init__(self, model_path):
  3. is_cuda_available = torch.cuda.is_available()
  4. self.device = torch.device("cuda" if is_cuda_available else "cpu")
  5. self.model = NetMNIST().to(self.device)
  6. self.model.load_state_dict(torch.load(model_path))
  7. def _preprocess_image(self, image_bytes):
  8. image = Image.open(image_bytes)
  9. image = image.resize((28,28)).convert('L')
  10. image_np = (255 - np.array(image.getdata())) / 255.0
  11. return torch.tensor(image_np).float().to(self.device)
  12. def predict(self, image_bytes):
  13. image_data = self._preprocess_image(image_bytes)
  14. with torch.no_grad():
  15. prediction = self.model(image_data.reshape(-1,1,28,28)).cpu().numpy()
  16. return np.argmax(prediction, axis=1)
'
运行

与我们之前的XGBoost模型一样,该文件包含一个类,InferenceModel。该类具有三个方法:__init___preprocess_imagepredict

__init__方法从文件加载模型并将其存储以供以后使用。它还会检测是否有可用的CUDA GPU

_preprocess_image方法调整图像大小并将其转换为模型可接受的格式。对于我们的PyTorch模型,它是一个单通道28x28张量,其浮点数范围为0.01.0。注意像素强度的反转。我们这样做是因为我们计划在标准的黑白图像上使用我们的模型,而MNIST训练数据集具有反转的黑白值。

最后一种predict方法使用检测到的设备和加载的模型对提供的图像数据进行推理。

与之前系列中的训练代码相反,我们不使用数据加载器。我们将图像数据直接提供给模型。数据加载器允许处理成批的数据。我们的API一次处理一个图像,使数据加载器变得多余。

构建FastAPI服务代码

现在我们有了处理预测的代码,我们可以在我们的Rest API服务中使用它。让我们创建一个自定义Flask服务来执行这项工作。

新的main.py文件将包含所有服务代码,文件结构将镜像上一篇文章中的app.py文件:

  1. from fastapi import FastAPI, File
  2. from io import BytesIO
  3. from inference_model import InferenceModel
  4. from azureml.core.authentication import MsiAuthentication
  5. from azureml.core import Workspace
  6. from azureml.core.model import Model
  7. def get_inference_model():
  8. global model
  9. if model == None:
  10. auth = MsiAuthentication()
  11. ws = Workspace(subscription_id="<your-subscription-id>",
  12. resource_group="azureml-rg",
  13. workspace_name="demo-ws",
  14. auth=auth)
  15. aml_model = Model(ws, 'mnist-pt-model', version=1)
  16. model_path = aml_model.download(target_dir='.', exist_ok=True)
  17. model = InferenceModel(model_path)
  18. return model
  19. app = FastAPI(title="PyTorch MNIST Service API", version="1.0")
  20. @app.post("/score")
  21. async def score(image: bytes = File(...)):
  22. if not image:
  23. return {"message": "No image_file"}
  24. model = get_inference_model()
  25. preds = model.predict(BytesIO(image))
  26. return {"preds": str(preds)}
  27. model = None

和以前一样,我们使用MsiAuthentication类进行身份验证,以从我们的Azure机器学习工作区访问资源。该类MsiAuthentication依赖于Azure Active Directory中的托管标识。我们将托管标识分配给Azure资源,例如虚拟机或应用服务。使用托管身份使我们免于维护任何凭据或机密。

与上一篇文章相比,该get_inference_model方法的唯一变化是模型名称。

最后一个方法 score负责运行预测。与FastAPI中的每个API方法一样,它需要是异步的,因此是async关键字。请注意,score方法声明中的File(…)语句不是占位符。

使用PythonFastAPI发布应用服务

我们几乎拥有使用PythonFastAPIApp ServiceAzure上运行REST API服务的所有代码。

我们需要的最后一个文件是requirements.txt,内容如下:

  1. fastapi
  2. gunicorn
  3. uvicorn
  4. python-multipart==0.0.5
  5. torch==1.9.0
  6. pillow==8.3.2
  7. azureml-defaults==1.35.0

此代码没有为 fastapigunicornuvicorn依赖项定义显式版本。这是故意的,我们会在本节结束之前证明它的合理性。

现在,我们可以使用Azure CLI使用以下命令(从包含我们文件的文件夹)发布我们的应用服务应用程序:

  1. $ APP_SERVICE_PLAN="<your-app-service-plan-name>"
  2. $ APP_NAME="<your-app-name>"
  3. $ az webapp up --name $APP_NAME --plan $APP_SERVICE_PLAN --sku B2 --os-type Linux
  4. --runtime "python|3.7" --subscription $AZURE_SUBSCRIPTION --resource-group $RESOURCE_GROUP

请记住,该APP_NAME值必须是全局唯一的,因为它将是服务URL的一部分。

不过,发布应用程序可能需要更长的时间。在一些不那么罕见的情况下,它可能根本不会结束。这可能与已安装依赖项的大小有关。仅PyTorch就占用了1 GB,迫使服务计划SKU从免费的F1层(具有1 GBRAM)更改为至少B2(具有3.5GB RAM)。即便如此,部署过程中也发生了随机超时,这导致了看似永无止境或失败的部署过程。

强制执行特定版本的依赖项会进一步增加尝试失败的可能性。它可能与默认应用服务映像中的其他包冲突有关。一些库的requirements.txt文件中的显式版本已被删除以减少这些问题。

所有这些都表明,使用应用服务部署为复杂模型提供服务可能并不总是生产应用程序的最佳选择。幸运的是,Azure为我们提供了许多替代方案。

一种是Web App for Containers。我们可以使用自己的Docker容器完全控制所有依赖项,从而限制潜在问题。我们还可以使用Container InstancesAKS

不过,有一个新选项:托管在线端点。我们将在本文后面使用它。

现在,让我们回到我们的应用服务部署。当我们的命令完成时,它应该返回类似于以下内容的JSON

  1. {
  2. "URL": "http://<your-app-name>.azurewebsites.net",
  3. "appserviceplan": "<your-app-service-plan-name>",
  4. "location": "westeurope",
  5. "name": "<your-app-name>",
  6. "os": "Linux",
  7. "resourcegroup": "azureml-rg",
  8. "runtime_version": "python|3.7",
  9. "runtime_version_detected": "-",
  10. "sku": "B2",
  11. "src_path": "<local-path-to-your-files>"
  12. }

更新FastAPI的应用服务配置

应用服务使用默认的Gunicorn工作者自动检测和处理Flask应用程序。但是,这次我们使用FastAPI

我们需要使用以下Azure CLI命令显式设置应用程序的启动命令:

  1. $ az webapp config set --name $APP_NAME --startup-file "gunicorn --workers=2
  2. --worker-class uvicorn.workers.UvicornWorker main:app" --resource-group $RESOURCE_GROUP

此命令确保将GunicornUvicorn工作人员一起使用,这是运行我们的FastAPI服务所必需的。

或者,我们可以使用Azure门户并将启动命令粘贴到General settingsStartup Command字段中:

授予应用服务应用程序权限

我们授予权限的方式与XGBoost模型相同。我们的服务必须从Azure机器学习工作区下载经过训练的模型,这需要授权。我们使用托管身份来避免凭据管理。

为我们的应用程序分配一个新的托管标识很简单。输入以下内容:

$ az webapp identity assign --name $APP_NAME --resource-group $RESOURCE_GROUP

完成后,我们应该期待一个JSON输出:

  1. {
  2. "principalId": "<new-principal-id>",
  3. "tenantId": "<tenant-id>",
  4. "type": "SystemAssigned",
  5. "userAssignedIdentities": null
  6. }

配备返回<new-principal-id>值,我们可以添加所需的权限:

  1. $ az role assignment create --role reader --assignee <new-principal-id>
  2. --scope /subscriptions/$AZURE_SUBSCRIPTION/resourceGroups/$RESOURCE_GROUP/providers/
  3. Microsoft.MachineLearningServices/workspaces/$AML_WORKSPACE

读者角色应该足以满足我们的目的。如果我们的应用程序需要创建额外的资源,我们应该使用贡献者角色。

测试服务

我们使用示例代码中的图像(test-images/d0.pngd9.png)来测试我们的服务:

这些图像没有严格的要求。我们服务的代码将重新缩放图像并将其转换为预期的大小和格式。

我们需要发送POST请求来调用我们的服务。我们可以使用Postmancurl。使用curl,我们可以直接从命令行执行我们的请求。

$ curl -X POST -F 'image=@./test-images/d6.png' https://$APP_NAME.azurewebsites.net/score

如果一切顺利,我们应该期待以下响应:

{"preds": [6]}

答案似乎是正确的。我们的PyTorch模型应该比上一篇文章中的简单XGBoost模型工作得更好。

检查应用服务日志

如果您遇到任何问题,您可能需要检查您的服务日志。您可以通过Azure 门户 监控 > 日志流选项卡执行此操作。

或者,您可以使用Azure CLI访问日志。

  1. $ az webapp log tail --name $APP_NAME --subscription $AZURE_SUBSCRIPTION
  2. --resource-group $RESOURCE_GROUP

请注意,只会出现执行日志。如果部署存在问题,请尝试Azure门户的部署中心日志。

使用Azure机器学习和托管终结点

使用托管为Azure应用服务的自定义Flask应用程序发布我们的模型非常简单。但是,我们的环境越复杂,我们在设置过程中遇到的问题就越多。我们可以使用Web App for Containers托管在线端点(预览版)来避免这些设置问题。在以下部分中,我们将使用端点。

创建在线端点代码

与上一篇文章一样,我们将在inference_model.py文件中使用我们为Flask应用程序创建的相同InferenceModel类,并将其复制到新文件夹endpoint-code中。

因为InferenceModel类完全抽象了模型,所以我们在文件endpoint-code/aml-score.py中的端点代码可以与XGBoost模型几乎完全相同:

  1. import os
  2. import json
  3. from inference_model import InferenceModel
  4. from azureml.contrib.services.aml_request import rawhttp
  5. from azureml.contrib.services.aml_response import AMLResponse
  6. def init():
  7. global model
  8. model_path = os.path.join(
  9. os.getenv("AZUREML_MODEL_DIR"), "mnist.pt_model"
  10. )
  11. model = InferenceModel(model_path)
  12. @rawhttp
  13. def run(request):
  14. if request.method != 'POST':
  15. return AMLResponse(f"Unsupported verb: {request.method}", 400)
  16. image_data = request.files['image']
  17. preds = model.predict(image_data)
  18. return AMLResponse(json.dumps({"preds": preds.tolist()}), 200)

唯一的区别是模型的名称,mnist.pt_model

其余的保持不变。代码在端点启动时调用第一个init方法。这是加载我们模型的好地方。我们使用AZUREML_MODEL_DIR环境变量来执行此操作,它指示我们的模型文件在哪里。

下面的run方法很简单。首先,我们确保只接受POST请求。接下来,我们从请求中检索图像,然后运行并返回预测。

注意@rawhttp装饰器。它访问原始请求数据,例如二进制图像内容。没有它,传递给run方法的请求参数将仅限于解析的JSON

配置在线端点

除了代码,我们还需要三个配置文件。

第一个,endpoint-code/aml-env.yml,存储Conda环境定义。

  1. channels:
  2. - pytorch
  3. - conda-forge
  4. - defaults
  5. dependencies:
  6. - python=3.7.10
  7. - pytorch=1.9.0
  8. - cpuonly # Replace with cudatoolkit to use GPU
  9. - pillow=8.3.2
  10. - gunicorn=20.1.0
  11. - numpy=1.19.5
  12. - pip:
  13. - azureml-defaults==1.35.0
  14. - inference-schema[numpy-support]==1.3.0

以下两个文件看起来与上一篇文章中的相同。它们包含端点及其部署的配置。

端点配置文件aml-endpoint.yml包含:

  1. $schema: https://azuremlschemas.azureedge.net/latest/managedOnlineEndpoint.schema.json
  2. name: mnistptoep
  3. auth_mode: key

最后一个文件aml-endpoint-deployment.yml包含:

  1. $schema: https://azuremlschemas.azureedge.net/latest/managedOnlineDeployment.schema.json
  2. name: blue
  3. endpoint_name: mnistptoep
  4. model: azureml:mnist-pt-model:1
  5. code_configuration:
  6. code:
  7. local_path: ./endpoint-code
  8. scoring_script: aml-score.py
  9. environment:
  10. conda_file: ./endpoint-code/aml-env.yml
  11. image: mcr.microsoft.com/azureml/minimal-ubuntu18.04-py37-cpu-inference:latest
  12. instance_type: Standard_F2s_v2
  13. instance_count: 1

您可以使用自定义图像或Microsoft精选图像目录中的图像。可用图像的列表非常广泛,尽管在我们的案例中没有理由使用其中的大部分。无论您选择哪个图像,代码仍将根据您的规范创建一个新的Conda环境。

我们需要在我们的环境中包含通用组件,即使它们已经存在于图像中。例如,我们可以使用azureml-inference-server-http(azureml-defaults)gunicorn

尽管如此,如果您需要一些未包含在您的Conda环境中的系统级依赖项(例如Open MPICUDA驱动程序),您很可能会在那里找到您需要的东西。在我们的例子中,选择的最小图像就足够了。

请注意,虽然Microsoft建议始终使用最新的图像标签,但在某些情况下,您可能会考虑使用固定值以获得最大的再现性。您可以使用以下URL模板找到给定图像的所有可用标签:

https://mcr.microsoft.com/v2/<namespace>/<repository>/tags/list

例如,这个网址:

https://mcr.microsoft.com/v2/azureml/minimal-ubuntu18.04-py37-cpu-inference/tags/list

返回:

截图显示每个月有多个版本,所以图片更新频繁。

创建托管在线端点

准备好所有这些文件后,我们就可以开始部署了。我们从端点开始:

  1. $ ENDPOINT_NAME="<your-endpoint-name>"
  2. $ az ml online-endpoint create -n $ENDPOINT_NAME -f aml-endpoint.yml
  3. --subscription $AZURE_SUBSCRIPTION --resource-group $RESOURCE_GROUP
  4. --workspace-name $AML_WORKSPACE

与之前的应用服务应用程序名称一样,每个Azure区域的端点名称必须是唯一的,因为它将成为URL的一部分,格式如下:

https://<your-endpoint-name>.<region-name>.inference.ml.azure.com/score

现在我们已经创建了端点,我们终于可以部署我们的推理代码了:

  1. $ az ml online-deployment create -n blue
  2. --endpoint $ENDPOINT_NAME -f aml-endpoint-deployment.yml
  3. --all-traffic --subscription $AZURE_SUBSCRIPTION --resource-group $RESOURCE_GROUP
  4. --workspace-name $AML_WORKSPACE

很长一段时间后,该命令应返回确认部署已完成且端点已准备好使用。预配虚拟机、下载基础映像和设置环境需要时间。

要检查端点日志,请键入:

  1. $ az ml online-deployment get-logs -n blue --endpoint $ENDPOINT_NAME
  2. --subscription $AZURE_SUBSCRIPTION --resource-group $RESOURCE_GROUP
  3. --workspace-name $AML_WORKSPACE

输出应类似于以下屏幕截图:

测试在线端点

我们需要三个东西来调用我们的端点:端点URL、端点键和示例图像。我们已经使用了示例图像,因此我们只需要URL和密钥。

获取它们的一种方法是通过Azure Machine Learning Studio,从端点的使用选项卡。

我们还可以使用Azure CLI获取这些值:

  1. $ SCORING_URI=$(az ml online-endpoint show -n $ENDPOINT_NAME -o tsv
  2. --query scoring_uri --resource-group $RESOURCE_GROUP --workspace $AML_WORKSPACE)
  3. $ ENDPOINT_KEY=$(az ml online-endpoint get-credentials --name $ENDPOINT_NAME
  4. --subscription $AZURE_SUBSCRIPTION --resource-group $RESOURCE_GROUP
  5. --workspace-name $AML_WORKSPACE -o tsv --query primaryKey)

现在已经填充了SCORING_URIENDPOINT_KEY变量,我们可以调用我们的服务了。

  1. $ curl -X POST -F 'image=@./test-images/d5.png' -H
  2. "Authorization: Bearer $ENDPOINT_KEY" $SCORING_URI

如果一切顺利,我们应该得到与Flask应用程序相同的答案:

{"preds": [5]}'
运行

本地部署

托管端点的一个简洁功能是可以选择使用本地Docker实例将它们部署在您的计算机上。它可以在调试部署问题时提供帮助,因为它使您可以完全访问Docker日志和创建的容器。

只需在az ml online-endpoint命令中添加--local参数即可使用此功能。例如,以下命令将创建端点的本地版本:

  1. $ az ml online-endpoint create --local -n $ENDPOINT_NAME -f aml-endpoint.yml
  2. --subscription $AZURE_SUBSCRIPTION --resource-group $RESOURCE_GROUP
  3. --workspace-name $AML_WORKSPACE
  4. $ az ml online-deployment create --local -n blue
  5. --endpoint $ENDPOINT_NAME -f aml-endpoint-deployment.yml
  6. --all-traffic --subscription $AZURE_SUBSCRIPTION
  7. --resource-group $RESOURCE_GROUP --workspace-name $AML_WORKSPACE

大多数(如果不是全部)az ml online-endpoint / online-deployment子命令都支持该--local标志。例如,您可以通过以下方式调用本地端点URI并检查其日志:

  1. $ LOCAL_SCORING_URI=$(az ml online-endpoint show --local -n $ENDPOINT_NAME -o tsv
  2. --query scoring_uri --resource-group $RESOURCE_GROUP --workspace $AML_WORKSPACE)
  3. $ curl -X POST -F 'image=@./test-images/d5.png' $LOCAL_SCORING_URI
  4. $ az ml online-deployment get-logs --local -n blue --endpoint $ENDPOINT_NAME
  5. --subscription $AZURE_SUBSCRIPTION --resource-group $RESOURCE_GROUP
  6. --workspace-name $AML_WORKSPACE

我们这里不需要API_KEY,因为本地部署不需要身份验证。

不过,还有更多。本地部署完成后,您就可以完全访问相应的Docker镜像和容器。例如:

$ docker images

$ docker ps

默认情况下,镜像名称与$ENDPOINT_NAME值匹配,标签等于部署名称(在我们的示例中为蓝色)。

您可以为镜像创建和运行自己的容器,或者像往常一样将镜像附加到现有容器。例如,您可以将镜像附加到正在运行的容器并在其中列出可用的Conda环境:

$ docker exec -it <container-id> bash

amlenv是基础镜像中包含的默认Azure机器学习环境。我们使用YAML配置创建inf-conda-env环境。

当端点启动时,它不会明确选择活动环境。它将每个环境的二进制路径添加到PATH环境变量中。它最初添加inf-conda-env, 所以它首先考虑来自您的自定义环境的二进制文件。

当端点启动时,它会执行azureml-inference-server-http命令。如果自定义inf-conda-env环境中不存在,则运行默认版本amlenv。通过这种方式,它隐式地确定了执行的Conda环境。

删除Azure资源

你可以删除不再需要的所有资源以减少Azure费用。尤其要记住应用服务计划和托管终结点。

下一步

我们在本文中发布了一个经过训练可识别手写数字的PyTorch模型。我们将FastAPI与应用服务一起使用,然后使用Azure机器学习在线端点。

在本系列的第三篇也是最后一篇文章中,我们将使用在线端点发布TensorFlow模型。然后,我们将创建一个Azure函数作为此终结点的公共代理。此外,我们将探索托管端点的配置选项,例如自动缩放和蓝绿部署概念。

继续下一篇文章发布一个TensorFlow模型

要了解如何使用在线端点(预览版)部署机器学习模型,请查看使用在线端点部署机器学习模型并对其进行评分

https://www.codeproject.com/Articles/5328264/Deploying-Models-at-Scale-on-Azure-Part-2-Deployin

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/一键难忘520/article/detail/916761
推荐阅读
相关标签
  

闽ICP备14008679号