2024年4月

前言

写过移动端的同学或多或少都遇到过软键盘带来的各种各样的问题,最典型的就是输入框被软键盘遮挡、fixed元素失效等问题,并且这些问题在iOS上的表现让人难以接受。

webview的差异

在移动端上,我们的H5页面一般是运行在宿主APP提供的
webview
中,简单点理解,你其实可以把它当作浏览器,就是用来展现页面内容的。目前移动端主流系统分为
Android

iOS
,然而两者提供的
webview
容器也存在着诸多差异,今天我们就只探讨两者软键盘带来的影响。

首先,我们先来写个简单的页面布局:头部fixed+中间自适应+底部fixed

k2.png

Android

事实上Android的表现并不会有太大问题,它只不过是在键盘弹起来之后把
webview
的高度减小了,变成了:
原来的webview高度减去键盘的高度

k2.png

这样的表现正是我们期待的,完全没有影响整个页面的布局

iOS

软键盘

在iOS 8.2 之后,iOS 唯一指定浏览器内核、Webkit 鼻祖 Safari 将
fixed
元素的布局基准区域从键盘上方的可见区域改成了键盘背后的整个视窗,也就是说此时的webview高度并不会发生变化,键盘是直接盖在webview上方的。

这样是为了在键盘弹起来之后,不用重新渲染页面,他们是方便了,但遭殃的是我们前端开发人员...

比如上面这个页面,我们看看iOS的表现是怎样的:

k3.png

可以看到,iOS为了不让
webview
压缩,并且为了不让软键盘遮挡输入框,他们自作聪明地把
webview
整体往上移动,最大移动距离为软键盘的高度。

这样就导致我们的头部以及页面上半部分内容移动到了可视区之外,这个表现是难以接受的,至少头部应该还要在可视区。(这就会让我们误以为fixed失效,实际上它相对于webview的位置并没有变,只不过是webview发生了移动)

这个移动似乎没有逻辑,不信大家可以试试把输入框放到页面的各个位置,我发现只有输入框在最顶部,
webview
才不会发生上移,其它位置都或多或少的会产生移动。

还有一个问题就是,此时的webview是可以滑动的,那么就会出现有用户会将输入框滑动到键盘下方,想想这个体验也是难以接受的...

k3-1.gif

并且你会发现,在页面的上方与下方都多出了一个不论是
Viewport
还是
VisualViewport
都无法到达的白色衬底区域,我们可以尝试把页面所有元素背景都改成黑色再来看,会更加明显

k3-2.gif

看到这些奇奇怪怪的问题你心里作何感想??

所有问题产生的根本原因是:
iOS为了不用在键盘弹起之后重新渲染页面,他们并没有去压缩
webview
容器的高度,而是对webview整体进行平移处理

软键盘监听

对于Android,我们通常可以通过监听
resize
事件来实现,但对于iOS,我们从上面了解到键盘弹起,iOS的
webview
高度并不会发生变化,所以也就触发不了
resize
事件。

在iOS中,可以通过
focusin & focusout
事件来进行监听

export const watchKeyBoard = (callback: (isShow: boolean) => void) => {
  //  IOS
  if (isIOSByUA()) {
    document.body.addEventListener('focusin', () => {
      //软键盘弹出的事件处理
      callback(true)
    })
    document.body.addEventListener('focusout', () => {
      //软键盘收起的事件处理
      callback(false)
    })
  } else {
    //  Android
    const originalHeight =
      document.documentElement.clientHeight || document.body.clientHeight
    window.addEventListener('resize', () => {
      const resizeHeight =
        document.documentElement.clientHeight || document.body.clientHeight
      if (resizeHeight - 0 < originalHeight - 0) {
        // 键盘弹起事件
        callback(true)
      } else {
        // 键盘收起事件
        callback(false)
      }
    })
  }
}

解决方案

了解完产生问题的原因,我们就可以来尝试着解决问题,但想要纯前端去解决这个问题,或多或少都会存在一些体验问题,也许你可以去推动你们的客户端同学来协助处理这个问题,只要让iOS的webview在键盘弹起时的表现与Android一致,就不会存在这些奇怪的问题了,但似乎他们处理起来也非常棘手...

模仿Android的处理

虽然我们改不了webview的高度,但我们可以改我们布局的高度,我们只需要将页面高度改为页面可视区的高度即可,如果页面内容有滚动交互的话,需要额外处理,要与webview的滚动隔离开。

VisualViewport

先来了解下这个API,它可以用来获取对应 window 的视觉视口

  • VisualViewport.offsetLeft
    :返回视觉视口的左边框到布局视口的左边框的 CSS 像素距离。
  • VisualViewport.offsetTop
    :返回视觉视口的上边框到布局视口的上边框的 CSS 像素距离。
  • VisualViewport.pageLeft
    :返回相对于初始的 viewport 属性的 X 轴坐标所对应的 CSS 像素数。
  • VisualViewport.pageTop
    :返回相对于初始的 viewport 属性的 Y 轴坐标所对应的 CSS 像素数。
  • VisualViewport.width
    :返回视觉视口的宽度所对应的 CSS 像素数。
  • VisualViewport.height
    :返回视觉视口的高度所对应的 CSS 像素数。
  • VisualViewport.scale
    :返回当前视觉视口所应用的缩放比例。

这里我们需要的就是这个
VisualViewport.height
,用来获取可视区的高度。

但需要注意的是,这个API最低只支持iOS13
,ios13以下的使用
window.innerHeight
兜底

页面布局

整体布局采用flex布局,头部和底部也就不需要fixed来定位了,中间自适应撑满剩余高度,超长滚动

k4.png

键盘打开计算高度重新布局

我们需要在键盘弹起后,计算可视区的高度,并将最外层容器高度赋值为可视区高度

watchKeyBoard((status) => {
  setTimeout(() => {
    console.log(
      'status',
      status ? '键盘打开' : '键盘关闭',
    )
    const container = document.getElementById('container')
    if (status) {
      container.style.height = `${
      window.visualViewport.height || window.innerHeight
    }px`
      window.scrollTo(0, 0)
    } else {
      container.style.height = `100vh`
      document.removeEventListener('touchmove', this.stopMove)
    }
  }, 100)
})

这样页面展示算是正常了

k5.png


但是随之而来的是滚动问题

前面我们简单的实现了一个从swagger生成实体类的Generator,在实际使用中,通过nuget包引用使用会更方便,那么本篇文章将介绍如何将Generator打包成Nuget来使用。

打包Nuget

这里我们将GenerateClassFromSwagger.Analysis打包成Nuget进行使用。
首先需要修改项目文件。

修改项目文件

在PropertyGroup添加
true
表示在编译时生成nuget包以及
false
表示不要将生成器作为库依赖项包括在内。
以及添加ItemGroup 包含
将生成器打包到nuget包的分析器目录中
如果没有第三方依赖的时候,仅这些配置已经足够了。如果需要依赖第三方组件。
这里我们依赖了Newtonsoft.Json的包,则需要添加 来指定和Generator的依赖关系。
完整修改后的项目配置如下:

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<TargetFramework>netstandard2.0</TargetFramework>
		<LangVersion>latest</LangVersion>
		<Version>1.0.2</Version>
		<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
		<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
		<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
		<GeneratePackageOnBuild>true</GeneratePackageOnBuild><!-- Generates a package at build -->
		<IncludeBuildOutput>false</IncludeBuildOutput><!-- Do not include the generator as a lib dependency -->
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Newtonsoft.Json" Version="13.0.3" PrivateAssets="all" GeneratePathProperty="true" />
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.2.0" PrivateAssets="all" />
		<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
	</ItemGroup>

	<Target Name="GetDependencyTargetPaths" AfterTargets="ResolvePackageDependenciesForBuild">
		<ItemGroup>
			<TargetPathWithTargetPlatformMoniker Include="@(ResolvedCompileFileDefinitions)" IncludeRuntimeDependency="false" />
		</ItemGroup>
	</Target>

	<ItemGroup>
		<!-- Package the generator in the analyzer directory of the nuget package -->
		<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />

		<!-- Package the Newtonsoft.Json dependency alongside the generator assembly -->
		<None Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
	</ItemGroup>
</Project>

编译项目

修改项目文件后,编译项目,可以在Debug目录下看到nupkg文件。
image.png
将文件上传到nuget.org,等待上架成功后可以在NUGET包管理器搜索并安装。

安装Nuget

首先创建一个GenerateClassFromSwaggerNuget的控制台项目,然后添加Nuget依赖。搜索GenerateClassFromSwagger.Analysis即可找到。
image.png
将我们的swagger.json复制一份过来并设置成AdditionalFiles。
image.png
修改包属性的OutputItemType和ReferenceOutputAssembly
完整配置如下:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <AdditionalFiles Include="Files\swagger.json" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="GenerateClassFromSwagger.Analysis" Version="1.0.2" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
  </ItemGroup>

</Project>

编译控制台项目

接下来编译GenerateClassFromSwaggerNuget项目,编译完成后。可以在分析器看到我们生成的文件
image.png
image.png
跟我们直接引用项目依赖的效果一致。

输出文件

同样,如果我们需要输出文件,在项目文件中添加EmitCompilerGeneratedFiles标签。

<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>

然后再次编译。可以在obj目录中找到生成的文件列表。
image.png
如果需要输出到指定目录,则跟上篇文章的方式一致,这里就不赘述了。

结语

本片文章介绍了将Generator打包成Nuget进行使用,可以更方便的在不同项目中使用。

本文代码仓库地址https://github.com/fanslead/Learn-SourceGenerator

作者:vivo 互联网服务器团队-Wang Fei

Redis是一种基于客户端-服务端模型以及请求/响应的TCP服务。在遇到批处理命令执行时,Redis提供了Pipelining(管道)来提升批处理性能。本文结合实践分析了Spring Boot框架下Redis的Lettuce客户端和Redisson客户端对Pipeline特性的支持原理,并针对实践过程中遇到的问题进行了分析,可以帮助开发者了解不同客户端对Pipeline支持原理及避免实际使用中出现问题。

一、前言

Redis 已经提供了像 mget 、mset 这种批量的命令,但是某些操作根本就不支持或没有批量的操作,从而与 Redis 高性能背道而驰。为此, Redis基于管道机制,提供Redis Pipeline新特性。Redis Pipeline是一种通过一次性发送多条命令并在执行完后一次性将结果返回,从而减少客户端与redis的通信次数来实现降低往返延时时间提升操作性能的技术。目前,Redis Pipeline是被很多个版本的Redis 客户端所支持的。

二、Pipeline 底层原理分析

2.1 Redis单个命令执行基本步骤

Redis是一种基于客户端-服务端模型以及请求/响应的TCP服务。一次Redis客户端发起的请求,经过服务端的响应后,大致会经历如下的步骤:

  1. 客户端发起一个(查询/插入)请求,并监听socket返回,通常情况都是阻塞模式等待Redis服务器的响应。

  2. 服务端处理命令,并且返回处理结果给客户端。

  3. 客户端接收到服务的返回结果,程序从阻塞代码处返回。

2.2 RTT 时间

Redis客户端和服务端之间通过网络连接进行数据传输,数据包从客户端到达服务器,并从服务器返回数据回复客户端的时间被称之为RTT(Round Trip Time - 往返时间)。我们可以很容易就意识到,Redis在连续请求服务端时,如果RTT时间为250ms, 即使Redis每秒能处理100k请求,但也会因为网络传输花费大量时间,导致每秒最多也只能处理4个请求,导致整体性能的下降。

2.3 Redis Pipeline

为了提升效率,这时候Pipeline出现了。Pipelining不仅仅能够降低RRT,实际上它极大的提升了单次执行的操作数。这是因为如果不使用Pipelining,那么每次执行单个命令,从访问数据的结构和服务端产生应答的角度,它的成本是很低的。但是从执行网络IO的角度,它的成本其实是很高的。其中涉及到read()和write()的系统调用,这意味着需要从用户态切换到内核态,而这个上下文的切换成本是巨大的。

当使用Pipeline时,它允许多个命令的读通过一次read()操作,多个命令的应答使用一次write()操作,它允许客户端可以一次发送多条命令,而不等待上一条命令执行的结果。
不仅减少了RTT,同时也减少了IO调用次数(IO调用涉及到用户态到内核态之间的切换),最终提升程序的执行效率与性能。
如下图:

要支持Pipeline,其实既要服务端的支持,也要客户端支持。对于服务端来说,所需要的是能够处理一个客户端通过同一个TCP连接发来的多个命令,可以理解为,这里将多个命令切分,和处理单个命令一样,Redis就是这样处理的。而客户端,则是要将多个命令缓存起来,缓冲区满了就发送,然后再写缓冲,最后才处理Redis的应答。

三、Pipeline 基本使用及性能比较

下面我们以给10w个set结构分别插入一个整数值为例,分别使用jedis单个命令插入、jedis使用Pipeline模式进行插入和redisson使用Pipeline模式进行插入以及测试其耗时。

@Slf4j
public class RedisPipelineTestDemo {
    public static void main(String[] args) {
        //连接redis
        Jedis jedis = new Jedis("10.101.17.180", 6379);
 
        //jedis逐一给每个set新增一个value
        String zSetKey = "Pipeline-test-set";
        int size = 100000;
 
        long begin = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            jedis.sadd(zSetKey + i, "aaa");
        }
        log.info("Jedis逐一给每个set新增一个value耗时:{}ms", (System.currentTimeMillis() - begin));
 
        //Jedis使用Pipeline模式         Pipeline Pipeline = jedis.Pipelined();
        begin = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {             Pipeline.sadd(zSetKey + i, "bbb");
        }         Pipeline.sync();
        log.info("Jedis Pipeline模式耗时:{}ms", (System.currentTimeMillis() - begin));
 
        //Redisson使用Pipeline模式
        Config config = new Config();
        config.useSingleServer().setAddress("redis://10.101.17.180:6379");
        RedissonClient redisson = Redisson.create(config);
        RBatch redisBatch = redisson.createBatch();
 
        begin = System.currentTimeMillis();
        for (int i = 0; i < size; i++) {
            redisBatch.getSet(zSetKey + i).addAsync("ccc");
        }
        redisBatch.execute();
        log.info("Redisson Pipeline模式耗时:{}ms", (System.currentTimeMillis() - begin));
 
        //关闭         Pipeline.close();
        jedis.close();
        redisson.shutdown();
    }
}

测试结果如下:

Jedis逐一给每个set新增一个value耗时:162655ms

Jedis Pipeline模式耗时:504ms

Redisson Pipeline模式耗时:1399ms

我们发现使用Pipeline模式对应的性能会明显好于单个命令执行的情况。

四、项目中实际应用

在实际使用过程中有这样一个场景,很多应用在节假日的时候需要更新应用图标样式,在运营进行后台配置的时候, 可以根据圈选的用户标签预先计算出单个用户需要下发的图标样式并存储在Redis里面,从而提升性能,这里就涉及Redis的批量操作问题,业务流程如下:

为了提升Redis操作性能,我们决定使用Redis Pipelining机制进行批量执行。

4.1 Redis 客户端对比

针对Java技术栈而言,目前Redis使用较多的客户端为Jedis、Lettuce和Redisson。

目前项目主要是基于SpringBoot开发,针对Redis,其默认的客户端为Lettuce,所以我们基于Lettuce客户端进行分析。

4.2 Spring环境下Lettuce客户端对Pipeline的实现

在Spring环境下,使用Redis的Pipeline也是很简单的。spring-data-redis提供了StringRedisTemplate简化了对Redis的操作,  只需要调用StringRedisTemplate的executePipelined方法就可以了,但是在参数中提供了两种回调方式:
SessionCallback和RedisCallback

两种使用方式如下(这里以操作set结构为例):

  • RedisCallback的使用方式:

public void testRedisCallback() {
        List<Integer> ids= Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
        Integer contentId = 1;
        redisTemplate.executePipelined(new InsertPipelineExecutionA(ids, contentId));
    }
  
@AllArgsConstructor
    private static class InsertPipelineExecutionA implements RedisCallback<Void> {
  
        private final List<Integer> ids;
        private final Integer contentId;
  
        @Override
        public Void doInRedis(RedisConnection connection) DataAccessException {
            RedisSetCommands redisSetCommands = connection.setCommands();
  
            ids.forEach(id-> {
                String redisKey = "aaa:" + id;
                String value = String.valueOf(contentId);
                redisSetCommands.sAdd(redisKey.getBytes(), value.getBytes());
            });
            return null;
        }
    }
  • SessionCallback的使用方式:
public void testSessionCallback() {
        List<Integer> ids= Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
        Integer contentId = 1;
        redisTemplate.executePipelined(new InsertPipelineExecutionB(ids, contentId));
    }
  
@AllArgsConstructor
    private static class InsertPipelineExecutionB implements SessionCallback<Void> {
  
        private final List<Integer> ids;
        private final Integer contentId;
  
        @Override
        public <K, V> Void execute(RedisOperations<K, V> operations) throws DataAccessException {
            SetOperations<String, String> setOperations = (SetOperations<String, String>) operations.opsForSet();
            ids.forEach(id-> {
                String redisKey = "aaa:" + id;
                String value = String.valueOf(contentId);
                setOperations.add(redisKey, value);
            });
            return null;
        }
    }

4.3 RedisCallBack和SessionCallback之间的比较

1、RedisCallBack和SessionCallback都可以实现回调,通过它们可以在同一条连接中一次执行多个redis命令。

2、RedisCallback使用的是原生RedisConnection,用起来比较麻烦,比如上面执行set的add操作,key和value需要进行转换,可读性差,但原生api提供的功能比较齐全。

3、SessionCalback提供了良好的封装,可以优先选择使用这种回调方式。

最终的代码实现如下:

public void executeB(List<Integer> userIds, Integer iconId) {
        redisTemplate.executePipelined(new InsertPipelineExecution(userIds, iconId));
}
 
 
@AllArgsConstructor
private static class InsertPipelineExecution implements SessionCallback<Void> {
 
     private final List<Integer> userIds;
     private final Integer iconId;
 
     @Override
     public <K, V> Void execute(RedisOperations<K, V> operations) throws DataAccessException {
         SetOperations<String, String> setOperations = (SetOperations<String, String>) operations.opsForSet();
         userIds.forEach(userId -> {
             String redisKey = "aaa:" + userId;
             String value = String.valueOf(iconId);
             setOperations.add(redisKey, value);
         });
         return null;
     }
}

4.4 源码分析

那么为什么使用Pipeline方式会对性能有较大提升呢,我们现在从源码入手着重分析一下:

4.4.1 Pipeline方式下获取连接相关原理分析:

@Override
    public List<Object> executePipelined(SessionCallback<?> session, @Nullable RedisSerializer<?> resultSerializer) {
 
        Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it");
        Assert.notNull(session, "Callback object must not be null");
 
        //1. 获取对应的Redis连接工厂
        RedisConnectionFactory factory = getRequiredConnectionFactory();
        //2. 绑定连接过程
        RedisConnectionUtils.bindConnection(factory, enableTransactionSupport);
        try {
            //3. 执行命令流程, 这里请求参数为RedisCallback, 里面有对应的回调操作
           return execute((RedisCallback<List<Object>>) connection -> {
                //具体的回调逻辑
                connection.openPipeline();
                boolean PipelinedClosed = false;
                try {
                    //执行命令
                    Object result = executeSession(session);
                    if (result != null) {
                        throw new InvalidDataAccessApiUsageException(
                                "Callback cannot return a non-null value as it gets overwritten by the Pipeline");
                    }
                    List<Object> closePipeline = connection.closePipeline();      PipelinedClosed = true;
                    return deserializeMixedResults(closePipeline, resultSerializer, hashKeySerializer, hashValueSerializer);
                } finally {
                    if (!PipelinedClosed) {
                        connection.closePipeline();
                    }
                }
            });
        } finally {
            RedisConnectionUtils.unbindConnection(factory);
        }
    }

① 获取对应的Redis连接工厂,这里要使用Pipeline特性需要使用LettuceConnectionFactory方式,这里获取的连接工厂就是LettuceConnectionFactory。

② 绑定连接过程,具体指的是将当前连接绑定到当前线程上面, 核心方法为:doGetConnection。

public static RedisConnection doGetConnection(RedisConnectionFactory factory, boolean allowCreate, boolean bind,
            boolean enableTransactionSupport) {
 
        Assert.notNull(factory, "No RedisConnectionFactory specified");
 
        //核心类,有缓存作用,下次可以从这里获取已经存在的连接
        RedisConnectionHolder connHolder = (RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);
 
        //如果connHolder不为null, 则获取已经存在的连接, 提升性能
        if (connHolder != null) {
            if (enableTransactionSupport) {
                potentiallyRegisterTransactionSynchronisation(connHolder, factory);
            }
            return connHolder.getConnection();
        }
 
        ......
 
        //第一次获取连接,需要从Redis连接工厂获取连接
        RedisConnection conn = factory.getConnection();
 
        //bind = true 执行绑定
        if (bind) {
 
            RedisConnection connectionToBind = conn;
            ......
            connHolder = new RedisConnectionHolder(connectionToBind);
 
            //绑定核心代码: 将获取的连接和当前线程绑定起来
            TransactionSynchronizationManager.bindResource(factory, connHolder);
            ......
 
            return connHolder.getConnection();
        }
 
        return conn;
    }

里面有个核心类RedisConnectionHolder,我们看一下RedisConnectionHolder connHolder =

(RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);

@Nullable
    public static Object getResource(Object key) {
        Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
        Object value = doGetResource(actualKey);
        if (value != null && logger.isTraceEnabled()) {
            logger.trace("Retrieved value [" + value + "] for key [" + actualKey + "] bound to thread [" +
                    Thread.currentThread().getName() + "]");
        }
        return value;
    }

里面有一个核心方法doGetResource(actualKey),大家很容易猜测这里涉及到一个map结构,如果我们看源码,也确实是这样一个结构。

@Nullable
    private static Object doGetResource(Object actualKey) {
        Map<Object, Object> map = resources.get();
        if (map == null) {
            return null;
        }
        Object value = map.get(actualKey);
        // Transparently remove ResourceHolder that was marked as void...
        if (value instanceof ResourceHolder && ((ResourceHolder) value).isVoid()) {
            map.remove(actualKey);
            // Remove entire ThreadLocal if empty...
            if (map.isEmpty()) {
                resources.remove();
            }
            value = null;
        }
        return value;
    }

resources是一个ThreadLocal类型,这里会涉及到根据RedisConnectionFactory获取到连接connection的逻辑,如果下一次是同一个actualKey,那么就直接使用已经存在的连接,而不需要新建一个连接。第一次这里map为null,就直接返回了,然后回到doGetConnection方法,由于这里bind为true,我们会执行TransactionSynchronizationManager.bindResource(factory, connHolder);,也就是将连接和当前线程绑定了起来。

public static void bindResource(Object key, Object value) throws IllegalStateException {
        Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
        Assert.notNull(value, "Value must not be null");
        Map<Object, Object> map = resources.get();
        // set ThreadLocal Map if none found
        if (map == null) {
            map = new HashMap<>();
            resources.set(map);
        }
        Object oldValue = map.put(actualKey, value);
        ......
    }

③ 我们回到executePipelined,在获取到连接工厂,将连接和当前线程绑定起来以后,就开始需要正式去执行命令了, 这里会调用execute方法

@Override
@Nullable
public <T> T execute(RedisCallback<T> action) {
    return execute(action, isExposeConnection());
}

这里我们注意到execute方法的入参为RedisCallback<T>action,RedisCallback对应的doInRedis操作如下,这里在后面的调用过程中会涉及到回调。

connection.openPipeline();
boolean PipelinedClosed = false;
try {
    Object result = executeSession(session);
    if (result != null) {
        throw new InvalidDataAccessApiUsageException(
                "Callback cannot return a non-null value as it gets overwritten by the Pipeline");
    }
    List<Object> closePipeline = connection.closePipeline();  PipelinedClosed = true;
    return deserializeMixedResults(closePipeline, resultSerializer, hashKeySerializer, hashValueSerializer);
} finally {
    if (!PipelinedClosed) {
        connection.closePipeline();
    }
}

