REST API 设计的最佳实践

REST API 是当今最常见的网络服务之一。它们允许包括浏览器应用在内的各种客户端通过 REST API 与服务器进行通信。因此,正确设计 REST API 非常重要,这样我们就不会在路上遇到问题。我们必须考虑 API 消费者的安全性、性能和易用性。

否则,我们会为使用 API 的客户制造问题,这不令人愉快,并会减损人们使用我们的 API。如果我们不遵循普遍接受的惯例,那么我们混淆了 API 的维护者和使用它们的客户,因为它与每个人的期望不同。

在本文中,我们将研究如何设计 REST API,以便于任何消费 API 的人了解,防未来,安全快捷,因为它们为可能保密的客户端提供数据。

目录

什么是REST API?

接受并回复杰森

在端点路径中使用名词而不是动词

在端点上使用逻辑嵌套

优雅地处理错误并返回标准错误代码

允许过滤、排序和整理

保持良好的安全做法

缓存数据以提高性能

版本我们的 API

结论


什么是REST API?

REST API 是一个应用程序编程界面,符合特定的架构约束,如无状态通信和可缓存数据。它不是协议或标准。虽然 REST API 可以通过多个通信协议访问,但最常见的是,它们通过 HTTPS 进行调用,因此下面的准则

接受并回复杰森

REST API 应接受 JSON 请求有效载荷,并向 JSON 发送回复。JSON 是传输数据的标准。几乎每种网络技术都可以使用它:JavaScript 有内置的方法,通过获取 API 或其他 HTTP 客户端对 JSON 进行编码和解码。服务器端技术具有库,可以解码 JSON 而不做大量工作。

还有其他传输数据的方法。如果不将数据自己转换为可以使用的东西,XML 就不会得到框架的广泛支持,这通常是 JSON。我们不能在客户端(尤其是在浏览器中)轻松操作这些数据。它最终是大量的额外工作,只是为了做正常的数据传输。

表单数据有利于发送数据,尤其是在我们想要发送文件时。但对于文本和数字,我们不需要表单数据来传输这些数据,因为——在大多数框架下——我们可以直接从客户端获取数据来传输JSON。这是迄今为止最直接的。

为了确保当我们的 REST API 应用程序与 JSON 一起响应时,客户会将其解释为此类应用,因此,我们应该在请求发出后设置响应标题。许多服务器端应用框架会自动设置响应标题。一些 HTTP 客户端查看响应标题并根据该格式解析数据。Content-Typeapplication/jsonContent-Type

唯一的例外是,如果我们试图发送和接收客户端和服务器之间的文件。然后,我们需要处理文件响应,并将表单数据从客户端发送到服务器。但那是另一个时间的话题。

我们也应该确保我们的终点返回JSON作为回应。许多服务器端框架都将此作为内置功能。

让我们来看看接受 JSON 有效载荷的 API 示例。此示例将使用点的快速后端框架.js。我们可以使用身体分析器中间件来解析 JSON 请求的主体,然后我们可以使用要返回的对象调用该方法,作为 JSON 响应如下:res.json

<span style="color:var(--highlight-color)"><code>const express = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'express'</span>);
const bodyParser = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'body-parser'</span>);

const app = express();

app.use(bodyParser.json());

app.post(<span style="color:var(--highlight-variable)">'/'</span>, (req, res) => {
  res.json(req.body);
});

app.listen(<span style="color:var(--highlight-namespace)">3000</span>, () => <span style="color:var(--highlight-literal)">console</span>.log(<span style="color:var(--highlight-variable)">'server started'</span>));
</code></span>

bodyParser.json()将 JSON 请求的主体字符串解析为 JavaScript 对象,然后将其分配给对象。req.body

在响应中设置标题,无需任何更改。上述方法适用于大多数其他后端框架。Content-Typeapplication/json; charset=utf-8

在端点路径中使用名词而不是动词

我们不应该在终端路径中使用动词。相反,我们应该使用代表我们检索或操纵的末点作为路名的实体的名词。

