spring boot3 / spring cloud遇到的一系列问题记录(二) —— 努力成为优秀的架构师

in 日常随笔 with 0 comment 访问: 1,223 次

Spring Cloud

注:本章内容承接 spring boot / spring cloud遇到的一系列问题记录(一) —— 努力成为优秀的架构师

由于数据库字段有限,特此进行拆分。 完整源码参考 https://github.com/ShyZhen/scd

搭建配置中心 spring-cloud-config-server

目前我们的项目是微服务架构,如果每个项目都有自己的配置文件,首先管理起来麻烦,其次不够高端,于是需要搭建一个统一管理配置的服务,也就是我们的config模块。

        <!--  在config模块中引入spring-cloud-config-server依赖,搭建一个配置服务器  -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-config-server</artifactId>
        </dependency>

/scd/config/src/main/java/com/litblc/config/ConfigApplication.java

// 标注@EnableConfigServer搭建配置服务器
@EnableConfigServer
@SpringBootApplication
public class ConfigApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigApplication.class, args);
    }
}

更多配置参考文档 https://springdoc.cn/spring-cloud-config/

# 配置服务器的端口,通常设置为8888:
server:
  port: ${APP_PORT:8888}

spring:
  application:
    name: config-server
  profiles:
    # 从本地文件读取配置时,Config Server激活的profile必须设定为native:
    active: native
  cloud:
    config:
      server:
        # 禁用 JdbcEnvironmentRepository 的自动配置。
        jdbc:
          enabled: false
        native:
          # 设置配置文件的搜索路径:
          search-locations: file:./config-repo, file:../config-repo, file:../../config-repo
 
  # 参考 `步骤(7)`,可以删掉这段配置项
  # 依赖库中有spring-boot-starter-jdbc,必须配置本项目的DataSource
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/fmock
    username: root
    password: root

11.png

一些示例配置源码如下:

/scd/config-repo/application.yml

# 通用common configuration
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/fmock
    username: root
    password: root

  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      #password:
      database: ${REDIS_DATABASE:0}
      database1: ${REDIS_DATABASE1:1}

storage:
  local:
    # 文件存储根目录:
    root-dir: ${STORAGE_LOCAL_ROOT:/var/storage}
    max-size: ${STORAGE_LOCAL_MAX_SIZE:102400}
    allow-empty: false
    allow-types: jpg, png, gif

