Spring Boot 和 Kotlin构建web应用程序

原文地址:Getting Started | Building web applications with Spring Boot and Kotlin

目录

创建一个新项目

使用Initializr网站

使用命令行

使用IntelliJ IDEA

 支持构建方式

了解生成的应用程序

编写您的第一个Kotlin控制器

使用JUnit 5进行测试

用Kotlin编写JUnit 5测试

测试实例生命周期

创建自己的扩展

JPA的持久性

实施博客引擎

公开HTTP API

配置属性

结论


本教程向您展示如何通过结合Spring BootKotlin的功能来有效地构建示例博客应用程序。

如果您是从Kotlin开始的,则可以通过阅读参考文档,遵循在线Kotlin Koans教程或仅使用Spring Framework参考文档(现在在Kotlin中提供代码示例)来学习该语言。

Spring Kotlin支持在Spring FrameworkSpring Boot参考文档中有所记录。如果您需要帮助,请在StackOverflow上使用spring和kotlin标记进行搜索或提出问题,或者在Kotlin Slack#spring频道中进行讨论。

创建一个新项目

首先,我们需要创建一个Spring Boot应用程序,可以通过多种方式来完成。

使用Initializr网站

访问https://start.spring.io并选择Kotlin语言。Gradle是Kotlin中最常用的构建工具,它提供了Kotlin DSL,在生成Kotlin项目时默认使用该DSL,因此这是推荐的选择。但是,如果您更喜欢Maven,也可以使用它。请注意,您可以使用https://start.spring.io/#!language=kotlin&type=gradle-project默认情况下选择Kotlin和Gradle。

1.选择“ Gradle Project”或根据您要使用的构建工具设置默认的“ Maven Project”

2.输入以下工件坐标: blog

3.添加以下依赖项:

  • Spring Web

  • Mustache

  • Spring Data JPA

  • H2 Database

  • Spring Boot DevTools

4.点击“生成项目”。

.zip文件在根目录中包含一个标准项目,因此您可能需要在解压缩之前创建一个空目录。

使用命令行

您可以从命令行中使用Initializr HTTP API ,例如,在类似UN * X的系统上使用curl:

$ mkdir blog && cd blog
$ curl https://start.spring.io/starter.zip -d language=kotlin -d dependencies=web,mustache,jpa,h2,devtools -d packageName=com.example.blog -d name=Blog -o blog.zip

-d type=gradle-project如果要使用Gradle,请添加。

使用IntelliJ IDEA

Spring Initializr还集成在IntelliJ IDEA Ultimate版本中,使您可以创建和导入新项目,而不必将IDE留给命令行或Web UI。

要访问该向导,请转到“文件” |“其他”。新增| 项目,然后选择Spring Initializr。

请按照向导的步骤使用以下参数:

  • Artifact: "blog"

  • Type: Maven project or Gradle Project

  • Language: Kotlin

  • Name: "Blog"

  • Dependencies: "Spring Web Starter", "Mustache", "Spring Data JPA", "H2 Database" and "Spring Boot DevTools"

 支持构建方式

  • Gradle Build

  • Maven Build

了解生成的应用程序

src/main/kotlin/com/example/blog/BlogApplication.kt

package com.example.blog

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class BlogApplication

fun main(args: Array<String>) {
  runApplication<BlogApplication>(*args)
}

与Java相比,您会注意到缺少分号,在空类上缺少括号(如果需要通过@Bean注释声明bean,可以添加一些括号)以及使用runApplication顶层函数。runApplication<BlogApplication>(*args)是Kotlin的惯用替代品SpringApplication.run(BlogApplication::class.java, *args),可用于使用以下语法来自定义应用程序。

src/main/kotlin/com/example/blog/BlogApplication.kt

fun main(args: Array<String>) {
  runApplication<BlogApplication>(*args) {
    setBannerMode(Banner.Mode.OFF)
  }
}

编写您的第一个Kotlin控制器

让我们创建一个简单的控制器来显示一个简单的网页。

src/main/kotlin/com/example/blog/HtmlController.kt

package com.example.blog

import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.ui.set
import org.springframework.web.bind.annotation.GetMapping

@Controller
class HtmlController {

  @GetMapping("/")
  fun blog(model: Model): String {
    model["title"] = "Blog"
    return "blog"
  }

}

请注意,这里我们使用的是Kotlin扩展,该扩展允许向现有的Spring类型添加Kotlin函数或运算符。在这里,我们导入org.springframework.ui.set扩展功能是为了能够model["title"] = "Blog"代替编写model.addAttribute("title", "Blog")。在Spring框架KDOC API列出了所有提供丰富的Java API的科特林扩展。

