jupyter 嵌入_嵌入Jupyter笔记本

本文介绍了如何将Jupyter Notebook嵌入到其他应用中,详细解释了嵌入过程,为Python开发者提供了一种展示和分享代码的新方式。
摘要由CSDN通过智能技术生成

jupyter 嵌入

We’ve recently started building a new service for sharing data between funders. As much of this data is highly restricted, all analysis must occur within our platform. Users will not be able to download data to their local machine. We’ve therefore created our own functionality to plot metrics, and to create tables of summarized data but for more advanced analysis we’d like to provide a notebook-style way of working. Instead of rewriting the wheel, it seemed more prudent to look at ways of integrating existing notebooks within our application, the most common of which is Jupyter.

我们最近开始建立一项新服务,以在资助者之间共享数据。 由于许多数据受到严格限制,因此所有分析都必须在我们的平台内进行。 用户将无法将数据下载到其本地计算机。 因此,我们已经创建了自己的功能来绘制指标并创建汇总数据表,但是对于更高级的分析,我们希望提供一种笔记本式的工作方式。 与其重新编写轮子, 不如审慎地研究将现有笔记本集成到我们的应用程序中的方法,其中最常见的是Jupyter

Jupyter notebooks integrate code and markdown text into a single document, allowing users to analyze and interpret their results all in one place. Most users will typically run Jupyter locally, which will start a local web server allowing users to view, edit and manage files within their browser. For those who wish to provide Jupyter notebooks as a service, for example within a classroom or department, there is JupyterHub. This runs another web server, which allows users to login and will spawn a Jupyter notebook server per user. Both Jupyter and JupyterHub are completely configurable, allowing you to use your own authentication, choose how notebooks are spawned and define the environment within those notebooks.

Jupyter笔记本将代码和降价文本集成到单个文档中,使用户可以在一处分析和解释其结果。 大多数用户通常会在本地运行Jupyter,这将启动本地Web服务器,允许用户在其浏览器中查看,编辑和管理文件。 对于那些希望将Jupyter笔记本作为服务提供的人,例如在教室或部门内,可以使用JupyterHub。 这将运行另一个Web服务器,该Web服务器允许用户登录,并将为每个用户生成Jupyter笔记本服务器。 Jupyter和JupyterHub都是完全可配置的,允许您使用自己的身份验证,选择生成笔记本的方式以及在这些笔记本中定义环境。

Our goal was therefore to embed a Jupyter notebook within our application in a way that would scale with our user base, provide a secure method of analysing internal data and wouldn’t conflict with our other analysis functions.

因此,我们的目标是将Jupyter笔记本嵌入我们的应用程序中,以适应我们的用户群,提供一种安全的内部数据分析方法,并且不会与我们的其他分析功能冲突。

我们是怎么做的 (How did we do it)

In this blog post I’ll walk through the individual steps we took to embed Jupyter within our application. Although this isn’t a tutorial and we don’t show all the written code, this should provide enough information for other developers looking to embed Jupyter.

在这篇博客文章中,我将逐步介绍将Jupyter嵌入到应用程序中的各个步骤。 尽管这不是教程,并且我们没有显示所有书面代码,但是它应该为其他希望嵌入Jupyter的开发人员提供足够的信息。