这是因为我们的 HTTP 请求方法已经具有动词。在我们的 API 端点路径中使用动词是没有用的,它使得它不必要地长时间,因为它不传达任何新信息。所选动词可能因开发人员的异想天开而有所不同。例如,有些喜欢"获取",有些喜欢"检索",因此最好让 HTTP 获取动词告诉我们什么是和终点。

操作应通过我们正在制定的 HTTP 请求方法表示。最常见的方法包括获取、开机自检、PUT 和删除。

  • 获取检索资源。
  • POST 向服务器提交新数据。
  • 更新现有数据。
  • 删除删除数据。

动词映射到CRUD操作。

考虑到我们上面讨论的两项原则,我们应该创建像 GET 这样的获取新闻文章的路线。同样,POST 是添加一个新的文章,PUT 是更新文章与给定的。删除是用于删除带有给定 ID 的现有文章。/articles//articles//articles/:idid/articles/:id

/articles表示"REST API"资源。例如,我们可以使用 Express 添加以下端点来操纵文章,具体如下:

<span style="color:var(--highlight-color)"><code><span style="color:var(--highlight-keyword)">const</span> express = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'express'</span>);
<span style="color:var(--highlight-keyword)">const</span> bodyParser = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'body-parser'</span>);

<span style="color:var(--highlight-keyword)">const</span> app = express();

app.use(bodyParser.json());

app.get(<span style="color:var(--highlight-variable)">'/articles'</span>, (req, res) => {
  <span style="color:var(--highlight-keyword)">const</span> articles = [];
  <span style="color:var(--highlight-comment)">// code to retrieve an article...</span>
  res.json(articles);
});

app.post(<span style="color:var(--highlight-variable)">'/articles'</span>, (req, res) => {
  <span style="color:var(--highlight-comment)">// code to add a new article...</span>
  res.json(req.body);
});

app.put(<span style="color:var(--highlight-variable)">'/articles/:id'</span>, (req, res) => {
  <span style="color:var(--highlight-keyword)">const</span> { id } = req.params;
  <span style="color:var(--highlight-comment)">// code to update an article...</span>
  res.json(req.body);
});

app.delete(<span style="color:var(--highlight-variable)">'/articles/:id'</span>, (req, res) => {
  <span style="color:var(--highlight-keyword)">const</span> { id } = req.params;
  <span style="color:var(--highlight-comment)">// code to delete an article...</span>
  res.json({ <span style="color:var(--highlight-attribute)">deleted</span>: id });
});

app.listen(<span style="color:var(--highlight-namespace)">3000</span>, () => <span style="color:var(--highlight-literal)">console</span>.log(<span style="color:var(--highlight-variable)">'server started'</span>));</code></span>

在上面的代码中,我们定义了操作文章的端点。正如我们所看到的,路径名称中没有任何动词。我们拥有的都是名词。动词在 HTTP 动词中。

邮政、PUT 和删除端点均以 JSON 为请求主体,并且它们都会返回 JSON 作为响应,包括 GET 端点。

在端点上使用逻辑嵌套

在设计端点时,对包含相关信息的端点进行分组是有意义的。即,如果一个对象可以包含另一个对象,则应设计最终点以反映这一点。无论您的数据是否在数据库中是这样构建的,这都是一个很好的做法。事实上,最好避免在终端中镜像数据库结构,以避免向攻击者提供不必要的信息。

例如,如果我们想要一个终点来获取新闻文章的评论,我们应该附加路径到路径的末尾。我们可以在快车中通过以下代码做到这一点:/comments/articles

<span style="color:var(--highlight-color)"><code><span style="color:var(--highlight-keyword)">const</span> express = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'express'</span>);
<span style="color:var(--highlight-keyword)">const</span> bodyParser = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'body-parser'</span>);

<span style="color:var(--highlight-keyword)">const</span> app = express();

app.use(bodyParser.json());

app.get(<span style="color:var(--highlight-variable)">'/articles/:articleId/comments'</span>, (req, res) => {
  <span style="color:var(--highlight-keyword)">const</span> { articleId } = req.params;
  <span style="color:var(--highlight-keyword)">const</span> comments = [];
  <span style="color:var(--highlight-comment)">// code to get comments by articleId</span>
  res.json(comments);
});