我们再来看execute(action, isExposeConnection())方法,这里最终会调用<T>execute(RedisCallback<T>action, boolean exposeConnection, boolean Pipeline)方法。

@Nullable
public <T> T execute(RedisCallback<T> action, boolean exposeConnection, boolean Pipeline) {
 
    Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it");
    Assert.notNull(action, "Callback object must not be null");
 
    //获取对应的连接工厂
    RedisConnectionFactory factory = getRequiredConnectionFactory();
    RedisConnection conn = null;
    try {
        if (enableTransactionSupport) {
            // only bind resources in case of potential transaction synchronization
            conn = RedisConnectionUtils.bindConnection(factory, enableTransactionSupport);
        } else {
            //获取对应的连接(enableTransactionSupport=false)   
            conn = RedisConnectionUtils.getConnection(factory);
        }
 
        boolean existingConnection = TransactionSynchronizationManager.hasResource(factory);
 
        RedisConnection connToUse = preProcessConnection(conn, existingConnection);
 
        boolean PipelineStatus = connToUse.isPipelined();
        if (Pipeline && !PipelineStatus) {
            connToUse.openPipeline();
        }
 
        RedisConnection connToExpose = (exposeConnection ? connToUse : createRedisConnectionProxy(connToUse));
         
        //核心方法,这里就开始执行回调操作
        T result = action.doInRedis(connToExpose);
 
        // close Pipeline
        if (Pipeline && !PipelineStatus) {
            connToUse.closePipeline();
        }
 
        // TODO: any other connection processing?
        return postProcessResult(result, connToUse, existingConnection);
    } finally {
        RedisConnectionUtils.releaseConnection(conn, factory, enableTransactionSupport);
    }
}

我们看到这里最开始也是获取对应的连接工厂,然后获取对应的连接(enableTransactionSupport=false),具体调用是RedisConnectionUtils.getConnection(factory)方法,最终会调用RedisConnection doGetConnection(RedisConnectionFactory factory, booleanallowCreate, boolean bind, boolean enableTransactionSupport),此时bind为false

public static RedisConnection doGetConnection(RedisConnectionFactory factory, boolean allowCreate, boolean bind,
        boolean enableTransactionSupport) {
 
    Assert.notNull(factory, "No RedisConnectionFactory specified");
 
    //直接获取与当前线程绑定的Redis连接
    RedisConnectionHolder connHolder = (RedisConnectionHolder) TransactionSynchronizationManager.getResource(factory);
 
    if (connHolder != null) {
        if (enableTransactionSupport) {
            potentiallyRegisterTransactionSynchronisation(connHolder, factory);
        }
        return connHolder.getConnection();
    }
 
    ......
 
    return conn;
}

前面我们分析过一次,这里调用RedisConnectionHolder connHolder = (RedisConnectionHolder)TransactionSynchronizationManager.getResource(factory);会获取到之前和当前线程绑定的Redis,而不会新创建一个连接。

然后会去执行T result = action.doInRedis(connToExpose),这里的action为RedisCallback,执行doInRedis为:

//开启Pipeline功能
connection.openPipeline();
boolean PipelinedClosed = false;
try {
    //执行Redis命令
    Object result = executeSession(session);
    if (result != null) {
        throw new InvalidDataAccessApiUsageException(
                "Callback cannot return a non-null value as it gets overwritten by the Pipeline");
    }
    List<Object> closePipeline = connection.closePipeline();  PipelinedClosed = true;
    return deserializeMixedResults(closePipeline, resultSerializer, hashKeySerializer, hashValueSerializer);
} finally {
    if (!PipelinedClosed) {
        connection.closePipeline();
    }
}

这里最开始会开启Pipeline功能,然后执行Object result = executeSession(session);

private Object executeSession(SessionCallback<?> session) {
    return session.execute(this);
}

这里会调用我们自定义的execute方法

@AllArgsConstructor
private static class InsertPipelineExecution implements SessionCallback<Void> {
 
     private final List<Integer> userIds;
     private final Integer iconId;
 
     @Override
     public <K, V> Void execute(RedisOperations<K, V> operations) throws DataAccessException {
         SetOperations<String, String> setOperations = (SetOperations<String, String>) operations.opsForSet();
         userIds.forEach(userId -> {
             String redisKey = "aaa:" + userId;
             String value = String.valueOf(iconId);
             setOperations.add(redisKey, value);
         });
         return null;
     }
}

进入到foreach循环,执行DefaultSetOperations的add方法。

@Override
public Long add(K key, V... values) {
 
    byte[] rawKey = rawKey(key);
    byte[][] rawValues = rawValues((Object[]) values);
    //这里的connection.sAdd是后续回调要执行的方法
   return execute(connection -> connection.sAdd(rawKey, rawValues), true);
}

这里会继续执行redisTemplate的execute方法,里面最终会调用我们之前分析过的<T>T execute(RedisCallback<T>action, boolean exposeConnection, boolean Pipeline)方法。

@Nullable
public <T> T execute(RedisCallback<T> action, boolean exposeConnection, boolean Pipeline) {
 
    Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it");
    Assert.notNull(action, "Callback object must not be null");
 
    RedisConnectionFactory factory = getRequiredConnectionFactory();
    RedisConnection conn = null;
    try {
 
        ......
        //再次执行回调方法,这里执行的Redis基本数据结构对应的操作命令
        T result = action.doInRedis(connToExpose);
         
        ......
 
        // TODO: any other connection processing?
        return postProcessResult(result, connToUse, existingConnection);
    } finally {
        RedisConnectionUtils.releaseConnection(conn, factory, enableTransactionSupport);
    }
}

这里会继续执行T result = action.doInRedis(connToExpose);,这里其实执行的doInRedis方法为:

connection -> connection.sAdd(rawKey, rawValues)

4.4.2 Pipeline方式下执行命令的流程分析:

① 接着上面的流程分析,这里的sAdd方法实际调用的是DefaultStringRedisConnection的sAdd方法

@Override
public Long sAdd(byte[] key, byte[]... values) {
    return convertAndReturn(delegate.sAdd(key, values), identityConverter);
}

② 这里会进一步调用DefaultedRedisConnection的sAdd方法

@Override
@Deprecated
default Long sAdd(byte[] key, byte[]... values) {
    return setCommands().sAdd(key, values);
}

③ 接着调用LettuceSetCommands的sAdd方法

@Override
public Long sAdd(byte[] key, byte[]... values) {
 
    Assert.notNull(key, "Key must not be null!");
    Assert.notNull(values, "Values must not be null!");
    Assert.noNullElements(values, "Values must not contain null elements!");
 
    try {
        // 如果开启了 Pipelined 模式,获取的是 异步连接,进行异步操作
        if (isPipelined()) {    Pipeline(connection.newLettuceResult(getAsyncConnection().sadd(key, values)));
            return null;
        }

        if (isQueueing()) {
            transaction(connection.newLettuceResult(getAsyncConnection().sadd(key, values)));
            return null;
        }
        //常规模式下,使用的是同步操作
        return getConnection().sadd(key, values);
    } catch (Exception ex) {
        throw convertLettuceAccessException(ex);
    }
}

这里我们开启了Pipeline, 实际会调用Pipeline(connection.newLettuceResult(getAsyncConnection().sadd(key, values))); 也就是获取异步连接getAsyncConnection,然后进行异步操作sadd,而常规模式下,使用的是同步操作,所以在Pipeline模式下,执行效率更高。

从上面的获取连接和具体命令执行相关源码分析可以得出使用Lettuce客户端Pipeline模式高效的根本原因:

  1. 普通模式下,每执行一个命令都需要先打开一个连接,命令执行完毕以后又需要关闭这个连接,执行下一个命令时,又需要经过连接打开和关闭的流程;而Pipeline的所有命令的执行只需要经过一次连接打开和关闭。

  2. 普通模式下命令的执行是同步阻塞模式,而Pipeline模式下命令的执行是异步非阻塞模式。

五、项目中遇到的坑

前面介绍了涉及到批量操作,可以使用Redis Pipelining机制,那是不是任何批量操作相关的场景都可以使用呢,比如list类型数据的批量移除操作,我们的代码最开始是这么写的:

public void deleteSet(String updateKey, Set<Integer> userIds) {
        if (CollectionUtils.isEmpty(userIds)) {
            return;
        }
 
        redisTemplate.executePipelined(new DeleteListCallBack(userIds, updateKey));
    }
 
@AllArgsConstructor
private static class DeleteListCallBack implements SessionCallback<Object> {
 
    private Set<Integer> userIds;
 
    private String updateKey;
 
    @Override
    public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
        ListOperations<String, String> listOperations = (ListOperations<String, String>) operations.opsForList();
        userIds.forEach(userId -> listOperations.remove(updateKey, 1, userId.toString()));
        return null;
    }
}

在数据量比较小的时候没有出现问题,直到有一条收到了Redis的内存和cpu利用率的告警消息,我们发现这么使用是有问题的,核心原因在于list的lrem操作的时间复杂度是O(N+M),其中N是list的长度, M是要移除的元素的个数,而我们这里还是一个一个移除的,当然会导致Redis数据积压和cpu每秒ops升高导致cpu利用率飚高。也就是说,即使使用Pipeline进行批量操作,但是由于单次操作很耗时,是会导致整个Redis出现问题的。

后面我们进行了优化,选用了list的ltrim命令,一次命令执行批量remove操作:

public void deleteSet(String updateKey, Set<Integer> deviceIds) {
        if (CollectionUtils.isEmpty(deviceIds)) {
            return;
        }
 
        int maxSize = 10000;
        redisTemplate.opsForList().trim(updateKey, maxSize + 1, -1);
    }

由于ltrim本身的时间复杂度为O(M), 其中M要移除的元素的个数,相比于原始方案的lrem,效率提升很多,可以不需要使用Redis Pipeline,优化结果使得Redis内存利用率和cpu利用率都极大程度得到缓解。

六、Redisson 对 Redis Pipeline 特性支持

在redisson官方文档中额外特性介绍中有说到批量命令执行这个特性, 也就是多个命令在一次网络调用中集中发送,该特性是RBatch这个类支持的,从这个类的描述来看,主要是为Redis Pipeline这个特性服务的,并且主要是通过队列和异步实现的。

/**
 * Interface for using Redis Pipeline feature.
 * <p>
 * All method invocations on objects got through this interface
 * are batched to separate queue and could be executed later
 * with <code>execute()</code> or <code>executeAsync()</code> methods.
 *
 *
 * @author Nikita Koksharov
 *
 */
public interface RBatch {
 
    /**
     * Returns stream instance by <code>name</code>
     *
     * @param <K> type of key
     * @param <V> type of value
     * @param name of stream
     * @return RStream object
     */
    <K, V> RStreamAsync<K, V> getStream(String name);
     
    /**
     * Returns stream instance by <code>name</code>
     * using provided <code>codec</code> for entries.
     *
     * @param <K> type of key
     * @param <V> type of value
     * @param name - name of stream
     * @param codec - codec for entry
     * @return RStream object
     */
    <K, V> RStreamAsync<K, V> getStream(String name, Codec codec);
     
    ......
    
    /**
     * Returns list instance by name.
     *
     * @param <V> type of object
     * @param name - name of object
     * @return List object
     */
    <V> RListAsync<V> getList(String name);
 
    <V> RListAsync<V> getList(String name, Codec codec);
 
    ......
 
    /**
     * Executes all operations accumulated during async methods invocations.
     * <p>
     * If cluster configuration used then operations are grouped by slot ids
     * and may be executed on different servers. Thus command execution order could be changed
     *
     * @return List with result object for each command
     * @throws RedisException in case of any error
     *
     */
    BatchResult<?> execute() throws RedisException;
 
    /**
     * Executes all operations accumulated during async methods invocations asynchronously.
     * <p>
     * In cluster configurations operations grouped by slot ids
     * so may be executed on different servers. Thus command execution order could be changed
     *
     * @return List with result object for each command
     */
    RFuture<BatchResult<?>> executeAsync();
 
    /**
     * Discard batched commands and release allocated buffers used for parameters encoding.
     */
    void discard();
 
    /**
     * Discard batched commands and release allocated buffers used for parameters encoding.
     *
     * @return void
     */
    RFuture<Void> discardAsync();
 
 
}