mybatis-plus:
  mapper-locations: classpath:/mapper/*.xml
  configuration:
    map-underscore-to-camel-case: true

/scd/config-repo/fmock.yml

# fmock configuration
# http://localhost:8888/fmock/default
server:
  port: ${APP_PORT:8081}
  servlet:
    context-path: /api

/scd/config-repo/push.yml

# push configuration
# http://localhost:8888/push/default
server:
  port: ${APP_PORT:8083}
  servlet:
    context-path: /api

完成上一步我们已经配置好了配置中心,启动项目输入http://localhost:8888/fmock/default即可看到生效的配置,application.yml的配置是会一起返回的(访问地址跟文件名对应)

11.png

接下来我们要做的是让我们的fmock模块、push模块使用上配置中心服务

首先需要在fmock/pom.xml添加客户端依赖,否则无法解析本地配置的import: configserver:xxx参数

        <!-- 使用配置中心,需要依赖SpringCloud Config客户端 -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>

然后修改模块的fmock/src/main/resources/application.yml配置文件

spring:
  application:
    # 必须设置app名称,要跟config-repo中的`文件名`对应:
    name: ${APP_NAME:fmock}
  config:
    # 导入Config Server地址:
    import: configserver:${CONFIG_SERVER:http://localhost:8888}

push/src/main/resources/application.yml

spring:
  application:
    # 必须设置app名称,要跟config-repo中的`文件名`对应:
    name: ${APP_NAME:push}
  config:
    # 导入Config Server地址:
    import: configserver:${CONFIG_SERVER:http://localhost:8888}

比如添加config-repo/fmock-dev.yml开发环境配置文件,然后更改fmock模块的spring.application.name=fmock-dev即可。

我们在(3)步骤中存在依赖库中有spring-boot-starter-jdbc,必须配置本项目的DataSource的配置,

这个jdbc并不是spring-cloud-config-server引起,因为引入的是可选<optional>true</optional>

实际上问题是之前我们的parent模块中统一引入了mybatis-plus依赖,导致间接引入了jdbc依赖。

正确处理方案是删除parent中的mybatis-plus依赖,放在dependencyManagement中,其他模块需要的时候单独引入。

这样就可以删除配置中心配置了无用的datasource字段问题。

如何调用其他模块的服务、方法等

总结:直接引用调用是不行的,毕竟不是一个jar包,想要访问其他模块的服务,只能通过http请求,使用类似openfeign的包;common模块或者其他模块能使用,是因为它就是单独的代码,并没有启动类,没启动服务所以,所以没有进入spring容器也无法使用注解,也不涉及IP和端口之类的。

参数接收@PathVariable@RequestParam@RequestBody的使用

这里我们重点介绍@RequestBody,在使用他之前,必须定义raw的参数结构。
这里我们在common模块中创建一个bean:
/common/src/main/java/com/litblc/common/requestBean/test/TestRaw.java

package com.litblc.common.requestBean.test;

public class TestRaw {
    public long id;
    public long userId;
    public String nickname;
    public String avatar;
}

然后在使用该bean的模块中引入common依赖(我是在push模块)

        <!-- 内部模块引入 -->
        <dependency>
            <groupId>com.litblc</groupId>
            <artifactId>common</artifactId>
        </dependency>

控制器使用示例

    @Operation(summary = "三种接受参数测试")
    @PostMapping(value = "/raw/{path_id}/{sort_type}")
    public TestRaw raw(
            @PathVariable(value = "path_id") @Parameter(description = "path参数可以多个") long pathId,
            @PathVariable(value = "sort_type") @Parameter(description = "path参数") String sort_type,
            @RequestParam(value = "page", required = false, defaultValue = "1") @Parameter(description = "url参数可以多个") long page,
            @RequestParam(value = "page_size", required = false, defaultValue = "15") @Parameter(description = "url参数") long pageSize,
            @RequestBody TestRaw testRaw
            ) {

        System.out.println(pathId);
        System.out.println(sort_type);
        System.out.println(page);
        System.out.println(pageSize);
        System.out.println(testRaw.nickname);

        return testRaw;
    }

优化文档knife4j

我们从一开始使用的是springboot推荐的默认文档包springdoc-openapi-starter-webmvc-ui,这个包里集成了swagger-ui,但是用着不太方便,于是这里我们尝试换成knife4j。

我们在老项目中经常看到knife4j-spring-boot-starter或者knife4j-openapi2-spring-boot-starter这两个包,是因为该项目使用的是springboot2。

我们目前使用的是springboot3,需要使用knife4j-openapi3-jakarta-spring-boot-starter这个包。

参考官网 https://doc.xiaominfo.com/docs/quick-start/start-knife4j-version#21-spring-boot-2x

QQ截图20231026125028.png

我们也可以从源码上看到一些端倪:
knife4j-spring-boot-starter引用的是旧版knife4j,其中properties规定java版本1.8

knife4j-openapi2-spring-boot-starterknife4j-openapi3-jakarta-spring-boot-starter虽然都引入的最新版knife4j,

默认的java<knife4j-java.version>1.8</knife4j-java.version>也是1.8

但是,后者覆盖了properties中的<knife4j-java.version>17</knife4j-java.version>版本为17。

感兴趣的朋友可以自己查看,这里不放图了。

首先引入依赖knife4j-openapi3-jakarta-spring-boot-starter

        <!-- 接口文档 swagger UI 本地访问 http://ip:port/swagger-ui/index.html-->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.1.0</version>
        </dependency>

        <!-- 接口文档 knife4j测试 http://ip:port/doc.html-->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
            <version>4.3.0</version>
        </dependency>

现在其实就可以使用了,现在访问http://ip:port/doc.html即可访问knife4j的优化文档。

注:我们曾经引入过springdoc-openapi-starter-webmvc-ui依赖,访问http://ip:port/swagger-ui/index.html依然可以用默认的swagger。

QQ截图20231026130659.png

单个项目使用不需要配置,使用默认的即可,如果需要其他配置可以参考官网:

增强特性https://doc.xiaominfo.com/docs/features/enhance

# http://ip:port/swagger-ui/index.html
springdoc:
  swagger-ui:
    path: /swagger-ui.html
    tags-sorter: alpha
    operations-sorter: alpha
  api-docs:
    path: /v3/api-docs
  group-configs:
    - group: 'springbootstudy'
      paths-to-match: '/**'
      packages-to-scan: com.example.springbootstudy    # packages-to-scan 默认为启动类所在的路径
    - group: 'project2'
      paths-to-match: '/**'
      packages-to-scan: com.example.springbootstudy

# knife4j的增强配置,继承springdoc的配置  http://ip:port/doc.html
knife4j:
  enable: true
  setting:
    language: ZH_CN    # EN

单个springboot项目多个模块的文档配置

网上都是springboot2然后写配置类的文档,都一个样,生气,还得靠自己多思考多尝试。

在上面的“自定义配置”中,我们有个配置group-configs并没有具体说明,参数packages-to-scan就是扫描的模块地址。

QQ截图20231028160913.jpg

# http://ip:port/swagger-ui/index.html
springdoc:
  swagger-ui:
    path: /swagger-ui.html
    tags-sorter: alpha
    operations-sorter: alpha
  api-docs:
    path: /v3/api-docs
  group-configs:
    - group: 'springbootstudy模块'
      paths-to-match: '/**'
      packages-to-scan: com.example.springbootstudy    # packages-to-scan 默认为启动类所在的路径
    - group: 'push模块'
      paths-to-match: '/**'
      packages-to-scan: com.example.push

# knife4j的增强配置,继承springdoc的配置  http://ip:port/doc.html
knife4j:
  enable: true
  setting:
    language: ZH_CN    # EN

QQ截图20231028161837.jpg

QQ截图20231028162045.jpg

题外话:这里我们加了一个push模块,可以测试@ComponentScan注解的使用

// 设置了这个就只扫描push包了,srpingbootstudy包就不会加载了,任何springbootstudy中的方法都不会生效
// @ComponentScan(value = "com.example.push") 
@SpringBootApplication
public class SpringBootStudyApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringBootStudyApplication.class, args);
    }
}

spring cloud 配置knife4j

使用缓存@EnableCaching@Cacheable

参考https://springdoc.cn/spring-cache-redis-json/

spring的Spring Cache包可以设置多种缓存模式,我们使用redis的方式。注意前提是配置好redis能用。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
spring:
  # 缓存设置
  cache:
    type: redis
    redis:
      # 缓存有效的时间,默认永久有效,默认单位为毫秒,如60000=1m
      time-to-live: 5m
      # 如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
      key-prefix: "fmock:"
      use-key-prefix: true
      # 是否缓存空值,防止缓存穿透
      # cache-null-values: true
package com.litblc.fmock.moduleA.config;

import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.util.StringUtils;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

/**
 * 使`@Cacheable`操作存储的数据自动格式化为json,而不是默认的二进制
 * 注意与RedisTemplateConfig不要混淆,都要进行处理
 *
 * @Author zhenhuaixiu
 * @Date 2023/11/6 14:34
 * @Version 1.0
 */