我们还需要创建关联的Mustache模板。

src/main/resources/templates/header.mustache

<html>
<head>
  <title>{{title}}</title>
</head>
<body>

src/main/resources/templates/footer.mustache

</body>
</html>

src/main/resources/templates/blog.mustache

{{> header}}

<h1>{{title}}</h1>

{{> footer}}

通过运行的main功能启动Web应用程序BlogApplication.kt,然后转到http://localhost:8080/,您应该会看到一个带有“ Blog”标题的醒目的网页。

使用JUnit 5进行测试

现在在Spring Boot中默认使用的JUnit 5提供了Kotlin非常方便的各种功能,包括自动装配构造函数/方法参数,该参数允许使用不可为null的val属性,并可以在常规的非静态方法上使用@BeforeAll@AfterAll

用Kotlin编写JUnit 5测试

为了这个示例,让我们创建一个集成测试以演示各种功能:

  • 我们在反引号之间使用实词而不是驼峰式大小写来提供表达性的测试函数名称

  • JUnit 5允许注入构造函数和方法参数,这与Kotlin只读和不可为空的属性非常吻合

  • 此代码利用getForObjectgetForEntityKotlin扩展(您需要导入它们)

src/test/kotlin/com/example/blog/IntegrationTests.kt

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {

  @Test
  fun `Assert blog page title, content and status code`() {
    val entity = restTemplate.getForEntity<String>("/")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains("<h1>Blog</h1>")
  }

}

测试实例生命周期

有时您需要在给定类的所有测试之前或之后执行一个方法。像Junit 4一样,默认情况下,JUnit 5要求这些方法是静态的(companion object在Kotlin中转换为,这很冗长且不简单),因为每个测试都会实例化一次测试类。

但是Junit 5允许您更改此默认行为,并在每个类一次实例化测试类。这可以通过多种方式完成,这里我们将使用属性文件来更改整个项目的默认行为:

src/test/resources/junit-platform.properties

junit.jupiter.testinstance.lifecycle.default = per_class

通过此配置,我们现在可以在常规方法中使用@BeforeAll@AfterAll注释,如IntegrationTests上面更新版本中所示。

src/test/kotlin/com/example/blog/IntegrationTests.kt

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {

  @BeforeAll
  fun setup() {
    println(">> Setup")
  }

  @Test
  fun `Assert blog page title, content and status code`() {
    println(">> Assert blog page title, content and status code")
    val entity = restTemplate.getForEntity<String>("/")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains("<h1>Blog</h1>")
  }

  @Test
  fun `Assert article page title, content and status code`() {
    println(">> TODO")
  }

  @AfterAll
  fun teardown() {
    println(">> Tear down")
  }

}

创建自己的扩展

并非像Java中那样将util类与抽象方法一起使用,而是在Kotlin中通常通过Kotlin扩展来提供此类功能。在这里,我们将向format()现有LocalDateTime类型添加一个函数,以生成具有英语日期格式的文本。

src/main/kotlin/com/example/blog/Extensions.kt

fun LocalDateTime.format() = this.format(englishDateFormatter)

private val daysLookup = (1..31).associate { it.toLong() to getOrdinal(it) }

private val englishDateFormatter = DateTimeFormatterBuilder()
    .appendPattern("yyyy-MM-dd")
    .appendLiteral(" ")
    .appendText(ChronoField.DAY_OF_MONTH, daysLookup)
    .appendLiteral(" ")
    .appendPattern("yyyy")
    .toFormatter(Locale.ENGLISH)

private fun getOrdinal(n: Int) = when {
  n in 11..13 -> "${n}th"
  n % 10 == 1 -> "${n}st"
  n % 10 == 2 -> "${n}nd"
  n % 10 == 3 -> "${n}rd"
  else -> "${n}th"
}

fun String.toSlug() = toLowerCase()
    .replace("\n", " ")
    .replace("[^a-z\\d\\s]".toRegex(), " ")
    .split(" ")
    .joinToString("-")
    .replace("-+".toRegex(), "-")

我们将在下一部分中利用这些扩展。

JPA的持久性

为了使延迟获取按预期方式工作,实体应openKT-28525中所述。我们将allopen为此目的使用Kotlin插件。

使用Gradle:build.gradle.kts

plugins {
  ...
  kotlin("plugin.allopen") version "1.3.61"
}

allOpen {
  annotation("javax.persistence.Entity")
  annotation("javax.persistence.Embeddable")
  annotation("javax.persistence.MappedSuperclass")
}

或使用Maven:pom.xml