简单的测试代码如下:

@Slf4j
public class RedisPipelineTest {
    public static void main(String[] args) {
        //Redisson使用Pipeline模式
        Config config = new Config();
        config.useSingleServer().setAddress("redis://xx.xx.xx.xx:6379");
        RedissonClient redisson = Redisson.create(config);
        RBatch redisBatch = redisson.createBatch();
 
        int size = 100000;
        String zSetKey = "Pipeline-test-set";
        long begin = System.currentTimeMillis();
         
        //将命令放入队列中
        for (int i = 0; i < size; i++) {
            redisBatch.getSet(zSetKey + i).addAsync("ccc");
        }
        //批量执行命令
        redisBatch.execute();
        log.info("Redisson Pipeline模式耗时:{}ms", (System.currentTimeMillis() - begin));
 
        //关闭
        redisson.shutdown();
    }
}

核心方法分析:

1.建Redisson客户端RedissonClient redisson = redisson.create(config), 该方法最终会调用Reddison的构造方法Redisson(Config config)。

protected Redisson(Config config) {
        this.config = config;
        Config configCopy = new Config(config);
 
        connectionManager = ConfigSupport.createConnectionManager(configCopy);
        RedissonObjectBuilder objectBuilder = null;
        if (config.isReferenceEnabled()) {
            objectBuilder = new RedissonObjectBuilder(this);
        }
        //新建异步命令执行器
      commandExecutor = new CommandSyncService(connectionManager, objectBuilder);
        //执行删除超时任务的定时器
      evictionScheduler = new EvictionScheduler(commandExecutor);
        writeBehindService = new WriteBehindService(commandExecutor);
}

该构造方法中会新建异步命名执行器CommandAsyncExecutor commandExecutor和用户删除超时任务的EvictionScheduler evictionScheduler。

2.创建RBatch实例RBatch redisBatch = redisson.createBatch(), 该方法会使用到步骤1中的commandExecutor和evictionScheduler实例对象。

@Override
public RBatch createBatch(BatchOptions options) {
    return new RedissonBatch(evictionScheduler, commandExecutor, options);
}
 
public RedissonBatch(EvictionScheduler evictionScheduler, CommandAsyncExecutor executor, BatchOptions options) {
        this.executorService = new CommandBatchService(executor, options);
        this.evictionScheduler = evictionScheduler;
}

其中的options对象会影响后面批量执行命令的流程。

3. 异步给set集合添加元素的操作addAsync,这里会具体调用RedissonSet的addAsync方法

@Override
public RFuture<Boolean> addAsync(V e) {
    String name = getRawName(e);
    return commandExecutor.writeAsync(name, codec, RedisCommands.SADD_SINGLE, name, encode(e));
}

(1)接着调用CommandAsyncExecutor的异步写入方法writeAsync。

@Override
public <T, R> RFuture<R> writeAsync(String key, Codec codec, RedisCommand<T> command, Object... params) {
    RPromise<R> mainPromise = createPromise();
    NodeSource source = getNodeSource(key);
    async(false, source, codec, command, params, mainPromise, false);
    return mainPromise;
}

(2) 接着调用批量命令执行器CommandBatchService的异步发送命令。

@Override
public <V, R> void async(boolean readOnlyMode, NodeSource nodeSource,
        Codec codec, RedisCommand<V> command, Object[] params, RPromise<R> mainPromise, boolean ignoreRedirect) {
    if (isRedisBasedQueue()) {
        boolean isReadOnly = options.getExecutionMode() == ExecutionMode.REDIS_READ_ATOMIC;
        RedisExecutor<V, R> executor = new RedisQueuedBatchExecutor<>(isReadOnly, nodeSource, codec, command, params, mainPromise,
                false, connectionManager, objectBuilder, commands, connections, options, index, executed, latch, referenceType);
        executor.execute();
    } else {
        //执行分支
        RedisExecutor<V, R> executor = new RedisBatchExecutor<>(readOnlyMode, nodeSource, codec, command, params, mainPromise,
                false, connectionManager, objectBuilder, commands, options, index, executed, referenceType);
        executor.execute();
    }
     
}

(3) 接着调用了RedisBatchExecutor.execute方法和BaseRedisBatchExecutor.addBatchCommandData方法。

@Override
public void execute() {
    addBatchCommandData(params);
}
 
protected final void addBatchCommandData(Object[] batchParams) {
    MasterSlaveEntry msEntry = getEntry(source);
    Entry entry = commands.get(msEntry);
    if (entry == null) {
        entry = new Entry();
        Entry oldEntry = commands.putIfAbsent(msEntry, entry);
        if (oldEntry != null) {
            entry = oldEntry;
        }
    }
 
    if (!readOnlyMode) {
        entry.setReadOnlyMode(false);
    }
 
    Codec codecToUse = getCodec(codec);
    BatchCommandData<V, R> commandData = new BatchCommandData<V, R>(mainPromise, codecToUse, command, batchParams, index.incrementAndGet());
    entry.getCommands().add(commandData);
}

这里的commands以主节点为KEY,以待发送命令队列列表为VALUE(Entry),保存一个MAP.然后会把命令都添加到entry的commands命令队列中, Entry结构如下面代码所示。

public static class Entry {
 
    Deque<BatchCommandData<?, ?>> commands = new LinkedBlockingDeque<>();
    volatile boolean readOnlyMode = true;
 
    public Deque<BatchCommandData<?, ?>> getCommands() {
        return commands;
    }
 
    public void setReadOnlyMode(boolean readOnlyMode) {
        this.readOnlyMode = readOnlyMode;
    }
 
    public boolean isReadOnlyMode() {
        return readOnlyMode;
    }
     
 
    public void clearErrors() {
        for (BatchCommandData<?, ?> commandEntry : commands) {
            commandEntry.clearError();
        }
    }
 
}

4. 批量执行命令redisBatch.execute(),这里会最终调用CommandBatchService的executeAsync方法,该方法完整代码如下,我们下面来逐一进行拆解。

public RFuture<BatchResult<?>> executeAsync() {
         
        ......
 
        RPromise<BatchResult<?>> promise = new RedissonPromise<>();
        RPromise<Void> voidPromise = new RedissonPromise<Void>();
        if (this.options.isSkipResult()
                && this.options.getSyncSlaves() == 0) {
            ......
        } else {
            //这里是对异步执行结果进行处理,可以先忽略, 后面会详细讲,先关注批量执行命令的逻辑
            voidPromise.onComplete((res, ex) -> {
                ......
            });
        }
 
        AtomicInteger slots = new AtomicInteger(commands.size());
 
        ......
         
        //真正执行的代码入口,批量执行命令
        for (Map.Entry<MasterSlaveEntry, Entry> e : commands.entrySet()) {
            RedisCommonBatchExecutor executor = new RedisCommonBatchExecutor(new NodeSource(e.getKey()), voidPromise,
                                                    connectionManager, this.options, e.getValue(), slots, referenceType);
            executor.execute();
        }
        return promise;
    }

里面会用到我们在3.3步骤所生成的commands实例。

(1)接着调用了基类RedisExecutor的execute方法

public void execute() {
         
        ......
 
        connectionFuture.onComplete((connection, e) -> {
            if (connectionFuture.isCancelled()) {
                connectionManager.getShutdownLatch().release();
                return;
            }
 
            if (!connectionFuture.isSuccess()) {
                connectionManager.getShutdownLatch().release();
                exception = convertException(connectionFuture);
                return;
            }
 
            //调用RedisCommonBatchExecutor的sendCommand方法, 里面会将多个命令放到一个List<CommandData<?, ?>> list列表里面
        sendCommand(attemptPromise, connection);
 
            writeFuture.addListener(new ChannelFutureListener() {
                @Override
                public void operationComplete(ChannelFuture future) throws Exception {
                    checkWriteFuture(writeFuture, attemptPromise, connection);
                }
            });
        });
 
        ......
    }

(2)接着调用RedisCommonBatchExecutor的sendCommand方法,里面会将多个命令放到一个List<commanddata> list列表里面。

@Override
    protected void sendCommand(RPromise<Void> attemptPromise, RedisConnection connection) {
        boolean isAtomic = options.getExecutionMode() != ExecutionMode.IN_MEMORY;
        boolean isQueued = options.getExecutionMode() == ExecutionMode.REDIS_READ_ATOMIC
                                || options.getExecutionMode() == ExecutionMode.REDIS_WRITE_ATOMIC;
 
        //将多个命令放到一个List<CommandData<?, ?>> list列表里面
      List<CommandData<?, ?>> list = new ArrayList<>(entry.getCommands().size());
        if (source.getRedirect() == Redirect.ASK) {
            RPromise<Void> promise = new RedissonPromise<Void>();
            list.add(new CommandData<Void, Void>(promise, StringCodec.INSTANCE, RedisCommands.ASKING, new Object[] {}));
        }
        for (CommandData<?, ?> c : entry.getCommands()) {
            if ((c.getPromise().isCancelled() || c.getPromise().isSuccess())
                    && !isWaitCommand(c)
                        && !isAtomic) {
                // skip command
                continue;
            }
            list.add(c);
        }
         
        ......
        //调用RedisConnection的send方法,将命令一次性发到Redis服务器端
      writeFuture = connection.send(new CommandsData(attemptPromise, list, options.isSkipResult(), isAtomic, isQueued, options.getSyncSlaves() > 0));
    }

(3)接着调用RedisConnection的send方法,通过Netty通信发送命令到Redis服务器端执行,这里也验证了Redisson客户端底层是采用Netty进行通信的。

public ChannelFuture send(CommandsData data) {
        return channel.writeAndFlush(data);
}

5. 接收返回结果,这里主要是监听事件是否完成,然后组装返回结果, 核心方法是步骤4提到的CommandBatchService的executeAsync方法,里面会对返回结果进行监听和处理, 核心代码如下:

public RFuture<BatchResult<?>> executeAsync() {
    ......
     
    RPromise<BatchResult<?>> promise = new RedissonPromise<>();
    RPromise<Void> voidPromise = new RedissonPromise<Void>();
    if (this.options.isSkipResult()
            && this.options.getSyncSlaves() == 0) {
        ......
    } else {
        voidPromise.onComplete((res, ex) -> {
            //对返回结果的处理
            executed.set(true);
            ......
            List<Object> responses = new ArrayList<Object>(entries.size());
            int syncedSlaves = 0;
            for (BatchCommandData<?, ?> commandEntry : entries) {
                if (isWaitCommand(commandEntry)) {
                    syncedSlaves = (Integer) commandEntry.getPromise().getNow();
                } else if (!commandEntry.getCommand().getName().equals(RedisCommands.MULTI.getName())
                        && !commandEntry.getCommand().getName().equals(RedisCommands.EXEC.getName())
                        && !this.options.isSkipResult()) {
                     
                    ......
                    //获取单个命令的执行结果
                    Object entryResult = commandEntry.getPromise().getNow();
                    ......
                    //将单个命令执行结果放到List中
                    responses.add(entryResult);
                }
            }
             
            BatchResult<Object> result = new BatchResult<Object>(responses, syncedSlaves);
            promise.trySuccess(result);
            ......
        });
    }
 
    ......
    return promise;
}

这里会把单个命令的执行结果放到responses里面,最终返回RPromise<batchresult>promise。

从上面的分析来看,Redisson客户端对Redis Pipeline的支持也是从多个命令在一次网络通信中执行和异步处理来实现的。

七、总结

Redis提供了Pipelining进行批量操作的高级特性,极大地提高了部分数据类型没有批量执行命令导致的执行耗时而引起的性能问题,但是我们在使用的过程中需要考虑Pipeline操作中单个命令执行的耗时问题,否则带来的效果可能适得其反。最后扩展分析了Redisson客户端对Redis Pipeline特性的支持原理,可以与Lettuce客户端对Redis Pipeline支持原理进行比较,加深Pipeline在不同Redis客户端实现方式的理解。

参考资料:

创建型设计模式抽象了实例化过程。它们帮助一个系统独立于如何创建、组合和表示它的那些对象。一个类创建型模式使用继承改变被实例化的类,而一个对象创建型模式将实例化委托给另一个对象。

