如何基于已有的 REST API 实现 GraphQL API

发表于 3年以前  | 总阅读数:352 次

你的 dad jokes 放在哪儿?当然是在 dadabase 里。我们来想象一下,你是全世界最受欢迎的 dad jokes 数据库的管理员。项目的技术概况是:使用 REST API 与数据库通信,这种 REST API 具有搜索笑话和对笑话进行评分的功能;网站的访问者可以通过一个简单的用户界面对每条笑话进行评分。 最近你了解到一种新技术,它叫做 GraphQL,它具有一定的灵活性,可以精准获取你需要的数据,而且是使用单一的 API 结点。这听上去很不错,于是你打算在应用程序中使用这种技术。但是,你不希望对原有的 REST API 作过多的改动。能否让你的项目同时支持 REST API 和 GraphQL API?

在本文中,我们会讨论如何基于已有的 REST API 来实现 GraphQL API。你使用这种方法,不需要对基于原有的 REST API 框架进行调整,就可以在项目的未完成的模块中使用 GraphQL。

如果你想看到最终的结果,可以访问 REST API 代码 和 前端和 GraphQL API 代码。还要记得浏览一下网站,那些笑话很值得看哦。

初始架构

项目的后台原先是使用 Node 和 JSON Server 开发的。JSON Server 利用 Express 为一个模拟的数据库提供了完整的 REST API,并且这个数据库是由一个简单的 JSON 文件生成的。前端是使用 Vanilla JS 实现的,并使用浏览器内嵌的 Fetch API 发出 API 请求。该应用程序托管在 Heroku 上,可以方便地对它进行部署和监控。我们使用的 JSON 文件含有一些笑话和评分信息。下面,我们把它完整地复制出来:

{
  "jokes": [
    {
      "id": 1,
      "content": "I don't often tell dad jokes, but when I do, sometimes he laughs."
    },
    {
      "id": 2,
      "content": "Why was the scarecrow promoted? For being outstanding in his field."
    },
    {
      "id": 3,
      "content": "What did the grape do when someone stepped on him? He let out a little whine."
    },
    {
      "id": 4,
      "content": "Einstein, Pascal, and Newton are playing hide and seek. Einstein covers his eyes and begins counting. While Pascal runs off and hides, Newton takes out some chalk and marks a square on the ground with side lengths of exactly 1 meter, then sits down inside the square. When Einstein is finished counting and sees Newton sitting on the ground, he yells, \"Ha, I've found you, Newton!\". Newton replies, \"No you haven't! You've found one Newton over a square meter. You've found Pascal!"
    }
  ],
  "ratings": [
    { "id": 1, "jokeId": 1, "score": 8 },
    { "id": 2, "jokeId": 2, "score": 3 },
    { "id": 3, "jokeId": 3, "score": 6 },
    { "id": 4, "jokeId": 1, "score": 7 },
    { "id": 5, "jokeId": 2, "score": 6 },
    { "id": 6, "jokeId": 3, "score": 4 },
    { "id": 7, "jokeId": 1, "score": 9 },
    { "id": 8, "jokeId": 2, "score": 10 },
    { "id": 9, "jokeId": 3, "score": 2 },
    { "id": 10, "jokeId": 4, "score": 10 },
    { "id": 11, "jokeId": 4, "score": 10 },
    { "id": 12, "jokeId": 4, "score": 10 },
    { "id": 13, "jokeId": 4, "score": 10 },
    { "id": 14, "jokeId": 4, "score": 10 },
    { "id": 15, "jokeId": 4, "score": 10 }
  ]
}
复制代码

JSON Server 系统把这个文件中的数据作为数据库的初始数据,接着实现一套 REST API,其中包括对 GET, POST, PUT, PATCH 和 DELETE 请求的支持。JSON Server 的神奇之处在于,使用这套 API 就能实现对 JSON 文件的修改,因此数据库就是完全交互式的。JSON Server 不经安装就可以直接由 npm 脚本启动,但为了对它进行一些配置以及端口的设置,我们可以写下几行代码并运行它,代码如下:

const jsonServer = require('json-server')
const server = jsonServer.create()
const router = jsonServer.router('db.json')
const middlewares = jsonServer.defaults()