<plugin>
  <artifactId>kotlin-maven-plugin</artifactId>
  <groupId>org.jetbrains.kotlin</groupId>
  <configuration>
    ...
    <compilerPlugins>
      ...
      <plugin>all-open</plugin>
    </compilerPlugins>
    <pluginOptions>
      <option>all-open:annotation=javax.persistence.Entity</option>
      <option>all-open:annotation=javax.persistence.Embeddable</option>
      <option>all-open:annotation=javax.persistence.MappedSuperclass</option>
    </pluginOptions>
  </configuration>
</plugin>

然后,我们使用Kotlin主要构造函数的简洁语法创建模型,该语法允许同时声明属性和构造函数参数。

src/main/kotlin/com/example/blog/Entities.kt

@Entity
class Article(
    var title: String,
    var headline: String,
    var content: String,
    @ManyToOne var author: User,
    var slug: String = title.toSlug(),
    var addedAt: LocalDateTime = LocalDateTime.now(),
    @Id @GeneratedValue var id: Long? = null)

@Entity
class User(
    var login: String,
    var firstname: String,
    var lastname: String,
    var description: String? = null,
    @Id @GeneratedValue var id: Long? = null)

注意,我们在这里使用String.toSlug()扩展来为构造函数的slug参数提供默认参数Article。具有默认值的可选参数定义在最后一个位置,以便在使用位置参数时可以忽略它们(Kotlin也支持命名参数)。请注意,在Kotlin中,将简洁的类声明分组在同一文件中并不少见。