随着系统演化得越来越依赖于对象组合而不是继承,创建型模式变得更为重要。当这种情况发生时,重心从对一组固定行为的硬编码(hard- coding)转移为定义一个较小的基本行为集,这些行为可以被组合成仁义数目的更复杂的行为。这样创建有特定行为的对象要求的不仅仅是实例化一个类。

在这些模式中有两个不断出现的主旋律。第一,它们都将关于该系统使用哪些具体的类的信息封装起来。第二,它们隐藏了这些类的实例是如何被创建和放在一起。整个系统关于这些对象所知道的是由抽象类所定义的接口。因此,创建型模式在
什么
被创建、

创建它、它是
怎么
被创建的,以及
何时
创建等方面给予很大的灵活性。它们允许你用结构和功能差别很大的“产品”对象配置一个系统。配置可以是静态的(即在编译时指定),也可以是动态的(在运行时指定)。

有时创建型模式是互相竞争的。例如,有些情况下
Prototype

Abstract Factory
用起来都很好。而在有些情况下它们是互补的:
Builder
可以使用其他模式去实现某个构件的创建;Prototype 可以在它的实现中使用 Singleton。

下面针对创建迷宫这个例子讲述每个模式:

我们仅关注迷宫怎样创建的。我们将一个迷宫定义为一系列房间,一个房间知道它的邻居;可能的邻居要么是另一个房间,要么是一堵墙或者是到另一个房间的一扇门。每一个房间有四面。

类Room、Door和Wall定义了我们所有的例子中用到的构件。类MapSite是所有迷宫构件的公共抽象类。

/// <summary>
    ///迷宫构件基类/// </summary>
    public classMapSite
{
publicMapSite()
{
}
public virtual voidEnter()
{

}
}
    /// <summary>
    ///房间类/// </summary>
    public classRoom: MapSite
{
private int_roomNo;private Dictionary<DirectionEnum,MapSite>_sides;public Room(introomNo)
{
_roomNo
=roomNo;

}
public int RoomNo() =>_roomNo;public voidSetSide(DirectionEnum direction,MapSite mapSite)
{
_sides.Add(direction, mapSite);
}
publicMapSite GetSide(DirectionEnum direction)
{
return_sides[direction];
}
}
/// <summary> ///房间四面方向枚举/// </summary> public enumDirectionEnum
{
东,南,西,北
}
    /// <summary>
    ////// </summary>
    public classWall:MapSite
{
publicWall()
{
}
}
    /// <summary>
    ////// </summary>
    public classDoor:MapSite
{
privateRoom _room1;privateRoom _room2;publicDoor(Room room1,Room room2)
{
_room1
=room1;
_room2
=room2;
}
}
    /// <summary>
    ///迷宫类/// </summary>
    public classMaze
{
publicMaze()
{
}
private HashSet<Room> rooms = new HashSet<Room>();public voidAddRoom(Room room)
{
rooms.Add(room);
}
public Room GetRoomByNo(intnum)
{
return rooms.FirstOrDefault(r=>r.RoomNo() ==num);
}
}

下面我们定义另一个类 Maze Game,由它来创建迷宫。一个简单直接的创建迷宫的方法是使用一系列操作将构件增加到迷宫中,然后连接它们。例如,下面的成员函数将创建一个迷宫,这个迷宫由两个房间和它们之间的一扇门组成:

    public classMazeGame
{
publicMazeGame()
{
}
publicMaze CreateMaze()
{
Maze maze
= newMaze();
Room r1
= new Room(1);
Room r2
= new Room(2);
Door door
= newDoor(r1,r2);

maze.AddRoom(r1);
maze.AddRoom(r2);

r1.SetSide(DirectionEnum.东,
newWall());
r1.SetSide(DirectionEnum.北, door);
r1.SetSide(DirectionEnum.南,
newWall());
r1.SetSide(DirectionEnum.西,
newWall());

r2.SetSide(DirectionEnum.东,
newWall());
r2.SetSide(DirectionEnum.北,
newWall());
r2.SetSide(DirectionEnum.南, door);
r2.SetSide(DirectionEnum.西,
newWall());returnmaze;
}
}

这个成员函数真正的问题不在于它的大小而在于它
不灵活
。很像面向过程编程,而不是面向对象。它对迷宫的布局进行硬编码。改变布局意味着改变这个函数,通过以下方式:重定义它——意味着重新实现整个过程;对它的部分进行改变——这容易产生错误并且不利于复用。

创建型模式表明如何使得整个设计更灵活,但未必会更小。特别是,它们将便于修改定义迷宫构件的类。

假设你想在一个包含(所有东西)施了魔法的迷宫的新游戏中复用一个已有的迷宫布局。施了魔法的迷宫游戏有新的构件,如DoorNeedingSpell,它是一扇只能用咒语才能被锁上和打开的门;以及EnchantedRoom,一个可以有不寻常东西的房间,比如魔法钥匙或者咒语。
你怎样才能比较容易地改变CreateMaze 以让它用这些新类型的对象创建迷宫呢?

这种情况下,改变的最大障碍是对已实例化的类进行硬编码。创建型模式提供了多种不同方法,
从实例化它们的代码中除去这些具体类的显式引用

  • 如果 CreateMaze 调用虚函数而不是构造器来创建它需要的房间、墙壁和门,那么可以创建一个MazeGame的子类并重定义这些虚函数,从而改变被实例化的类。这一方法是Factory Method 模式的一个例子。
  • 如果传递一个对象给 CreateMaze 作为参数来创建房间、墙壁和门,那么可以传递不同的参数来改变房间、墙壁和门的类。这是 Abstract Factory 模式的一个例子。
  • 如果传递一个对象给 CreateMaze,这个对象可以在它所建造的迷宫中使用增加房间、墙壁和门的操作来全面创建一个新的迷宫,那么可以使用继承来改变迷宫的一些部分或迷宫的建造方式。这是 Builder 模式的一个例子。
  • 如果 CreateMaze 由多种原型的房间、墙壁和门对象参数化,它复制并将这些对象增加到迷宫中,那么可以用不同的对象替换这些原型对象以改变迷宫的构成。这是 Prototype 模式的一个例子。

剩下的创建型模式 Singleton 可以保证每个游戏中仅有一个迷宫而且所有的对象都可以快速访问它——不需要求助于全局变量或函数。Singleton 也使得迷宫易于扩展或替换,且不需要变动已有的代码。

一、Abstract Factory(抽象工厂)—— 对象创建模型

1、意图

提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。

2、动机

考虑一个支持多种视感标准的用户界面工具包,例如 Motif 和 Presentation Manager。不同的视感风格为诸如滚动条、窗口和按钮等用户界面“窗口组件”定义不同的外观和行为。一个应用不应该为一个特定的视感外观硬编码它的窗口组件。在整个应用中实例化特定视感风格的窗口组件将使得以后很难改变视感风格。

为解决这一问题,我们可以定义一个抽象的 WidgetFactory 类,这个类声明了一个用来创建每一类基本窗口组件的接口。每一类窗口组件都有一个抽象类,而具体子类则实现了窗口组件的特定视感风格。对于每一个抽象窗口组件类,WidgetFactory 接口都有一个返回新窗口组件对象的操作。客户调用这些操作以获得窗口组件实例,但客户并不知道其正在使用的是哪些具体类。这样客户就不依赖于一般的视感风格,如下图所示。

每一种视感标准都对应于一个具体的 WidgetFactory 子类。每一子类实现那些用于创建合适视感风格的窗口组件的操作。例如,MotifWidgetFactory 的 CreateScrollBar 操作实例化并返回一个 Motif 滚动条,而相应的 PMWidgetFactory 操作返回一个Presentation Manager 的滚动条。客户仅通过 WidgetFactory 接口创建窗口组件,而并不知道哪些类实现了特定视感风格的窗口组件。换言之,客户仅与抽象类定义的接口交互,而不使用特定的具体类的接口。

WidgetFactory 也增强了具体窗口组件类之间的依赖关系。一个 Motif 的滚动条应该与 Motif按钮、Motif文本编辑器一起使用,这一约束条件作为使用 MotifWidgetFactory 的结果被自动加上。

3、适用性

在以下情况使用 Abstract Factory 模式:

  • 一个系统要独立于它的产品的创建、组合和表示。
  • 一个系统要由多个产品系列中的一个来配置。
  • 要强调一系列相关的产品对象以便进行联合使用。
  • 提供一个产品类库,但只想显示它们的接口而不是实现。

4、结构

5、参与者

  • AbstractFactory

--声明一个创建抽象产品对象的操作接口。

  • ConcreteFactory (MotifWidgetFactory, PMWidgetFactory)

--实现创建具体产品对象的操作。

  • AbstractProduct (Windows, ScrollBar)

一为一类产品对象声明一个接口。

  • Concrete Product (MotifWindow, MotifScrollBar)

一定义一个将被相应的具体工厂创建的产品对象。
--实现AbstractProduct接口。

  • Client

--仅使用由AbstractFactory和AbstractProduct类声明的接口。

6、协作

  • 通常在运行时创建一个 ConcreteFactory 类的实例。这一具体的工厂创建具有特定实现的产品对象。为创建不同的产品对象,客户应使用不同的具体工厂。
  • AbstractFactory 将产品对象的创建延迟到它的 ConcreteFactory 子类。

7、效果

AbstractFactory 模式有以下优点和缺点:

(1)
它分离了具体的类
Abstract Factory 模式帮助你控制一个应用创建的对象的类。因为一个工厂封装创建产品对象的责任和过程,它将客户与类的实现分离。客户通过它们的抽象接口操纵实例。产品的类名也在具体工厂的实现中被隔离,即它们不出现在客户代码中。

(2)
它使得易于交换产品系列
一个具体工厂类在一个应用中仅出现一次——在它初始化的时候
。这使得改变一个应用的具体工厂变得很容易。只需要改变具体的工厂即可使用不同的产品配置,这是因为一个抽象工厂创建了一个完整的产品系列,所以整个产品系列会立刻改变。

(3)
它有利于产品的一致性
当一个系列中的产品对象被设计成一起工作时,一个应用一次只能使用同一系列中的对象,这一点很重要。而 AbstractFactory 很容易实现这一点。

(4)
难以支持新种类的产品
难以扩展抽象工厂以生产新种类的产品。这是因为 AbstractFactory 接口确定了可以被创建的产品集合。支持新种类的产品就需要扩展该工厂接口,这将涉及 AbstractFactory 类及其所有子类的概念。在下面实现部分讨论解决这个问题的办法。

8、实现

下面是实现 Abstract Factory 模式的一些有用的技术:

(1)
将工厂作为单件
一个应用中一般每个产品系列之需要一个 ConcreteFactory 的实例。因此工厂通常最好实现为一个 Singleton。

(2)
创建产品
AbstractFactory 仅声明一个创建产品的接口,真正创建产品是由 ConcreteProduct 子类实现的。最通常的办法是为每一个产品定义一个工厂方法(见 Factory Method)。一个具体的工厂将为每个产品重定义该工厂方法以指定产品。虽然这样很简单,但它却要求每个产品系列都要有一个新的具体工厂子类,即使这些产品系列的差别很小。

如果有多个可能的产品系列,具体工厂也可以使用 prototype模式来实现。具体工厂使用产品系列中每一个产品的原型实例来初始化,且它通过复制它的原型来创建新的产品。基于原型的方法使得并非每个新的产品系列都需要一个新的具体工厂类。

(3)
定义可扩展的工厂
AbstractFactory 通常为每一种它可以生产的产品定义一个操作。产品的种类被编码在操作型构中。增加一种新的产品要求改变 AbstractFactory 的接口以及所有与它相关的类。

一个更灵活但不太安全的设计是给创建对象的操作增加一个参数。该参数指定了将被创建的对象的种类。它可以是一个类标识符、一个整数、一个字符串,或其他任何可以表示这种产品的东西。实际上,使用这种方法 AbstractFactory 只需要一个 Make 操作和一个指示要创建对象的种类的参数。这是前面已经讨论过的基于原型的和基于类的抽象工厂的技术。