@Configuration
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig();

        // 先载入配置文件中的配置信息
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();

        // 根据配置文件中的定义,初始化 Redis Cache 配
        if (redisProperties.getTimeToLive() != null) {
            redisCacheConfiguration = redisCacheConfiguration.entryTtl(redisProperties.getTimeToLive());
        }
        if (StringUtils.hasText(redisProperties.getKeyPrefix())) {
            redisCacheConfiguration = redisCacheConfiguration.prefixCacheNameWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            redisCacheConfiguration = redisCacheConfiguration.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            redisCacheConfiguration = redisCacheConfiguration.disableKeyPrefix();
        }

        // 缓存对象中可能会有 LocalTime/LocalDate/LocalDateTime 等 java.time 段,所以需要通过 JavaTimeModule 定义其序列化、反序列化格式
        JavaTimeModule javaTimeModule = new JavaTimeModule();

        javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSSSSS")));
        javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
        javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSSSS")));

        javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSSSSS")));
        javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
        javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSSSS")));

        // 基于 Jackson 的 RedisSerializer 实现:GenericJackson2JsonRedisSerializer
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();

        // 把 javaTimeModule 配置到 Serializer 中
        serializer = serializer.configure(config -> {
            config.registerModules(javaTimeModule);
        });

        // 设置 Value 的序列化方式
        return redisCacheConfiguration
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));
    }
}