There are multiple different ways to launch JupyterHub from running it locally to within a Kubernetes cluster. In our case we ran JupyterHub within a docker container, and used DockerSpawner, which, as the name suggests, spawns Jupyter servers within their own docker container. This setup meant that Jupyterhub was accessible locally on port 8000 (http://localhost:8000) and should scale well up to ~100 concurrent users. Although the steps below should be generic enough for any JupyterHub installation, please bear in mind that some details could differ.

从本地运行JupyterHub到Kubernetes集群中,有多种方法可以启动JupyterHub。 在我们的案例中,我们在Docker容器中运行JupyterHub,并使用了DockerSpawner,顾名思义,该工具在其自己的Docker容器中生成Jupyter服务器。 此设置意味着Jupyterhub可在端口8000(http:// localhost:8000)上本地访问,并且可以扩展到约100个并发用户。 尽管以下步骤对于所有JupyterHub安装都应该足够通用,但是请记住,某些细节可能有所不同。

连接到API (Connect to the API)

For our application to interact with JupyterHub we needed to use the API. Both JupyterHub and the Jupyter notebook servers provide a web API, so depending on the URL and HTTP request method we can programmatically interact with them from within our service. Note that all requests go through the JupyterHub proxy, and therefore all our requests are sent to localhost:8000 irrespective of their final destination.

为了使我们的应用程序与JupyterHub交互,我们需要使用API​​。 JupyterHub和Jupyter笔记本服务器都提供了Web API,因此,根据URL和HTTP请求方法,我们可以在服务中以编程方式与它们进行交互。 请注意,所有请求都通过JupyterHub代理进行,因此,无论其最终目的地如何,我们所有的请求都将发送到localhost:8000。

Accessing an API like this is extremely powerful, we can create and delete users, access tokens and even start Jupyter notebook servers. For this reason, access to certain functions within the JupyterHub API is restricted to administrators only. Therefore the first thing we needed to do was configure JupyterHub to recognise our service as an admin. To do this we create a jupyterhub_config.py file and copy this into the /srv/jupyterhub/ folder within our JupyterHub container. This file is then read when JupyterHub starts, specifying how it should function. As part of installing JupyterHub and a spawner, you have probably already created this file so the below settings just need to be added.

访问这样的API极其强大,我们可以创建和删除用户,访问令牌,甚至启动Jupyter笔记本服务器。 因此,仅限管理员才能访问JupyterHub API中的某些功能。 因此,我们需要做的第一件事是配置JupyterHub以将我们的服务识别为管理员。 为此,我们创建一个jupyterhub_config.py文件,并将其复制到JupyterHub容器中的/srv/jupyterhub/文件夹中。 然后,在JupyterHub启动时读取该文件,并指定其功能。 在安装JupyterHub和Spawner的过程中,您可能已经创建了此文件,因此只需添加以下设置。

c.JupyterHub.services = [
{“name”: “my-app”, “api_token”: secret-token, “admin”: True,}
]

This sets a name for our service, a token which provides access to the API, and sets this service to be an administrator. Both the name and token should be changed, as the token provides full access to the Jupyter server it should be kept secret. To test that it’s working, try using this token to connect to the API via curl within a terminal window. The following command should return a list of all users (beware that you might not have any users yet).

这将为我们的服务设置一个名称,一个提供对API的访问权的令牌,并将该服务设置为管理员。 名称和令牌都应更改,因为令牌可提供对Jupyter服务器的完全访问权限,因此应将其保密。 要测试它是否正常工作,请尝试使用此令牌通过终端窗口中的curl连接到API。 以下命令应返回所有用户的列表(请注意,您可能还没有任何用户)。

curl localhost:8000/hub/api/users -H “Authorization: Token secret-token”

Now that we have the means to connect to the API, we can use our secret token to start making requests. The first thing we want to do when a user tries to access a notebook within our service is to ensure that they have a user account in Jupyter. According to the JupyterHub documentation we can do this through the API by accessing the URL /hub/api/users/ followed by their username. A GET request to this URL would just return information about that user, but a POST request would create a new user account. If the user in our application is called Bob, we can therefore run the following Python code to create a new Jupyter user account for them.

现在我们有了连接到API的方法,就可以使用我们的秘密令牌开始发出请求了。 当用户尝试访问我们服务中的笔记本时,我们要做的第一件事是确保他们在Jupyter中拥有用户帐户。 根据JupyterHub文档,我们可以通过访问URL /hub/api/users/然后输入用户名来通过API执行此操作。 对该URL的GET请求将仅返回有关该用户的信息,但是POST请求将创建一个新的用户帐户。 如果我们应用程序中的用户名为Bob,那么我们可以运行以下Python代码为他们创建一个新的Jupyter用户帐户。

import requestsheaders = {
“Authorization”: “Token secret-token”,
“Content-Type”: “application/json”,
}R = requests.post(“http://localhost:8000/hub/api/users/Bob”, headers=headers)

As well as providing our secret token, we also set the content type to be JSON so the results will be returned in this format. It should be noted that we’re making this request within our python server-side code, so that our secret token is never revealed to the client.

除了提供我们的秘密令牌,我们还将内容类型设置为JSON,以便以这种格式返回结果。 应该注意的是,我们是在python服务器端代码中发出此请求的,这样我们的秘密令牌就不会泄露给客户端。

Now that we have created a new user, we need a way for that user to authenticate themselves with Jupyter. By default JupyterHub will display a login screen where users can enter their password, but as our users are already authenticated within our application we want to make this a seamless transition. We therefore create a token specific for that user, this won’t give them full access to the JupyterHub API but it does allow them to connect to their own Jupyter notebook server. For our user Bob, we’d create and access that token using the following.

现在我们已经创建了一个新用户,我们需要一种使该用户通过Jupyter进行身份验证的方法。 默认情况下,JupyterHub将显示一个登录屏幕,用户可以在其中输入密码,但是由于我们的用户已经在我们的应用程序中进行了身份验证,因此我们希望使其无缝过渡。 因此,我们为该用户创建了一个令牌,该令牌不会授予他们对JupyterHub API的完全访问权限,但确实允许他们连接到自己的Jupyter笔记本服务器。 对于我们的用户Bob,我们将使用以下代码创建和访问该令牌。

...r = requests.post(“http://localhost:8000/hub/api/users/Bob/tokens", headers=headers)res = r.json()Token = res.get(“token”)

创建一个笔记本 (Create a Notebook)

Now that we have a user account within JupyterHub, and a token to authenticate them, we can start a Jupyter notebook and create our first file. These requests only use our new user-token so can occur client-side, within our javascript application. We first request a new server, which as we saw earlier must be a POST request.

现在,我们在JupyterHub中拥有一个用户帐户,并具有用于对其进行身份验证的令牌,我们可以启动Jupyter笔记本并创建我们的第一个文件。 这些请求仅使用我们的新用户令牌,因此可以在我们的javascript应用程序内的客户端发生。 我们首先请求一个新服务器,正如我们之前看到的,它必须是POST请求。

fetch(“http://locahost:8000/hub/api/users/Bob/server”, {
method: ‘POST’,
headers: {
‘Authorization’: ‘Token ‘ + token,
‘Content-Type’: ‘application/json’,
}
})

Depending on the state of the requested server, this will either return a status of 201 to say the server has started, a status of 202 to say the server has been requested but hasn’t yet started, or a status of 400 saying that the server is already running.

根据请求服务器的状态,这将返回状态201表示服务器已启动,状态202表示服务器已被请求但尚未启动,或者状态400表示服务器已启动。服务器已经在运行。

Once the server is running, we could set the iframe to the appropriate URL and the user would have an empty home directory in which they can create files, notebooks etc. However, throughout our application we store all analyses within our own database, provide a list of previously created plots or tables and allow users to edit, view or delete them. To now show a directory means that we are providing a very different interface, and we’ll need a different method to store files. Ideally we’d want to keep the user interactions the same as with the non-jupyter analysis parts of our application. We therefore want to show or open a specific file within our iframe, instead of the working directory.

服务器运行后,我们可以将iframe设置为适当的URL,用户将拥有一个空的主目录,他们可以在其中创建文件,笔记本等。但是,在整个应用程序中,我们将所有分析存储在我们自己的数据库中,先前创建的图或表的列表,并允许用户编辑,查看或删除它们。 现在显示目录意味着我们提供了一个非常不同的界面,并且需要一种不同的方法来存储文件。 理想情况下,我们希望保持用户交互与应用程序的非jupyter分析部分相同。 因此,我们希望在iframe中显示或打开一个特定文件,而不是工作目录。

To do this we again turn to the API, however this time we need the API within the single user Jupyter server we just started. The URL is therefore more complicated as we first need to point it towards the correct server, and then access the API. Once we reach that API though, there’s an extremely useful endpoint which can allow us to access the contents of a file. If we access this endpoint with a GET request, we will return the contents allowing us to save the file within our database. Accessing the same endpoint with a POST request will create a new empty notebook file, returning the filename that we’d need to direct the iframe. We can also access this endpoint with a PUT request, passing in some data and it will create a new file with that content. These three methods give us all the functionality we need to provide a one-to-one relationship between our application and a Jupyter notebook file.

为此,我们再次转向API,但是这次我们需要在刚启动的单用户Jupyter服务器中使用API​​。 因此,URL更复杂,因为我们首先需要将其指向正确的服务器,然后访问API。 但是,一旦到达该API,就会有一个非常有用的终结点,可以使我们访问文件的内容。 如果使用GET请求访问此端点,则将返回内容,使我们可以将文件保存在数据库中。 使用POST请求访问相同的端点将创建一个新的空笔记本文件,并返回引导iframe所需的文件名。 我们还可以通过PUT请求访问此端点,传入一些数据,它将创建具有该内容的新文件。 这三种方法为我们提供了在应用程序与Jupyter笔记本文件之间提供一对一关系所需的所有功能。

Image for post
URL needed to access notebook file contents
访问笔记本文件内容所需的URL

So, to create an empty notebook file we can make the following request. This will generate a new empty notebook file within the `work` directory.

因此,要创建一个空的笔记本文件,我们可以提出以下请求。 这将在`work`目录中生成一个新的空白笔记本文件。

fetch(“http://localhost:8000/user/Bob/api/contents/work", {
method: ‘POST’,
headers: {
‘Authorization’: ‘Token ‘ + token,
‘Content-Type’: ‘application/json’,
},
body: JSON.stringify({
type: “notebook”
})
})

If we have stored content from a previous notebook file, we can then re-engineer this file using the same API endpoint. This time we assign it a specific filename and pass in the file contents within the body, we also use the PUT method.

如果我们已经存储了先前笔记本文件中的内容,则可以使用相同的API端点重新设计该文件。 这次我们给它分配一个特定的文件名,并在正文中传递文件内容,我们也使用PUT方法。

fetch(“http://localhost:8000/user/Bob/api/contents/work/"+filename, {
method: ‘PUT’,
headers: {
‘Authorization’: ‘Token ‘ + token,
‘Content-Type’: ‘application/json’,
},
body: JSON.stringify({
“name”: filename,
“type”: “notebook”,
“format”: “text”,
“content”: content
})
})

Depending on which method we use, we’re now able to generate all the files we need, without having to mount any storage/folders to our docker containers. We can then set the iframe to display the newly created notebook file. Thus providing our one-to-one relationship between our application and a single Jupyter notebook analysis. For a file named ‘Untitled.ipynb’, we’d set the iframe to the following:

根据我们使用的方法,我们现在能够生成所需的所有文件,而无需将任何存储/文件夹挂载到我们的Docker容器中。 然后,我们可以将iframe设置为显示新创建的笔记本文件。 这样就提供了我们的应用程序与一个Jupyter笔记本分析之间的一对一关系。 对于名为“ Untitled.ipynb”的文件,我们将iframe设置为以下内容:

http://localhost:8000/user/Bob/notebooks/work/Untitled.ipynb?token=token

This connects to our JupyterHub server running at localhost:8000, goes through the proxy to the user-specific notebook server, and then opens the file we created earlier. We also pass the token across to authenticate our request.

这将连接到运行在localhost:8000的JupyterHub服务器,通过代理到达用户特定的笔记本服务器,然后打开我们之前创建的文件。 我们还会传递令牌以验证我们的请求。

内容安全政策 (Content Security Policy)

At this point, what you might be seeing instead of the notebook file, is a warning about the Content Security Policy (CSP) restricting your access. This is because CSP is designed to protect servers from cross-site scripting attacks, and will stop someone from using an iframe to display your own website within theirs. What we therefore need to do is to set the CSP for our JupyterHub and notebook servers to permit access from our application. So, if for example our application that we want to embed Jupyter within is running on localhost:8090, we need to set certain exceptions for that domain. This will mean that only our application, running on localhost:8090, will be able to use an iframe to embed the Jupyter servers. The setting for JupyterHub is:

此时,您可能看到的不是笔记本文件,而是关于内容安全策略(CSP)限制访问的警告。 这是因为CSP旨在保护服务器免受跨站点脚本攻击,并阻止某人使用iframe在其内部显示您自己的网站。 因此,我们需要做的是为JupyterHub和笔记本服务器设置CSP,以允许从我们的应用程序进行访问。 因此,例如,如果要在其中嵌入Jupyter的应用程序在localhost:8090上运行,则需要为该域设置某些例外。 这意味着只有在localhost:8090上运行的应用程序才能使用iframe嵌入Jupyter服务器。 JupyterHub的设置是:

c.JupyterHub.tornado_settings = {
“headers”: {
“Content-Security-Policy”: “frame-ancestors ‘self’ http://localhost:8090”,
“Access-Control-Allow-Origin”: “http://locahost:8090”,
}
}

We also need to apply the same setting to our single user Jupyter servers, in this case we copy the file jupyter_notebook_config.py into /etc/jupyter/ within our docker image. This file contains the same headers settings as above, but for the c.NotebookApp.tornado_settings option.

我们还需要对单用户Jupyter服务器应用相同的设置,在这种情况下,我们将文件jupyter_notebook_config.py复制到jupyter_notebook_config.py /etc/jupyter/中。 该文件包含与上述相同的标头设置,但包含c.NotebookApp.tornado_settings选项。

Once these changes have been made, and the servers restarted, you should then be able to access Jupyter within the iframe.

完成这些更改并重新启动服务器后,您就应该能够在iframe中访问Jupyter。

保存笔记本 (Saving a Notebook)

Now that we have a working notebook file being created and displayed within our iframe, the next stage is to save the contents within our application’s database. To do this, as mentioned above, we can use the same API endpoint that creates a file to return the file contents. Therefore the following request can be made when we tell our application to save the Jupyter notebook.

现在我们已经在iframe中创建并显示了一个工作笔记本文件,下一步是将内容保存在应用程序的数据库中。 为此,如上所述,我们可以使用创建文件的相同API端点来返回文件内容。 因此,当我们告诉我们的应用程序保存Jupyter笔记本时,可以提出以下请求。

fetch(“http://locahost:8000/user/Bob/api/contents/work/Untitled.ipynb?type=file&format=text&content=1”, {
method: ‘GET’,
headers: {
‘Authorization’: ‘Token ‘ + token,
‘Content-Type’: ‘application/json’,
}
})