server.use(middlewares)
server.use(router)
server.listen(process.env.PORT || 3000, () => {
  console.log(` JSON Server is running on port ${process.env.PORT || 3000}`)
})

复制代码

欲对这个模拟的数据库进行测试,你可以把 API 有关的仓库克隆到本地,并运行 npm installnpm start。在浏览器中访问 http://localhost:3000/jokes ,页面会显示所有的笑话。访问 http://localhost:3000/ratings ,页面会显示所有的评分信息。 太棒了。我们可以在浏览器上运行应用程序的后台。现在我们把 API 托管在 Heroku 中。首先需要安装 Heroku 命令行工具。然后,我们可以进行这些操作:登录,创建项目,推送到 Heroku 服务端,在浏览器中打开项目的操作界面。

# 登录你的 Heroku 账户
heroku login

# 创建项目
heroku create dad-joke-dadabase-rest-api

# 将代码部署到 Heroku 服务端
git push heroku master

# 打开项目的后台页面
heroku open
复制代码

看,现在我们把 API 发布到公网上了!

构建用户界面既然我们已经部署了一个运行中的 REST API,就可以制作前端页面,并使用 API 把这些笑话数据呈现在页面上,还可以对这些笑话进行评分。下面的 HTML 页面代码实现了一个显示笑话内容的容器,笑话内容由 JavaScript 代码加载进来。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta >
  <title>Dad Joke Dadabase</title>
  <meta Where do you keep your dad jokes? In a dadabase of course!">
  <meta >
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <h1>Dad Joke Dadabase</h1>

  <div class="project">
    <h2 class="jokeContent"></h2>
    <div class="rateThisJokeContainer">
      <p>Rate this joke:</p>
      <div class="rateThisJokeOptions">
        <span class="formGroup"><input type="radio" id="score-1" >1</label></span>
        <span class="formGroup"></span><input type="radio" id="score-2" >2</label></span>
        <span class="formGroup"></span><input type="radio" id="score-3" >3</label></span>
        <span class="formGroup"></span><input type="radio" id="score-4" >4</label></span>
        <span class="formGroup"></span><input type="radio" id="score-5" >5</label></span>
        <span class="formGroup"></span><input type="radio" id="score-6" >6</label></span>
        <span class="formGroup"></span><input type="radio" id="score-7" >7</label></span>
        <span class="formGroup"></span><input type="radio" id="score-8" >8</label></span>
        <span class="formGroup"></span><input type="radio" id="score-9" >9</label></span>
        <span class="formGroup"></span><input type="radio" id="score-10" >10</label></span>
      </div>
    </div>
    <p class="averageRating">Average Rating: <span class="jokeRatingValue">7.8</span></p>
    <button id="nextJoke">See Next Joke</button>
</div>
  <script src="./script.js"></script>
</body>
</html>
复制代码

JavaScript 代码如下。跟 REST API 交互的关键代码在于两个获取数据的请求。第一个请求通过访问 /jokes?_embed=ratings 获取数据库中所有的笑话,第二个请求是 POST 类型的,它通过访问 /ratings 提交对某个笑话的评分。

const jokeContent = document.querySelector('.jokeContent')
const jokeRatingValue = document.querySelector('.jokeRatingValue')
const nextJokeButton = document.querySelector('#nextJoke')

const jokes = []
let currentJokeIndex = -1

const displayNextJoke = () => {
  currentJokeIndex++
  if (currentJokeIndex >= jokes.length) {
    currentJokeIndex = 0
  }

  const joke = jokes[currentJokeIndex]

  jokeContent.textContent = joke.content

  const totalScore = joke.ratings.reduce(
    (total, rating) => (total += rating.score),
  )
  const numberOfRatings = joke.ratings.length
  const averageRating = totalScore / numberOfRatings

  jokeRatingValue.textContent = averageRating.toFixed(1)
}

const submitJokeRating = () => {
  const ratingInput = document.querySelector('input[]:checked')

  if (ratingInput && ratingInput.value) {
    const score = Number(ratingInput.value)
    const jokeId = jokes[currentJokeIndex].id
    const postData = { jokeId, score }

    fetch('/ratings', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(postData),
    })
      .then(response => response.json())
      .then(responseData => {
        const jokeToUpdate = jokes.find(joke => joke.id === responseData.jokeId)
        jokeToUpdate && jokeToUpdate.ratings.push(responseData)
      })
      .finally(() => {
        ratingInput.checked = false
        displayNextJoke()
      })
  } else {
    displayNextJoke()
  }
}