比如放在启动类上,或者放在你的redis配置上,比如我放在redis的序列化配置上

package com.litblc.fmock.moduleA.config;

import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * 使redis保存的数据支持<string,object>,并保存的数据为json字符串
 *
 * @Author zhenhuaixiu
 * @Date 2023/11/6 10:51
 * @Version 1.0
 */
@Configuration
@EnableCaching  // @EnableCaching为开启缓存,可以放在任何一个能被自动加载的地方
public class RedisTemplateConfig extends RedisTemplate<String, Object> {

    public RedisTemplateConfig(RedisConnectionFactory redisConnectionFactory) {

        // 构造函数注入 RedisConnectionFactory,设置到父类
        super.setConnectionFactory(redisConnectionFactory);

        // 使用 Jackson 提供的通用 Serializer
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
        serializer.configure(mapper -> {
            // 如果涉及到对 java.time 类型的序列化,反序列化那么需要注册 JavaTimeModule
            mapper.registerModule(new JavaTimeModule());
        });

        // String 类型的 key/value 序列化
        super.setKeySerializer(StringRedisSerializer.UTF_8);
        super.setValueSerializer(serializer);

        // Hash 类型的 key/value 序列化
        super.setHashKeySerializer(StringRedisSerializer.UTF_8);
        super.setHashValueSerializer(serializer);
    }
}
    @GetMapping(value = "listDesc")
    @Operation(summary = "获取文章列表")
    @Cacheable(value = "posts")
    public List<Posts> postsListDesc() {
        System.out.println("再次访问这个接口,这句话不会输出,证明走了缓存");

        List<Posts> res = this.postsService.getAllPosts();
        this.redisTemplate.opsForValue().set("posts1", res, 60L, TimeUnit.SECONDS);  // 手动存储的是字符串
        this.redisTemplate.opsForValue().set("posts2", res);  // 永久期限,正常json格式

        return res;
    }

QQ截图20231108163411.png

定时任务

<dependency>
    <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
</dependency>
package com.litblc.fmock.moduleA.crontab;

import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
 * @Author zhenhuaixiu
 * @Date 2023/11/10 17:03
 * @Version 1.0
 */

// @Scheduled 参数可以接受两种定时的设置,一种是我们常用的`cron="*/6 * * * * ?"`,一种是 fixedRate = 6000,两种都表示每隔六秒跑一次。
// @Scheduled(fixedRate = 6000) :上一次开始执行时间点之后6秒再执行
// @Scheduled(fixedDelay = 6000) :上一次执行完毕时间点之后6秒再执行
// @Scheduled(initialDelay=1000, fixedRate=6000) :第一次延迟1秒后执行,之后按 fixedRate 的规则每6秒执行一次
// cron參考 https://blog.csdn.net/Linweiqiang5/article/details/86741258

@EnableScheduling
@Component
public class SchedulerTask {

    private int count = 0;

    @Scheduled(cron = "*/6 * * * * ?")
    private void task1() {
        System.out.println("这样就执行定时任务,第几次:" + (++this.count));
    }

    @Scheduled(fixedRate = 6000)
    public void task2() {
        System.out.println("现在时间:" + (new Date()));
    }
}

在单应用中可以这么使用,比较简单,但是在微服务架构中就不行了,比如启用了多个实例,那么定时任务也会执行多次。
所以后面我们一点一点完善升级。