app.listen(<span style="color:var(--highlight-namespace)">3000</span>, () => <span style="color:var(--highlight-literal)">console</span>.log(<span style="color:var(--highlight-variable)">'server started'</span>));</code></span>

在上面的代码中,我们可以在路径上使用 GET 方法。我们得到的文章确定,然后返回它在响应中。我们添加路径段后,以表明它是一个儿童资源。'/articles/:articleId/comments'commentsarticleId'comments''/articles/:articleId'/articles

这是有道理的,因为是儿童对象的,假设每篇文章都有自己的评论。否则,它就会使用户感到困惑,因为通常认为此结构用于访问儿童对象。同样的原则也适用于邮政、PUT 和删除端点。它们都可以使用相同的嵌套结构来命名路径名称。commentsarticles

然而,筑巢可能走得太远。大约在第二或第三级之后,嵌套端点可能会变得笨拙。相反,请考虑将 URL 返回到这些资源,特别是如果该数据不一定包含在顶层对象中。

例如,假设您想返回特定评论的作者。你可以使用。但是,这是失控。相反,在 JSON 响应中返回该特定用户的 URI:/articles/:articleId/comments/:commentId/author

"author": "/users/:userId"

优雅地处理错误并返回标准错误代码

为了消除 API 用户在发生错误时的混淆,我们应优雅地处理错误,并返回指示发生何种错误的 HTTP 响应代码。这为 API 的维护人员提供了足够的信息,以了解所发生的问题。我们不希望错误导致我们的系统崩溃,因此我们可以让它们无法处理,这意味着 API 消费者必须处理它们。

常见错误 HTTP 状态代码包括:

  • 400错误请求–这意味着客户端输入无法验证。
  • 401未授权–这意味着用户未授权访问资源。它通常在用户未身份验证时返回。
  • 403禁止–这意味着用户已获得身份验证,但不允许访问资源。
  • 404未找到–这表明找不到资源。
  • 500内部服务器错误–这是一个通用的服务器错误。它可能不应该被明确抛出。
  • 502坏网关-这表示来自上游服务器的无效响应。
  • 503服务不可用-这表明服务器方面发生了一些意想不到的事情(它可以是任何像服务器超载,系统的某些部分失败,等等)。

我们应该抛出与应用遇到的问题相对应的错误。例如,如果我们要拒绝请求有效载荷中的数据,则应返回快件 API 中的 400 响应:

<span style="color:var(--highlight-color)"><code><span style="color:var(--highlight-keyword)">const</span> express = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'express'</span>);
<span style="color:var(--highlight-keyword)">const</span> bodyParser = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'body-parser'</span>);

<span style="color:var(--highlight-keyword)">const</span> app = express();

<span style="color:var(--highlight-comment)">// existing users</span>
<span style="color:var(--highlight-keyword)">const</span> users = [
  { <span style="color:var(--highlight-attribute)">email</span>: <span style="color:var(--highlight-variable)">'abc@foo.com'</span> }
]

app.use(bodyParser.json());

app.post(<span style="color:var(--highlight-variable)">'/users'</span>, (req, res) => {
  <span style="color:var(--highlight-keyword)">const</span> { email } = req.body;
  <span style="color:var(--highlight-keyword)">const</span> userExists = users.find(u => u.email === email);
  <span style="color:var(--highlight-keyword)">if</span> (userExists) {
    <span style="color:var(--highlight-keyword)">return</span> res.status(<span style="color:var(--highlight-namespace)">400</span>).json({ <span style="color:var(--highlight-attribute)">error</span>: <span style="color:var(--highlight-variable)">'User already exists'</span> })
  }
  res.json(req.body);
});


app.listen(<span style="color:var(--highlight-namespace)">3000</span>, () => <span style="color:var(--highlight-literal)">console</span>.log(<span style="color:var(--highlight-variable)">'server started'</span>));</code></span>

在上面的代码中,我们拥有包含给定电子邮件的数组中现有用户的列表。users