9、代码示例

    /// <summary>
    ///抽象工厂基类/// </summary>
    public classMazeFactory
{
publicMazeFactory()
{
}
public virtualMaze MakeMaze()
{
return newMaze();
}
public virtualWall MakeWall()
{
return newWall();
}
public virtual Room MakeRoom(intnum)
{
return newRoom(num);
}
public virtualDoor MakeDoor(Room r1, Room r2)
{
return newDoor(r1,r2);
}
}

最前面创建迷宫的方法 CreateMaze 中,CreateMaze 对类名进行硬编码,这使得很难用不同的构件创建迷宫。

这里是一个以 MazeFactory 为参数的新版本的 CreateMaze,它修改了以上缺点:

        publicMaze CreateMaze()
{
Maze maze
= newMaze();
Room r1
= new Room(1);
Room r2
= new Room(2);
Door door
= newDoor(r1,r2);

maze.AddRoom(r1);
maze.AddRoom(r2);

r1.SetSide(DirectionEnum.东,
newWall());
r1.SetSide(DirectionEnum.北, door);
r1.SetSide(DirectionEnum.南,
newWall());
r1.SetSide(DirectionEnum.西,
newWall());

r2.SetSide(DirectionEnum.东,
newWall());
r2.SetSide(DirectionEnum.北,
newWall());
r2.SetSide(DirectionEnum.南, door);
r2.SetSide(DirectionEnum.西,
newWall());returnmaze;
}
/// <summary> ///使用抽象工厂的方法/// </summary> /// <param name="mazeFactory"></param> /// <returns></returns> publicMaze CreateMaze(MazeFactory mazeFactory)
{
Maze maze
=mazeFactory.MakeMaze();
Room r1
= mazeFactory.MakeRoom(1);
Room r2
= mazeFactory.MakeRoom(2);
Door door
=mazeFactory.MakeDoor(r1, r2);

maze.AddRoom(r1);
maze.AddRoom(r2);

r1.SetSide(DirectionEnum.东, mazeFactory.MakeWall());
r1.SetSide(DirectionEnum.北, door);
r1.SetSide(DirectionEnum.南, mazeFactory.MakeWall());
r1.SetSide(DirectionEnum.西, mazeFactory.MakeWall());

r2.SetSide(DirectionEnum.东, mazeFactory.MakeWall());
r2.SetSide(DirectionEnum.北, mazeFactory.MakeWall());
r2.SetSide(DirectionEnum.南, door);
r2.SetSide(DirectionEnum.西, mazeFactory.MakeWall());
returnmaze;
}

我们创建 MazeFactory 的子类 EnchantedMazeFactory ,这是一个创建施了魔法的迷宫的工厂。EnchantedMazeFactory 将重定义不同的成员函数并返回 Room、Wall 等不同的子类。

//<summary>
    ///施了魔法的迷宫的工厂/// </summary>
    public classEnchantedMazeFactory:MazeFactory
{
publicEnchantedMazeFactory()
{
}
public override Room MakeRoom(intnum)
{
return newEnchantedRoom(num);
}
public overrideDoor MakeDoor(Room r1, Room r2)
{
return newDoorNeedingSpell(r1, r2);
}
}

CreateMaze 可以用一个 EnchantedMazeFactory 实例来建造施了魔法的迷宫。

注意 MazeFactory 仅是工厂方法的一个集合。这是通常的实现 Abstract Factory 模式的方式。同时注意 MazeFactory 不是一个抽象类,因此它既作为 AbstractFactory 也作为 ConcreteFactory 。这是 Abstract Factory 模式的简单应用的另一个通常的实现。

AbstractFactory 类通常用工厂方法(Factory Method)实现,但她们也可以用 Prototype 实现。

一个具体的工厂通常是一个单件(Singleton)。

二、Builder(生成器)——对象创建模式

1、意图

将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。

2、动机

一个RTF文档交换格式的阅读器应能将RTF转换为多种文本格式。该阅读器可以将RTF文档转换成普通ASCII文本或转换成一个能以交互方式编辑的文本窗口组件。但问题在于可能转换的数目是无限的。因此要能够很容易实现新的转换的增加,同时又不改变RTF阅读器。

一个解决办法是用一个可以将RTF转换成另一种文本表示的 TestConverter 对象来配置这个 RTFReader 类。当 RTFReader 对 RTF 文档进行语法分析时,它使用 TestConverter 去做转换。无论何时 RTFReader 识别了一个RTF标记(或是普通文本或是一个RTF控制字),它都发送一个请求给 TestConverter 去转换这个标记。TestConverter 对象负责进行数据转换以及用特定格式表示该标记,如图。

TestConverter 的子类对不同转换和不同格式进行特殊处理。例如,一个 ASCIIConverter 只负责转换普通文本,而忽略其他转换请求。另一方面,一个 TeXConverter 将会实现对所有请求的操作,以便生成一个获取文本中组呕呕风格信息的TEX表示。一个TextWidgetConverter将生成一个复杂的用户界面对象以便用户浏览和编辑文本。

每种转换器类将创建和装配一个复杂对象的机制隐含在抽象接口的后面。转换器独立于阅读器,阅读器负责对一个RTF文档进行语法分析。

Builder 模式描述了所有这些关系。每一个转换器类在该模式中被称为
生成器
(builder),而阅读器则称为
导向器
(director)。在上面的例子中,Builder模式将分析文本格式的算法(即RTF文档的语法分析程序)与描述怎样创建和表示一个转换后格式的算法分离开来。这使我们可以复用 RTFReader 的语法分析算法,根据 RTF 文档创建不同的文本表示——仅需使用不同的 TestConverter 的子类配置该 RTFReader 即可。

3、适用性

在以下情况使用 Builder 模式:

  • 当创建复杂对象的算法应该独立于该对象的组成部分以及它们的装配方式时。
  • 当构造过程必须允许被构造的对象有不同的表示时。

4、结构

此模式的结构如下图所示。

5、参与者

  • Builder(TextConverter)

— 为创建一个 Product 对象的各个部件指定抽象接口。

  • ConcreteBuilder(ASCIIConverter、...)

— 实现 Builder 的接口以及构造和装配该产品的各个部件。

— 定义并跟踪它所创建的表示。

— 提供一个检索产品的接口(例如,GetASCIIText和GetTextWidget)。

  • Director(RTFReader)

— 构造一个使用 Builder 接口的对象。

  • Product(ASCIIText、...)

— 表示被构造的对象。ConcreteBuilder 创建该产品的内部表示并定义它的装配过程。

— 包含定义组成部件的类,包括将这些部件装配成最终产品的接口。

6、协作

  • 客户创建 Director 对象,并用它所想要的 Builder 对象进行配置。
  • 一旦生成了产品部件,导向器就会通知生成器。
  • 生成器处理导向器的请求,并将部件添加到该产品中。
  • 客户从生成器中检索产品。

下面的交互图说明了 Builder 和 Director 是如何与一个客户协作的。

7、效果

这里是 Builder 模式的主要效果:

(1)
它使你可以改变一个产品的内部表示
Builder 对象提供给导向器一个构造产品的抽象接口。该接口使得生成器可以隐藏这个产品的表示和内部结构。它同时隐藏了该产品是如何装配的。因为产品是通过抽象接口构造的,你在改变该产品的内部表示时所要做的只是定义一个新的生成器。

(2)
它将构造代码和表示代码分开
Builder 模式通过封装一个复杂对象的创建和表示方式提高了对象的模块性。
客户不需要知道定义产品结构的类的所有信息,这些类是不出现在 Builder 接口中的
。每个 ConcreteBuilder 包含了创建和装配一个特定产品的所有代码。这些代码只需要写一次;然后不同的 Director 可以复用它以在相同部件集合的基础上构建不同的 Product。在前面的 RTF 例子中,我们可以为RTF格式以外的格式定义个阅读器,比如一个SGMLReader,并使用相同的 TextConverter 生成SGML文档的 ASCIIText、TexText和TextWidget译本。

(3)
它使你可对构造过程进行更精细的控制
Builder 模式与一下子就生成产品的创建模式不同,它是在向导器的控制下一步一步的构造产品的。仅当该产品完成时向导器材=才从生成器中取回它。因此 Builder 接口相比其他创建型模式能更好地反映产品的构造过程。这使你可以更精细地控制构建过程,从而能更精细地控制所得产品的内部结构。

8、实现

通常有一个抽象的 Builder 类为导向器可能要求创建的每一个构件定义一个操作。这些操作缺省情况下什么都不做。一个 ConcreteBuilder 类对它有兴趣创建的构件重定义这些操作。

这里是其他一些要考虑的实现问题:

(1)
装配和构造接口
生成器逐步地构造它们的产品。因此 Builder 类接口必须足够普遍,以便为各种类型的具体生成器构造产品。

一个关键的设计问题在于构造和装配过程的模型。构造请求的结果只是被添加到产品中,通常这样的模型就已足够了。在 RTF 的例子中,生成器转换下一个标记并将它添加到它已经转换了的文本中。

但有时你可能需要访问前面已经构造了的产品部件。我们在代码示例一节所给出的 Maze 例子中,MazeBuilder 接口允许你在已经存在的房间之间增加一扇门。在这种情况下,生成器会将构件返回给导向器。

(2)
为什么产品没有抽象类
通常情况下,由具体生成器生成的产品,其表示相差非常大,以至于给不同的产品以公共父类没有太大意思。在 RTF 例子中,ASCIIText 和 TextWidget 对象不太可能有公共接口,它们也不需要这样的接口。因为客户通常用合适的具体生成器来配置导向器,客户所处的位置使它知道 Builder 的哪一个具体子类被使用,并能相应地处理它的产品。

9、代码示例

我们将定义一个 CreateMaze 成员函数的变体,它以类 MazeBuilder 的一个生成器对象作为参数。

MazeBuilder 类定义下面的接口来创建迷宫:

    /// <summary>
    ///迷宫生成器父类/// </summary>
    public classMazeBuilder
{
publicMazeBuilder()
{
}
public virtual voidBuildMaze()
{

}
public virtual void BuildRoom(introomNo)
{

}
public virtual void BuildDoor(int roomFrom, introomTo)
{

}
publicMaze GetMaze()
{
return null;
}
}

该接口可以创建:(1)迷宫;(2)有一个特定房间号的房间;(3)在有号码的房间之间门。GetMaze 操作返回这个迷宫给客户。MazeBuilder 的子类将重定义这些操作,返回它们做创建的迷宫。

用 MazeBuilder 接口,我们可以改变 CreateMaze 成员函数,以生成器作为它的参数。

        publicMaze CreateMaze(MazeBuilder mazeBuilder)
{
mazeBuilder.BuildMaze();

mazeBuilder.BuildRoom(
1);
mazeBuilder.BuildRoom(
2);

mazeBuilder.BuildDoor(
1,2);returnmazeBuilder.GetMaze();
}

将这个 CreateMaze 版本与原来的相比,注意生成器是如何隐藏迷宫的内部表示的——定义房间、门和墙壁的那些类——以及这些部件是如何组装成最终的迷宫的。有人可能猜测到有一些类是用来表示房间和门的,但没有迹象显示哪个类是用来表示墙壁的。这就是使得改变一个迷宫的表示方式要容易一些,
因为所有 MazeBuilder 的客户都不需要被改变

子类 StandardMazeBuilder 是一个创建简单迷宫的实现。它将正在创建的迷宫放在变量  _currentMaze 中。

    public classStandardMazeBuilder:MazeBuilder
{
privateMaze _currentMaze;publicStandardMazeBuilder()
{
}
publicMaze GetMaze()
{
return_currentMaze;
}
/// <summary> ///实例化一个Maze,它将被其他操作装配并返回给客户/// </summary> public override voidBuildMaze()
{
_currentMaze
= newMaze();
}
/// <summary> ///BuildRoom创建一个房间并建造它周围的墙壁/// </summary> /// <param name="roomNo"></param> public override void BuildRoom(introomNo)
{
if (_currentMaze != null)
{
Room room
= newRoom(roomNo);
_currentMaze.AddRoom(room);

room.SetSide(DirectionEnum.东,
newWall());
room.SetSide(DirectionEnum.北,
newWall());
room.SetSide(DirectionEnum.南,
newWall());
room.SetSide(DirectionEnum.西,
newWall());
}
}
/// <summary> ///建造一扇两个房间之间的门,查找迷宫中的这两个房间并找到它们相邻的墙/// </summary> /// <param name="roomFrom"></param> /// <param name="roomTo"></param> public override void BuildDoor(int roomFrom, introomTo)
{
Room r1
=_currentMaze.GetRoomByNo(roomFrom);
Room r2
=_currentMaze.GetRoomByNo(roomTo);
Door d
= newDoor(r1,r2);

r1.SetSide(CommonWall(r1,r2) ,d);
r1.SetSide(CommonWall(r2, r1), d);
}
/// <summary> ///一个功能性操作,它决定两个两个房间之间的公共墙壁的方位。/// </summary> /// <param name="r1"></param> /// <param name="r2"></param> /// <returns></returns> /// <exception cref="NotImplementedException"></exception> privateDirectionEnum CommonWall(Room r1, Room r2)
{
throw newNotImplementedException();
}
}