nextJokeButton.addEventListener('click', submitJokeRating)

fetch('/jokes?_embed=ratings')
  .then(response => response.json())
  .then(data => {
    jokes.push(...data)
    displayNextJoke()
  })

复制代码

安装并使用 Apollo Server


这样,我们已经完成了项目的架构:它有一个简单的页面,该页面通过 REST API 跟数据库通信。那么,我们如何使用 GraphQL?使用 GraphQL 之前需要哪些准备工作呢?第一步,我们安装 [apollo-server-express](https://www.npmjs.com/package/apollo-server-express),它是一个程序包,用于实现 Apollo Server 和 Express 的集成。也需要安装 [apollo-datasource-rest](https://www.npmjs.com/package/apollo-datasource-rest) 包,用于 REST API 和 Apollo Server 的集成。然后,我们来配置服务器,需要编写以下代码:

const express = require('express')
const path = require('path')
const { ApolloServer } = require('apollo-server-express')
const JokesAPI = require('./jokesAPI')
const RatingsAPI = require('./ratingsAPI')
const typeDefs = require('./typeDefs')
const resolvers = require('./resolvers')

const app = express()
const server = new ApolloServer({
  typeDefs,
  resolvers,
  dataSources: () => ({
    jokesAPI: new JokesAPI(),
    ratingsAPI: new RatingsAPI(),
  }),
})

server.applyMiddleware({ app })

app
  .use(express.static(path.join(__dirname, 'public')))
  .get('/', (req, res) => {
    res.sendFile('index.html', { root: 'public' })
  })
  .get('/script.js', (req, res) => {
    res.sendFile('script.js', { root: 'public' })
  })
  .get('/style.css', (req, res) => {
    res.sendFile('style.css', { root: 'public' })
  })

app.listen({ port: process.env.PORT || 4000 }, () => {
  console.log(` Server ready at port ${process.env.PORT || 4000}`)
})

复制代码

你可以看到,我们配置了 Apollo Server 的三个属性:typeDefs, resolversdataSources。其中,typeDefs 属性包含了与我们的 GraphQL API 相关的 schema,我们在相应的包中定义笑话和评分的数据类型,以及如何查询和更新数据;resolvers 告诉服务器如何处理各种各样的查询和更新需求,以及如何连接数据源;最后,dataSources 大致描述了 GraphQL API 与 REST API 的关联关系。下面的代码定义了 JokeRating 数据类型,以及如何查询和更新数据。

const { gql } = require('apollo-server-express')

const typeDefs = gql`
  type Joke {
    id: Int!
    content: String!
    ratings: [Rating]
  }
  type Rating {
    id: Int!
    jokeId: Int!
    score: Int!
  }
  type Query {
    joke(id: Int!): Joke
    jokes: [Joke]
    rating(id: Int!): Rating
    ratings: [Rating]
  }
  type Mutation {
    rating(jokeId: Int!, score: Int!): Rating
  }
`

module.exports = typeDefs
复制代码

下面是 JokesAPI 类的代码,主要定义了笑话数据创建、查询、更新、删除的方法,这些方法分别调用相应的 REST API 实施相关的数据操作。

const { RESTDataSource } = require('apollo-datasource-rest')

class JokesAPI extends RESTDataSource {
  constructor() {
    super()
    this.baseURL = 'https://dad-joke-dadabase-rest-api.herokuapp.com/'
  }

  async getJoke(id) {
    return this.get(`jokes/${id}?_embed=ratings`)
  }

  async getJokes() {
    return this.get('jokes?_embed=ratings')
  }

  async postJoke(jokeContent) {
    return this.post('jokes', jokeContent)
  }

  async replaceJoke(joke) {
    return this.put('jokes', joke)
  }

  async updateJoke(joke) {
    return this.patch('jokes', { id: joke.id, joke })
  }

  async deleteJoke(id) {
    return this.delete(`jokes/${id}`)
  }
}

module.exports = JokesAPI
复制代码

评分数据跟笑话相似,只是在每个实例中把 “joke” 变为 “rating”。欲获取这部分代码,可以参考 GitHub 上的代码仓库。最后,我们设置解析器,在其中定义如何使用数据源。

const resolvers = {
  Query: {
    joke: async (_source, { id }, { dataSources }) =>
      dataSources.jokesAPI.getJoke(id),
    jokes: async (_source, _args, { dataSources }) =>
      dataSources.jokesAPI.getJokes(),
    rating: async (_source, { id }, { dataSources }) =>
      dataSources.ratingsAPI.getRating(id),
    ratings: async (_source, _args, { dataSources }) =>
      dataSources.ratingsAPI.getRatings(),
  },
  Mutation: {
    rating: async (_source, { jokeId, score }, { dataSources }) => {
      const rating = await dataSources.ratingsAPI.postRating({ jokeId, score })
      return rating
    },
  },
}

module.exports = resolvers
复制代码

完成这些步骤,我们一切准备就绪,可以通过 Apollo Server 调用 GraphQL API 了。为了把新的前端页面和 GraphQL API 托管在 Heroku 上,我们需要创建并部署第二个应用程序:

# 创建 Heroku 应用程序
heroku create dad-joke-dadabase

# 把代码部署在 Heroku 上
git push heroku master

# 在本地打开 Heroku 应用程序
heroku open
复制代码

把 API 端点功能改为获取笑话的代码

你应当回忆下,我们有两个 API 端点供前端页面调用:它们的功能分别是获取笑话和提交评分。现在我们把 REST API 中获取笑话的代码改为 GraphQL API 形式:

fetch('/jokes?_embed=ratings')
  .then(response => response.json())
  .then(data => {
    jokes.push(...data)
    displayNextJoke()
  })
复制代码

我们把上述代码改为:

fetch('/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `
    query GetAllJokesWithRatings {
      jokes {
        id
        content
        ratings {
          score
          id
          jokeId
        }
      }
    }
  `,
  }),
})
  .then(res => res.json())
  .then(res => {
    jokes.push(...res.data.jokes)
    displayNextJoke()
  })
复制代码

现在,我们可以在本地运行应用程序了。实际上,从用户的角度来说,没有发生任何变化。但假如你在浏览器的开发者工具中查看网络请求,你会发现,现在获取笑话是通过访问 /graphql 端点来实现的了。真棒!

把 API 端点功能改为提交评分的代码

一个 API 请求已完成,还有一个!我们现在对评分功能的代码进行修改。提交评分的代码原来类似于:

fetch('/ratings', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(postData),
})
  .then(response => response.json())
  .then(responseData => {
    const jokeToUpdate = jokes.find(joke => joke.id === responseData.jokeId)
    jokeToUpdate && jokeToUpdate.ratings.push(responseData)
  })
  .finally(() => {
    ratingInput.checked = false
    displayNextJoke()
  })
复制代码

现在我们作如下的改动,让它使用我们的 GraphQL API:

fetch('/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: `
    mutation CreateRating {
      rating(jokeId: ${jokeId}, score: ${score}) {
        id
        score
        jokeId
      }
    }
  `,
  }),
})
  .then(res => res.json())
  .then(res => {
    const rating = res.data.rating
    const jokeToUpdate = jokes.find(joke => joke.id === rating.jokeId)
    jokeToUpdate && jokeToUpdate.ratings.push(rating)
  })
  .finally(() => {
    ratingInput.checked = false
    displayNextJoke()
  })
复制代码

经过快速测试,这段代码符合需求。再次说明,用户体验没有变,但现在我们请求数据使用的都是 /graphql 端点。

结论

我们做到了。我们以已有的 REST API 为基础,成功地实现了一个 GraphQL API 端点。因此,我们也能使用 GraphQL 来实现一些核心功能,而且已有的功能和原来的 REST API 都不需要修改。如今我们可以弃用 REST API,它将来也可能会退出历史舞台。

虽然 dad joke 数据库是完全虚拟的项目,但几乎所有的在 2015 年 GraphQL 发布会之前成立的科技公司都发现:如果他们改变技术路线,使用 GraphQL,他们自身的情况跟 dad jokes 一样,也是可行的。另外,还有个好消息,Apollo Server 属于较灵活的产品,它也可以从包括 REST API 端点在内的各种数据源获取数据。

本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/9gVZiFNRSoHncqmxdVWmhg

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237231次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8065次阅读
 目录