然后,如果我们尝试提交有效载荷与已经存在的值,我们将得到一个400响应状态代码与消息,让用户知道用户已经存在。有了这些信息,用户可以通过将电子邮件更改为不存在的操作来纠正操作。emailusers'User already exists'

错误代码需要随附消息,以便维护人员有足够的信息来排除问题,但攻击者不能使用错误内容来进行攻击,如窃取信息或关闭系统。

每当我们的 API 无法成功完成时,我们都应该通过发送信息错误来帮助用户采取纠正措施来优雅地失败。

允许过滤、排序和整理

REST API 背后的数据库可能会变得非常大。有时,有这么多的数据,它不应该一次全部返回,因为它太慢或将带下我们的系统。因此,我们需要过滤项目的方法。

我们还需要对数据进行填充的方法,以便我们一次只返回几个结果。我们不想通过尝试同时获取所有请求的数据来将资源连接太久。

过滤和分铺都通过减少服务器资源的使用来提高性能。随着数据库中累积的数据越多,这些功能就越重要。

下面是一个小示例,API 可以接受具有各种查询参数的查询字符串,以便我们按其字段筛选出项目:

<span style="color:var(--highlight-color)"><code><span style="color:var(--highlight-keyword)">const</span> express = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'express'</span>);
<span style="color:var(--highlight-keyword)">const</span> bodyParser = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'body-parser'</span>);

<span style="color:var(--highlight-keyword)">const</span> app = express();

<span style="color:var(--highlight-comment)">// employees data in a database</span>
<span style="color:var(--highlight-keyword)">const</span> employees = [
  { <span style="color:var(--highlight-attribute)">firstName</span>: <span style="color:var(--highlight-variable)">'Jane'</span>, <span style="color:var(--highlight-attribute)">lastName</span>: <span style="color:var(--highlight-variable)">'Smith'</span>, <span style="color:var(--highlight-attribute)">age</span>: <span style="color:var(--highlight-namespace)">20</span> },
  <span style="color:var(--highlight-comment)">//...</span>
  { <span style="color:var(--highlight-attribute)">firstName</span>: <span style="color:var(--highlight-variable)">'John'</span>, <span style="color:var(--highlight-attribute)">lastName</span>: <span style="color:var(--highlight-variable)">'Smith'</span>, <span style="color:var(--highlight-attribute)">age</span>: <span style="color:var(--highlight-namespace)">30</span> },
  { <span style="color:var(--highlight-attribute)">firstName</span>: <span style="color:var(--highlight-variable)">'Mary'</span>, <span style="color:var(--highlight-attribute)">lastName</span>: <span style="color:var(--highlight-variable)">'Green'</span>, <span style="color:var(--highlight-attribute)">age</span>: <span style="color:var(--highlight-namespace)">50</span> },
]

app.use(bodyParser.json());

app.get(<span style="color:var(--highlight-variable)">'/employees'</span>, (req, res) => {
  <span style="color:var(--highlight-keyword)">const</span> { firstName, lastName, age } = req.query;
  <span style="color:var(--highlight-keyword)">let</span> results = [...employees];
  <span style="color:var(--highlight-keyword)">if</span> (firstName) {
    results = results.filter(r => r.firstName === firstName);
  }

  <span style="color:var(--highlight-keyword)">if</span> (lastName) {
    results = results.filter(r => r.lastName === lastName);
  }

  <span style="color:var(--highlight-keyword)">if</span> (age) {
    results = results.filter(r => +r.age === +age);
  }
  res.json(results);
});

app.listen(<span style="color:var(--highlight-namespace)">3000</span>, () => <span style="color:var(--highlight-literal)">console</span>.log(<span style="color:var(--highlight-variable)">'server started'</span>));</code></span>

在上面的代码中,我们有变量来获取查询参数。然后,我们使用 JavaScript 解构语法将单个查询参数分解为变量,从而提取属性值。最后,我们继续运行每个查询参数值,以查找要返回的项目。req.queryfilter