客户现在可以用 CreateMaze 和 StandardMazeBuilder 来创建一个迷宫:

Maze maze;
MazeGame mazeGame;
StandardMazeBuilder mazeBuilder;

mazeGame.CreateMaze(mazeBuilder);
maze
= mazeBuilder.GetMaze();

我们本可以将所有的 StandardMazeBuilder 操作放在 Maze 中并让每一个 Maze 创建自身,但将Maze变得小一些使得它能更容易被理解和修改,而且 StandardMazeBuilder 易于从 Maze 中分离。
更重要的是,将两者分离使得你可以有多种 MazeBuilder ,每一种使用不同的房间、墙壁和门的类

Abstract Factory 与 Builder 相似,因为它也可以复杂对象。主要的区别是 Builder 模式着重于一步步构造一个复杂对象,而 Abstract Factory 着重于多个系列的产品对象(简单的或是复杂的)。Builder 在最后一步返回产品,而对于 Abstract Factory  ,产品是立即返回的。

三、Factory Method(工厂方法)—— 对象创建型模式

1、意图

定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。

2、别名

虚构造器

3、动机

框架使用抽象类定义和维护对象之间的关系。这些对象的创建通常也由框架负责。

考虑这样一个应用框架,它可以向用户显示多个文档。在这个框架中,两个主要的抽象是类 Application 和 Document。这两个类都是抽象的,客户必须通过它们的子类来做与具体应用相关的实现。例如,为创建一个绘图应用,我们定义类 DrawingApplication 和 DrawingDocument。Application 类负责管理 Document 并根据需要创建它们——例如,当用户从菜单中选择 Open 或 New 的时候。

因为被实例化的特定 Document 子类是与特定应用相关的,所以 Application 类不可能预测到哪个 Document 子类将被实例化——Application类仅知道一个新的文档何时应被创建,而不知道哪种Document将被创建。这就产生了一个尴尬的局面:框架必须实例化类,但是它只知道不能被实例化的抽象类。

Factory Method 模式提供了一个解决方案。它封装了哪个Document子类将被创建的信息并将这些信息从该框架中分离出来,如图:

Application 的子类重定义 Application 的抽象操作 CreateDocument 以返回适当的 Document 子类对象。一旦一个 Application 子类实例化,它就可以实例化与应用相关的文档,而无需知道这些文档的类。我们称 CreateDocument 是一个工厂方法,因为它负责生产一个对象。

4、适用性

在下列情况下可以使用 Factory Method 模式:

  • 当一个类不知道它所必须创建的对象的类的时候。
  • 当一个类希望由它的子类来指定它所创建的对象的时候。
  • 当类将创建对象的职责委托给多个帮助子类的某一个,并且你希望将哪一个帮助子类是代理者这一信息局部化的时候。

5、结构

6、参与者

Product(Document)

— 定义工厂方法所创建的对象的接口。

ConcreteProduct(MyDocument)

— 实现 Product 接口。

Creator(Application)

— 声明工厂方法,该方法返回一个 Product 类型的对象。Creator 也可以定义一个工厂方法的缺省实现,它返回一个缺省的 ConcreteProduct 对象。

— 可以调用工厂方法以创建一个 Product 对象。

ConcreteCreator(MyApplication)

— 重定义工厂方法以返回一个 ConcreteProduct 对象。

7、效果

工厂方法不再将与特定应用有关的类绑定到你的代码中。代码进处理 Product 接口,因此它可以与用户定义的任何 ConcreteProduct 类一起使用。

工厂方法的一个潜在缺点在于,客户可能仅仅为了创建一个特定的 ConcreteProduct 对象,就不得不创建 Creator 的子类。当 Creator 子类不是必需的时候,客户现在必然处理类演化的其他方面。

下面是 Factory Method 模式的另外两种效果:

(1)
为子类提供钩子(hook)
用工厂方法在一个类的内部创建对象通常比直接创建对象更灵活。Factory Method 给子类一个钩子以提供对象的扩展版本。

在 Document 的例子中,Document 类可以定义一个称为 CreateFileDialog 的工厂方法,该方法为打开一个已有的文档创建默认的文件对话框对象。Document 的子类可以重定义这个工厂方法以定义一个与特定应用相关的文件对话框。在这种情况下,工厂方法就不再抽象了,而是提供了一个合理的缺省实现。

(2)
连接平行的类层次
迄今为止,在我们所考虑的例子中,工厂方法并不只是被 Creator 调用,客户可以找到一些有用的工厂方法,尤其在平行的类层次的情况下。

当一个类将它的一些职责委托给一个独立的类的时候,就产生了平行类层次。
考虑可以被交互操纵的图形,也就是说,可以用鼠标对它们进行伸展、移动或者旋转。实现这样一些交互并不总是那么容易。它通常需要存储和更新在给定时刻记录操纵状态的信息,这个状态仅仅在操纵时需要。因此它不需要被保存在图形对象中。此外,当用户操纵图形时,不同图形有不同的行为。例如,将直线图形拉长可能会产生一个端点被移动的效果,而伸展文本图形可能会改变行距。

有了这些限制,最好使用一个独立的 Manipulator 对象实现交互并保存所需要的任何与特定操纵有关的状态。不同的图形将使用不同的 Manipulator 子类来处理特定的交互。得到的 Manipulator 类层次与 Figure 类层次是平行的(至少部分平行),如图:

Figure 类提供了一个 CreateManipulator 工厂方法,它使得客户可以创建一个与 Figure 相对应的 Manipulator 。Figure 子类重定义该方法以返回一个合适的 Manipulator 子类实例,而 Figure 子类可以只是继承这个缺省实现。这样的 Figure 类不需要相应的 Manipulator 子类——
因此该层次只是部分平行

注意工厂方法是怎样定义两个类层次之间的连接的。它将哪些类应一同工作的信息局部化了。

8、实现

当应用 Factory Method 模式时要考虑下面一些问题:

(1)主要有两种不同的情况 Factory Method 模式主要有两种不同的情况  一是,Creator 类是一个抽象类并且不提供它所声明的工厂方法的实现;二是,Creator 是一个具体的类而且为工厂方法提供一个缺省的实现。也有可能有一个定义了缺省实现的抽象类,但这并不常见。

(2)参数化工厂方法  该模式的另一种情况使得工厂可以创建多种产品。工厂方法采用一个标识要被创建的对象种类的参数。工厂方法创建的所有对象将共享 Product 接口。在 Document 的例子中,Application 可能支持不同种类的 Document。你给 CreateDocument 传递一个外部参数来制定将要创建的文档的种类。

一个参数化的工厂具有如下的一般形式,此处 MyProduct 和 YourProduct 是 Product 的子类:

public classCreator
{
publicCreator()
{
}
public virtualProduct Create(ProductId id)
{
if (id ==MINE)
{
return newMyProduct();
}
if (id ==YOUR)
{
return newYourProduct();
}
return null;
}
}

重定义一个参数化的工厂使你可以简单而有选择性地扩展或改变一个 Creator 生产的产品。你可以为新产品引入新的标识,或将已有的标识符与不同的产品相关联。

(3)使用模版以避免创建子类  正如我们已经提及的,工厂方法另一个潜在的问题是它们可能仅为了创建适当的 Product 对象而迫使你创建 Creator 子类。可以使用范型,将 Product 类作为参数:

public class StandardCreator<T> where T : Product, new()
{
publicT Create()
{
return newT();
}
}

(4)命名约定  使用命名约定是一个好习惯,它可以清楚地说明你正在使用工厂方法。

9、代码示例

函数 CreateMaze 建造并返回一个迷宫。这个函数存在的一个问题是它对迷宫、房间、门和墙壁的类进行了硬编码。我们将引入工厂方法使子类可以选择这些构件。首先我们在 MazeGame 中定义工厂方法以创建迷宫、房间、墙壁和门对象,然后用这些工厂方法重写 CreateMaze:

    public classMazeGame
{
publicMazeGame()
{
}
#region 工厂方法 public virtualMaze MakeMaze()
{
return newMaze();
}
public virtual Room MakeRoom(intn)
{
return newRoom(n);
}
public virtualWall MakeWall()
{
return newWall();
}
public virtualDoor MakeDoor(Room r1, Room r2)
{
return newDoor(r1,r2);
}
#endregion publicMaze CreateMaze()
{
Maze maze
=MakeMaze();

Room r1
= MakeRoom(1);
Room r2
= MakeRoom(2);
Door door
=MakeDoor(r1,r2);

maze.AddRoom(r1);
maze.AddRoom(r2);

r1.SetSide(DirectionEnum.北,MakeWall());
r1.SetSide(DirectionEnum.东, door);
r1.SetSide(DirectionEnum.南, MakeWall());
r1.SetSide(DirectionEnum.西, MakeWall());

r2.SetSide(DirectionEnum.北, MakeWall());
r2.SetSide(DirectionEnum.东, MakeWall());
r2.SetSide(DirectionEnum.南, MakeWall());
r2.SetSide(DirectionEnum.西, door);
returnmaze;
}
}

不同的游戏可以创建 MazeGame 的子类以特别指明一些迷宫的部件。MazeGame 子类可以重定义一些或所有的工厂方法以指定产品中的变化。例如,一个 BombedMazeGame 可以重定义产品 Room 和 Wall 以返回爆炸后的变体:

public classBombedMazeGame: MazeGame
{
publicBombedMazeGame()
{
}
public overrideWall MakeWall()
{
return newBombedWall();
}
public override Room MakeRoom(intn)
{
returnRoomWithABomb(n);
}
}

Abstract Factory 经常用工厂方法来实现。Abstract Factory 模式中动机一节的例子也对 Factory Method 进行了说明。

工厂方法通常在 Template Method 中调用。在上面的文档例子中,NewDocument 就是一个模板方法。

Prototype 不需要创建 Creator 的子类。但是,它们需要要求一个针对 Product 类的 Initialize 操作。 Creator 使用Initialize 来初始化对象,而Factory Method 不需要这样的操作。

四、Prototype(原型)

1、意图

用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

2、动机

你可以通过定制一个通用的图形编辑器框架以及增加一些表示音符、休止符和五线谱的新对象来构造一个乐谱编辑器。这个编辑器框架可能有一个工具选择板用于将这些音乐对象加到乐谱中。这个选择板可能还包括选择、移动和其他操纵音乐对象的工具。用户可以点击四分音符工具并使用它将四分音符加到乐谱中,或者可以使用移动工具在五线谱上上下移动一个音符,从而改变它的音调。

我们假定该框架为音符和五线谱这样的图形构件提供了一个抽象的 Graphic 类。此外,为定义选择板中的那些工具,还提供了一个抽象类 Tool。该框架还为一些创建图形对象实例并将它们加入文档中的工具预定义了一个 GraphicTool 子类。

但 GraphicTool 给框架设计者带来一个问题。音符和五线谱的类特定于我们的应用,而 GraphicTool 类却属于框架。GraphicTool 不知道如何创建我们的音乐类的实例,并将它们添加到乐谱中。我们可以为每一种音乐对象创建一个 GraphicTool 的子类,但这样会产生大量的子类,这些子类仅仅在它们所初始化的音乐对象的类别上有所不同。我们知道对象组合是比创建子类更灵活的一种选择。
问题是,该框架怎样用它来参数化 GraphicTool 的实例,而这些实例是有 Graphic 类所支持创建的。