Unfortunately what became clear is that this request will only return the last saved content of the file, and therefore if we click ‘Save’ within our application before the Jupyter notebook has autosaved, we return empty contents. This brings up another point, within the notebook there is a button to save the file which will save it within the Jupyter server but this is a different functionality to the Save button within our own application. To have two methods of saving the file within the same page, which have different functionality, is confusing so we needed a way to align both methods. This meant communicating between our application and the iframe.

不幸的是,很明显,此请求将仅返回文件的最后保存的内容,因此,如果我们在Jupyter笔记本自动保存之前在应用程序中单击“保存”,则会返回空内容。 这就引出了另一点,笔记本电脑中有一个保存文件的按钮,它将保存在Jupyter服务器中,但这与我们自己的应用程序中的“保存”按钮不同。 在同一个页面中有两种保存文件的方法,它们具有不同的功能,这很令人困惑,因此我们需要一种对齐这两种方法的方法。 这意味着我们的应用程序与iframe之间进行通信。

Accessing elements within an iframe is, sensibly, highly restricted and we therefore couldn’t just trigger a ‘click’ on the internal save button. Instead we used an HTML method called postMessage to send an event to the window holding the iframe, these messages can work between embedded pages and their parent page or between tabs and most importantly can work across different domains. Therefore when a user clicks save within our application, we can post a message to the iframe in the hopes of triggering the save within the notebook itself. The post message has to contain the domain name of the target to confirm that these match.