一旦我们这样做了,我们就会作为回应返回。因此,当我们使用查询字符串向以下路径提出 GET 请求时:results

/employees?lastName=Smith&age=30

我们得到:

<span style="color:var(--highlight-color)"><code>[
    {
        <span style="color:var(--highlight-attribute)">"firstName"</span>: <span style="color:var(--highlight-variable)">"John"</span>,
        <span style="color:var(--highlight-attribute)">"lastName"</span>: <span style="color:var(--highlight-variable)">"Smith"</span>,
        <span style="color:var(--highlight-attribute)">"age"</span>: <span style="color:var(--highlight-namespace)">30</span>
    }
]</code></span>

作为返回的反应,因为我们过滤和。lastNameage

同样,我们可以接受查询参数,并将一组条目从位置返回到。page(page - 1) * 20page * 20

我们还可以在查询字符串中指定要排序的字段。例如,我们可以从查询字符串中获取参数,以及要对数据进行排序的字段。然后,我们可以按这些单独的字段对它们进行排序。

例如,我们可能希望从 URL 中提取查询字符串,例如:

http://example.com/articles?sort=+author,-datepublished

哪里意味着上升,哪里意味着下降。因此,我们按作者的名字按字母顺序排序,从最近到最近。+-datepublished

保持良好的安全做法

客户端和服务器之间的大多数通信应该是私人的,因为我们经常发送和接收私人信息。因此,必须使用 SSL/TLS 进行安全性。

SSL 证书不难加载到服务器上,成本是免费的或非常低的。没有理由不让我们的 REST API 通过安全的渠道而不是在开放渠道进行通信。

人们不应该能够访问他们要求的更多信息。例如,正常用户不应能够访问其他用户的信息。他们也不应该能够访问管理员的数据。

为了执行最少特权原则,我们需要为单个角色添加角色检查,或者为每个用户添加更精细的角色。

如果我们选择将用户分组到几个角色,那么角色应该拥有涵盖所有他们需要的权限,并且不再具有更多权限。如果我们对用户有权访问的每项功能拥有更多细粒度的权限,则我们必须确保管理员可以相应地从每个用户中添加和删除这些功能。此外,我们需要添加一些预设角色,这些角色可以应用于组用户,这样我们就不必手动为每个用户执行此操作。

缓存数据以提高性能

我们可以添加缓存以从本地内存缓存返回数据,而不是每次想要检索用户请求的某些数据时都会查询数据库以获取数据。缓存的好处是用户可以更快地获取数据。但是,用户获得的数据可能已过时。当我们不断看到旧数据时,在生产环境中调试时,这还可能导致问题。

有许多种缓存解决方案,如Redis,内存缓存,等等。随着需求的变化,我们可以更改数据缓存的方式。

例如,Express 有apicache中间件添加到我们的应用程序,而无需太多的配置。我们可以在服务器中添加一个简单的内存缓存,这样:

<span style="color:var(--highlight-color)"><code><span style="color:var(--highlight-keyword)">const</span> express = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'express'</span>);
<span style="color:var(--highlight-keyword)">const</span> bodyParser = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'body-parser'</span>);
<span style="color:var(--highlight-keyword)">const</span> apicache = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'apicache'</span>);
<span style="color:var(--highlight-keyword)">const</span> app = express();
<span style="color:var(--highlight-keyword)">let</span> cache = apicache.middleware;
app.use(cache(<span style="color:var(--highlight-variable)">'5 minutes'</span>));

