2024年10月

前言

在之前的内容中,我们详细讲解了Spring AI的基础用法及其底层原理。如果还有小伙伴对此感到困惑,欢迎参考下面这篇文章,深入学习并进一步掌握相关知识:
https://www.cnblogs.com/guoxiaoyu/p/18441709

今天,我们将重点关注AI在实际应用中的落地表现,特别是Spring AI如何能够帮助企业实现功能优化以及推动AI与业务的深度融合。我们将以当前大厂广泛追逐的智能体赛道为切入点,探讨其在实际场景中的应用。考虑到许多同学可能已经接触过智能体,以这一主题作为讨论的基础,能够更有效地帮助大家理解相关概念和技术的实际操作与效果。

因此,在本章节中,我们将以智能体为出发点,带领大家轻松实现一个本地稳定且可部署的智能体解决方案。在这一过程中,我将详细介绍每一个步骤,确保大家能够顺利跟上。此外,在章节的最后,我会根据我的理解,分析这一方案与现有智能体的优缺点,以帮助大家全面了解不同选择的利弊。

准备工作

当然,Spring AI集成了许多知名公司的接口实现。如果你真的想使用OpenAI的接口,可以考虑国内的混元API。混元API兼容OpenAI的接口规范,这意味着你可以直接使用OpenAI官方提供的SDK来调用混元的大模型。这一设计大大简化了迁移过程,你只需将base_url和api_key替换为混元相关的配置,而无需对现有应用进行额外修改。这样,你就能够无缝地将您的应用切换到混元大模型,享受到强大的AI功能和支持。

申请API KEY

大家完全不必担心,经过我亲自测试,目前所有接口都能够正常兼容,并且没有发现任何异常或问题。可以通过以下链接申请:
混元API申请地址

image

请确保在您个人的账户下申请相关的API KEY。

image

请务必妥善保存您的API KEY信息,因为在后续使用过程中,这一信息将会变得非常重要。

对接文档

在这里,了解一些注意事项并不是强制性的,因为我们并不需要直接对接混元(Hunyuan)的接口。实际上,我们可以在Spring AI中直接使用兼容OpenAI的接口,这样能够大大简化我们的操作流程。如果您有兴趣深入了解相关的API文档,可以自行查找接口文档地址,里面有详尽的说明和指导:
API接口文档

请大家特别注意,由于智能体在运行时需要调用相关的插件或工作流,因此支持函数回调的模型仅限于以下三个。这意味着,除了这三个模型之外,其他模型都不具备这一支持功能。请确保在选择模型时考虑这一点。

image

请大家留意,目前混元尚未推出预付费的大模型资源包,用户只能进行并发包的预购。有关计费详情,请参见下方图示。

image

项目配置

接下来,我们将继续使用之前的 Spring AI 演示项目,并对其进行必要的修改。具体需要调整的 Maven POM 依赖项如下所示:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>

如图所示,在我第一次配置文件时选择了使用 functioncall 模型,因为它的成本相对较低。然而,后来我发现该模型在对系统提示词的识别上表现并不理想,后面我都换成了pro模型,大家可以根据自己的具体需求和预算做出相应的选择。

functioncall对提示词不敏感但是对函数回调的结果可以很好的解析,pro对提示词敏感但是函数回调的结果他不直接回答,一直输出planner内容但就是不回复用户。后面会有详细说明。

image

application.properties
文件用于全局配置,所有的 ChatClient 都会遵循这一设置。这样做的一个显著好处是,开发人员在代码层面无需进行任何修改,只需在 Maven 的 POM 文件中更改相应的依赖项,即可轻松切换到不同的 AI 大模型厂商。这种灵活性不仅提高了项目的可维护性,还方便了模型的替换与升级。

Spring AI 智能体构建

现在,假设你已经完成了所有的准备工作,我们可以开始构建属于自己的智能体。首先,我们将专注于单独定制配置参数。之前提到过,application.properties 文件是全局设置,适用于所有的 ChatClient,但每个模型实际上都有自己特定的领域和应用场景。因此,我们首先需要配置如何为每个接口进行个性化定制,以确保模型的表现更加贴合实际的业务需求。

个性化配置模型

普通调用

首先,让我们来观察在正常情况下代码应该如何编写:

@PostMapping("/ai-function")
ChatDataPO functionGenerationByText(@RequestParam("userInput")  String userInput) {
    String content = this.myChatClientWithSystem
            .prompt()
            .system("你是努力的小雨,一名 Java 服务端码农,潜心研究着 AI 技术的奥秘。热爱技术交流与分享,对开源社区充满热情。")
            .user(userInput)
            .advisors(messageChatMemoryAdvisor)
            .functions("CurrentWeather")
            .call()
            .content();
    log.info("content: {}", content);
    ChatDataPO chatDataPO = ChatDataPO.builder().code("text").data(ChildData.builder().text(content).build()).build();;
    return chatDataPO;
}

如图所示,在我们发起请求之前,如果提前设置一个断点,我们就能够在这一时刻查看到
chatOptions
参数,这个参数代表了我们默认的配置设置。因此,我们的主要目标就是在发送请求之前,探讨如何对
chatOptions
参数进行有效的修改。

image

在对提示词进行测试的过程中,我们发现
functioncall
模型对于
system
提示词的响应效果并不显著,似乎没有发挥出预期的作用。然而,这个模型的一个显著优点是它支持函数回调功能(在前面的章节中已经详细讲解过),此外,与
pro
模型相比,
functioncall
模型的使用费用也相对较低,这使得它在某些情况下成为一个更具成本效益的选择。

image

特殊调用

为了使模型的回复更加贴合提示词的要求,我们可以对模型进行单独配置。如果你希望对某一个特定方法进行调整,而不是采用像 application.properties 中的全局设置,那么可以通过自行修改相应的参数来实现。具体的配置方法如下所示:

//省略重复代码
OpenAiChatOptions openAiChatOptions = OpenAiChatOptions.builder()
        .withModel("hunyuan-pro").withTemperature(0.5f).build();
String content = this.myChatClientWithSystem
        .prompt()
        .system("你是努力的小雨,一名 Java 服务端码农,潜心研究着 AI 技术的奥秘。热爱技术交流与分享,对开源社区充满热情。")
        .user(userInput)
        .options(openAiChatOptions)
        .advisors(messageChatMemoryAdvisor)
        //省略重复代码
}

在此,我们只需简单地配置相关的选项即可完成设置。接下来,我们可以在断点的部分检查相关的配置,以确保这些设置已经生效并正常运行。

image

同样的写法,例如,我们之前设置的 pro 模型相比于 function-call 模型在处理系统提示词时显得更加友好。

image

思考路径

实际上,在绝大多数智能体中,这些思考路径并不会被显示出来,只有百度那边的智能体系统会将其呈现给用户。这些思考路径都是由大模型生成并返回的,因此我并没有在这里进行额外的配置。实际上,我们也可以选择返回这些路径,相关的源代码也在此处:

private void writeWithMessageConverters(Object body, Type bodyType, ClientHttpRequest clientRequest)
        throws IOException {

//省略代码
    for (HttpMessageConverter messageConverter : DefaultRestClient.this.messageConverters) {
        if (messageConverter instanceof GenericHttpMessageConverter genericMessageConverter) {
            if (genericMessageConverter.canWrite(bodyType, bodyClass, contentType)) {
                logBody(body, contentType, genericMessageConverter);
                genericMessageConverter.write(body, bodyType, contentType, clientRequest);
                return;
            }
        }
        if (messageConverter.canWrite(bodyClass, contentType)) {
            logBody(body, contentType, messageConverter);
            messageConverter.write(body, contentType, clientRequest);
            return;
        }
    }
//省略代码
}

如图所示,目前我们仅仅进行了简单的打印操作,并未实现消息转换器(message converter)。考虑到我们的业务系统并不需要将这些信息展示给客户,因此我们认为当前的实现方式已足够满足需求。

image

大家可以看下思考路径的信息打印结果如下所示:

org.springframework.web.client.DefaultRestClient [453] -| Writing [ChatCompletionRequest[messages=[ChatCompletionMessage[

省略其他, 关键代码如下:

role=SYSTEM, name=null, toolCallId=null, toolCalls=null, refusal=null], ChatCompletionMessage[rawContent=长春的天气咋样?, role=USER, name=null, toolCallId=null, toolCalls=null, refusal=null], ChatCompletionMessage[rawContent=使用'CurrentWeather'功能来获取长春的天气情况。用户想要知道长春当前的天气情况。用户的请求是关于获取特定地点的天气信息,这与工具提供的'CurrentWeather'功能相匹配。

,##省略其他

配置插件

我之前在视频中详细讲解了智能体如何创建自定义插件。在这次的实践中,我们将继续利用百度天气插件来获取实时的天气信息。不过,与之前不同的是,这一次我们将把这一功能集成到Spring AI项目中。

数据库配置

每个业务系统通常都会配备自有数据库,以便更好地服务用户。为了演示这一点,我们将创建一个MySQL示例,具体内容是获取地区编码值,并将其传递给API进行调用。在这个过程中,你可以通过插件对数据库进行各种操作,但在此我们主要专注于查询的演示。

本次示例中,我将继续使用腾讯云轻量应用服务器来搭建一个MySQL单机环境。在成功搭建环境后,我们将继续进行后续操作。请确保在开始之前,所有必要的配置和设置都已完成,以便顺利进行数据库的查询和API的调用。

image

以下是与相关配置有关的POM文件依赖项:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.49</version>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
    <version>3.5.7</version>
</dependency>

数据库连接配置信息如下:

spring.datasource.url=jdbc:mysql://ip:3306/agent?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8
spring.datasource.username=agent
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

导入数据

我已经成功完成了百度地图提供的数据导入工作,具体情况请参见下图所示:

image

操作数据库

接下来,您只需在插件类内部直接进行数据库操作即可。关于
SearchHttpAK
实体类,您可以直接从百度地图提供的 Java SDK 中复制,无需额外说明。同时,请注意,
areaInfoPOMapper
需要您在配置类中自行进行 Bean 注入,以确保其正常使用。

public class BaiDuWeatherService implements Function<Request, Response> {

    AreaInfoPOMapper areaInfoPOMapper;
    
    public BaiDuWeatherService(AreaInfoPOMapper areaInfoPOMapper) {
        this.areaInfoPOMapper = areaInfoPOMapper;
    }
    @JsonClassDescription("location:城市地址,例如:长春市")
    public record Request(String location) {}
    public record Response(String weather) {}

    public Response apply(Request request) {
        SearchHttpAK snCal = new SearchHttpAK();
        Map params = new LinkedHashMap<String, String>();
        QueryWrapper<AreaInfoPO> queryWrapper = new QueryWrapper<>();
        queryWrapper.like("city", request.location());
        List<AreaInfoPO> areaInfoPOS = areaInfoPOMapper.selectList(queryWrapper);
        String reslut = "";
        try {
            params.put("district_id", areaInfoPOS.get(0).getCityGeocode());
            reslut = "天气信息以获取完毕,请你整理信息,以清晰易懂的方式回复用户:" + snCal.requestGetAKForPlugins(params);
            log.info("reslut:{}", reslut);
        } catch (Exception e) {
            //此返回慎用,会导致无线调用工具链,所以请自行设置好次数或者直接返回错误即可。
            //reslut = "本次调用失败,请重新调用CurrentWeather!";
            reslut = "本次调用失败了!";
    }
    return new Response(reslut);
}

无论此次操作是否成功,都请务必避免让大模型自行再次发起调用。这样做可能会导致程序陷入死循环,从而影响系统的稳定性和可靠性。务必要确保在操作结束后进行适当的控制和管理,以防止这种情况发生。

image

插件调用

通过这种方式,当我们再次询问关于长春的天气时,大模型将能够有效地利用插件返回的数据,以准确且及时地回答我们的问题。

image

在之前的讨论中,我们提到过Pro模型对系统提示词非常敏感。然而,需要注意的是,它并不会直接优化返回的回调结果。

image

为了确保系统的响应符合预期,这里建议再次使用系统提示词进行限制和指导。通过明确的提示词,我们可以更好地控制模型的输出。

请将工具返回的数据格式化后以友好的方式回复用户的问题。

优化后,返回结果正常:

image

工作流配置

在这里,我将不再演示Spring AI中的工作流,实际上,我们的某些插件所编写的业务逻辑本质上就构成了一个工作流的逻辑框架。接下来,我想重点讲解如何利用第三方工作流工具来快速满足业务需求。

集成第三方工作流

在考虑使用Spring AI实现智能体功能时,我们不应轻易抛弃第三方可视化平台。集成这些第三方工作流可以帮助我们快速实现所需的功能,尤其是在开发过程中,编写Java代码的要求往往繁琐且复杂,一个简单的需求可能需要涉及多个实体类的创建与维护。相较之下,某些简单的业务逻辑通过第三方工作流来实现,无疑能提升我们的开发效率,减少不必要的工作量。

以Coze智能体平台为例,我们可以首先专注于编写一个高效的工作流。这个工作流的主要目标是为用户提供全面的查询服务,包括旅游航班、火车时刻、酒店预订等信息。

image

我们需要在申请到API密钥后,进行后续的对接工作,并仔细研究开发文档,以确保顺利整合和实现所需的功能。

image

工作流插件

根据以上信息,我们可以将工作流调用封装成插件。实际上,对于智能体平台而言,工作流与插件本质上都是以函数调用的形式存在,因此将工作流转换为插件的过程是相对简单且直接的。

public class TravelPlanningService implements Function<RequestParamer, ResponseParamer> {

    @JsonClassDescription("dep_city:出发城市地址,例如长春市;arr_city:到达城市,例如北京市")
    public record RequestParamer(String dep_city, String arr_city) {}
    public record ResponseParamer(String weather) {}

    public ResponseParamer apply(RequestParamer request) {
        CozeWorkFlow cozeWorkFlow = new CozeWorkFlow<RequestParamer>();

        Map params = new LinkedHashMap<String, String>();
        String reslut = "";
        try {
          //这里我已经封装好了http调用
            reslut = cozeWorkFlow.getCoze("7423018070586064915",request);;
            log.info("reslut:{}", reslut);
        } catch (Exception e) {
            reslut = "本次调用失败了!";
        }
        return new ResponseParamer(reslut);
    }
}

由于我们的RequestParamer中使用了Java 14引入的record记录特性,而旧版本的Fastjson无法支持将其转换为JSON格式,因此在项目中必须使用最新版本的Fastjson依赖。如果使用不兼容的旧版本,将会导致功能无法正常执行或发生失败。

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>2.0.31</version>
</dependency>

经过配置后,如果Coze插件能够正常运行,那么我们就可以开始为混元大模型提供相应的回答。

image

工作流调用

我们已成功将该插件集成到请求处理流程中,具体实现的代码如下所示:

//省略重复代码
.functions("CurrentWeather","TravelPlanning")
.call()
.content();
//省略重复代码

由于返回的信息较为冗长,因此混元大模型的响应时间通常会显著延长。在这种情况下,我们的普通API调用可能会超时,导致无法成功获取预期的结果。具体的错误信息如下所示:

I/O error on POST request for "
https://api.hunyuan.cloud.tencent.com/v1/chat/completions
": timeout

retryTemplate超时修复

我们需要对当前的配置进行重新调整。起初,我认为问题出在retryTemplate的配置上,因为我们在之前的讨论中提到过这一点。然而,经过仔细检查后,我发现retryTemplate仅负责重试相关的信息配置,并没有涉及到超时设置。为了进一步排查问题,我深入查看了后面的源码,最终发现需要对RestClientAutoConfiguration类进行相应的修改。

值得一提的是,RestClientAutoConfiguration类提供了定制化配置的选项,允许我们对请求的行为进行更细致的控制。以下是该类的源码示例,展示了我们可以进行哪些具体调整:

@Bean
@ConditionalOnMissingBean
RestClientBuilderConfigurer restClientBuilderConfigurer(ObjectProvider<RestClientCustomizer> customizerProvider) {
    RestClientBuilderConfigurer configurer = new RestClientBuilderConfigurer();
    configurer.setRestClientCustomizers(customizerProvider.orderedStream().toList());
    return configurer;
}

@Bean
@Scope("prototype")
@ConditionalOnMissingBean
RestClient.Builder restClientBuilder(RestClientBuilderConfigurer restClientBuilderConfigurer) {
    RestClient.Builder builder = RestClient.builder()
        .requestFactory(ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS));
    return restClientBuilderConfigurer.configure(builder);
}

因此,我们需要对restClientBuilder进行必要的修改。目前,restClientBuilder中的DEFAULTS配置全部为null,这意味着它正在使用默认的配置。而在我们调用coze工作流时,由于使用了okhttp类,内部实际上集成了okhttp,因此也遵循了okhttp的配置方式。

为了解决这一问题,我们可以直接调整ClientHttpRequestFactorySettings的配置,以设置我们所需的超时时间。具体的配置调整如下所示:

@Bean
RestClient.Builder restClientBuilder(RestClientBuilderConfigurer restClientBuilderConfigurer) {
    ClientHttpRequestFactorySettings defaultConfigurer =  ClientHttpRequestFactorySettings.DEFAULTS
            .withReadTimeout(Duration.ofMinutes(5))
            .withConnectTimeout(Duration.ofSeconds(30));
    RestClient.Builder builder = RestClient.builder()
            .requestFactory(ClientHttpRequestFactories.get(defaultConfigurer));
    return restClientBuilderConfigurer.configure(builder);
}

请注意,在刚才提到的思考路径中,messageConverter也是在此处进行配置的。如果有特定的需求,您完全可以进行个性化的定制。关键的代码部分如下,这段代码将调用我们自定义的方法,以便实现定制化的逻辑。

如果您希望设置其他的个性化配置或信息,可以参考以下示例进行调整。

public RestClient.Builder configure(RestClient.Builder builder) {
    applyCustomizers(builder);
    return builder;
}

private void applyCustomizers(Builder builder) {
    if (this.customizers != null) {
        for (RestClientCustomizer customizer : this.customizers) {
            customizer.customize(builder);
        }
    }
}

至此,经过一系列的调整和配置,我们成功解决了超时问题。这意味着在调用hunyuan模型时,我们现在可以顺利获取到返回的结果。

image

私有知识库

由于智能体具备知识库这一常见且重要的功能,我们也将实现这一部分。值得注意的是,hunyuan的API兼容向量功能,这意味着我们可以直接利用知识库来增强智能体的能力。通过这一实现,我们不仅能够享受到无限制的访问权限,还能够进行高度的定制化,以满足特定的业务需求。

更重要的是,这种设计使得我们在使用知识库时具有完全的自主可控性,你无需担心数据泄露的问题。

向量数据库配置

接下来,我们将继续集成Milvus,这是一个我们之前使用过的向量数据库功能。虽然腾讯云也提供了自己的向量数据库解决方案,但目前尚未将其集成到Spring AI中。为了便于演示和开发,我们决定首先使用Milvus作为我们的向量数据库。

为了顺利完成这一集成,我们需要配置相应的依赖项,具体如下:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-milvus-store-spring-boot-starter</artifactId>
</dependency>

需要的配置文件如下:

# 配置Milvus客户端主机地址
spring.ai.vectorstore.milvus.client.host=
# 配置Milvus客户端端口号
spring.ai.vectorstore.milvus.client.port=19530

# 配置Milvus数据库名称
spring.ai.vectorstore.milvus.databaseName=
# 配置Milvus集合名称
spring.ai.vectorstore.milvus.collectionName=
# 如果没有集合会默认创建一个,默认值为false
spring.ai.vectorstore.milvus.initialize-schema=true
# 配置向量嵌入维度
spring.ai.vectorstore.milvus.embeddingDimension=1024
# 配置索引类型
spring.ai.vectorstore.milvus.indexType=IVF_FLAT
# 配置距离度量类型
spring.ai.vectorstore.milvus.metricType=COSINE

腾讯混元的embedding 接口目前仅支持 input 和 model 参数,model 当前固定为 hunyuan-embedding,dimensions 固定为 1024。

spring.ai.openai.embedding.base-url=https://api.hunyuan.cloud.tencent.com
spring.ai.openai.embedding.options.model=hunyuan-embedding
spring.ai.openai.embedding.options.dimensions=1024

在这里,我们依然使用申请的混元大模型的API-key,因此无需再次进行配置。值得强调的是,这些参数的正确配置至关重要。如果未能妥善设置,将会导致系统在调用时出现错误。

基本操作

大多数智能体平台都将对知识库进行全面开放,以便用户能够自由地进行查看、修改、删除和新增等操作。接下来,我们将演示如何进行这些操作:

@GetMapping("/ai/embedding")
public Map embed(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
    EmbeddingResponse embeddingResponse = this.embeddingModel.embedForResponse(List.of(message));
    return Map.of("embedding", embeddingResponse);
}

@GetMapping("/ai/addKnowledage")
public boolean addKnowledage(@RequestParam(value = "meta-message") String message,@RequestParam(value = "vector-content") String content) {
    String uuid = UUID.randomUUID().toString();
    DocumentInfoPO documentInfoPO = new DocumentInfoPO();
    documentInfoPO.setVectorId(uuid);
    documentInfoPO.setMetaMessage(message);
    documentInfoPO.setVectorContent(content);
    documentInfoPOMapper.insert(documentInfoPO);
    List<Document> documents = List.of(
            new Document(uuid,content, Map.of("text", message)));
    vectorStore.add(documents);
    return true;
}

@GetMapping("/ai/selectKnowledage")
public List<Document> selectKnowledage(@RequestParam(value = "vector-content") String content) {
    List<Document> result = vectorStore.similaritySearch(SearchRequest.query(content).withTopK(5).withSimilarityThreshold(0.9));
    return result;
}

@GetMapping("/ai/deleteKnowledage")
public Boolean deleteKnowledage(@RequestParam(value = "vector-id") String id) {
    Optional<Boolean> delete = vectorStore.delete(List.of(id));
    return delete.get();
}

以下是我个人的观点:增删查操作的基本实现已经完成。第三方智能体平台提供修改操作的原因在于,后续的流程中,都是在删除数据后重新插入,这一操作是不可避免的,因为大家都有修改的需求。此外,值得注意的是,默认的向量数据库并不支持显示所有数据,这一限制促使我们需要引入相应的数据库操作,以弥补这一缺陷,确保数据的完整性和可操作性。

为了更好地验证这一过程的有效性,我提前调用了接口,上传了一些知识库的数据。接下来,我将展示这些数据的查询效果。

image

这是我刚刚上传的知识库信息。为了提高效率,接下来我将直接展示知识库的RAG(Retrieval-Augmented Generation)检索功能在我们的智能体中的应用。

自动调用

根据我目前的观察,所有智能体平台主要可以分为两种实现方式:自动调用和按需调用。大部分平台的实现还是以自动调用为主,除非写在了工作流中也是就我们的函数里,那就和上面的插件一样了,我就不讲解了。今天,我将重点讨论自动调用是如何实现的。

自动调用知识库的实现依赖于Advisor接口,具体方法是在每次请求前构造一个额外的提示词。目前,Spring AI已经实现了长期记忆的功能,其具体类为VectorStoreChatMemoryAdvisor。因此,我们可以直接参考该类的实现方式,以便构建一个符合我们需求的知识库自动调用系统。

我们可以进行一次实现。由于我们的主要目标是在将参考信息提供给大模型时,使其能够更好地理解上下文,因此对于响应的增强部分可以直接忽略。这意味着我们不需要在此过程中对响应的内容进行额外的处理或优化,以下是具体的代码示例:

public class PromptChatKnowledageAdvisor implements RequestResponseAdvisor {

    private VectorStore vectorStore;
    private static final String userTextAdvise = """

            请使用以下参考信息回答问题.如果没有参考信息,那么请直接回答即可。

            ---------------------
            参考信息如下:
            {memory}
            ---------------------

            """;

    public PromptChatKnowledageAdvisor(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }
    @Override
    public AdvisedRequest adviseRequest(AdvisedRequest request, Map<String, Object> context) {
        // 1. 添加一段知识库提示
        String advisedSystemText = request.userText() + System.lineSeparator() + this.userTextAdvise;
        List<Document> documents = vectorStore.similaritySearch(request.userText());
        // 2. 拼接知识库数据
        String data = documents.stream().map(d -> d.getMetadata().get("text").toString()).collect(Collectors.joining(","));
        Map<String, Object> advisedParams = new HashMap<>(request.userParams());
        advisedParams.put("memory", data);
        // 3. 赋值提示词参数
        AdvisedRequest advisedRequest = AdvisedRequest.from(request)
            .withSystemText(advisedSystemText)
            .withSystemParams(advisedParams) //知识库RAG检索数据
            .build();

        return advisedRequest;
    }

    @Override
    public ChatResponse adviseResponse(ChatResponse chatResponse, Map<String, Object> context) {
        //不需要修改任何东西
        return chatResponse;
    }

    @Override
    public Flux<ChatResponse> adviseResponse(Flux<ChatResponse> fluxChatResponse, Map<String, Object> context) {
        //不需要修改任何东西
        return fluxChatResponse;
    }

}

需要在配置类中通过构造器注入来传递相同的
VectorStore
实例。

@Bean
PromptChatKnowledageAdvisor promptChatKnowledageAdvisor(VectorStore vectorStore) {
    return new PromptChatKnowledageAdvisor(vectorStore);
}

接下来,我们只需在请求方式中添加相应的代码或配置,以便整合新功能。

//省略重复代码
.advisors(messageChatMemoryAdvisor,promptChatKnowledageAdvisor)
.functions("CurrentWeather","TravelPlanning")
.call()
.content();
//省略重复代码

这正是自动调用所带来的显著效果,所有操作都得到了完全的封装,清晰明了且易于理解。

image

接下来,我们来看下第二种按需调用的方式,这种方法是通过使用插件(即函数回调)来实现的。在这种模式下,系统可以根据实际需要动态调用相应的插件,以提供灵活而高效的功能支持。我们之前已经演示过两个相关的插件,因此在这里就不再详细展示。

线上部署

我决定不再单独将其部署到服务器上,而是采用本地启动的方式来暴露接口。此外,我还特别制作了一个独立的页面,考虑到这部分内容并不是本章的重点,因此我将不对前端知识进行详细讲解。

为了更好地展示这些内容,我提供了相关的演示视频,供大家参考:

权衡利弊

首先,我想谈谈目前各大智能体平台的一些显著优势:

  1. 可视化操作
    :这些平台提供了直观的可视化界面,使得即使是初学者也能快速开发出适合自己的业务智能体,从而更好地满足自身的业务需求。
  2. 多样的发布渠道
    :许多平台支持多种发布渠道,如公众号等,这对于新手来说非常友好。相比之下,单纯配置服务器后台往往需要专业知识,而这些平台则大大降低了入门门槛。
  3. 丰富的插件商店
    :无论是哪家智能体平台,插件的多样性都至关重要。这些平台通常提供官方和开发者创建的各种插件,帮助用户扩展功能,满足不同的需求。
  4. 多元的工作流
    :工作流功能实际上与插件的作用类似,只是名称有所不同。对外部系统而言,这些工作流都通过API接口实现集成,提升了系统间的互操作性与灵活性。

世间万物都有缺陷,智能体也不例外。即使像Coze这样的强大平台,同样存在一些不足之处。以下几点尤为明显:

  1. 功能异常处理
    :当智能体出现功能异常时,即使你提交了工单,客服和技术人员解决问题的速度往往很慢。这种情况下,你只能无奈地等待,无法确定问题出在哪里。如果只是个人用户的问题,可能连排期都不会给予反馈。而如果是自己开发的智能体,遇到错误时,你可以迅速定位问题,无论需求如何,都能随时进行修复并发布新版本。
  2. 知识库存储限制
    :由于这些智能体是面向广大用户的,因此知识库的存储额度往往受到限制,而且未来可能会开始收费。Coze已经逐步引入了不同的收费标准,各种收费标准让你看都看不懂。在这种情况下,自己维护一个服务器无疑更加划算。此外,当前各大云服务商和国产数据库均有向量数据库的推荐,且通常会提供优惠政策,极具吸引力。
  3. 知识库资料优化
    :各大智能体平台的知识库管理方式各异,用户需要花时间适应其操作方式。而自己维护向量数据库的好处在于,所有的额外元数据信息都可以自由配置,能够根据具体业务需求进行信息过滤,从而更好地符合自身的业务标准。这是其他智能体平台所无法提供的灵活性。
  4. 费用不可控
    :对于企业而言,管理各种费用的可控性至关重要。然而,智能体平台的收费往往随着流量的增加而不受控制,可能会出现乱收费的情况,使企业陷入被动局面。相比之下,自行开发智能体时,可以自由更换模型,费用也在自己的掌控之中,无论是服务器费用还是大模型费用,都能有效管理。
  5. 选择性弱
    :智能体平台通常与自身企业绑定,限制了用户的选择自由。某一天,平台可能会决定不再支持某个大模型,这样一来,相关的工作流也需要全部更换,因为不同的大模型在回复能力上存在显著差异,导致用户不得不重新适应。
  6. 等等.....

说了这么多,并不是说Spring AI未来会完全取代智能体平台。毕竟,对于小众客户而言,通常缺乏开发和维护人员去管理代码。因此,未来的趋势很可能是这两者相辅相成。智能体平台的开发速度和能力能够基本满足业务中80%的需求,这一原则与大厂所践行的二八法则不谋而合。而剩下的20%则可能需要公司内部自行开发智能体平台来弥补,这一比例甚至有可能更高。

因此,掌握相关技术才是企业在这一变革中最为关键的因素。拥有技术能力将使企业在选择和使用智能体平台时更加灵活,能够根据自身的具体需求进行定制和优化。同时,我也希望混元大模型能够尽快兼容OpenAI的接口,或者融入Spring AI的大家庭,这样将为用户提供更多的选择与灵活性。

总结

今天,我们深入探讨了Spring AI在智能体构建中的实际应用,特别是在企业环境中的价值与效能。通过逐步实现一个本地部署的智能体解决方案,我们不仅展示了Spring AI的灵活性与易用性,还强调了它在推动AI技术与业务深度融合方面的潜力。

智能体的核心在于其能够高效处理复杂的业务需求,而这一切的实现离不开合理的架构设计与技术选型。通过Spring AI的集成,我们可以灵活地调用不同的API,不论是使用国内的混元API还是其他主流的AI接口,开发者都能在项目中快速切换,确保系统的可维护性与扩展性。这一特性不仅提升了开发效率,还使得企业在面对市场需求变化时能够快速反应,灵活调整技术路线。

我们在过程中涉及到的个性化配置和插件调用,充分展示了如何将传统的开发模式与现代AI技术相结合。通过自定义插件与工作流,企业可以根据具体的业务需求,设计出更具针对性的智能体,从而提高服务质量和客户满意度。例如,在天气查询的场景中,智能体不仅能够通过API获取实时数据,还能将其与数据库中的信息相结合,实现精准而个性化的服务。这种深度的功能整合,不仅简化了用户的操作流程,也提高了系统的响应速度。

此外,我们还提到私有知识库的集成,强调了数据安全与自主可控的重要性。利用向量数据库如Milvus,企业不仅能够高效管理海量数据,还能通过嵌入技术提升智能体的智能水平。这为企业在信息安全与知识产权保护方面提供了更为坚实的保障,尤其是在当前信息化快速发展的背景下,这一点显得尤为重要。

总之,本文不仅仅是对Spring AI智能体构建过程的阐述,更是对企业如何有效利用这一技术实现业务升级与转型的深入思考。希望通过我们的探讨,能为您在智能体开发与应用中提供新的视角与启示,助力您在未来的AI之路上走得更加稳健。

相关文章

数据库系列:MySQL慢查询分析和性能优化
数据库系列:MySQL索引优化总结(综合版)
数据库系列:高并发下的数据字段变更
数据库系列:覆盖索引和规避回表
数据库系列:数据库高可用及无损扩容
数据库系列:使用高区分度索引列提升性能
数据库系列:前缀索引和索引长度的取舍
数据库系列:MySQL引擎MyISAM和InnoDB的比较
数据库系列:InnoDB下实现高并发控制
数据库系列:事务的4种隔离级别
数据库系列:RR和RC下,快照读的区别
数据库系列:MySQL InnoDB锁机制介绍
数据库系列:MySQL不同操作分别用什么锁?
数据库系列:业内主流MySQL数据中间件梳理
数据库系列:巨量数据表的分页性能问题
数据库系列: 主流分库分表中间件介绍(图文总结)
数据裂变,数据库高可用架构设计实践

1 本文索引

image

1 啥是MySQL主从复制?

主从复制,是指建立一个和主数据库完全一样的数据库环境(称为从数据库),并将主库的操作行为进行复制的过程,那如何保证从库跟主库的数据一致性?
即将主数据库的DDL和DML的操作日志同步到从数据库上,然后在从数据库上对这些日志进行重新执行,来保证从数据库和主数据库的数据的一致性。

1.1 为什么要做主从复制?

1、在复杂的业务操作中,经常会有操作导致锁行甚至锁表的情况,如果读写不解耦,会很影响运行中的业务,使用主从复制,让主库负责写,从库负责读,职责更单一,性能更优良。
即使主库出现了锁表的情景,通过读从库也可以保证业务的正常运行。

2、保证数据的热备份,主库宕机后能够及时替换主库,保障业务可用性。

3、架构的演进:业务量扩大,I/O访问频率增高,单机无法满足,主从复制可以做多库方案,降低磁盘I/O访问的频率,提高单机的I/O性能。

4、本质上也是分治理念,主从复制、读写分离即是压力分拆的过程。

5、读写比也影响整个拆分方式,读写比越高,主从库比例应越高,才能保证读写的均衡,才能保证较好的运行性能。读写比下的主从分配策略如下:

读写比(大约) 主库 从库
50:50 1 1
66.6:33.3 1 2
80:20 1 4
-- -- -- -- -- --

1.2 为什么会出现主从延迟?

当在从库上启动复制时,首先创建I/O线程连接主库,主库随后创建Binlog Dump线程读取数据库事件并发送给I/O线程,I/O线程获取到事件数据后更新到从库的中继日志Relay Log中去,之后从库上的SQL线程读取中继日志Relay Log中更新的数据库事件并应用,如下图所示:
image
细化一下有如下几个步骤:
1、MySQL主库在事务提交时把数据变更(insert、delet、update)作为事件日志记录在二进制日志表(binlog)里面。
2、主库上有一个工作线程 binlog dump thread,把binlog的内容发送到从库的中继日志relay log中。
3、从库根据中继日志relay log重做数据变更操作,通过逻辑复制来达到主库和从库的数据一致性。
4、MySQL通过三个线程来完成主从库间的数据复制,其中binlog dump线程跑在主库上,I/O线程和SQL线程跑在从库上。拥有多个从库的主库会为每一个连接到主库的从库创建一个binlog dump线程。

2 主从延迟的原因分析

先理解以下概念,说说啥是主从延时?

主从延时,通常指的是在数据库的主从复制架构中,从服务器(Slave)在接收并应用主服务器(Master)上的数据变更时所经历的时间延迟。具体来说,当主服务器上的数据发生变化后,这些变更需要通过复制机制同步到从服务器,而从服务器处理这些变更并完成数据同步所需的时间就构成了所谓的延时。
直接的后果就是从库中查询到的数据结果和主库中是不一致的,我们经常在查询数据的过程中会查询到旧数据,可能就是这个原因导致的!

MySQL主从复制,读写分离是我们常用的数据库架构,但是在并发量较大、数据变化大的场景下,主从延时会比较严重。
延迟的本质原因是:系统TPS并发较高时,主库产生的DML(也包含一部分DDL)数量超过Slave一个Sql线程所能承受的范围,效率就降低了。
我们看到这个sql thread 是单个线程,所以他在重做RelayLog的时候,能力也是有限的。
image

还有一些其他原因,比如:

  • 主服务器负载过高
    :当主服务器需要处理大量的数据变更时,可能会产生较高的负载,导致数据变更的生成和传输速度变慢,从而增加从服务器的同步延迟。
  • 从服务器负载过高
    :从服务器在接收并应用主服务器上的数据变更时,如果自身的负载过高,可能会导致处理速度变慢,从而产生同步延迟。
  • 网络延迟
    :主从服务器之间的网络连接不稳定或带宽不足,也可能导致数据变更的传输速度变慢,从而增加同步延迟。
  • 机器性能差异
    :主从服务器的硬件配置不同,例如CPU、内存、磁盘等性能差异,也可能导致同步延迟的产生。
  • MySQL配置不合理
    :例如,如果主服务器的二进制日志(binlog)设置过大,或者从服务器的中继日志(relay log)配置不合理,都可能导致处理速度变慢,从而产生同步延迟。
  • 从库大量的大型的 query 语句产生了锁等待

3 主从延时优化方案

3.1 最优的系统配置

优化系统配置(系统级、链接层、存储引擎层),让数据库处在最优状态:最大连接数、允许错误数、允许超时时间、pool_size、log_size等,保证内存、CPU、存储空间的扩容(硬件部分)。

倒金字塔法则
告诉我们,这一块往往是被忽略的,但是又是必不可少的。
image

如果MySQL部署在linux系统上,可以适当调整操作系统的参数来优化MySQL性能,下面是对Linux内核参数进行适当调整。

 1 # TIME_WAIT超时时间,默认是60s
 2 net.ipv4.tcp_fin_timeout = 30 
 3 # 增加tcp支持的队列数,加大队列长度可容纳更多的等待连接
 4 net.ipv4.tcp_max_syn_backlog = 65535
 5 # 减少断开连接时 ,资源回收
 6 net.ipv4.tcp_max_tw_buckets = 8000
 7 net.ipv4.tcp_tw_reuse = 1
 8 net.ipv4.tcp_tw_recycle = 1
 9 net.ipv4.tcp_fin_timeout = 10
10 # 打开文件的限制
11 *soft nofile 65535
12 *hard nofile 65535 

MySQL5.5+版本之后,默认存储引擎为InnoDB,我们这边列出部分可能影响数据库性能的参数。
公共参数默认值:

1 max_connections = 151
2 # 同时处理最大连接数,建议设置最大连接数是上限连接数的80%左右,一般默认值为151,可以做适当调整。
3 sort_buffer_size = 2M
4 # 查询排序时缓冲区大小,只对order by和group by起作用,建议增大为16M
5 open_files_limit = 1024 
6 # 打开文件数限制,如果show global status like 'open_files'查看的值等于或者大于open_files_limit值时,程序会无法连接数据库或卡死

InnoDB参数默认值:

 1 innodb_buffer_pool_size = 128M
 2 # 索引和数据缓冲区大小,建议设置物理内存的70%左右(这个前提是这个服务器只用做Mysql数据库服务器)
 3 innodb_buffer_pool_instances = 1    
 4 # 缓冲池实例个数,推荐设置4个或8个
 5 innodb_flush_log_at_trx_commit = 1  
 6 # 关键参数,0代表大约每秒写入到日志并同步到磁盘,数据库故障会丢失1秒左右事务数据。1为每执行一条SQL后写入到日志并同步到磁盘,I/O开销大,执行完SQL要等待日志读写,效率低。2代表只把日志写入到系统缓存区,再每秒同步到磁盘,效率很高,如果服务器故障,才会丢失事务数据。对数据安全性要求不是很高的推荐设置2,性能高,修改后效果明显。
 7 sync_binlog=1
 8 
 9 innodb_file_per_table = ON  
10 # 是否共享表空间,5.7+版本默认ON,共享表空间idbdata文件不断增大,影响一定的I/O性能。建议开启独立表空间模式,每个表的索引和数据都存在自己独立的表空间中,可以实现单表在不同数据库中移动。
11 innodb_log_buffer_size = 8M  
12 # 日志缓冲区大小,由于日志最长每秒钟刷新一次,所以一般不用超过16M 

3.2 数据库层做合理分治

数据库分区是永恒的话题,主从延迟一定程度上是单台数据库主服务操作过于频繁,使得
单线程的SQL thread 疲于应付
。可以适当的从功能上对数据库进行拆分,分担压力。

数据库拆分可以参考我的这篇文章《
分库分表
》,这边就不赘述。

3.3 从库同步完成后响应

假如你的业务时间允许,你可以在写入主库的时候,确保数据都同步到从库了之后才返回这条数据写入成功,当然如果有多个从库,你也必须确保每个从库都写入成功。当然,
这个方案对性能和时间的消耗是极大的,会直接降低你的系统吞吐量,不推荐。
image

3.4 适当引入缓存

可以引入redis或者其他nosql数据库来存储我们经常会产生主从延迟的业务数据。当我在写入数据库的同时,我们再写入一份到redis中。

读取数据的时候,我们可以先去查看redis中是否有这个数据,如果有我们就可以直接从redis中读取这个数据。当数据真正同步到数据库中的时候,再从redis中把数据删除。如下图:

image

这边还需注意两点,很重要哟,面试必问:

1、虽然一定程度上缓解延迟的问题,但如果遇到高并发的情况,对Redis的频繁删除也不合理,所以需要结合场景综合考虑,比如定期删除缓存。

2、高并发情况下可能存在slave还没同步,又有新的值写进来了,这时候Master --> Slave 还在排队中,但是Cache已经被更新了。所以如果对Redis进行删除,可能会误删除最新的缓存值,导致读取到的数据是旧的。

image

如上图情况,对一个值分别更新 1,2,3,主从同步按照顺序进行,刚同步完1,Cache就更新到3了,这时候如果把Cache删除了,读请求就会走到从库去读,读到了1,数据就会出现短暂不一致了。

所以这个地方也需要注意,可以同时将唯一键(比如主键)也做保存,删除之前做一个判断,避免误删。或者干脆不实时删除缓存,低峰值期再来处理。

3.5 多线程重放RelayLog

MySQL使用单线程重放RelayLog,那能不能在这上面做解法呢,比如使用多线程并行重放RelayLog,就可以缩短时间。但是这个对数据一致性是个考验。

需要考虑如何分割RelayLog,
才能够让多个数据库实例,多个线程并行重放RelayLog
,不会出现不一致。比如RelayLog包含这三条语句给学生授予学分的记录,你就不知道结果会变成什么。可能是806甚至是721。

1 update t_score set score = 721 where stu_code=374532;
2 update t_score set score = 806 where stu_code=374532;
3 update t_score set score = 899 where stu_code=374532;

解法就是:

相同库表上的写操作,用相同的线程来重放RelayLog;不同库表上的写操作,可以并发用多个线程并发来重放RelayLog。
image

设计一个哈希算法,hash(db-name) % thread-num,表名称hash之后再模上线程数,就能很轻易做到,同一个库表上的写操作,被同一个重放线程串行执行,做到提效的目的。

这其实也是一种分治的思维,类似上面直接对数据库进行拆分。

3.6 少量读业务直连主库

业务量不多的情况下,不做主从分离.既然主从延迟是由于从库同步写库不及时引起的,那我们也可以在有主从延迟的地方改变读库方式,由原来的读从库改为读主库。当然这也会增加代码的一些逻辑复杂性。
这边需要注意的是,直接读主库的业务量不宜多,而且是读实时一致性有刚性需求的业务才这么做。否则背离读写分离的目的。

3.7 适当的限流、降级

任何的服务器都是有吞吐量的限制的,没有任何一个方案可以无限制的承载用户的大量流量。所以我们必须估算好我们的服务器能够承载的流量上限是多少。
达到这个上限之后,就要采取缓存,限流,降级的这三大杀招来应对我们的流量。这也是应对主从延迟的根本处理办法。

3.8 高版本MySQL支持多线程复制

MySQL 5.6版本开始支持多线程复制(也称为并行复制),MySQL5.7之后,提供了基于GTID并行复制的能力。在5.6这个版本中,默认是单线程复制,但是可以通过配置参数slave_parallel_workers来启用多线程复制。
要启用多线程复制,请按照以下步骤操作:

1. 确保你的MySQL版本是5.6+

2. 修改多线程配置
修改MySQL的my.cnf(或my.ini)配置文件,在从服务器上设置slave_parallel_workers参数为期望的工作线程数,例如:

[mysqld]
slave_parallel_workers = 8

3. 重启MySQL服务以使配置生效。

4. 确认多线程复制是否已经启用:

SHOW VARIABLES LIKE 'slave_parallel_workers';

如果返回值大于0,则表示多线程复制已经开启,并且可以使用指定数量的线程来应用日志事件。

4 总结

上面提到了多种方案都是可以讨论,每个方法都有弊端和优势,根据实际情况进行选型。

前言:

我们已经分享过如何快速实现自己需要的全局弹框组件;

在开发 Vue 项目时,特别是H5页面的项目,还有一个组件是我们非常常用的,它相对弹框来说没有那么大,并且不需要手动关闭在需要更简洁的提示用户一些信息时非常常用,它就是
toast
提示组件;

接下来我们会带着大家手写一个全局的
toast
提示组件,当你在项目任何地方需要使用时,都可直接调用。

查看往期文章:

十五分钟两百行代码,手写一个vue项目全局通用的弹框

第一步:新建文件夹及主要文件

Vue项目中,一般来说我们公用组件是放在
src/components
,所以我们直接在
src/components/toast
下新建如下两个文件:

  1. index.vue:该文件是
    toast
    组件的内容,跟我们写普通 vue 组件一样,包含
    toast
    的结构、样式以及基础逻辑;
  2. index.js:注册
    index.vue
    组件为全局组件。因为该组件我们不需要手动关闭,并且涉及到添加元素和自动删除元素,所以该文件会有一些元素层面上的操作和逻辑,相对上期的弹框组件的
    index.js
    的文件来说会相对复杂些,我们下边会逐行讲解,当然也会提供完整代码,请往下看。

第二步:书写组件内容

index.vue组件内容如下:

  1. 结构 + js 代码
<template>
  <transition name="appear">
    <section class="toast" v-if="show">
      <div v-html="msg" class="toast-con"></div>
    </section>
  </transition>
</template>

<script type="text/ecmascript-6">
export default {
  name: "toast",
  data() {
    return {
      show: false,
      msg: '',
      time: 1000
    }
  },
  methods: {
    async open() {
      if (this.show) {
        return;
      }
      this.show = true;
      let result = await this.close();
      return result;
    },
    close() {
      return new Promise((resolve) => {
        setTimeout(() => {
          this.show = false;
          resolve(true);
        }, this.time);
      });
    }
  }
}
</script>

代码说明:

  • 我们这里用到了
    Vue

    transition
    组件,用于包裹需要动画效果的元素。name="appear" 指定了使用名为 "appear" 的过渡效果;
  • <div v-html="msg" class="toast-con"></div>
    这句代码是我们toast的主要内容,通过
    v-html + msg
    来动态属性来插入,msg在index.js 中修改;
  • methods:定义组件的方法。
    • open():异步方法,用于显示 Toast。如果已显示,则直接返回;否则设置 show 为 true 并调用 close() 方法等待其完成。
    • close():返回一个 Promise,该 Promise 在 time 毫秒后解决,同时将 show 设置为 false,从而隐藏 Toast。
  1. 样式
<style lang="less" scoped>
.default-message {
  position: fixed;
  right: 0;
  top: 0;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1000;
  background: rgba(0, 0, 0, 0.7);

  .default-message-title {
    color: #333;
    margin: 0;
    line-height: 1.5;
    font-size: 18px;
    min-height: 18px;
    padding-top: 20px;
    text-overflow: ellipsis;
    font-weight: bold;
    cursor: move;
    text-align: center;
  }

  .default-message-content {
    width: 85%;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate3d(-50%, -50%, 0);
    background-color: #fff;
    border-radius: 6px;
    transition: all 0.2s ease-in;
    color: #999;
    font-size: 18px;
  }

  .default-message-value {
    padding: 28px 18px;
    text-align: center;
    position: relative;
    color: #999;
    text-align: center;
    font-size: 14px;
    color: rgba(102, 102, 102, 1);
  }
  .default-message-btns {
    // border-top: 1px solid #ddd;
    display: flex;
    height: 60px;
    position: relative;
    &:after {
      position: absolute;
      content: "";
      display: inline-block;
      left: 0;
      right: 0;
      top: 0;
      height: 1px;
      transform: scaleY(0.5);
      background: #ddd;
    }
    .default-message-btn {
      flex: 1;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 16px;
      padding: 0 3px;
    }
    .default-message-submit {
      color: #26a2ff;
    }
    .default-message-cancle {
      color: #999;
      position: relative;
      &:after {
        position: absolute;
        content: "";
        display: inline-block;
        top: 0;
        right: 0;
        bottom: 0;
        width: 1px;
        transform: scaleX(0.5);
        background: #ddd;
      }
    }
  }
  @keyframes fadeIn {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
}
</style>

第三步:注册成全局组件

import { createApp } from 'vue';
import ToastComponents from './index.vue';

const LayerToastId = 'layer-Toast-wrapper';

let Toast = async function (msg, time) {
  time = time || 2000;

  let ToastEl = document.getElementById(LayerToastId);
  // 如果DOM中含有这个元素 不执行
  if (ToastEl) {
    return;
  }
  const div = document.createElement('div');
  div.setAttribute('id', LayerToastId)
  document.body.appendChild(div);
  let layerToastEl = createApp(ToastComponents).mount('#' + LayerToastId);
  // 修改组件中的data的值
  layerToastEl.msg = msg;
  layerToastEl.time = time;
  // 执行组件中的方法 等待关闭后返回promise
  let hasClosed = await layerToastEl.open();
  // 当Toast提示关闭后再删除外层元素 时间最好与css动画一致
  if (hasClosed) {
    setTimeout(() => {
      document.body.removeChild(div);
    }, 400);
  }
};
export default {
  install (app) {
    // 通过this.$toast访问
    app.config.globalProperties.$toast = Toast;
  }
}

到这里,我们的弹框组件就完成了。下边我们对一些比较重要的代码做个解释:

  • let Toast = async function
    这里表示Toast消息,我们把它注册成一个异步函数,因为内部需要使用到定时器控制定时移除消息容器;

  • let layerToastEl = createApp(ToastComponents).mount('#' + LayerToastId);


    • 这句代码的意思是:把我们引入的
      index.vue
      文件创建成一个 Vue 的应用实例,并挂载到新创建的 div 上。
  • 以下是
    index.js
    文件的逐行解释:

// 引入 Vue 的 createApp 函数,用于创建 Vue 应用实例
import { createApp } from 'vue';
// 引入 Toast 组件
import ToastComponents from './index.vue';

// 定义一个常量,用于存储 Toast 组件的容器元素的 ID
const LayerToastId = 'layer-Toast-wrapper';

// 定义一个异步函数 Toast,用于显示 Toast 消息
let Toast = async function (msg, time) {
  // 如果未指定显示时间,默认为 2000 毫秒
  time = time || 2000;

  // 获取页面上是否已存在 Toast 容器元素
  let ToastEl = document.getElementById(LayerToastId);
  // 如果已存在,不执行后续代码,直接返回
  if (ToastEl) {
    return;
  }
  // 创建一个 div 元素,用作 Toast 组件的容器
  const div = document.createElement('div');
  // 为该 div 设置 ID
  div.setAttribute('id', LayerToastId)
  // 将创建的 div 添加到 body 中
  document.body.appendChild(div);

  // 创建一个 Vue 应用实例,并挂载到新创建的 div 上
  let layerToastEl = createApp(ToastComponents).mount('#' + LayerToastId);
  // 设置 Toast 组件的消息内容和显示时间
  layerToastEl.msg = msg;
  layerToastEl.time = time;

  // 调用 Toast 组件的 open 方法,显示 Toast 并等待其关闭
  let hasClosed = await layerToastEl.open();
  // 当 Toast 关闭后,延迟 400 毫秒后移除 Toast 容器元素
  // 这里的 400 毫秒延时可以与 CSS 动画的时间相匹配,确保动画播放完成
  if (hasClosed) {
    setTimeout(() => {
      document.body.removeChild(div);
    }, 400);
  }
};

// 导出一个对象,包含 install 方法,用于在 Vue 应用中安装这个 Toast 功能
export default {
  install (app) {
    // 将 Toast 函数添加到 Vue 应用的全局属性中,使其可以通过 this.$toast 在任何组件中访问
    app.config.globalProperties.$toast = Toast;
  }
}

项目中使用弹框

使用就非常简单便利了,主要有以下几种用法:

  1. Vue2 中使用:
// Vue2 中简单使用
this.$toast("Toast提示在Vue2项目中的简单使用"));

// Vue2中需要在提示后有进一步操作:可以任何你想的逻辑,包括发接口、页面处理等。
await this.$toast("Toast提示在Vue2项目中使用后有后续逻辑", 3000);
handleFunction(); // 这里的函数代表提示后的逻辑代码
  1. Vue3 中使用:
// 在 Vue3 中使用时需要先引入app
import { app } from "@/main";

// Vue3 中简单使用
app.config.globalProperties.$toast("Toast提示在Vue3项目中的简单使用", 3000);

// Vue2中需要在提示后有进一步操作:可以任何你想的逻辑,包括发接口、页面处理等。
app.config.globalProperties.$toast("Toast提示在Vue3项目中使用后有后续逻辑");
handleFunction(); // 这里的函数代表提示后的逻辑代码

说明:

  • 我们可以在使用时传入合适的显示停留时间,如果为传入,则按默认的 2000 毫秒显示;
  • 在 Vue3 中,你也可以把
    $toast
    重新保存一下,后续不用每次都写很长的
    app.config....
import { app } from "@/main";

const toast = app.config.globalProperties.$toast

toast("简便地使用toast提示");

toast 图片示例

说明:在停留 2000 毫秒(或者我们设置的停留时间)之后会自动关闭。

写在后面

这是一个比较基础和通用的黑色半透明提示消息,这边示例的代码是比较全的,对细节要求不大的小伙伴可以直接抄作业;

背景颜色、字体、布局等这些细节,因为每个业务场景不同,大家可以根据自己的需要适当调整;

通过修改结构和样式代码,你可以让消息的样式变得更加丰富或者更符合你的业务需求;

消息组件我们一样是使用固定单位的,如果小伙伴的项目需要使用响应式大小,直接对应替换大小单位(rem、vw)即可;

对你有帮助的话给作者点点关注吧,你的支持是我不断更新的动力!Peace and love~~

技术背景

在前面的一篇
博客
中,我们介绍了拉格朗日插值法的基本由来和表示形式。这里我们要介绍一种拉格朗日插值法的应用场景:格点拉格朗日插值法。这种场景的优势在于,如果我们要对整个实数空间进行求和或者积分,计算量是随着变量的形状增长的。例如分子动力学模拟中
计算静电势能
,光是计算电荷分布函数都是一个
\(O(N^2)\)
的计算量,其中
\(N\)
表示点电荷数量。而如果我们对空间进行离散化,划分成一系列的格点,再对邻近的常数个格点进行插值,那么我们的求和计算量可以缩减到
\(O(N)\)

格点拉格朗日插值

给定一个函数
\(y=f(x-x_r)\)
,我们可以将其插值到最近的4个整数格点上:
\(\lfloor x_r\rfloor-1.5,\lfloor x_r\rfloor-0.5,\lfloor x_r\rfloor+0.5,\lfloor x_r\rfloor+1.5\)
,根据拉格朗日插值形式有:

\[y_{interp}=c_1(x)f(\lfloor x_r\rfloor-1.5-x_r)+c_2(x)f(\lfloor x_r\rfloor-0.5-x_r)+c_3(x)f(\lfloor x_r\rfloor+0.5-x_r)+c_4(x)f(\lfloor x_r\rfloor+1.5-x_r)
\]

如果以
\(\lfloor x_r\rfloor\)
最近的中心点为原点,即
\(\lfloor x_r\rfloor=0\)
,则其系数有:

\[\begin{align*}
c_1(x)&=\frac{(x-\lfloor x_r\rfloor+0.5)(x-\lfloor x_r\rfloor-0.5)(x-\lfloor x_r\rfloor-1.5)}{-6}=\frac{1}{48}(-8x^3+12x^2+2x-3)\\
c_2(x)&=\frac{(x-\lfloor x_r\rfloor+1.5)(x-\lfloor x_r\rfloor-0.5)(x-\lfloor x_r\rfloor-1.5)}{2}=\frac{1}{16}(8x^3-4x^2-18x+9)\\
c_3(x)&=\frac{(x-\lfloor x_r\rfloor+1.5)(x-\lfloor x_r\rfloor+0.5)(x-\lfloor x_r\rfloor-1.5)}{-2}=\frac{1}{16}(-8x^3-4x^2+18x+9)\\
c_4(x)&=\frac{(x-\lfloor x_r\rfloor+1.5)(x-\lfloor x_r\rfloor+0.5)(x-\lfloor x_r\rfloor-0.5)}{6}=\frac{1}{48}(8x^3+12x^2-2x-3)
\end{align*}
\]

其图像大致如下图所示(图片来自于参考链接1):

对于多维的格点拉格朗日插值,则是一个叉乘的关系,其图像为:

远程相互作用项的截断

我们把上面得到的这个格点拉格朗日插值应用到静电势能的计算中。在前面一篇
博客
介绍的静电势计算中,有一项电荷分布函数是这样的:

\[s(\mathbf{k})=|S(\mathbf{k})|^2=\sum_{i=0}^{N-1}q_ie^{-j\mathbf{k}\mathbf{r}_i}\sum_{l=0}^{N-1}q_le^{j\mathbf{k}\mathbf{r}_l}
\]

其中
\(S(\mathbf{k})=\sum_{i=0}^{N-1}q_ie^{j\mathbf{k}\mathbf{r}_i}=\sum_{i=0}^{N-1}q_ie^{j\mathbf{k}_xx_i}e^{j\mathbf{k}_yy_i}e^{j\mathbf{k}_zz_i}\)
。把后面这几个指数项用格点拉格朗日插值替代得:

\[S(\mathbf{k})=\sum_{i=0}^{N-1}q_i\sum_{x,y,z}\left[c_1(x)f(\lfloor x_i\rfloor-1.5-x_i)+c_2(x)f(\lfloor x_i\rfloor-0.5-x_i)+c_3(x)f(\lfloor x_i\rfloor+0.5-x_i)+c_4(x)f(\lfloor x_i\rfloor+1.5-x_i)\right]\left[c_1(y)f(\lfloor y_i\rfloor-1.5-y_i)+c_2(y)f(\lfloor y_i\rfloor-0.5-y_i)+c_3(y)f(\lfloor y_i\rfloor+0.5-y_i)+c_4(y)f(\lfloor y_i\rfloor+1.5-y_i)\right]\left[c_1(z)f(\lfloor z_i\rfloor-1.5-z_i)+c_2(z)f(\lfloor z_i\rfloor-0.5-z_i)+c_3(z)f(\lfloor z_i\rfloor+0.5-z_i)+c_4(z)f(\lfloor z_i\rfloor+1.5-z_i)\right]
\]

有了函数形式以后,我们可以简写
\(S(\mathbf{k})\)
为一个关于三维空间格点的求和:

\[S(\mathbf{k})=\sum_{i=0}^{N-1}q_i\sum_{m_x=\lfloor x_{min}\rfloor-1.5}^{\lfloor x_{max}\rfloor+1.5}\sum_{m_y=\lfloor y_{min}\rfloor-1.5}^{\lfloor y_{max}\rfloor+1.5}\sum_{m_z=\lfloor z_{min}\rfloor-1.5}^{\lfloor z_{max}\rfloor+1.5}c_{m_x}(m_x)e^{jk_xm_{x}}c_{m_y}(m_y)e^{jk_ym_{y}}c_{m_z}(m_z)e^{jk_zm_{z}}
\]

再把系数项单独拿出来:

\[Q(m_x,m_y,m_z)=\sum_{i=0}^{N-1}q_ic_{m_x}(m_x)c_{m_y}(m_y)c_{m_z}(m_z)
\]

这里的
\(Q\)
其实是一个shape为
\((N_x,N_y,N_z)\)
的张量,而
\(m_x,m_y,m_z\)
对应的是某一个格点的张量索引,每一个索引对应的张量元素都是通过系数函数计算出来的,有了这样的一个概念之后,再重写
\(S(\mathbf{k})\)
的函数:

\[S(\mathbf{k})=\sum_{m_x=\lfloor x_{min}\rfloor-1.5}^{\lfloor x_{max}\rfloor+1.5}\sum_{m_y=\lfloor y_{min}\rfloor-1.5}^{\lfloor y_{max}\rfloor+1.5}\sum_{m_z=\lfloor z_{min}\rfloor-1.5}^{\lfloor z_{max}\rfloor+1.5}Q(m_x,m_y,m_z)e^{j\mathbf{k}\cdot\mathbf{m}}
\]

我们会发现,这个插值出来的
\(S(\mathbf{k})\)
函数其实是在计算张量
\(Q\)

\(\mathbf{k}\)
处的傅里叶变换,那么就可以进一步简写
\(S(\mathbf{k})\)
的形式:

\[S(\mathbf{k})=VF_{\mathbf{k}}^{*}(Q)(m_x,m_y,m_z)
\]

其中
\(F^{*}\)
表示逆傅里叶变换,
\(V\)
表示逆傅里叶变换归一化常数。按照前面的4-格点拉格朗日插值法,此时得到的
\(S(\mathbf{k})\)
的值是一个shape为(4,4,4)的张量,这个张量的含义是64个格点分别对于倒格矢
\(\mathbf{k}\)
的贡献(插值出来的单个点电荷的作用效果)。那么类似的可以得到:

\[s(\mathbf{k})=VF_{\mathbf{k}}^{*}(Q)(m_x,m_y,m_z)F_{\mathbf{k}}(Q)(m_x,m_y,m_z)=V|F_{\mathbf{k}}(Q)(m_x,m_y,m_z)|^2
\]

代入到Ewald形式的长程相互作用项(可以参考这篇
文章
)中可以得到:

\[\begin{align*}
E^L&=\frac{1}{2k_xk_yk_z\epsilon_0}\sum_{|\mathbf{k}|>0}\frac{e^{-\frac{\sigma^2 k^2}{2}}}{k^2}s(\mathbf{k})\\
&=\frac{V}{2k_xk_yk_z\epsilon_0}\sum_{|\mathbf{k}|>0}\frac{e^{-\frac{\sigma^2 k^2}{2}}}{k^2}|F_{\mathbf{k}}(Q)(m_x,m_y,m_z)|^2
\end{align*}
\]

这就是Particle-Mesh-Ewald方法计算中计算长程相互作用势能的技巧。既然
\(\mathbf{k}\)
空间无法快速收敛,那就减少电荷分布项的计算复杂度,同样也可以起到大量节约计算量的效果。

短程相互作用项的截断

在前面Ewald求和的文章中我们介绍过,把静电势能的计算分成长程、短程和自我相互作用项之后,分别有不同的收敛形式。长程相互作用项已经通过上述章节完成了计算量的简化,另外还有一个短程相互作用项
\(E^{S}\)
,我们知道短程相互作用项关于原子实空间的间距是快速收敛的,并且在计算LJ势能的时候我们已经计算过一次给定cutoff截断的近邻表。那么,我们很容易考虑到引入近邻表的概念,直接利用这个近邻表对静电势能的短程相互作用项做一个截断。于是短程相互作用项可以写为:

\[\begin{align*}
E^S&=\sum_{\mathbf{n}}\sum_{i=0}^{N-2}\sum_{j=i+1}^{N-1}\frac{q_iq_j}{4\pi\epsilon_0|\mathbf{r}_j-\mathbf{r}_i+\mathbf{n}\mathbf{L}|}Erfc\left(\frac{|\mathbf{r}_j-\mathbf{r}_i+\mathbf{n}\mathbf{L}|}{\sqrt{2}\sigma}\right)+\sum_{|\mathbf{n}|>0}\frac{q_i^2}{4\pi\epsilon_0|\mathbf{n}\mathbf{L}|}Erfc\left(\frac{|\mathbf{n}\mathbf{L}|}{\sqrt{2}\sigma}\right)\\
&\approx \sum_{i,j\in \{Neigh\}}\frac{q_iq_j}{4\pi\epsilon_0|\mathbf{r}_j-\mathbf{r}_i|}Erfc\left(\frac{|\mathbf{r}_j-\mathbf{r}_i|}{\sqrt{2}\sigma}\right)
\end{align*}
\]

这里有个前提假设是
\(d_{cutoff}<<L_{pbc}\)
,所以略去了周期性盒子中其他盒子内的
\(i\)
电荷对中心盒子的
\(\mathbf{r}_i\)
处的作用项。

Particle-Mesh-Ewald

根据上面章节中得到的近似的远程相互作用项和短程相互作用项之后,我们可以重写PME(Particle-Mesh-Ewald)算法中的总静电势能为:

\[\begin{align*}
E&=E^S+E^L-E^{self}\\
&=\sum_{i,j\in \{Neigh\}}\frac{q_iq_j}{4\pi\epsilon_0|\mathbf{r}_j-\mathbf{r}_i|}Erfc\left(\frac{|\mathbf{r}_j-\mathbf{r}_i|}{\sqrt{2}\sigma}\right)\\
&+\frac{V}{2k_xk_yk_z\epsilon_0}\sum_{|\mathbf{k}|>0}\frac{e^{-\frac{\sigma^2 k^2}{2}}}{k^2}|F_{\mathbf{k}}(Q)(m_x,m_y,m_z)|^2\\
&-\frac{1}{4\pi\epsilon_0}\frac{1}{\sqrt{2\pi}\sigma}\sum_{i=0}^{N-1}q_i^2
\end{align*}
\]

总结概要

本文介绍了使用基于格点拉格朗日插值法的Particle Mesh Ewald算法,降低分子力场中的静电势能项计算复杂度的基本原理。静电势能的计算在Ewald求和框架下被拆分成了远程相互作用项和短程相互作用项,其中短程相互作用项关于实空间的点电荷间距快速收敛,而远程相互作用项在倒易空间慢速收敛。因此在远程相互作用的计算中,可以使用插值法降低单个倒易格点的计算复杂度,从而使得整体的远程相互作用项计算也能够快速收敛。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/pme.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html

参考链接

  1. https://bohrium.dp.tech/notebooks/62979247598

如何开发微信小程序

前言

因为最近沉迷和朋友们一起下班去打麻将,他们推荐了一个计分的小程序,就不需要每局都转账或者用扑克牌记录了,但是这个小程序不仅打开有广告,各个页面都植入了广告,用起来十分不适。

于是我就心里暗自下定决心,一定要撸一个没有广告的小程序。一周后,这个小程序发布了。

欢迎大家参观和使用我的小程序!小程序名称:
MahjongScorer

MahjongScorer

思路

1.注册,获取头像和昵称。已注册的用户直接自动登录。

2.创建房间,扫一扫加入房间或者转发微信好友、群聊,通过点击加入。

3.添加台板以记录每局抽出来的台费(无需台费可忽略)。

4.每一局对局结束后,记录每个人的输赢情况。

5.散场后,将本次游戏所有记录保存至个人历史记录中,解散房间。

6.评价,随机弹出评价页面对此次体验进行评价。

7.特色功能:为方便第 2 步的记录,增加长按语音识别。

准备工作

前端

工具:HBuilderX、微信开发者工具

框架:uni-app(Vue3)、pinia
由于页面简单,所以没有使用 UI 框架。

附 uni-app 基本项目结构:

┌─uniCloud 云空间目录,阿里云为 uniCloud-aliyun,腾讯云为 uniCloud-tcb(详见 uniCloud)
│─components 符合 vue 组件规范的 uni-app 组件目录
│ └─comp-a.vue 可复用的 a 组件
├─hybrid App 端存放本地 html 文件的目录
├─platforms 存放各平台专用页面的目录
├─pages 业务页面文件存放的目录
│ ├─index
│ │ └─index.vue index 页面
│ └─list
│ └─list.vue list 页面
├─static 存放应用引用的本地静态资源(如图片、视频等)的目录,注意:静态资源只能存放于此
├─uni_modules 存放 uni_modules 规范的插件。
├─wxcomponents 存放小程序组件的目录
├─main.js Vue 初始化入口文件
├─App.vue 应用配置,用来配置 App 全局样式以及监听 应用生命周期
├─manifest.json 配置应用名称、appid、logo、版本等打包信息
└─pages.json 配置页面路由、导航条、选项卡等页面类信息

1.新建项目

在 HBuilderX 新建项目。
vxmp-develop-1

2.配置开发工具路径

在 HBuilderX 配置微信开发者工具的安装路径:工具-设置-运行配置-小程序运行配置-微信开发者工具路径。
vxmp-develop-2

3.开启端口

在微信开发者工具,开启端口:设置-安全设置-服务端口。
vxmp-develop-3

4.运行项目

在 HBuilderX 运行项目:运行-运行到小程序模拟器-微信开发者工具。
vxmp-develop-4

这时会根据第 2 步配置的路径自动打开微信开发者工具。
vxmp-develop-5

至此,前端基础工作准备完成。如果您想写的小程序只有前端静态页面而无需后端服务,那么可以不用继续往下看了,只需要:

  • 编写静态页面的代码
  • 在微信开发者工具左上角点击登录,点击右上角详情-基本信息-AppId,点击修改为您注册的小程序 ID,点击上传。
    vxmp-develop-8
    • 其中 AppID 获取方式为:进入
      微信小程序官网
      ,找到开发-开发管理-开发设置,在“开发者 ID”下即可获取 AppID(小程序 ID)
  • 找到管理-版本管理-开发版本,点击提交审核,待审核通过后将审核版本发布,即可完成小程序开发和发布。

后端

工具:IDEA

框架:SpringBoot

数据库:MySQL

1.基础工作

参考之前的博客:
如何搭建自己的网站

如何搭建自己的网站(二)
进行服务器的搭建和 jar 包的部署。

2.配置服务器域名

进入
微信小程序官网
,找到开发-开发管理-开发设置,在“开发者 ID”下获取 AppID(小程序 ID)、AppSecret(小程序密钥)并保存下来,后续接口需要使用。继续下滑至“服务器域名”,在 request 合法域名、uploadFile 合法域名、downloadFile 合法域名中填写服务器域名,即第 1 步基础工作中部署的服务器域名。

编写代码

前端

vxmp-develop-1
vxmp-develop-2

下面粘贴部分关键代码。

1.pinia 状态管理库

由于有很多公用的全局的属性和方法,所以将该部分内容都放在 pinia 的全局状态管理库里。
::: details 点击查看代码

import { defineStore } from "pinia";

const useUserStore = defineStore("useUserStore", {
  state: () => {
    return {
      info: {
        // 用户信息
        openid: "",
        avatar: "",
        nickname: "",
        roomid: "",
      },
      isLogin: null, // 是否登录,用于判断是否显示登录页
      shareid: "", // 通过二维码或点击分享进入的房间id
      members: [], // 房间的成员
      records: [], // 本次游戏所有对局
      circle: 1, // 第几局
      sumArr: [], // 本次游戏目前总分
      timer: null, // 定时器,在生成房间后 2 秒触发一次监听房间成员的变更
      scene: null, // 场景:区分是否是通过朋友圈进入,朋友圈进入时无法获取openid会导致报错
      qrCode: "", // 房间二维码
      baseURL: "https://xxxxx.xxx/mahjong/", // 后端接口前缀,省去每次调用接口写一大堆前缀
    };
  },
  actions: {
    async updateInfo(data, mode) {}, // 添加用户、修改头像/昵称
		async getOpenid() {}, // 获取用户的openid
		async autoLogin(roomid) {}, // 如果缓存中有用户openid,则直接登录,否则获取用户openid,并存到缓存。
		async getRecords() {}, // 获取本次游戏所有对局
		async getMembers() {}, // 获取房间的成员
		getSum() {}, // 获取本次游戏目前总分
		async updateRoomid(roomid) {}, // 更新当前用户的房间id
		async gameOver() {}, // 结束游戏并解散房间
		setTimer() {}, // 设置定时器获取成员信息变更
});

:::

2.注册

注册时,可以点击头像选择头像,不选就是默认头点击昵称输入昵称,昵称不可为空,且最多八个字。

::: details 点击查看代码

<view class="container">
  <view class="avatarUrl">
    <button
      type="balanced"
      open-type="chooseAvatar"
      @chooseavatar="onChooseavatar"
    >
      <image
        :src="avatarUrl"
        class="refreshIcon"
        v-if="avatarUrl !== defaultAvatar"
      ></image>
      <image v-else src="/static/upload-avatar.svg" class="upload"></image>
    </button>
  </view>
  <view class="nickname">
    <input
      maxlength="8"
      type="nickname"
      :value="nickName"
      @blur="bindblur"
      placeholder="点击输入昵称"
      @input="bindinput"
    />
  </view>
  <view class="operation">
    <button class="confirm" @click="onSubmit">保存</button>
    <button v-if="userStore.info.nickname" class="cancel" @click="cancel">
      取消
    </button>
  </view>
</view>

:::

3.首页

进入首页,点击自己头像可以修改头像、昵称,点击加号可以将房间分享联系人或群聊,其他用户可以通过点击分享加入房间。

长按加号可以生成台板,用于记录台费,点击台板可以删除台板。

点击扫码加入,可以生成当前房间小程序码,其他用户可通过扫描该小程序码加入房间。

点击开始,跳转到计分面板。点击结束,将会结束对局并解散当前房间,同时对局的得分情况也将保存至“记录”菜单。

::: details 点击查看代码

async generateCode() { // 生成当前房间小程序码
  this.qrLoading = true
  uni.showLoading()
  let roomid = ''
  if (userStore.info.roomid) {
    roomid = userStore.roomid
  } else {
    roomid = md5(userStore.info.openid + new Date().getTime())
    userStore.updateRoomid(roomid)
  }
  let res = await uni.request({
    url: userStore.baseURL + `get-code?roomid=${userStore.info.roomid}`,
    method: 'post',
    responseType: 'arraybuffer',
  })
  userStore.qrCode = 'data:image/PNG;BASE64,' + uni.arrayBufferToBase64(res.data)
  uni.hideLoading()
  this.qrLoading = false
}

:::

4.计分

在胜负栏选择胜负,在得分栏输入每个成员的得分。也可以不选择胜负,直接在得分栏输入正/负数。 最后一个成员的得分无需填写,将会根据所有成员得分之和为 0 的规则自动计算。

长按确定按钮可以进行语音识别,将识别出的结果自动填写在得分栏中。语音模板为: 昵称/第 n 个+输/赢/加/减/正/胜/负+多少。

::: details 点击查看代码

startRecording() {
  if (this.isRecording) return;
  this.isRecording = true;
  this.recorderManager.start({
    duration: 60000, // 录音时长,单位为毫秒
    format: "mp3", // 录音格式
  });
  this.recognitionResult = "正在讲话…";
},
stopRecording() {
  if (!this.isRecording) return;
  this.isRecording = false;
  this.recorderManager.stop();
},
async handleRecordingStop(res) {
  let base64code = uni
    .getFileSystemManager()
    .readFileSync(res.tempFilePath, "base64");
  uni.showLoading();
  let rst = await uni.request({
    url: userStore.baseURL + "/translate/voice",
    method: "post",
    data: {
      data: base64code,
      customizationId: "xxxxxxxxxxxxxxxxxxxxx",
    },
    header: {
      "content-type": "application/x-www-form-urlencoded",
    },
  });
  uni.hideLoading();
  if (rst.data.Result) {
    this.recognitionResult = rst.data.Result;
    this.processingData();
  } else {
    this.recognitionResult = "";
    uni.showToast({
      title: "好像什么也没有听到~",
      icon: "none",
    });
  }
},
words2Number(words) { // 将句子转换成数字
  const one2ten = [
    "一",
    "二",
    "三",
    "四",
    "五",
    "六",
    "七",
    "八",
    "九",
    "十",
    "两",
  ];
  const wordArr = words.split("");
  let number = null;
  for (let i = 1; i < 12; i++) {
    if (wordArr.includes(one2ten[i - 1])) {
      number = i < 11 ? i : 2;
      break;
    }
  }
  return number;
},
processingData() {
  // 定义表示赢和输的意思的词及其对应的正负号
  const winKeywords = ["赢", "加", "正", "胜"];
  const loseKeywords = ["输", "减", "负"];
  const indexs = ["第一个", "第二个", "第三个", "第四个", "第五个"];

  // 初始化结果对象
  const result = {};

  // 按照逗号分割文本
  const phrases = this.recognitionResult.split(",");

  // 遍历每个短语
  phrases.forEach((phrase) => {
    let nickname = "";
    let score = 0;
    // 判断短语中是否包含赢和输的意思的词
    const winKeyword = winKeywords.find((keyword) =>
      phrase.includes(keyword)
    );
    if (winKeyword) {
      // 提取昵称和得分
      const data = phrase.split(winKeyword);
      if (data.length === 2) {
        nickname = data[0];
        indexs.forEach((x, i) => {
          if (nickname.includes(x))
            nickname = userStore.members[i].nickname;
        });
        const matchScore = data[1].match(/\d+/g);
        if (matchScore?.length) {
          score = 1 * matchScore[0];
        } else {
          score = this.words2Number(data[1]);
        }
      }
    } else {
      const loseKeyword = loseKeywords.find((keyword) =>
        phrase.includes(keyword)
      );
      if (loseKeyword) {
        // 提取昵称和得分
        const data = phrase.split(loseKeyword);
        if (data.length === 2) {
          nickname = data[0];
          indexs.forEach((x, i) => {
            if (nickname.includes(x))
              nickname = userStore.members[i].nickname;
          });
          const matchScore = data[1].match(/\d+/g);
          if (matchScore?.length) {
            score = -1 * matchScore[0];
          } else {
            score = -1 * this.words2Number(data[1]);
          }
        }
      }
    }
    // 添加到结果对象中
    if (nickname && score) result[nickname] = score;
  });
  if (Object.keys(result).length === 0) {
    uni.showToast({
      title: "没听清~",
      icon: "none",
    });
  } else {
    for (let key in result) {
      userStore.members.forEach((x, i) => {
        if (key.includes(x.nickname) || x.nickname.includes(key)) {
          this.scores[i] = Math.abs(result[key]);
          this.outcomes[i] = result[key] >= 0 ? "+" : "-";
        }
      });
    }
    this.autoWriteLast(); // 自动填写最后一个成员的得分
  }
}

:::

5.记录

点击“记录”菜单,可以查看自己所有历史对局,

点击对局可以查看该对局详情。

6.详情

点击头像可以显示昵称。

::: details 点击查看代码

async showUser(record, index) {
  let nickname = ''
  let openid = record.openids.split(',')[index]
  let findUser = this.viewd.find((x) => x.openid === openid)
  if (findUser) {
    nickname = findUser.nickname
  } else {
    let res = await uni.request({
      url: userStore.baseURL + `search-info`,
      method: 'get',
      data: { openid },
    })
    nickname = res.data[0].nickname
    this.viewd.push({ openid, nickname }) // 已经点过的不再调用接口
    console.log(this.viewd)
  }
  uni.showToast({
    title: nickname,
    icon: 'none',
  })
}

:::

7.评价

首先在 pages.json 里面引入「评价发布组件」。

"plugins": {
	"wxacommentplugin": {
		"version": "latest",
		"provider": "wx82e6ae1175f264fa"
	}
}

如果还未添加插件,则在开发者工具 Console 里点击「添加插件」。
然后就可以在页面的 js 文件里面调用组件接口。

var plugin = requirePlugin("wxacommentplugin");
plugin.openComment({
  success: (res) => {
    console.log("plugin.openComment success", res);
  },
  fail: (res) => {
    console.log("plugin.openComment fail", res);
  },
});

可在
微信小程序官网
的功能-体验评价中查看评价。

后端

1.建表

使用了两张表,一张用户表 mahjong,一张对局表 room。

vxmp-develop-6

id:用户id
openid:用户的 openid
avatar:用户的头像
nickname:用户的昵称
roomid:用户当前所在房间
updateTime: 用户加入房间时间

vxmp-develop-7

id:对局id
roomid:房间id
openids:该局对局的所有成员的openid
scores:该局对局的得分情况
circle:局数
createTime:该局对局结束时间
active:游戏是否结束

2.注册

① 前端通过 uni.login 这个请求获取 code,通过该 code(js_code) 结合小程序的 appid、secret 以及 grant_type=authorization_code,调用微信官方接口“
https://api.weixin.qq.com/sns/jscode2session”,返回用户的openid。

::: details 点击查看代码

public JSONObject getOpenid(String code) throws Exception {
    String appid = "xxx";
    String secret = "xxx";
    System.out.println("code=" + code);
    HttpClient httpClient = HttpClients.createDefault();
    URI url = new URIBuilder("https://api.weixin.qq.com/sns/jscode2session")
            .setParameter("appid", appid)
            .setParameter("secret", secret)
            .setParameter("js_code", code)
            .setParameter("grant_type", "authorization_code")
            .build();
    HttpGet httpGet = new HttpGet(url);
    JSONObject json = new JSONObject();
    try {
        HttpResponse res = httpClient.execute(httpGet);
        if (res.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
            String result = EntityUtils.toString(res.getEntity());// 返回json格式:
            json = json.parseObject(result);
        } else {
            throw new Exception("获取openid失败!");
        }
    } catch (Exception e) {
        throw new Exception("获取openid异常!");
    }
    return json;
}

:::

② 在用户填完头像和昵称后,前端发送请求,后端将接收的头像存储至 minio 中并返回头像路径,然后结合 openid、昵称、房间 id 等信息存储到 mahjong 表中。

3.生成当前房间小程序码

先通过 appid、secre、grant_type=client_credential 调用微信官方接口获取 token,然后使用此 token 结合前端传递的当前房间 id,生成当前房间小程序码。

::: details 点击查看代码

public byte[] getCode(String roomid) throws Exception {
    String appid = "xxx";
    String secret = "xxx";
    HttpClient httpClient = HttpClients.createDefault();
    URI tokenURI = new URIBuilder("https://api.weixin.qq.com/cgi-bin/token")
            .setParameter("appid", appid)
            .setParameter("secret", secret)
            .setParameter("grant_type", "client_credential")
            .build();
    HttpGet httpGet = new HttpGet(tokenURI);
    JSONObject json = new JSONObject();
    try {
        HttpResponse res = httpClient.execute(httpGet);
        if (res.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
            json = json.parseObject(EntityUtils.toString(res.getEntity()));
            URI codeURI = new URIBuilder("https://api.weixin.qq.com/wxa/getwxacode")
                    .setParameter("access_token", json.getString("access_token"))
                    .build();
            HttpPost httpPost = new HttpPost(codeURI);
            String body = "{\"path\": \"pages/home/index?roomid=" + roomid + "\"}";
            httpPost.setEntity(new StringEntity(body));
            try {
                HttpResponse rst = httpClient.execute(httpPost);
                if (rst.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                    return EntityUtils.toByteArray(rst.getEntity());
                } else {
                    throw new Exception("获取小程序码失败!");
                }
            } catch (Exception e) {
                throw new Exception("获取小程序码异常!");
            }
        } else {
            throw new Exception("获取token失败!");
        }
    } catch (Exception e) {
        throw new Exception("获取token异常!");
    }
}

:::

4.语音识别

使用的是腾讯云的一句话识别,每个月免费 5000 次。前端将录音临时文件转换成 base64 编码传递至后端,结合使用场景 EngSerViceType、语音数据来源 SourceType(0:语音 URL;1:语音数据)、语音 Url、音频格式 VoiceFormat 以及时间戳、热词、自学习模型。

::: details 点击查看代码

public JSONObject voiceTrans(@RequestParam(required = false) String engSerViceType, @RequestParam(required = false) Long sourceType,
                              @RequestParam(required = false) String url, @RequestParam(required = false) String voiceFormat,
                              @RequestParam(required = false) String data, @RequestParam(required = false) String customizationId ) {
    if (engSerViceType == null) engSerViceType = "16k_zh";
    if (sourceType == null) {
        if (url == null) sourceType = 1L;
        else sourceType = 0L;
    }
    if (voiceFormat == null) voiceFormat = "mp3";
    JSONObject json = new JSONObject();
    String result = VoiceUtils.voiceTrans(engSerViceType, sourceType, url, voiceFormat, data, customizationId);
    json = json.parseObject(result);
    return json;
}

public static String voiceTrans(String EngSerViceType, Long SourceType, String Url, String VoiceFormat, String Data, String CustomizationId ) {
    try {
        // 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
        Credential cred = new Credential("xxx", "xxx");
        // 实例化一个http选项,可选的,没有特殊需求可以跳过
        HttpProfile httpProfile = new HttpProfile();
        httpProfile.setEndpoint("asr.ap-beijing.tencentcloudapi.com");
        // 实例化一个client选项,可选的,没有特殊需求可以跳过
        ClientProfile clientProfile = new ClientProfile();
        clientProfile.setHttpProfile(httpProfile);
        // 实例化要请求产品的client对象,clientProfile是可选的
        AsrClient client = new AsrClient(cred, "", clientProfile);
        // 实例化一个请求对象,每个接口都会对应一个request对象
        SentenceRecognitionRequest req = new SentenceRecognitionRequest();
        req.setEngSerViceType(EngSerViceType);
        req.setSourceType(SourceType);
        req.setUrl(Url);
        req.setVoiceFormat(VoiceFormat);
        req.setData(Data);
        req.setWordInfo(1L);
        req.setReinforceHotword(1L);
        req.setCustomizationId(CustomizationId);
        // 返回的resp是一个SentenceRecognitionResponse的实例,与请求对象对应
        SentenceRecognitionResponse resp = client.SentenceRecognition(req);
        // 输出json格式的字符串回包
        return SentenceRecognitionResponse.toJsonString(resp);
    } catch (TencentCloudSDKException e) {
        return e.toString();
    }
}

:::

5.添加记录

当一局结束后,将房间 id,所有成员 openid,分数,局数存储至 room 表,并将 active 设置为 1。
先查询 mahjong 表中是否存在此房间 id,如果不存在,说明对局结束,房间已经解散。然后查询 room 表是否存在此房间 id 且 active 等于 1 的对局:① 如果不存在且 circle 等于 1,再判断该房间成员数量和提交的成员数量,如果前者大于后者,说明有新成员加入,此次提交不生效。② 如果不存在且 circle 不等于 1,说明已经不是第一局,房间已经锁定,其他人无法加入,所以不需要判断,提交有效。③ 如果存在且该房间最后一条对局记录的局数小于提交的局数,提交有效。④ 如果存在且该房间最后一条对局记录的局数大于或等于提交的局数,说明已经有人在你提交前提交过了,提交无效。

::: details 点击查看代码

public String addRecord(Room record) {
    String openids = record.getOpenids();
    Integer circle = record.getCircle();
    List<Room> records = mahjongMapper.searchRecords(openids);
    List<Mahjong> mahjong = mahjongMapper.searchInfo("", record.getRoomid());
    if (mahjong.isEmpty()) {
        return "对局已结束";
    } else {
        if (records.isEmpty()) {
            if (circle == 1) {
                if (mahjong.size() > record.getOpenids().split(",").length) {
                    return "有新成员加入";
                }
            }
            mahjongMapper.addRecord(record);
            return "true";
        } else {
            if (records.get(records.size() - 1).getCircle() < circle) {
                mahjongMapper.addRecord(record);
                return "true";
            } else {
                return "其他成员已提交";
            }
        }
    }

}

:::

6.结束游戏

前端将此次游戏所有成员 openid、所有对局的 id、总分、以及房间 id 传递给后端,后端所做的工作依次为:
① 通过 mahjongMapper.gameOver(openids),遍历 openids,将 mahjong 表中 openid 与之相等的用户房间 id 清空。
② 通过 mahjongMapper.setActive(ids),遍历所有对局 id,将 room 表中 id 与之相等的对局的 active 设置为 0。
③ 通过 mahjongMapper.addRecord(record),将总分作为一条对局记录添加至 room 表中,其中 circle 等于-1,active 等于 0。

::: details 点击查看代码

public void gameOver(String[] openids, String[] ids, String scores, String roomid) {
    mahjongMapper.gameOver(openids);
    mahjongMapper.setActive(ids);
    SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    simpleDateFormat.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
    String createTime = simpleDateFormat.format(new Date());
    Room record = new Room(null, String.join(",", ids), String.join(",", openids), scores, -1, createTime, 0);
    mahjongMapper.addRecord(record);
}

:::

7.查询历史记录

这个接口逻辑写的有点草率,后续可以优化。

先通过个人 openid 查询 room 表与之相关且 circle 等于-1 的历史记录,
然后查询 mahjong 表中所有用户信息,遍历历史记录,遍历用户信息,
将历史记录的 openid 等于用户信息的 openid 的用户头像提取出来,与历史记录一并返回。

::: details 点击查看代码

public List searchHistory(String openid) {
    List<Room> records = mahjongMapper.searchHistory(openid);

    List<Mahjong> mahjongs = mahjongMapper.searchAllInfo();

    for (Room record : records) {
        String[] openids = record.getOpenids().split(",");
        String avatars = "";
        String avatar = "";

        for (String id : openids) {
            avatar = "";
            for (Mahjong mahjong: mahjongs)
            {
                if(mahjong.getOpenid().equals(id)){
                    avatar = mahjong.getAvatar();
                    break;
                }
            }
            avatars += avatar + ',';
        }
        record.setAvatars(avatars.substring(0, avatars.length() - 1));
    }
    return records;
}

:::

最后

感兴趣的朋友可以私聊我获取详细代码。