明智地,在iframe中访问元素受到严格限制,因此我们不能仅在内部保存按钮上触发“点击”。 取而代之的是,我们使用一种称为postMessageHTML方法将事件发送到保存iframe的窗口,这些消息可以在嵌入式页面及其父页面之间或选项卡之间工作,最重要的是可以在不同的域中工作。 因此,当用户在我们的应用程序中单击“保存”时,我们可以在iframe中发布一条消息,以希望在笔记本本身中触发保存。 帖子消息必须包含目标域名,以确认这些域名匹配。

const iframe = document.getElementById(‘jupyterframe’);iframe.contentWindow.postMessage(“save”,“http://localhost:8000”);

We now need to capture this message within the Jupyter notebook scripts, this means adding some custom javascript. A process to do this within Jupyter has already been set up for us, by copying a file named ‘custom.js’ to .jupyter/custom/ this file will be read and executed by the Jupyter notebook server. To capture the postMessage, we add the following event listener.

现在,我们需要在Jupyter笔记本脚本中捕获此消息,这意味着添加一些自定义javascript。 Jupyter中已经为我们设置了一个过程,方法是将名为“ custom.js”的文件复制到.jupyter/custom/此文件将由Jupyter笔记本服务器读取并执行。 为了捕获postMessage,我们添加以下事件侦听器。

define([‘base/js/namespace’, “base/js/events”], function(Jupyter, events){
Jupyter._target = ‘_self’;
window.addEventListener(‘message’, event => {
if (event.origin.startsWith(“http://locahost:8090”)){
console.log(“Notebook — Calling save Action”);
Jupyter.actions.call(“jupyter-notebook:save-notebook”);
}
})
});

Not only do we listen for the message event here, but we also check that it came from the origin we were expecting, i.e. our own application. If it does, then we call an action within the Jupyter object, which triggers a save within the notebook. Thus linking our save button to saving the actual Notebook file within the Jupyter server.

我们不仅在这里监听消息事件,而且还检查它是否来自我们期望的来源,即我们自己的应用程序。 如果是这样,那么我们将在Jupyter对象中调用一个动作,该动作将触发笔记本中的保存。 因此,将我们的保存按钮链接到在Jupyter服务器中保存实际的Notebook文件。

The next step is to know when this action has been completed, so we can pull the contents in our application and save them in our database. To do this, we again use PostMessage to communicate between our iframe and the parent window.

下一步是知道此操作何时完成,因此我们可以将内容提取到应用程序中并将其保存在数据库中。 为此,我们再次使用PostMessage在iframe和父窗口之间进行通信。

...events.on(‘notebook_saved.Notebook’, function() {
console.log(“Notebook — Saved”);
window.parent.postMessage(“SAVED”, “http://locahost:8090”);
});...

```

```

The above code is also in our custom.js file, and is triggered on the event called when a notebook has been saved. Note that this is therefore triggered not only when we call the save action, but also when a user clicks on the save button within the notebook. This postmessage is now from the iframe to the parent window, and therefore the target origin is the domain of our main application.

上面的代码也位于我们的custom.js文件中,并且在保存笔记本时调用的事件上触发。 请注意,因此,这不仅在我们调用保存操作时触发,而且在用户单击笔记本中的保存按钮时触发。 现在,此消息从iframe到父窗口,因此目标来源是我们主应用程序的域。

Within the javascript of our own application we can then add an event listener for this message, much as we did within the Jupyter notebook javascript. Once it’s triggered we can use the API call to return the file contents, which now contain the latest changes, and this can be saved into our database.

然后,可以像在Jupyter笔记本javascript中一样,在我们自己的应用程序的javascript中添加此消息的事件侦听器。 触发之后,我们可以使用API​​调用返回文件内容,该文件内容现在包含最新更改,并且可以将其保存到我们的数据库中。

Image for post
Overview of the connections between our app and Jupyter when saving a file
保存文件时我们的应用程序与Jupyter之间的连接概述

Using these two postMessages we’ve therefore been able to link up the save buttons within our application to those within the Jupyter notebook and both now perform the same actions.

因此,使用这两个postMessages,我们已经能够将应用程序中的保存按钮链接到Jupyter笔记本中的按钮,并且现在都执行相同的操作。

摘要 (Summary)

By utilizing the JupyterHub API, adding custom javascript and sending postMessages between windows we’ve been able to integrate Jupyter notebooks within our own application. Not only can users now use the full functionality of Jupyter to analyse data, but they’re also able to open and access files in the same manner as all other parts of our application.

通过利用JupyterHub API,添加自定义JavaScript并在Windows之间发送postMessage,我们已经能够将Jupyter笔记本集成到我们自己的应用程序中。 用户现在不仅可以使用Jupyter的全部功能来分析数据,而且还能够以与应用程序的所有其他部分相同的方式打开和访问文件。

We hope that the process described here, along with the code snippets, help any developers looking to embed Jupyter within their own application.

我们希望此处描述的过程以及代码片段能够帮助希望将Jupyter嵌入其自己的应用程序中的所有开发人员。

有用的链接 (Useful Links)

翻译自: https://medium.com/@t.forey/embedding-a-jupyter-notebook-65d79ad8e111

jupyter 嵌入

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值