发送邮件

<!-- 发送邮件的包 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
spring:
  mail:
    default-encoding: UTF-8
    host: smtp.mxhichina.com
    username: server@fmock.com
    password: ${MAIL_PASSWORD}
    from-addr: server@fmock.com

src/main/java/com/litblc/push/utils/SendMailUtils.java

package com.litblc.push.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Component;

/**
 * @Author zhenhuaixiu
 * @Date 2023/11/13 10:56
 * @Version 1.0
 */
@Component
public class SendMailUtils {

    @Value("${spring.mail.from-addr}")
    private String fromAddr;

    @Autowired
    JavaMailSender mailSender;

    public void send(String to, String subject, String text) {
        SimpleMailMessage mailMessage = new SimpleMailMessage();
        mailMessage.setFrom(fromAddr);
        mailMessage.setTo(to);
        mailMessage.setSubject(subject);
        mailMessage.setText(text);

        this.mailSender.send(mailMessage);
    }
}
/**
 * @Author zhenhuaixiu
 * @Date 2023/11/13 10:38
 * @Version 1.0
 */
@RestController
@RequestMapping(value = "/mail")
public class MailController {

    @Value("${spring.mail.from-addr}")
    private String fromAddr;

    @Autowired
    SendMailUtils sendMailUtils;

    @Operation(summary = "测试发邮件")
    @GetMapping(value = "/config")
    public String getConfig() {
        return this.fromAddr;
    }

    @Operation(summary = "测试发邮件")
    @GetMapping(value = "/send")
    public void send() {
        this.sendMailUtils.send("123456789@qq.com", "subject 是标题", "text 是内容");
    }
}

多个模块打包

  1. 先了解maven的<packaging>pom</packaging>如何使用

参考 https://blog.csdn.net/qq_31960623/article/details/123507486

  1. 比如我们当前的项目scd的结构,其中有个common模块,没有main启动类,只是一些通用的类,需要在pom文件中声明:
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <mainClass>none</mainClass>   <!-- 取消查找本项目下的Main方法:为了解决Unable to find main class的问题 -->
        <classifier>execute</classifier>  <!-- 为了解决依赖模块找不到此模块中的类或属性 -->
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>repackage</goal>
            </goals>
        </execution>
    </executions>
</plugin>
  1. 在根目录,也就是声明了其他模块的地方,执行mvn clean package即可

QQ截图20231127110731.png

  1. 执行jar包
java -jar config.jar
java -jar push.jar

携带参数的话,根据命令位置有三种修改方案:参考https://www.cnblogs.com/qtfwax/p/17250373.html
例如我的bat脚本

@echo off

start D:\wamp\jdk-17_windows-x64_bin\jdk-17.0.5\bin\java.exe -jar E:\githubShyzhen\scd\config\target\config.jar

timeout /t 10 > nul

start D:\wamp\jdk-17_windows-x64_bin\jdk-17.0.5\bin\java.exe -jar E:\githubShyzhen\scd\fmock\target\fmock.jar

timeout /t 10 > nul

start D:\wamp\jdk-17_windows-x64_bin\jdk-17.0.5\bin\java.exe -jar E:\githubShyzhen\scd\push\target\push.jar --MAIL_PASSWORD=xxxxx

@pause

更换启动时控制台输出的banner

在resources文件下创建banner.txt即可,输入如下字符串

.__           .__  .__                               .__       .___
|  |__   ____ |  | |  |   ____   __  _  _____________|  |    __| _/
|  |  \_/ __ \|  | |  |  /  _ \  \ \/ \/ /  _ \_  __ \  |   / __ |
|   Y  \  ___/|  |_|  |_(  <_> )  \     (  <_> )  | \/  |__/ /_/ |
|___|  /\___  >____/____/\____/    \/\_/ \____/|__|  |____/\____ |
     \/     \/                                                  \/

这里提供两个网址可以生成这种字符串:
文字生成字符串 http://www.network-science.de/ascii/
图片生成字符串 https://www.degraeve.com/img2txt.php

赞赏支持
Responses