<span style="color:var(--highlight-comment)">// employees data in a database</span>
<span style="color:var(--highlight-keyword)">const</span> employees = [
  { <span style="color:var(--highlight-attribute)">firstName</span>: <span style="color:var(--highlight-variable)">'Jane'</span>, <span style="color:var(--highlight-attribute)">lastName</span>: <span style="color:var(--highlight-variable)">'Smith'</span>, <span style="color:var(--highlight-attribute)">age</span>: <span style="color:var(--highlight-namespace)">20</span> },
  <span style="color:var(--highlight-comment)">//...</span>
  { <span style="color:var(--highlight-attribute)">firstName</span>: <span style="color:var(--highlight-variable)">'John'</span>, <span style="color:var(--highlight-attribute)">lastName</span>: <span style="color:var(--highlight-variable)">'Smith'</span>, <span style="color:var(--highlight-attribute)">age</span>: <span style="color:var(--highlight-namespace)">30</span> },
  { <span style="color:var(--highlight-attribute)">firstName</span>: <span style="color:var(--highlight-variable)">'Mary'</span>, <span style="color:var(--highlight-attribute)">lastName</span>: <span style="color:var(--highlight-variable)">'Green'</span>, <span style="color:var(--highlight-attribute)">age</span>: <span style="color:var(--highlight-namespace)">50</span> },
]

app.use(bodyParser.json());

app.get(<span style="color:var(--highlight-variable)">'/employees'</span>, (req, res) => {
  res.json(employees);
});

app.listen(<span style="color:var(--highlight-namespace)">3000</span>, () => <span style="color:var(--highlight-literal)">console</span>.log(<span style="color:var(--highlight-variable)">'server started'</span>));</code></span>

上面的代码只是引用中间件,然后我们有:apicacheapicache.middleware

app.use(cache('5 minutes'))

将缓存应用到整个应用程序。例如,我们将结果缓存五分钟。我们可以根据我们的需要来调整它。

如果您使用的是缓存,您还应在标题中包含信息。这将有助于用户有效地使用缓存系统。Cache-Control

版本我们的 API

如果我们对 API 进行任何可能破坏客户端的更改,则我们应该具有不同版本的 API。版本可以按照语义版本(例如,2.0.6表示主要版本 2 和第六个补丁)完成,就像现在大多数应用所做的那样。

这样,我们可以逐步淘汰旧的端点,而不是迫使每个人同时移动到新的 API。v1 端点可以为不想更改的人保持活跃,而 v2 具有闪亮的新功能,可以为准备升级的人提供服务。如果我们的 API 是公开的,这一点尤其重要。我们应该对它们进行版本,这样我们就不会破坏使用我们的API的第三方应用程序。

版本通常在 API 路径的开头添加等完成。/v1//v2/

例如,我们可以用快车做到这一点,如下所示:

<span style="color:var(--highlight-color)"><code><span style="color:var(--highlight-keyword)">const</span> express = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'express'</span>);
<span style="color:var(--highlight-keyword)">const</span> bodyParser = <span style="color:var(--highlight-literal)">require</span>(<span style="color:var(--highlight-variable)">'body-parser'</span>);
<span style="color:var(--highlight-keyword)">const</span> app = express();
app.use(bodyParser.json());

app.get(<span style="color:var(--highlight-variable)">'/v1/employees'</span>, (req, res) => {
  <span style="color:var(--highlight-keyword)">const</span> employees = [];
  <span style="color:var(--highlight-comment)">// code to get employees</span>
  res.json(employees);
});

app.get(<span style="color:var(--highlight-variable)">'/v2/employees'</span>, (req, res) => {
  <span style="color:var(--highlight-keyword)">const</span> employees = [];
  <span style="color:var(--highlight-comment)">// different code to get employees</span>
  res.json(employees);
});

app.listen(<span style="color:var(--highlight-namespace)">3000</span>, () => <span style="color:var(--highlight-literal)">console</span>.log(<span style="color:var(--highlight-variable)">'server started'</span>));</code></span>

我们只是将版本编号添加到端点URL路径的开头以将其版本。

结论

设计高质量 REST API 最重要的要点是遵循 Web 标准和惯例,保持一致性。JSON、SSL/TLS 和 HTTP 状态代码都是现代网络的标准构建基块。

绩效也是一个重要的考虑因素。我们可以通过不同时返回太多数据来增加它。此外,我们可以使用缓存,这样我们就不必一直查询数据。

端点路径应一致,我们仅使用名词,因为 HTTP 方法指示了我们要采取的行动。嵌套资源路径应在母资源路径之后出现。他们应该告诉我们我们得到什么或操纵什么, 而无需阅读额外的文档来了解它在做什么。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值