解决办法是让 GraphicTool 通过拷贝或“克隆”一个 Graphic 子类的实例来创建新的 Graphic ,我们称这个实例为一个
原型
。GraphicTool 将它应该克隆和添加到文档中的原型作为参数。如果所有 Graphic 子类都支持 Clone 操作,那么 GraphicTool 可以克隆所有种类的 Graphic,如图。

因此在我们的音乐编辑器中,用于创建一个音乐对象的每一种工具都是一个用不同原型进行初始化的 GraphicTool 实例。通过克隆一个音乐对象的原型并将这个克隆添加到乐谱中,每个 GraphicTool 实例都会产生一个音乐对象。

我们甚至可以进一步使用 Prototype 模式来减少类的数目。我们使用不同的类来表示全音符和半音符,但可能不需要这么做。它们可以是使用不同位图和时延初始化的相同的类实例。一个创建全音符的工具就是这样的 GraphicTool,它的原型是一个被初始化成全音符的 MusicalNote。这样可以极大地减少系统中类的数目,同时也更易于在音乐编辑器中增加新的音符。

3、适用性

在下列情况下可以使用 Prototype 模式:

  • 当一个系统应该独立于它的产品创建、构成和表示时。
  • 当要实例化的类是在运行时指定时,例如,通过动态装载。
  • 为了避免创建一个与产品类层次平行的工厂类层次时。
  • 当一个类的实例只能有几个不同状态组合中的一种时。建立相应数目的原型并克隆它们可能比每次用合适的状态手工实例化该类更方便一些。

4、结构

5、参与者

Prototype(Graphic)

- 声明一个克隆自身的接口。

ConcretePrototype (Staff、WholeNote)

- 实现一个克隆自身的操作。

Client(GraphicTool)

- 让一个原型克隆自身从而创建一个新的对象。

6、协作

客户请求一个原型克隆自身。

7、效果

Prototype 有许多与 Abstract Factory 和 Builder 一样的效果:它对客户隐藏了具体的产品类,因此减少了客户知道的名字的数目。此外,这些模式使客户无须改变即可使用与特定应用相关的类。

下面列出 Prototype 模式的另外一些优点。

(1)运行时增加和删除产品
Prototype 允许值通过客户注册原型实例就将一个新的具体产品类并入系统。它比其他创建型模式更为灵活,因为客户可以在运行时建立和删除原型。

(2)改变值以指定新对象
高度动态的系统允许你通过对象组合定义新的行为——例如,通过一个对象变量指定值——并且不定义新的类。你通过实例化已有类并且将这些实例注册为客户对象的原型,就可以有效定义新类别的对象。客户可以将职责代理给原型,从而表现出新的行为。

这种设计使得用户无须编程即可定义新“类”。实际上,克隆一个原型类似于实例化一个类。Prototype 模式可以极大地减少系统所需要的类的数目。在我们的音乐编辑器中,一个 GraphicTool 类可以创建无数种音乐对象。

(3)改变结构以指定新对象
许多应用由部件和子部件来创建对象。例如电路设计编辑器就是由子电路来构造电路的。为方便起见,这样的应用通常允许你实例化复杂的、用户定义的结构,比方说,一次又一次地重复使用一个特定的子电路。

(4)减少类的构造
Factory Method 经常产生一个与产品类层次平行的 Creator 类层次。Prototype 模式使得你克隆一个原型而不是请求一个工厂方法去产生一个新的对象,因此你根本不需要 Creator 类层次。这一优点主要适用于像 C++ 这样不将类作为一级类对象的语言。

(5)用类动态配置应用
一些运行时环境允许你动态地将类装在到应用中。

一个希望创建动态载入类的实例的应用不能静态引用类的构造器,而应该由运行环境在载入时自动创建每个类的实例,并用原型管理器来注册这个实例。这样应用就可以向原型管理器请求新装载的类的实例,这些类原本并没有和程序相连接。

Prototype 的主要缺陷时每一个 Prototype 的子类都必须实现 Clone 操作。

8、实现

因为在像 C++ 这样的静态语言中,类不是对象,并且运行时只能得到很少或得不到任何类型信息,所以 Prototype 特别有用。而在 C# 这样的语言中 Prototype 就是不那么重要了,因为这些语言提供了一个等价于原型的东西(即类对象)来创建每个类的实例。

当实现原型时,要考虑下面一些问题:

(1)使用一个原型管理器  当一个系统中原型数目不固定时(也就是说,它们可以动态地创建和销毁),要保持一个可用原型的注册表。客户不会自己来管理原型,但会在注册表中存储和检索原型。客户在克隆一个原型前会向注册表请求该原型。我们称这个注册表为原型管理器(prototype manager)。

原型管理器是一个关联存储器,它返回一个与给定关键字相匹配的原型。它有一些操作可以用来通过关键字注册原型和解除注册。客户可以在运行时更改甚至浏览这个注册表。这使得客户无需编写代码就可以扩展并得到系统清单。

(2)实现克隆操作  Prototype 模式最困难的部分在于正确实现 Clone 操作。当对象结构包含循环引用时,这尤为棘手。

克隆一个结构复杂的原型通常需要深拷贝,因为复制对象和原对象必须相互独立。

如果系统中的对象提供了 Save 和 Load 操作,那么你只需通过保存对象和立刻载入对象,就可以为 Clone 操作提供一个缺省实现。Save 操作将该对象保存在内存缓冲中,而 Load 则通过从该缓冲区中重构这个对象来创建一个副本。

(3)初始化克隆对象  当一些客户对克隆对象已经相当满意时,另一些客户将会希望使用他们所选择的一些值来初始化该对象的一些或是所有的内部状态。一般来说不可能在 Clone 操作中传递这些值,因为这些值的数目会由于原型的类的不同而有所不同。一些原型可能需要多个初始化参数,另一些可能什么也不要。在 Clone 操作中传递参数会破坏克隆接口的统一性。

可能会出现这样的情况,即原型的类已经为设定一些关键的状态值定义好了操作。如果这样的话,客户在克隆后马上就可以使用这些操作。否则,你就可能不得不引入一个 Initialize 操作,该操作使用初始化参数并据此设定克隆对象的内部状态。注意深拷贝 Clone 操作——一些副本在你重新初始化它们之前可能必须删除掉(删除可以显式地做也可以在 Initialize 内部做)。

9、代码示例

我们将定义 MazeFactory 的子类 MazePrototypeFactory 。该子类将使用它要创建的对象的原型来初始化,这样我们就不需要仅仅为了改变它所创建的墙壁或房间的类而生成子类了。

MazePrototypeFactory 用一个以原型为参数的构造器来扩充 MazeFactory 接口:

public classMazePrototypeFactory:MazeFactory
{
privateMaze _maze;privateWall _wall;privateRoom _room;privateDoor _door;/// <summary> ///新的构造函数只初始化它的原型/// </summary> /// <param name="maze"></param> /// <param name="wall"></param> /// <param name="room"></param> /// <param name="door"></param> publicMazePrototypeFactory(Maze maze,
Wall wall,Room room,Door door)
{
_maze
=maze;
_wall
=wall;
_room
=room;
_door
=door;
}
//用于创建晴碧、房间和门的成员函数是相似的:每个都要克隆一个原型,然后初始化 public overrideMaze MakeMaze()
{
var maze =_maze.Clone();returnmaze;
}
public overrideWall MakeWall()
{
var wall =_wall.Clone();returnwall;
}
public override Room MakeRoom(intnum)
{
var room =_room.Clone();//还需要一个独立的操作来重新初始化内部状态 room.Initialize(num);returnroom;
}
public overrideDoor MakeDoor(Room r1, Room r2)
{
var door =_door.Clone();
door.Initialize(r1,r2);
returndoor;
}
}

使用基本迷宫构件的原型进行初始化:

MazeGame mazeGame;var mazePrototypeFactory = new MazePrototypeFactory(newMaze(),new Wall(),new Room(),newDoor());

Maze maze
= mazeGame.CreateMaze(mazePrototypeFactory);

为了改变迷宫的类型,我们用不同的原型集合来初始化 MazePrototypeFactory ,下面的调用用一个 BombedWall 和 RoomWithABomb 创建迷宫:

var bombedPrototypeFactory = new MazePrototypeFactory(newMaze(),new BombedWall(), new RoomWithABomb(), new Door());

Prototype 和 Abstract Factory 模式在某些方面是相互竞争的。但是它们也可以一起使用。Abstract Factory 可以存储一个被克隆的原型的集合,并且返回产品对象。

2024年4月11日发布了.NET Aspire预览5版本,这个版本引入了对AWS的支持,并对Azure功能进行了改进。重点内容包括拆分Aspire.Hosting和Aspire.Hosting.Azure包,改进了应用程序模型,支持自定义资源在仪表板中更新状态,增强了与Visual Studio的集成,以及改进了安全性。

.NET Aspire预览5引入了许多新功能和改进,最大的更新之一是需要添加对
Aspire.Hosting.AppHost
NuGet 包的引用。以下是其中一些新功能的具体实现方式:

  • 扩展方法重命名:通过重命名扩展方法来支持更多的云服务,这些更改将使我们能够更灵活地提供服务,并确保在 .NET Aspire 的核心抽象和应用程序可能需要的各种云原生依赖项之间保持适当的边界。例如下表映射了您目前在 AppHost 中可能使用的 Aspire 扩展方法和包之间的映射 它们现在包含在其中:

image

这样可以更明确地指明正在向WebApplicationBuilder或HostApplicationBuilder中添加一个“客户端”对象。Azure上的各种服务资源也拆到单独的包里:

image

  • 应用程序模型变更,包括支持非安全传输、自定义资源等
  • 支持非安全传输:允许应用程序URL配置为使用非安全传输,通过设置环境变量ASPIRE_ALLOW_UNSECURED_TRANSPORT来实现。
  • 仪表板中的自定义资源:允许自定义资源在仪表板中更新其状态并记录控制台输出,通过DI容器中的ResourceNotificationService和ResourceLoggerService实现。
  • 改进持久化:改进了为许多基于容器的 .NET Aspire 资源配置容器重启之间的持久性的便利性。现在可以通过使用扩展方法在许多容器上启用持久性。通过新的扩展方法,如使用VolumeMount()和DataVolume(),可以在容器重启之间实现持久化。
  • 自动生成密码:在 Aspire 的早期预览版中,当资源添加到应用模型时,每个资源都会创建一个随机密码,如果需要,则采用可选的密码参数。在预览版 5 中,我们修改了 API 以采用用户名和密码的参数。如果省略这些参数,则会自动将具有默认随机值的参数注入到应用程序模型中。新增的IResourceBuilder参数用于指定用户名和密码,如果不提供这些参数,将自动注入一个带有默认随机值的参数。
  • Docker构建参数:通过PublishAsDockerfile()方法接受IEnumerable参数,以在构建时传递参数。
  • 安全性增强:通过TLS/SSL加密通信,并使用客户端证书或API密钥进行身份验证。
  • 性能改进:通过虚拟化技术优化大量数据渲染。

Azure资源改进:

  • Azure事件中心:通过AddAzureEventHubs()方法添加Azure事件中心。
  • NATS:通过AddNats()方法添加NATS消息队列。
  • Seq:通过AddSeq()方法添加Seq日志记录系统。

新组件:

  • Azure SignalR:通过AddAzureSignalR()方法添加Azure SignalR服务。
  • Azure AI Search:通过AddAzureSearch()方法添加Azure AI搜索文档。
  • Azure Application Insights:用于.NET Aspire遥测。

数据库管理工具

  • phpMyAdmin:用于管理MySQL数据库。
  • mongo-express:用于管理MongoDB数据库。
  • Redis Commander:用于管理Redis缓存。
  • PostgreSQL PgAdmin:用于管理PostgreSQL数据库。

这些新功能和改进主要通过扩展方法、DI容器、环境变量、构建参数等方式实现,以提供更灵活、安全、易用的.NET Aspire应用程序,使得开发者可以更轻松地集成和管理各种服务和数据库。更详细内容参考
.NET Aspire 预览版 5 - .NET Aspire |Microsoft学习