这里我们不使用带有属性的data类val因为JPA并非设计用于不可变的类或由data类自动生成的方法。如果您使用的是其他Spring Data风格,则大多数都旨在支持此类构造,因此您应使用诸如data class User(val login: String, …​)使用Spring Data MongoDB,Spring Data JDBC等之类的类。
虽然Spring Data JPA可以通过使用自然ID(它可能是类中的login属性UserPersistable,但由于KT-6653,它与Kotlin不太合适,因此建议始终在其中使用具有生成ID的实体科特林

我们还声明了我们的Spring Data JPA存储库,如下所示。

src/main/kotlin/com/example/blog/Repositories.kt

interface ArticleRepository : CrudRepository<Article, Long> {
  fun findBySlug(slug: String): Article?
  fun findAllByOrderByAddedAtDesc(): Iterable<Article>
}

interface UserRepository : CrudRepository<User, Long> {
  fun findByLogin(login: String): User?
}

我们编写JPA测试来检查基本用例是否按预期工作。

src/test/kotlin/com/example/blog/RepositoriesTests.kt

@DataJpaTest
class RepositoriesTests @Autowired constructor(
    val entityManager: TestEntityManager,
    val userRepository: UserRepository,
    val articleRepository: ArticleRepository) {

  @Test
  fun `When findByIdOrNull then return Article`() {
    val juergen = User("springjuergen", "Juergen", "Hoeller")
    entityManager.persist(juergen)
    val article = Article("Spring Framework 5.0 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
    entityManager.persist(article)
    entityManager.flush()
    val found = articleRepository.findByIdOrNull(article.id!!)
    assertThat(found).isEqualTo(article)
  }

  @Test
  fun `When findByLogin then return User`() {
    val juergen = User("springjuergen", "Juergen", "Hoeller")
    entityManager.persist(juergen)
    entityManager.flush()
    val user = userRepository.findByLogin(juergen.login)
    assertThat(user).isEqualTo(juergen)
  }
}

我们在这里CrudRepository.findByIdOrNull使用Spring Data默认提供的Kotlin扩展,它是Optionalbased的可空变量CrudRepository.findById。阅读Null是您的朋友,而不是错误的博客文章,以了解更多详细信息。

实施博客引擎

我们更新了“博客” Mustache模板。

src/main/resources/templates/blog.mustache

{{> header}}

<h1>{{title}}</h1>

<div class="articles">

  {{#articles}}
    <section>
      <header class="article-header">
        <h2 class="article-title"><a href="/article/{{slug}}">{{title}}</a></h2>
        <div class="article-meta">By  <strong>{{author.firstname}}</strong>, on <strong>{{addedAt}}</strong></div>
      </header>
      <div class="article-description">
        {{headline}}
      </div>
    </section>
  {{/articles}}
</div>

{{> footer}}

我们创建了一个新的“文章”。

src/main/resources/templates/article.mustache

{{> header}}

<section class="article">
  <header class="article-header">
    <h1 class="article-title">{{article.title}}</h1>
    <p class="article-meta">By  <strong>{{article.author.firstname}}</strong>, on <strong>{{article.addedAt}}</strong></p>
  </header>

  <div class="article-description">
    {{article.headline}}

    {{article.content}}
  </div>
</section>

{{> footer}}

我们更新HtmlController,以使用格式化的日期呈现博客和文章页面。ArticleRepository并且MarkdownConverter构造函数参数将自动自动关联,因为HtmlController只有一个构造函数(隐式@Autowired)。

src/main/kotlin/com/example/blog/HtmlController.kt

@Controller
class HtmlController(private val repository: ArticleRepository) {

  @GetMapping("/")
  fun blog(model: Model): String {
    model["title"] = "Blog"
    model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
    return "blog"
  }

  @GetMapping("/article/{slug}")
  fun article(@PathVariable slug: String, model: Model): String {
    val article = repository
        .findBySlug(slug)
        ?.render()
        ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
    model["title"] = article.title
    model["article"] = article
    return "article"
  }

  fun Article.render() = RenderedArticle(
      slug,
      title,
      headline,
      content,
      author,
      addedAt.format()
  )

  data class RenderedArticle(
      val slug: String,
      val title: String,
      val headline: String,
      val content: String,
      val author: User,
      val addedAt: String)

}

然后,我们将数据初始化添加到新BlogConfiguration类中。

src/main/kotlin/com/example/blog/BlogConfiguration.kt

@Configuration
class BlogConfiguration {

    @Bean
    fun databaseInitializer(userRepository: UserRepository,
                            articleRepository: ArticleRepository) = ApplicationRunner {

        val smaldini = userRepository.save(User("smaldini", "Stéphane", "Maldini"))
        articleRepository.save(Article(
                title = "Reactor Bismuth is out",
                headline = "Lorem ipsum",
                content = "dolor sit amet",
                author = smaldini
        ))
        articleRepository.save(Article(
                title = "Reactor Aluminium has landed",
                headline = "Lorem ipsum",
                content = "dolor sit amet",
                author = smaldini
        ))
    }
}

请注意使用命名参数来使代码更具可读性。 

并且我们还将相应地更新集成测试。

src/test/kotlin/com/example/blog/IntegrationTests.kt

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {

  @BeforeAll
  fun setup() {
    println(">> Setup")
  }

  @Test
  fun `Assert blog page title, content and status code`() {
    println(">> Assert blog page title, content and status code")
    val entity = restTemplate.getForEntity<String>("/")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains("<h1>Blog</h1>", "Reactor")
  }

  @Test
  fun `Assert article page title, content and status code`() {
    println(">> Assert article page title, content and status code")
    val title = "Reactor Aluminium has landed"
    val entity = restTemplate.getForEntity<String>("/article/${title.toSlug()}")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains(title, "Lorem ipsum", "dolor sit amet")
  }

  @AfterAll
  fun teardown() {
    println(">> Tear down")
  }

}

启动(或重新启动)Web应用程序,然后转到http://localhost:8080/,您应该看到带有可单击链接的文章列表,以查看特定文章。

公开HTTP API

现在,我们将通过带@RestController注释的控制器来实现HTTP API 。

src/main/kotlin/com/example/blog/HttpControllers.kt

@RestController
@RequestMapping("/api/article")
class ArticleController(private val repository: ArticleRepository) {

  @GetMapping("/")
  fun findAll() = repository.findAllByOrderByAddedAtDesc()

  @GetMapping("/{slug}")
  fun findOne(@PathVariable slug: String) =
      repository.findBySlug(slug) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")

}

@RestController
@RequestMapping("/api/user")
class UserController(private val repository: UserRepository) {

  @GetMapping("/")
  fun findAll() = repository.findAll()

  @GetMapping("/{login}")
  fun findOne(@PathVariable login: String) =
      repository.findByLogin(login) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This user does not exist")
}

对于测试,而不是集成测试,我们将利用@WebMvcTestMockk相似的Mockk,但更适合Kotlin。

由于@MockBean@SpyBean注释是针对的Mockito,我们要充分利用SpringMockK提供类似@MockkBean@SpykBean为Mockk注释。

使用Gradle:build.gradle.kts

testImplementation("org.springframework.boot:spring-boot-starter-test") {
  exclude(module = "junit")
  exclude(module = "mockito-core")
}
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("com.ninja-squad:springmockk:1.1.3")

或使用Maven:pom.xml

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
  <exclusions>
    <exclusion>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
    </exclusion>
    <exclusion>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-engine</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>com.ninja-squad</groupId>
  <artifactId>springmockk</artifactId>
  <version>1.1.3</version>
  <scope>test</scope>
</dependency>

src/test/kotlin/com/example/blog/HttpControllersTests.kt

@WebMvcTest
class HttpControllersTests(@Autowired val mockMvc: MockMvc) {

  @MockkBean
  private lateinit var userRepository: UserRepository

  @MockkBean
  private lateinit var articleRepository: ArticleRepository

  @Test
  fun `List articles`() {
    val juergen = User("springjuergen", "Juergen", "Hoeller")
    val spring5Article = Article("Spring Framework 5.0 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
    val spring43Article = Article("Spring Framework 4.3 goes GA", "Dear Spring community ...", "Lorem ipsum", juergen)
    every { articleRepository.findAllByOrderByAddedAtDesc() } returns listOf(spring5Article, spring43Article)
    mockMvc.perform(get("/api/article/").accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk)
        .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(jsonPath("\$.[0].author.login").value(juergen.login))
        .andExpect(jsonPath("\$.[0].slug").value(spring5Article.slug))
        .andExpect(jsonPath("\$.[1].author.login").value(juergen.login))
        .andExpect(jsonPath("\$.[1].slug").value(spring43Article.slug))
  }

  @Test
  fun `List users`() {
    val juergen = User("springjuergen", "Juergen", "Hoeller")
    val smaldini = User("smaldini", "Stéphane", "Maldini")
    every { userRepository.findAll() } returns listOf(juergen, smaldini)
    mockMvc.perform(get("/api/user/").accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk)
        .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
        .andExpect(jsonPath("\$.[0].login").value(juergen.login))
        .andExpect(jsonPath("\$.[1].login").value(smaldini.login))
  }
}
$ 需要在字符串中转义,因为它用于字符串插值。

配置属性

在Kotlin中,推荐的管理应用程序属性的方法是利用@ConfigurationProperties@ConstructorBinding以便能够使用只读属性。

src/main/kotlin/com/example/blog/BlogProperties.kt

@ConstructorBinding
@ConfigurationProperties("blog")
data class BlogProperties(var title: String, val banner: Banner) {
  data class Banner(val title: String? = null, val content: String)
}

然后,我们在BlogApplication级别上启用它。

src/main/kotlin/com/example/blog/BlogApplication.kt

@SpringBootApplication
@EnableConfigurationProperties(BlogProperties::class)
class BlogApplication {
  // ...
}

要生成自己的元数据,以获得您的IDE认识到这些自定义属性,kapt应配置spring-boot-configuration-processor依赖如下。

build.gradle.kts

plugins {
  ...
  kotlin("kapt") version "1.3.61"
}

dependencies {
  ...
  kapt("org.springframework.boot:spring-boot-configuration-processor")
}

请注意,由于kapt提供的模型的限制,某些功能(例如检测默认值或不推荐使用的项目)无法正常工作。此外,由于KT-18022,Maven还不支持注释处理,有关更多详细信息,请参见initializr#438

在IntelliJ IDEA中:

  • 确保在菜单File | File中启用了Spring Boot插件。设置| 插件| SpringBoot

  • 通过菜单文件|启用注释处理 设置| 构建,执行,部署| 编译器 注释处理器| 启用注释处理

  • 由于Kapt尚未集成在IDEA中,因此您需要手动运行命令./gradlew kaptKotlin以生成元数据

现在,在编辑application.properties(自动完成,验证等)时,应该可以识别您的自定义属性。

src/main/resources/application.properties

blog.title=Blog
blog.banner.title=Warning
blog.banner.content=The blog will be down tomorrow.

相应地编辑模板和控制器。

src/main/resources/templates/blog.mustache

{{> header}}

<div class="articles">

  {{#banner.title}}
  <section>
    <header class="banner">
      <h2 class="banner-title">{{banner.title}}</h2>
    </header>
    <div class="banner-content">
      {{banner.content}}
    </div>
  </section>
  {{/banner.title}}

  ...

</div>

{{> footer}}

src/main/kotlin/com/example/blog/HtmlController.kt

@Controller
class HtmlController(private val repository: ArticleRepository,
           private val properties: BlogProperties) {

  @GetMapping("/")
  fun blog(model: Model): String {
    model["title"] = properties.title
    model["banner"] = properties.banner
    model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
    return "blog"
  }

  // ...

重新启动Web应用程序,刷新http://localhost:8080/,您应该在博客主页上看到横幅。

结论

现在,我们已完成构建此示例Kotlin博客应用程序。源代码可在Github上获得。如果您需要有关特定功能的更多详细信息,还可以查看Spring FrameworkSpring Boot参考文档。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值