团结

团结 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织 yaotuanjie.com 编辑
编辑

诗酒趁年华

个人动态

团结 收藏了问题 · 1月7日

nginx+keepalived 集群最少需要几台机器?

小弟在研究nginx+keepalived 集群,查了一些网上的教程,但是对需要几台服务器说的不是很明白。

按照教程,nginx+keepalived 只安装到两台机器即可有“高可用负载均衡”的效果。
教程地址:http://blog.csdn.net/l1028386...

我以为至少需要四台机器,前两台做主从keepalived,后两台做nginx主从,nginx后有几台web服务器。
也就是keepalived给nginx做负载均衡,nginx给web服务器做负载均衡。

我有两个问题
1、nginx+keepalived 集群最少需要几台机器?
2、如果最少需要两台,那正式生产环境也适用吗?

团结 收藏了文章 · 2020-05-11

分布式WebSocket集群解决方案


问题起因

最近做项目时遇到了需要多用户之间通信的问题,涉及到了WebSocket握手请求,以及集群中WebSocket Session共享的问题。

期间我经过了几天的研究,总结出了几个实现分布式WebSocket集群的办法,从zuul到spring cloud gateway的不同尝试,总结出了这篇文章,希望能帮助到某些人,并且能一起分享这方面的想法与研究。

以下是我的场景描述

  • 资源:4台服务器。其中只有一台服务器具备ssl认证域名,一台redis+mysql服务器,两台应用服务器(集群)
  • 应用发布限制条件:由于场景需要,应用场所需要ssl认证的域名才能发布。因此ssl认证的域名服务器用来当api网关,负责https请求与wss(安全认证的ws)连接。俗称https卸载,用户请求https域名服务器(eg:https://oiscircle.com/xxx),但真实访问到的是http+ip地址的形式。只要网关配置高,能handle多个应用
  • 需求:用户登录应用,需要与服务器建立wss连接,不同角色之间可以单发消息,也可以群发消息
  • 集群中的应用服务类型:每个集群实例都负责http无状态请求服务与ws长连接服务

系统架构图

clipboard.png

在我的实现里,每个应用服务器都负责http and ws请求,其实也可以将ws请求建立的聊天模型单独成立为一个模块。从分布式的角度来看,这两种实现类型差不多,但从实现方便性来说,一个应用服务http+ws请求的方式更为方便。下文会有解释

本文涉及的技术栈

  • Eureka 服务发现与注册
  • Redis Session共享
  • Redis 消息订阅
  • Spring Boot
  • Zuul 网关
  • Spring Cloud Gateway 网关
  • Spring WebSocket 处理长连接
  • Ribbon 负载均衡
  • Netty 多协议NIO网络通信框架
  • Consistent Hash 一致性哈希算法

相信能走到这一步的人都了解过我上面列举的技术栈了,如果还没有,可以先去网上找找入门教程了解一下。下面的内容都与上述技术相关,题主默认大家都了解过了...
这里是描述一致性Hash算法最易懂的文章传送门

技术可行性分析

下面我将描述session特性,以及根据这些特性列举出n个解决分布式架构中处理ws请求的集群方案

WebSocketSession与HttpSession
在Spring所集成的WebSocket里面,每个ws连接都有一个对应的session:WebSocketSession,在Spring WebSocket中,我们建立ws连接之后可以通过类似这样的方式进行与客户端的通信:

protected void handleTextMessage(WebSocketSession session, TextMessage message) {
   System.out.println("服务器接收到的消息: "+ message );
   //send message to client
   session.sendMessage(new TextMessage("message"));
}

那么问题来了:ws的session无法序列化到redis,因此在集群中,我们无法将所有WebSocketSession都缓存到redis进行session共享。每台服务器都有各自的session。于此相反的是HttpSession,redis可以支持httpsession共享,但是目前没有websocket session共享的方案,因此走redis websocket session共享这条路是行不通的
有的人可能会想:我可不可以将sessin关键信息缓存到redis,集群中的服务器从redis拿取session关键信息然后重新构建websocket session...我只想说这种方法如果有人能试出来,请告诉我一声...

以上便是websocket session与http session共享的区别,总的来说就是http session共享已经有解决方案了,而且很简单,只要引入相关依赖:spring-session-data-redisspring-boot-starter-redis,大家可以从网上找个demo玩一下就知道怎么做了。而websocket session共享的方案由于websocket底层实现的方式,我们无法做到真正的websocket session共享。

解决方案的演变

Netty与Spring WebSocket

刚开始的时候,我尝试着用netty实现了websocket服务端的搭建。在netty里面,并没有websocket session这样的概念,与其类似的是channel,每一个客户端连接都代表一个channel。前端的ws请求通过netty监听的端口,走websocket协议进行ws握手连接之后,通过一些列的handler(责链模式)进行消息处理。与websocket session类似地,服务端在连接建立后有一个channel,我们可以通过channel进行与客户端的通信

   /**
    * TODO 根据服务器传进来的id,分配到不同的group
    */
   private static final ChannelGroup GROUP = new DefaultChannelGroup(ImmediateEventExecutor.INSTANCE);

   @Override
   protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
       //retain增加引用计数,防止接下来的调用引用失效
       System.out.println("服务器接收到来自 " + ctx.channel().id() + " 的消息: " + msg.text());
       //将消息发送给group里面的所有channel,也就是发送消息给客户端
       GROUP.writeAndFlush(msg.retain());
   }

那么,服务端用netty还是用spring websocket?以下我将从几个方面列举这两种实现方式的优缺点

  • 使用netty实现websocket

    玩过netty的人都知道netty是的线程模型是nio模型,并发量非常高,spring5之前的网络线程模型是servlet实现的,而servlet不是nio模型,所以在spring5之后,spring的底层网络实现采用了netty。如果我们单独使用netty来开发websocket服务端,速度快是绝对的,但是可能会遇到下列问题:
    1.与系统的其他应用集成不方便,在rpc调用的时候,无法享受springcloud里feign服务调用的便利性
    2.业务逻辑可能要重复实现
    3.使用netty可能需要重复造轮子
    4.怎么连接上服务注册中心,也是一件麻烦的事情
    5.restful服务与ws服务需要分开实现,如果在netty上实现restful服务,有多麻烦可想而知,用spring一站式restful开发相信很多人都习惯了。

  • 使用spring websocket实现ws服务

    spring websocket已经被springboot很好地集成了,所以在springboot上开发ws服务非常方便,做法非常简单
    第一步:添加依赖

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

    第二步:添加配置类

    @Configuration
    public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(myHandler(), "/")
            .setAllowedOrigins("*");
    }
    
    @Bean
     public WebSocketHandler myHandler() {
         return new MessageHandler();
     }
    }

    第三步:实现消息监听类

    @Component
    @SuppressWarnings("unchecked")
    public class MessageHandler extends TextWebSocketHandler {
       private List<WebSocketSession> clients = new ArrayList<>();
    
       @Override
       public void afterConnectionEstablished(WebSocketSession session) {
           clients.add(session);
           System.out.println("uri :" + session.getUri());
           System.out.println("连接建立: " + session.getId());
           System.out.println("current seesion: " + clients.size());
       }
    
       @Override
       public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
           clients.remove(session);
           System.out.println("断开连接: " + session.getId());
       }
    
       @Override
       protected void handleTextMessage(WebSocketSession session, TextMessage message) {
           String payload = message.getPayload();
           Map<String, String> map = JSONObject.parseObject(payload, HashMap.class);
           System.out.println("接受到的数据" + map);
           clients.forEach(s -> {
               try {
                   System.out.println("发送消息给: " + session.getId());
                   s.sendMessage(new TextMessage("服务器返回收到的信息," + payload));
               } catch (Exception e) {
                   e.printStackTrace();
               }
           });
       }
    }

    从这个demo中,使用spring websocket实现ws服务的便利性大家可想而知了。为了能更好地向spring cloud大家族看齐,我最终采用了spring websocket实现ws服务。
    因此我的应用服务架构是这样子的:一个应用既负责restful服务,也负责ws服务。没有将ws服务模块拆分是因为拆分出去要使用feign来进行服务调用。第一本人比较懒惰,第二拆分与不拆分相差在多了一层服务间的io调用,所以就没有这么做了。

从zuul技术转型到spring cloud gateway

要实现websocket集群,我们必不可免地得从zuul转型到spring cloud gateway。原因如下:

zuul1.0版本不支持websocket转发,zuul 2.0开始支持websocket,zuul2.0几个月前开源了,但是2.0版本没有被spring boot集成,而且文档不健全。因此转型是必须的,同时转型也很容易实现。

在gateway中,为了实现ssl认证和动态路由负载均衡,yml文件中以下的某些配置是必须的,在这里提前避免大家采坑
server:
  port: 443
  ssl:
    enabled: true
    key-store: classpath:xxx.jks
    key-store-password: xxxx
    key-store-type: JKS
    key-alias: alias
spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      httpclient:
        ssl:
          handshake-timeout-millis: 10000
          close-notify-flush-timeout-millis: 3000
          close-notify-read-timeout-millis: 0
          useInsecureTrustManager: true
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
      routes:
      - id: dc
        uri: lb://dc
        predicates:
        - Path=/dc/**
      - id: wecheck
        uri: lb://wecheck
        predicates:
        - Path=/wecheck/**

如果要愉快地玩https卸载,我们还需要配置一个filter,否则请求网关时会出现错误not an SSL/TLS record

@Component
public class HttpsToHttpFilter implements GlobalFilter, Ordered {
  private static final int HTTPS_TO_HTTP_FILTER_ORDER = 10099;
  @Override
  public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
      URI originalUri = exchange.getRequest().getURI();
      ServerHttpRequest request = exchange.getRequest();
      ServerHttpRequest.Builder mutate = request.mutate();
      String forwardedUri = request.getURI().toString();
      if (forwardedUri != null && forwardedUri.startsWith("https")) {
          try {
              URI mutatedUri = new URI("http",
                      originalUri.getUserInfo(),
                      originalUri.getHost(),
                      originalUri.getPort(),
                      originalUri.getPath(),
                      originalUri.getQuery(),
                      originalUri.getFragment());
              mutate.uri(mutatedUri);
          } catch (Exception e) {
              throw new IllegalStateException(e.getMessage(), e);
          }
      }
      ServerHttpRequest build = mutate.build();
      ServerWebExchange webExchange = exchange.mutate().request(build).build();
      return chain.filter(webExchange);
  }

  @Override
  public int getOrder() {
      return HTTPS_TO_HTTP_FILTER_ORDER;
  }

}

这样子我们就可以使用gateway来卸载https请求了,到目前为止,我们的基本框架已经搭建完毕,网关既可以转发https请求,也可以转发wss请求。接下来就是用户多对多之间session互通的通讯解决方案了。接下来,我将根据方案的优雅性,从最不优雅的方案开始讲起。

session广播

这是最简单的websocket集群通讯解决方案。场景如下:
教师A想要群发消息给他的学生们

  1. 教师的消息请求发给网关,内容包含{我是教师A,我想把xxx消息发送我的学生们}
  2. 网关接收到消息,获取集群所有ip地址,逐个调用教师的请求
  3. 集群中的每台服务器获取请求,根据教师A的信息查找本地有没有与学生关联的session,有则调用sendMessage方法,没有则忽略请求

clipboard.png

session广播实现很简单,但是有一个致命缺陷:计算力浪费现象,当服务器没有消息接收者session的时候,相当于浪费了一次循环遍历的计算力,该方案在并发需求不高的情况下可以优先考虑,实现很容易。

spring cloud中获取服务集群中每台服务器信息的方法如下
@Resource
private EurekaClient eurekaClient;

Application app = eurekaClient.getApplication("service-name");
//instanceInfo包括了一台服务器ip,port等消息
InstanceInfo instanceInfo = app.getInstances().get(0);
System.out.println("ip address: " + instanceInfo.getIPAddr());
服务器需要维护关系映射表,将用户的id与session做映射,session建立时在映射表中添加映射关系,session断开后要删除映射表内关联关系

一致性哈希算法实现(本文的要点)

这种方法是本人认为最优雅的实现方案,理解这种方案需要一定的时间,如果你耐心看下去,相信你一定会有所收获。再强调一次,不了解一致性哈希算法的同学请先看这里,现先假设哈希环是顺时针查找的。

首先,想要将一致性哈希算法的思想应用到我们的websocket集群,我们需要解决以下新问题:

  1. 集群节点DOWN,会影响到哈希环映射到状态是DOWN的节点。
  2. 集群节点UP,会影响到旧key映射不到对应的节点。
  3. 哈希环读写共享。
在集群中,总会出现服务UP/DOWN的问题。

针对节点DOWN的问题分析如下:

一个服务器DOWN的时候,其拥有的websocket session会自动关闭连接,并且前端会收到通知。此时会影响到哈希环的映射错误。我们只需要当监听到服务器DOWN的时候,删除哈希环上面对应的实际结点和虚结点,避免让网关转发到状态是DOWN的服务器上。
实现方法:在eureka治理中心监听集群服务DOWN事件,并及时更新哈希环。

针对节点UP的问题分析如下:

现假设集群中有服务CacheB上线了,该服务器的ip地址刚好被映射到key1和cacheA之间。那么key1对应的用户每次要发消息时都跑去CacheB发送消息,结果明显是发送不了消息,因为CacheB没有key1对应的session。

clipboard.png

此时我们有两种解决方案。
方案A简单,动作大:
eureka监听到节点UP事件之后,根据现有集群信息,更新哈希环。并且断开所有session连接,让客户端重新连接,此时客户端会连接到更新后的哈希环节点,以此避免消息无法送达的情况。
方案B复杂,动作小:
我们先看看没有虚拟节点的情况,假设CacheCCacheA之间上线了服务器CacheB。所有映射在CacheCCacheB的用户发消息时都会去CacheB里面找session发消息。也就是说CacheB一但上线,便会影响到CacheCCacheB之间的用户发送消息。所以我们只需要将CacheA断开CacheCCacheB的用户所对应的session,让客户端重连。

clipboard.png

接下来是有虚拟节点的情况,假设浅色的节点是虚拟节点。我们用长括号来代表某段区域映射的结果属于某个Cache。首先是C节点未上线的情况。图大家应该都懂吧,所有B的虚拟节点都会指向真实的B节点,所以所有B节点逆时针那一部分都会映射到B(因为我们规定哈希环顺时针查找)。

clipboard.png

接下来是C节点上线的情况,可以看到某些区域被C占领了。

clipboard.png

由以上情况我们可以知道:节点上线,会有许多对应虚拟节点也同时上线,因此我们需要将多段范围key对应的session断开连接(上图红色的部分)。具体算法有点复杂,实现的方式因人而异,大家可以尝试一下自己实现算法。

哈希环应该放在哪里?

  1. gateway本地创建并维护哈希环。当ws请求进来的时候,本地获取哈希环并获取映射服务器信息,转发ws请求。这种方法看上去不错,但实际上是不太可取的,回想一下上面服务器DOWN的时候只能通过eureka监听,那么eureka监听到DOWN事件之后,需要通过io来通知gateway删除对应节点吗?显然太麻烦了,将eureka的职责分散到gateway,不建议这么做。
  2. eureka创建,并放到redis共享读写。这个方案可行,当eureka监听到服务DOWN的时候,修改哈希环并推送到redis上。为了请求响应时间尽量地短,我们不可以让gateway每次转发ws请求的时候都去redis取一次哈希环。哈希环修改的概率的确很低,gateway只需要应用redis的消息订阅模式,订阅哈希环修改事件便可以解决此问题。
至此我们的spring websocket集群已经搭建的差不多了,最重要的地方还是一致性哈希算法。现在有最后一个技术瓶颈,网关如何根据ws请求转发到指定的集群服务器上?答案在负载均衡。spring cloud gateway或zuul都默认集成了ribbon作为负载均衡,我们只需要根据建立ws请求时客户端发来的user id,重写ribbon负载均衡算法,根据user id进行hash,并在哈希环上寻找ip,并将ws请求转发到该ip便完事了。流程如下图所示:

clipboard.png

接下来用户沟通的时候,只需要根据id进行hash,在哈希环上获取对应ip,便可以知道与该用户建立ws连接时的session存在哪台服务器上了!

spring cloud Finchley.RELEASE 版本中ribbon未完善的地方

题主在实际操作的时候发现了ribbon两个不完善的地方......

  1. 根据网上找的方法,继承AbstractLoadBalancerRule重写负载均衡策略之后,多个不同应用的请求变得混乱。假如eureka上有两个service A和B,重写负载均衡策略之后,请求A或B的服务,最终只会映射到其中一个服务上。非常奇怪!可能spring cloud gateway官网需要给出一个正确的重写负载均衡策略的demo。
  2. 一致性哈希算法需要一个key,类似user id,根据key进行hash之后在哈希环上搜索并返回ip。但是ribbon没有完善choose函数的key参数,直接写死了default

clipboard.png

难道这样子我们就没有办法了吗?其实还有一个可行并且暂时可替代的办法!
如下图所示,客户端发送一个普通的http请求(包含id参数)给网关,网关根据id进行hash,在哈希环中寻找ip地址,将ip地址返回给客户端,客户端再根据该ip地址进行ws请求。

clipboard.png

由于ribbon未完善key的处理,我们暂时无法在ribbon上实现一致性哈希算法。只能间接地通过客户端发起两次请求(一次http,一次ws)的方式来实现一致性哈希。希望不久之后ribbon能更新这个缺陷!让我们的websocket集群实现得更优雅一点。

后记

以上便是我这几天探索的结果。期间遇到了许多问题,并逐一解决难题,列出两个websocket集群解决方案。第一个是session广播,第二个是一致性哈希。这两种方案针对不同场景各有优缺点,本文并未用到ActiveMQ,Karfa等消息队列实现消息推送,只是想通过自己的想法,不依靠消息队列来简单地实现多用户之间的长连接通讯。希望能为大家提供一条不同于寻常的思路。

查看原文

团结 关注了标签 · 2020-04-28

关注 1

团结 收藏了文章 · 2020-04-26

spring-cloud-gateway动态路由

概述

线上项目发布一般有以下几种方案:

  1. 停机发布
  2. 蓝绿部署
  3. 滚动部署
  4. 灰度发布

停机发布 这种发布一般在夜里或者进行大版本升级的时候发布,因为需要停机,所以现在大家都在研究 Devops 方案。

蓝绿部署 需要准备两个相同的环境。一个环境新版本,一个环境旧版本,通过负载均衡进行切换与回滚,目的是为了减少服务停止时间。

滚动部署 就是在升级过程中,并不一下子启动所有新版本,是先启动一台新版本,再停止一台老版本,然后再启动一台新版本,再停止一台老版本,直到升级完成。基于 k8s 的升级方案默认就是滚动部署。

灰度发布 也叫金丝雀发布,灰度发布中,常常按照用户设置路由权重,例如 90%的用户维持使用老版本,10%的用户尝鲜新版本。不同版本应用共存,经常与 A/B 测试一起使用,用于测试选择多种方案。

上边介绍的几种发布方案,主要是引出我们接下来介绍的 spring-cloud-gateway 动态路由,我们可以基于动态路由、负载均衡和策略加载去实现 灰度发布。当然现在有很多开源的框架可以实现 灰度发布,这里只是研究学习。

动态路由

spring-cloud-gateway 默认将路由加载在内存中。具体可以参见 InMemoryRouteDefinitionRepository 类的实现。

这里我们基于 Redis 实现动态路由。基础项目见 spring-cloud-gateway 简介

1. 将 actuator 的端点暴露出来。

management:
  endpoints:
    web:
      exposure:
        include: "*"

2. redis 配置

@Configuration
public class RedisConfig {

    @Bean(name = {"redisTemplate", "stringRedisTemplate"})
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        StringRedisTemplate redisTemplate = new StringRedisTemplate();
        redisTemplate.setConnectionFactory(factory);
        return redisTemplate;
    }

}

3. 将原内存路由持久化到 redis

@Component
public class RedisRouteDefinitionRepository implements RouteDefinitionRepository {

    /**
     * hash存储的key
     */
    public static final String GATEWAY_ROUTES = "gateway_dynamic_route";

    @Resource
    private StringRedisTemplate redisTemplate;

    /**
     * 获取路由信息
     * @return
     */
    @Override
    public Flux<RouteDefinition> getRouteDefinitions() {
        List<RouteDefinition> routeDefinitions = new ArrayList<>();
        redisTemplate.opsForHash().values(GATEWAY_ROUTES).stream()
                .forEach(routeDefinition -> routeDefinitions.add(JSON.parseObject(routeDefinition.toString(), RouteDefinition.class)));
        return Flux.fromIterable(routeDefinitions);
    }

    @Override
    public Mono<Void> save(Mono<RouteDefinition> route) {
        return route.flatMap(routeDefinition -> {
            redisTemplate.opsForHash().put(GATEWAY_ROUTES, routeDefinition.getId(), JSONObject.toJSONString(routeDefinition));
            return Mono.empty();
        });
    }

    @Override
    public Mono<Void> delete(Mono<String> routeId) {
        return routeId.flatMap(id -> {
            if (redisTemplate.opsForHash().hasKey(GATEWAY_ROUTES, id)) {
                redisTemplate.opsForHash().delete(GATEWAY_ROUTES, id);
                return Mono.empty();
            }
            return Mono.defer(() -> Mono.error(new NotFoundException("route definition is not found, routeId:" + routeId)));
        });
    }

}

4. 重写动态路由服务

@Service
public class GatewayDynamicRouteService implements ApplicationEventPublisherAware {

    @Resource
    private RedisRouteDefinitionRepository redisRouteDefinitionRepository;

    private ApplicationEventPublisher applicationEventPublisher;

    /**
     * 增加路由
     * @param routeDefinition
     * @return
     */
    public int add(RouteDefinition routeDefinition) {
        redisRouteDefinitionRepository.save(Mono.just(routeDefinition)).subscribe();
        applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this));
        return 1;
    }

    /**
     * 更新
     * @param routeDefinition
     * @return
     */
    public int update(RouteDefinition routeDefinition) {
        redisRouteDefinitionRepository.delete(Mono.just(routeDefinition.getId()));
        redisRouteDefinitionRepository.save(Mono.just(routeDefinition)).subscribe();
        applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this));
        return 1;
    }

    /**
     * 删除
     * @param id
     * @return
     */
    public Mono<ResponseEntity<Object>> delete(String id) {
        return redisRouteDefinitionRepository.delete(Mono.just(id)).then(Mono.defer(() -> Mono.just(ResponseEntity.ok().build())))
                .onErrorResume(t -> t instanceof NotFoundException, t -> Mono.just(ResponseEntity.notFound().build()));
    }


    @Override
    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
        this.applicationEventPublisher = applicationEventPublisher;
    }
}

5. 对外暴露接口

@RestController
@RequestMapping("/gateway")
public class GatewayDynamicRouteController {

    @Resource
    private GatewayDynamicRouteService gatewayDynamicRouteService;

    @PostMapping("/add")
    public String create(@RequestBody RouteDefinition entity) {
        int result = gatewayDynamicRouteService.add(entity);
        return String.valueOf(result);
    }

    @PostMapping("/update")
    public String update(@RequestBody RouteDefinition entity) {
        int result = gatewayDynamicRouteService.update(entity);
        return String.valueOf(result);
    }

    @DeleteMapping("/delete/{id}")
    public Mono<ResponseEntity<Object>> delete(@PathVariable String id) {
        return gatewayDynamicRouteService.delete(id);
    }

}

测试

测试前删除我们配置的静态路由,因为静态路由和 redis 动态路由同时存在时取并集。

  1. 访问 http://localhost:2000/actuato... , 可以看到只有默认路由。
[
    {
        "route_id": "CompositeDiscoveryClient_consul",
        "route_definition": {
            "id": "CompositeDiscoveryClient_consul",
            "predicates": [
                {
                    "name": "Path",
                    "args": {
                        "pattern": "/consul/**"
                    }
                }
            ],
            "filters": [
                {
                    "name": "RewritePath",
                    "args": {
                        "regexp": "/consul/(?<remaining>.*)",
                        "replacement": "/${remaining}"
                    }
                }
            ],
            "uri": "lb://consul",
            "order": 0
        },
        "order": 0
    },
    {
        "route_id": "CompositeDiscoveryClient_idc-gateway",
        "route_definition": {
            "id": "CompositeDiscoveryClient_idc-gateway",
            "predicates": [
                {
                    "name": "Path",
                    "args": {
                        "pattern": "/idc-gateway/**"
                    }
                }
            ],
            "filters": [
                {
                    "name": "RewritePath",
                    "args": {
                        "regexp": "/idc-gateway/(?<remaining>.*)",
                        "replacement": "/${remaining}"
                    }
                }
            ],
            "uri": "lb://idc-gateway",
            "order": 0
        },
        "order": 0
    },
    {
        "route_id": "CompositeDiscoveryClient_idc-provider1",
        "route_definition": {
            "id": "CompositeDiscoveryClient_idc-provider1",
            "predicates": [
                {
                    "name": "Path",
                    "args": {
                        "pattern": "/idc-provider1/**"
                    }
                }
            ],
            "filters": [
                {
                    "name": "RewritePath",
                    "args": {
                        "regexp": "/idc-provider1/(?<remaining>.*)",
                        "replacement": "/${remaining}"
                    }
                }
            ],
            "uri": "lb://idc-provider1",
            "order": 0
        },
        "order": 0
    },
    {
        "route_id": "CompositeDiscoveryClient_idc-provider2",
        "route_definition": {
            "id": "CompositeDiscoveryClient_idc-provider2",
            "predicates": [
                {
                    "name": "Path",
                    "args": {
                        "pattern": "/idc-provider2/**"
                    }
                }
            ],
            "filters": [
                {
                    "name": "RewritePath",
                    "args": {
                        "regexp": "/idc-provider2/(?<remaining>.*)",
                        "replacement": "/${remaining}"
                    }
                }
            ],
            "uri": "lb://idc-provider2",
            "order": 0
        },
        "order": 0
    }
]

这个时候访问 http://192.168.124.5:2000/idc... 根据结果可以推测能正确路由到 provider1, 测试结果一致。

  1. 创建 provider1 路由,将路径设置为 /p1/**,测试是否生效。

POST 请求 http://localhost:2000/gateway...

{
   "id":"provider1",
   "predicates":[
      {
         "name":"Path",
         "args":{
            "_genkey_0":"/p1/**"
         }
      },
      {
         "name":"RemoteAddr",
         "args":{
            "_genkey_0":"192.168.124.5/16"
         }
      }
   ],
   "filters":[
      {
         "name":"StripPrefix",
         "args":{
            "_genkey_0":"1"
         }
      }
   ],
   "uri":"lb://idc-provider1",
   "order":0
}

查看 redis 存储,或者请求 http://localhost:2000/actuato... , 都可以看到配置成功。

访问

curl http://localhost:2000/p1/provider1/1

结果输出 2001,与期望一致。

由此可见动态路由已经生效。

结语

本文到此结束。感兴趣的小伙伴后续可以通过加载配置文件,基于权重进行灰度。欢迎大家关注公众号【当我遇上你】。

查看原文

团结 赞了文章 · 2020-04-26

spring-cloud-gateway动态路由

概述

线上项目发布一般有以下几种方案:

  1. 停机发布
  2. 蓝绿部署
  3. 滚动部署
  4. 灰度发布

停机发布 这种发布一般在夜里或者进行大版本升级的时候发布,因为需要停机,所以现在大家都在研究 Devops 方案。

蓝绿部署 需要准备两个相同的环境。一个环境新版本,一个环境旧版本,通过负载均衡进行切换与回滚,目的是为了减少服务停止时间。

滚动部署 就是在升级过程中,并不一下子启动所有新版本,是先启动一台新版本,再停止一台老版本,然后再启动一台新版本,再停止一台老版本,直到升级完成。基于 k8s 的升级方案默认就是滚动部署。

灰度发布 也叫金丝雀发布,灰度发布中,常常按照用户设置路由权重,例如 90%的用户维持使用老版本,10%的用户尝鲜新版本。不同版本应用共存,经常与 A/B 测试一起使用,用于测试选择多种方案。

上边介绍的几种发布方案,主要是引出我们接下来介绍的 spring-cloud-gateway 动态路由,我们可以基于动态路由、负载均衡和策略加载去实现 灰度发布。当然现在有很多开源的框架可以实现 灰度发布,这里只是研究学习。

动态路由

spring-cloud-gateway 默认将路由加载在内存中。具体可以参见 InMemoryRouteDefinitionRepository 类的实现。

这里我们基于 Redis 实现动态路由。基础项目见 spring-cloud-gateway 简介

1. 将 actuator 的端点暴露出来。

management:
  endpoints:
    web:
      exposure:
        include: "*"

2. redis 配置

@Configuration
public class RedisConfig {

    @Bean(name = {"redisTemplate", "stringRedisTemplate"})
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        StringRedisTemplate redisTemplate = new StringRedisTemplate();
        redisTemplate.setConnectionFactory(factory);
        return redisTemplate;
    }

}

3. 将原内存路由持久化到 redis

@Component
public class RedisRouteDefinitionRepository implements RouteDefinitionRepository {

    /**
     * hash存储的key
     */
    public static final String GATEWAY_ROUTES = "gateway_dynamic_route";

    @Resource
    private StringRedisTemplate redisTemplate;

    /**
     * 获取路由信息
     * @return
     */
    @Override
    public Flux<RouteDefinition> getRouteDefinitions() {
        List<RouteDefinition> routeDefinitions = new ArrayList<>();
        redisTemplate.opsForHash().values(GATEWAY_ROUTES).stream()
                .forEach(routeDefinition -> routeDefinitions.add(JSON.parseObject(routeDefinition.toString(), RouteDefinition.class)));
        return Flux.fromIterable(routeDefinitions);
    }

    @Override
    public Mono<Void> save(Mono<RouteDefinition> route) {
        return route.flatMap(routeDefinition -> {
            redisTemplate.opsForHash().put(GATEWAY_ROUTES, routeDefinition.getId(), JSONObject.toJSONString(routeDefinition));
            return Mono.empty();
        });
    }

    @Override
    public Mono<Void> delete(Mono<String> routeId) {
        return routeId.flatMap(id -> {
            if (redisTemplate.opsForHash().hasKey(GATEWAY_ROUTES, id)) {
                redisTemplate.opsForHash().delete(GATEWAY_ROUTES, id);
                return Mono.empty();
            }
            return Mono.defer(() -> Mono.error(new NotFoundException("route definition is not found, routeId:" + routeId)));
        });
    }

}

4. 重写动态路由服务

@Service
public class GatewayDynamicRouteService implements ApplicationEventPublisherAware {

    @Resource
    private RedisRouteDefinitionRepository redisRouteDefinitionRepository;

    private ApplicationEventPublisher applicationEventPublisher;

    /**
     * 增加路由
     * @param routeDefinition
     * @return
     */
    public int add(RouteDefinition routeDefinition) {
        redisRouteDefinitionRepository.save(Mono.just(routeDefinition)).subscribe();
        applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this));
        return 1;
    }

    /**
     * 更新
     * @param routeDefinition
     * @return
     */
    public int update(RouteDefinition routeDefinition) {
        redisRouteDefinitionRepository.delete(Mono.just(routeDefinition.getId()));
        redisRouteDefinitionRepository.save(Mono.just(routeDefinition)).subscribe();
        applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this));
        return 1;
    }

    /**
     * 删除
     * @param id
     * @return
     */
    public Mono<ResponseEntity<Object>> delete(String id) {
        return redisRouteDefinitionRepository.delete(Mono.just(id)).then(Mono.defer(() -> Mono.just(ResponseEntity.ok().build())))
                .onErrorResume(t -> t instanceof NotFoundException, t -> Mono.just(ResponseEntity.notFound().build()));
    }


    @Override
    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
        this.applicationEventPublisher = applicationEventPublisher;
    }
}

5. 对外暴露接口

@RestController
@RequestMapping("/gateway")
public class GatewayDynamicRouteController {

    @Resource
    private GatewayDynamicRouteService gatewayDynamicRouteService;

    @PostMapping("/add")
    public String create(@RequestBody RouteDefinition entity) {
        int result = gatewayDynamicRouteService.add(entity);
        return String.valueOf(result);
    }

    @PostMapping("/update")
    public String update(@RequestBody RouteDefinition entity) {
        int result = gatewayDynamicRouteService.update(entity);
        return String.valueOf(result);
    }

    @DeleteMapping("/delete/{id}")
    public Mono<ResponseEntity<Object>> delete(@PathVariable String id) {
        return gatewayDynamicRouteService.delete(id);
    }

}

测试

测试前删除我们配置的静态路由,因为静态路由和 redis 动态路由同时存在时取并集。

  1. 访问 http://localhost:2000/actuato... , 可以看到只有默认路由。
[
    {
        "route_id": "CompositeDiscoveryClient_consul",
        "route_definition": {
            "id": "CompositeDiscoveryClient_consul",
            "predicates": [
                {
                    "name": "Path",
                    "args": {
                        "pattern": "/consul/**"
                    }
                }
            ],
            "filters": [
                {
                    "name": "RewritePath",
                    "args": {
                        "regexp": "/consul/(?<remaining>.*)",
                        "replacement": "/${remaining}"
                    }
                }
            ],
            "uri": "lb://consul",
            "order": 0
        },
        "order": 0
    },
    {
        "route_id": "CompositeDiscoveryClient_idc-gateway",
        "route_definition": {
            "id": "CompositeDiscoveryClient_idc-gateway",
            "predicates": [
                {
                    "name": "Path",
                    "args": {
                        "pattern": "/idc-gateway/**"
                    }
                }
            ],
            "filters": [
                {
                    "name": "RewritePath",
                    "args": {
                        "regexp": "/idc-gateway/(?<remaining>.*)",
                        "replacement": "/${remaining}"
                    }
                }
            ],
            "uri": "lb://idc-gateway",
            "order": 0
        },
        "order": 0
    },
    {
        "route_id": "CompositeDiscoveryClient_idc-provider1",
        "route_definition": {
            "id": "CompositeDiscoveryClient_idc-provider1",
            "predicates": [
                {
                    "name": "Path",
                    "args": {
                        "pattern": "/idc-provider1/**"
                    }
                }
            ],
            "filters": [
                {
                    "name": "RewritePath",
                    "args": {
                        "regexp": "/idc-provider1/(?<remaining>.*)",
                        "replacement": "/${remaining}"
                    }
                }
            ],
            "uri": "lb://idc-provider1",
            "order": 0
        },
        "order": 0
    },
    {
        "route_id": "CompositeDiscoveryClient_idc-provider2",
        "route_definition": {
            "id": "CompositeDiscoveryClient_idc-provider2",
            "predicates": [
                {
                    "name": "Path",
                    "args": {
                        "pattern": "/idc-provider2/**"
                    }
                }
            ],
            "filters": [
                {
                    "name": "RewritePath",
                    "args": {
                        "regexp": "/idc-provider2/(?<remaining>.*)",
                        "replacement": "/${remaining}"
                    }
                }
            ],
            "uri": "lb://idc-provider2",
            "order": 0
        },
        "order": 0
    }
]

这个时候访问 http://192.168.124.5:2000/idc... 根据结果可以推测能正确路由到 provider1, 测试结果一致。

  1. 创建 provider1 路由,将路径设置为 /p1/**,测试是否生效。

POST 请求 http://localhost:2000/gateway...

{
   "id":"provider1",
   "predicates":[
      {
         "name":"Path",
         "args":{
            "_genkey_0":"/p1/**"
         }
      },
      {
         "name":"RemoteAddr",
         "args":{
            "_genkey_0":"192.168.124.5/16"
         }
      }
   ],
   "filters":[
      {
         "name":"StripPrefix",
         "args":{
            "_genkey_0":"1"
         }
      }
   ],
   "uri":"lb://idc-provider1",
   "order":0
}

查看 redis 存储,或者请求 http://localhost:2000/actuato... , 都可以看到配置成功。

访问

curl http://localhost:2000/p1/provider1/1

结果输出 2001,与期望一致。

由此可见动态路由已经生效。

结语

本文到此结束。感兴趣的小伙伴后续可以通过加载配置文件,基于权重进行灰度。欢迎大家关注公众号【当我遇上你】。

查看原文

赞 1 收藏 1 评论 0

团结 关注了标签 · 2020-04-23

spring-cloud

Spring Cloud

关注 65

团结 关注了标签 · 2020-04-23

gateway

网关,统一接入方,前置机

关注 5

团结 收藏了文章 · 2020-04-17

一个易迁移、兼容性高的 Flutter 富文本方案

简介:flutter富文本演进

作者:闲鱼技术-新宿

背景

在闲鱼消息体系中,富文本在 UI 侧占了非常大的比重。最近消息部分在整体 Flutter 化,如何解决 Flutter 侧富文本问题,成为了项目早期的风险点。

在 Native 中,消息使用了 HTML 协议来承载富文本的解析与展示,由于消息的历史数据有落库的特性,我们必须在 Flutter 侧兼容这种协议。对于 Flutter,我们是否可以在兼容的基础上,进行能力的扩充与完善?

当前闲鱼也在升级 Flutter 1.12,所以我们不光要在当前版本支持图文混排,也需要快速迁移到高版本的系统方案。因此我们需要找到一个兼容性高、易迁移的富文本方案。

行业现状

行业内,对于旧版的 RichText (Flutter 1.7.3 之前)已有了解决方案,详见玄川:《如何低成本实现Flutter 富文本,看这一篇就够了!》。但这里并没有对富文本的整个链路的解决思路,且 Flutter 自身的 RichText 也在随着版本迭代进行演进,我们需要一套完整的演进方案。

事实上,Flutter 1.7.3 开始的 RichText 解决了我们的很多麻烦,它是怎么实现的呢?和旧版的实现有什么区别呢?带着问题,我们先来分析它的实现原理。

RichText 图文混排原理

Flutter 1.7.3 开始,RichText 不再继承自 LeafRenderObjectWidget,而是继承自 MultiChildRenderObjectWidget,从这就很容易看出,RichText 将是一个布局控件,内部可以有多个子控件。

创建过程:

richText

如上图,我们传给 RichText 的 text 参数为 InlineSpan,TextSpan、WidgetSpan都是其子类。

  1. RichText 初始化过程中会将 text 中的所有 WidgetSpan 递归筛选出来,传递给父类 MultiChildRenderObjectWidget。
  2. 创建 MultiChildRenderObjectElement,接着 RichText 会通过 createRenderObject,生成 RenderParagraph。
  3. RenderParagraph 初始化过程中会创建 TextPainter,这个是绘制的核心,这里将会进行 layout、paint 和事件分发操作;然后递归筛选 PlaceholderSpan (其实还是 WidgetSpan)。
渲染过程:

carbon

上图为 RenderParagraph 内的 performLayout 函数。

  1. 如上图 RenderParagraph 执行 performLayout 。首先 _layoutChildren:为子控件布局,目的为获取子控件大小,如果没有子控件则直接 return。这里所说的子控件就是 WidgetSpan。
  2. 第二步 _layoutTextWithConstraints,就是执行 _textPainter 的 layout 方法,这里会让 text(InlineSpan)进行 build,此时会按照它的树形结构遍历执行。

    1. TextWidget build 时会将自身的 text(这是真实的字符串)addText 给 builder。
    2. WidgetSpan build 时会将自身控件的 PlaceholderDimensions 信息,addPlaceholder 给 builder。这里其实就是添加占位符,占位符将与控件同大小。
    3. 紧接着 _paragraph 会进行一次布局,然后获取各个占位符位置存储下来。
  3. Paint 过程会先将带着占位符的文本绘制完成,然后遍历子控件按照 2-3 步骤中获得的占位符位置,设置偏移。

概括来说,新版本对比旧版本,底层多了个 _addPlaceholder 能力,用来占位混排的 Widget,并获取位置信息。

设计思路

我们以 HTML 协议为抓手,不光可以解决普通 HTML 字符串的解析与渲染,也可以对用户发送的带闲鱼自定义 emoji 的字符串进行能力的扩充。下图为大致的设计思路:

richText2

当前消息展示分为两种场景,一种为带有闲鱼自定义 emoji 表情的字符串:

你好[微笑],你的宝贝不错哦[呲牙],包邮吗?[坏笑][坏笑]

另一种为简单的 HTML 字符串:

"<font color="#888888">交易全程在闲鱼,</font><strong><font color="#F54444">你敢买,我敢赔!</font></strong><font color="#888888">若遇欺诈造成</font><strong><font color="#F54444">钱货两失,可获赔</font></strong><strong><font color="#F54444">最高5000元</font></strong>"

当然,还有最普通的纯文本。

对于这三种字符串,服务端并没有用类型来给我们区分,客户端拿到的都为字符串。端侧该如何处理且高效展示呢?

过程设计成这样:

  1. 首先对于确定为纯文字的控件,直接使用单 TextSpan 的 RichText,免去 Text 的封装。
  2. 使用RegExp(r'\[[^\]\[]+\]')匹配[微笑]等 emoji 占位符,替换为<img data-original=003_微笑.png width=22.400000 height=22.400000/>
  3. 取最后的 HTMLString ,使用html | Dart Package,进行 HTML 解析,生成 HTML Node Tree
  4. 递归 HTML Node Tree

    1. 文本标签映射为 TextSpan
    2. 图片标签映射为 FDImageSpan;Flutter 升级后将其替换为 WidgetSpan,其 child 设置为 Image Widget
    3. 链接标签映射为 TextSpan,定义 GestureRecognizer 相应手势

流程上,先将闲鱼自定义 emoji 占位符转为 HTML 元素,接着统一处理 HTML 字符串。然后将 HTML 字符串统一转为富文本。设计上,分为两层:数据解析层、渲染层。

richText1

如上图,有了前面原生 Flutter 图文混排支撑,我们在低版本可以仿照实现,低版本 RichText 继承自 LeafRenderObjectWidget,我们把 RichText 与其他 Widget 组成新的 MultiChildRenderObjectWidget,通过占位符正常渲染文本,之后获取占位符位置,设置对应 Widget 的位置。

Flutter SDK 升级过程中如何保持业务方无感知?先看下图:

richText0

对比发现,在 TextSpan 树中,我们继承自 TextSpan 的自定义 FDImageSpan,实际上可以直接对应到原生的 WidgetSpan,这里我们可以在 HTML Node Tree 映射到 TextSpan Tree 的过程中直接修改。而 FDRichText build 里,我们可以直接返回系统 RichText。这样的改动,对于使用方可以做到无感知。

效果

上图中是一种最为简单和常见的系统消息,为了突出安全警示,使用了较多的红色字体。模块中定义的三个富文本,均可定制样式。

效果2

上图为涉及交互的富文本,买家可以点击蓝色文字「那儿发货」,然后买家会自动发送「那儿发货」给卖家,卖家会根据预设的问题自动回复买家。点击会触发 HTML 字符串中的 href 自定义协议链接,客户端会触发 openURL 的操作,以此来实现交互。

效果3

这是普通用户可以编辑发送的富文本,丰富的闲鱼自定义 emoji,穿插在文字中,不仅增加了聊天乐趣,也增强了用户的表达。

未来

当前的展示部分仅仅是图文混排,新版本中的富文本支持任意 Widget,可玩性更高,所以我们对 HTML 标签描述可以进行扩充,这部分未来还需要持续探索。

由于篇幅有限,上文并没有讲述富文本编辑器。消息中用户输入框也需支持闲鱼自定义 emoji,当前版本的方案为直接使用占位符(比如“[微笑]”),并不展示实际的图片。我们回头再看一下 HTML 协议,对于新版的 TextField,我们可以支持吗?这就不仅仅局限在自定义 emoji 里了。

我们把目光转向发布和宝贝详情:

展望富文本

我们后续可能会支持上图中,发布和宝贝详情的富文本编辑和展示。对比两个详情页,很明显能看出,使用富文本的方式,在表达上更加富有冲击力,买家阅读起来能很容易抓住卖家想表达的关键信息。

可见,未来在富文本的编辑、展示基础能力统一之后,可以让更多业务收益。

查看原文

团结 收藏了文章 · 2020-04-09

闲鱼Flutter图片框架架构演进(超详细)底部有实战书籍赠送

简介:flutter图片内存大?加载慢?本地资源利用率低?看这篇就够了

作者:闲鱼技术-意境

1.那些年

图片对一个端侧研发来说是一老生常谈的话题了。闲鱼作为业界在Flutter技术方向上最早一批投入的团队。从使用Flutter之初,图片就是我们核心关注和重点优化的功能。图片算是闲鱼业务场景下最为重要的内容表现形式之一。图片展示体验的好坏会对闲鱼用户的使用体验产生巨大影响。你们是否也曾遇到过:

  • 图片加载内存占用过多?
  • 使用flutter以后本地资源重复,利用率不高?
  • 混合方案下Flutter原生图片加载效率不高?

针对上述问题,从第一版Flutter业务上线开始,闲鱼对图片框架的优化就从未停止。从开始的原生优化,到后面黑科技的外接纹理;从内存占用,到包大小;文本会逐一介绍。希望其中的优化思路和手段,能给大家带去一些启发。

2. 原生模式

从技术层面看图片加载,其实简单来说,追求的是无非是加载的效率的最大化—用尽可能小的资源成本,尽可能快地加载尽可能多的图片。

闲鱼图片的第一个版本其实基本上是纯原生的方案。如果你不想魔改很多底层的逻辑,原生方案肯定是最简单和经济的方案。原生方案的功能模块如下:

原生图片方案的架构图

如果你啥都没做直接上了,那么你可能会发现效果并没有达到你预期的那么美好。那么如果从原生的方案入手,我们有哪些具体的优化手段呢?

2.1. 设置图片缓存

没错猜对了,是缓存。对于图片加载,最能想到的方案就是使用缓存。首先原生Image的组件是支持自定义图片缓存的,具体的实现类是ImageCache。ImageCache的设置维度是两个方向: 1.缓存图片的张数。通过maximumSize设置。默认是1000张。2. 缓存空间的大小。 通过maximumSizeBytes 来设置。默认值100M。相比张数的限制,其实大小的设置方式更加符合我们的最终的预期。

通过合理设置ImageCache的大小,能充分利用缓存机制加速图片加载。不仅如此,闲鱼在这个点上还做了额外两个重要优化:

  1. 低端手机适配

在上线以后,我们陆续收到线上舆情的反馈,发现全部机型设置同一个缓存大小的做法并非最优。特别是大缓存设置在低端机器上面,不仅会出现体验变差,甚至还会影响稳定性。基于实际情况,我们实现了一个能从Native侧获取机器基础信息的Flutter 插件。通过获取的信息,我们根据不同手机的配置设置不同的缓存策略。在低端机器上面适当降低图片缓存的大小,同时在高端手机上将其适当放大。这样能在不同配置的手机上获取最优的缓存性能。

  1. 磁盘缓存

熟悉APP开发的同学都知道,成熟的图片加载框架一般都有多级缓存。除了常见的内存缓存,一般都会配置一个文件缓存。从加载效率上来说,是通过空间换时间,提升加载速度。从稳定性来说,这又不会过分占用宝贵的内存资源,出现OOM。但是可惜的是,Flutter自带的图片加载框架并没有独立的磁盘缓存。所以我们在原生方案的基础上扩展了磁盘缓存能力。

在具体的架构实现上,我们并没有完全自己撸一个磁盘缓存。我们的策略还是复用现有能力。首先我们将Native图片加载框架的磁盘缓存的功能通过接口暴露出来。然后通过桥接的方式,将Native 磁盘缓存能力嫁接到Flutter层。Flutter侧进行图片加载的时候,如果内存没有命中,就去磁盘缓存中进行二次搜索。如果都没有命中才会走网络请求。

通过增加磁盘缓存,Flutter图片加载效率进一步提升。

多级缓存

2.2. 设置CDN优化

CDN 优化是另一个非常重要图片优化手段。CDN优化的效率提升主要是:最小化传输图片的大小。常见策略包括:

  1. 根据显示大小裁剪

简单来说,你要加载图片的真实尺寸,可能会大于你实际展示窗口的大小。那么你就没必要加载完整大图,你只需要加载一个能覆盖窗口大小的图片即可。通过这种方式,裁剪掉不需要的部分,就能最小化传输图片的大小。从端侧角度来说,一来可以提升加载速度,二来可以降低内存占用。

  1. 适当压缩图片大小

这里主要是根据实际情况增加图片压缩的比例。在不影响显示效果的情况下,通过压缩进一步降低图片的大小。

  1. 图片格式

建议优先使用webp这样格式,图片资源相对小。Flutter原生支持webp(包括动图)。这里特别强调一下webp动图不仅大小要比gif小很多,而且还对透明效果有更好的支持。webp动图是gif方案比较理想的一种替代方案。

用图演示一下

基于上述原因,闲鱼图片框架在Flutter侧实现了一套CDN尺寸匹配的算法。通过该算法,请求图片会根据实际显示的大小,自动匹配到最合适的尺寸上并适当压缩。如果图片格式允许,图片尽可能转化成webp格式下发。这样cdn图片的传输就能尽可能高效。

2.3. 其他优化

除了上面的策略,Flutter还有一些其他的手段可以优化图片的性能。

  1. 图片预加载

如果你想在展示的图片的尽可能的快,官方也提供了一套预加载的机制:precacheImage。precacheImage能预先将图片加载到内存,真正使用的时候就能秒出了。

  1. Element复用优化

其实这个算是一个Flutter通用的优化方案。复写didWidgetUpdate方案,通过比较前后两次widget中针对图片的描述是否一致,来决定是否重新渲染Element。这样能避免同一个图片,不必要的反复渲染。

  1. 长列表优化

一般情况下,Listview是flutter最为常见的滚动容器。在Listview中的性能好坏,直接影响最终的用户体验。

Flutter的Listview跟Native的实现思路并不相同。其最大的特点是有一个viewPort的概念。超出viewPort的部分会被强制回收掉。

基于上述的原理,我们有两点建议:

  1. cell拆分

尽量避免大型的cell出现,这样能大幅降低cell频繁创建过程中的性能损耗。其实这里影响的不仅仅是图片加载过程。文字,视频等其他组件也都应该避免cell过于复杂导致的性能问题。

  1. 合理使用缓冲区

ListView可以通过设置cacheExtent 来设置预先加载的内容大小。通过预先加载可以提升view渲染的速度。但是这个值需要合理设置,并非越大越好。因为预加载缓存越大,对页面整体内存的压力就越大。

2.4. 方案的不足

这里需要客观指出:如果是一个纯Flutter APP原生方案是完善,够用的。但是如果从混合APP的角度来说,有如下两个缺陷:

1. 无法复用Native图片加载能力

毫无疑问,原生的图片方案是完全独立的图片加载方案。对于一个混合APP来说,原生方案和Native的图片框架相互独立,能力无法复用。例如CDN裁剪&压缩等能力需要重复建设。特别是Native一些独特的图片解码能力,Flutter就很难使用。这会造成APP范围内的图片格式的支持不统一。

2. 内存性能不足

从整个APP的视角来说,采用原生图片方案的情况下,其实我们维护了两个大的缓存池:一个是Native的图片缓存,一个是Flutter侧的图片缓存。两个缓存无法互通,这无疑是一个巨大的浪费。特别是对内存的峰值内存性能产生了非常大的压力

3. 打通Native

经过多轮优化,基于原生的方案已经获得了非常大的性能提升。但是整个APP的内存水位线依然比较高(特别是Ios端)。现实的压力迫使我们继续对图片框架进行更深度的优化。基于上述原生方案缺点的分析,我们有了一个大胆的想法:能否完全复用Native的图片加载能力?

3.1. 外接纹理

怎样打通Flutter和Native的图片能力?我们想到了外接纹理。外接纹理并非是Flutter自有的技术,他是音视频领域常用的一种性能优化手段。

这个阶段我们基于shared-Context的方案实现了Flutter和Native的纹理外接。通过该方案,Flutter可以通过共享纹理的方式,拿到Native图片库加载好的图片并展示。为了实现这个纹理共享的通道,我们对engine层做了深度定制。细节过程如下:

该方案不仅打通了Native和Flutter的图片架构,整个过程图片加载的性能也得到了优化。想要了解细节的同学可以继续阅读这篇文章:万万没想到——Flutter外接纹理

外接纹理是闲鱼图片方案的一次大跨越。通过该技术,我们不仅实现图片方案的本地能力复用,而且还能实现视频能力的纹理外接。这避免了大量重复的建设,提升了整个APP的性能。

3.2. 多页面内存优化

这个优化策略是真真被逼出来的。在对线上数据分析以后,我们发现Flutter页面栈有一个非常有意思的特点:

多页面栈情况下,底层的页面不会被释放。即便是在内存非常紧张的情况下,也不会执行回收。这样就会导致一个问题:随着页面的增多,内存消耗会线性增长。这里占比最高的就是图片资源的占比了。

是不是可以在页面处于页面栈底层的时候直接回收掉该页面内的图片呢?

在这个想法的驱动下,我们对图片架构进行了新一轮的优化。整个图片框架中的图片都会监听页面栈的变化。当方发现自己已经处于非栈顶的时候,就自动回收掉对应的图片纹理释放资源。这种方案能使图片占用的内存大小不会随着页面数的变多呈现持续线性增长。原理如下:

TB1mF0NbZKfxu4jSZPfXXb3dXXa-780-558.png

需要注意的是:这个阶段页面判断位置其实是需要页面栈(具体来说就是混合栈)提供额外的接口来实现的。系统之间的耦合相对较高。

3.3. 意外收获包大小

打通Native和Flutter侧图片框架以后,我们发现了一个意外收获: Native和Flutter可以共用本地图片资源了。也就是说,我们不再需要将相同的图片资源在Flutter和Native侧各保留一份了。这样能大幅提升本地资源的复用率,从而降低整体的包大小。基于这个方案,我们实现了一套资源管理的功能,脚本能自动同步不同端的本地图片资源。通过这样提升本地资源利用率,降低包大小。

3.4. 其他优化

  1. PlaceHolder强化

原生的Image是没有PlaceHolder功能的。如果想用原生方案的话,需要使用FadeInImage。针对闲鱼的场景我们有很多定制,所以我们自己实现了一套PlaceHolder的机制。

从核心功能上来说,我们引入了加载状态的概念分为: 1. 未初始化 2. 加载中 3. 加载完成 等。针对不同的状态,可以细粒度的控制PlaceHolder的展示逻辑。

3.5. 整体架构

3.6. 方案的不足

  1. 毕竟改了engine

随着闲鱼业务的不断推进,engine的升级的成本是我们必须要考虑的事情。能否不改engine实现同样的功能是我们核心的述求。(PS: 我承认我们是贪心的)

  1. 通道性能还有优化空间

外接纹理的方案需要通过桥的方式跟native的能力做通信。这里包括图片请求的传递和图片加载各种状态的同步。特别是在listview快速滑动的时候,通过桥发送的数据量还是可观的。当前方案每个图片加载时都会单独进行桥的调用。在图片数量比较多的情况下,这显然会是一个瓶颈。

  1. 耦合过多

在实现图片回收方案的时候,目前方案需要栈提供是否在栈底层的接口。这里就产生方案耦合,很难抽象出一个独立干净的图片加载方案。

4. Clean&Efficient

时间来到了2020年,随着对Flutter基础能力理解的逐步深入,我们实现了一个整体方案更优的图片框架。

4.1. 无侵入外接纹理

外接纹理可以不用修改engine么?答案是肯定的。

其实Flutter是提供了官方的外接纹理方案的。

而且Native操作的texture和Flutter侧显示的texture在底层是同一对象,并没有产生额外的数据copy。这样就保证了纹理共享的足够高效。那为什么闲鱼之前会单独基于shared-Context自己实现一套呢?1.12版本之前,官方Ios的外接纹理方案有性能问题。每次渲染的过程中(不管纹理是否有更新)都会频繁获取CVPixelBuffer,造成不必要的性能损耗(过程有加锁损耗)。该问题已经在1.12版本中修复(官方commit地址),这样官方方案也足够满足需求。在这样的背景下,我们重新启用官方方案来实现外接纹理功能。

4.2. 独立的内存优化

之前提到过,老版本的基于页面栈的图片资源回收需要强依赖栈功能的接口。一方面产生了不必要的依赖,更重要的是,整体方案无法独立成通用方案。为了解决这个问题,我们对Flutter底层进行了深入的研究。我们发现Flutter的layer层可以稳定感知到页面栈的变化。

TB1bt7AzYr1gK0jSZR0XXbP8XXa-1710-796.png

然后每个页面通过context获取的router对象作为标识对一个页面中的所有的图片对象进行重新组织。所有获取到同一个router对象的标识成同一个页面。这样就能以页面为单位对所有的图片进行管理。整体上通过LRU的算法来模拟虚拟页面栈结构。这样就能对栈底页面的图片资源实现回收了。

4.3. 其他优化

1. 通道的高度复用

首先我们以一帧为单位对这一帧中的图片请求进行聚合,然后在一次通道请求中传递给Native的图片加载框架。这样能避免频繁的桥调用。特别在快速滚动等场景下优化效果尤为明显。

2. 高效的纹理复用

使用外接纹理进行图片加载以后,我们发现复用纹理可以进一步提升性能。举一个简单的场景。我们知道电商场景中,商品展示经常会有标签,打底图这样的图片。这类图片往往在不同的商品上会出现大量重复。这时候,可以将已经渲染好的纹理,直接复用给不同的显示组件。这样能进一步优化GPU内存的占用,避免重复创建。为了精确对纹理进行管理,我们引入了引用计数的算法来管理纹理的复用。通过这些方案,我们实现了纹理跨页面高效复用。

此外,我们将纹理和请求的映射关系移动到了Flutter侧。这样能在最短路径上完成纹理的复用,进一步减少了桥的通信的压力。

4.5. 整体架构

5. 优化效果

由于最新的版本目前还在灰度,具体数据后续会写文跟大家详细介绍。下属数据主要以方案二为主

  • 内存优化

    • 通过打通Native,相比于首次上线版本,在显示效果不变的情况下,Ios的abort率降低25%,用户体验明显提升。
    • 多页面栈内存优化
多页面栈的内存优化,在多页面场景下对内存优化作用明显。我们做了一个极限试验效果如下:(**测试环境**,非闲鱼APP)

TB1IK58AG61gK0jSZFlXXXDKFXa-868-473.png

​ 可见多页面栈的优化,可以将多Flutter页面的内存占用控制得更好。

  • 包大小减少

通过接入外接纹理,本地资源得到了更好的复用,包大小降低1M。早期闲鱼接入Flutter,会以改造现有页面为切入点。资源重复情况比较严重,但是随着闲鱼Flutter 新业务越来越多。Flutter和Native的重复资源越来越少。外接纹理对包大小的影响已经逐步变弱。

6. 总结

本文介绍了闲鱼在Flutter图片框架方向上所做的持续优化。介绍了闲鱼不同时期,典型的图片技术方案的细节。希望可以给到读者一些启发。这是一场没有尽头的旅行,我们对闲鱼图片的优化还会持续。特别是我们最新的方案,受限篇幅,本文只是做了初步介绍。更多技术细节,包括测试数据,我们随后还会专门写文继续给大家做介绍。方案完善以后,我们也会逐步开源。

更多相关内容:点击这里
福利:
1.点击这里(电子书)
2.点击这里(实物书以及闲鱼周边)
查看原文

团结 赞了文章 · 2020-04-08

Flutter 动态表单Dynamic FormField架构设计

架构图

Dynamic FormField
用了几年前设计的Table架构图,是kotlin版本的动态表单框架,也同样适用于现在的设计,这次从设计到实现,其实经历了很多,前期看官方文档FormField的用法,还有一些现有的动态表单框架,一开始选择用一般的StatefulWidget实现,但做了几个发现一个问题,各个Widget的状态管理,数据的变化,或者说统一的验证提交等操作,需要太多的实现,未来简化实现,最终还是选择用FormField,拓展它的子类来更好的管理表单。请仔细看图,我来解释下。
整个架构图分两个部分

  • 第一部分展示的是这次框架的主角FormBuilder在Page页面中的位置,以及基本的属性定义
    formController 是对表单统一管理的抽象,可以对表单做验证validator,重置所有表单状态reset,保存save等,未来根据需求再拓展
    showSubmitButton 显示提交按钮,有自己的提交按钮可以设置false隐藏
    onSubmit 数据校验后的callBack回调,返回数据验证结果
    mapperFactory 这个是FormField动态扩展的关键,通过它就是让其他人动态实现一个自己的FormField,用来满足特殊的业务需求。
    itemList 这个是mapperFactory将业务数据集合FormItem转换成对应的Widget集合,最终显示的当前页面。
  • 第二部分展示了一个动态表单的业务流程,从服务器下发数据,到映射成对应的FormItemList,再由MapperFactory转换成对应的Widget,最终交给FormBuilder,再由FormBuilder生成一个Form,通过一个ListView动态的展示所有的FormField,并通过FieldValidator的抽象实现来做最终的数据校验,这是大致的流程。

希望两种表达,能让你对整个框架有一个清晰的认识,接下来我们就聊一下,如何拓展一个FormField,这样你就能从源头了解到该框架。

实现一个FormField子类

创建一个FieldTest类

import 'package:flutter/material.dart';

class FieldTest extends FormField{

}

没有任何提示,也不用实现什么,那怎么办?这个时候就需要点进去看下FormField源码,通过对源码的分析,我们有一个清晰的认识

import 'framework.dart';
import 'navigator.dart';
import 'will_pop_scope.dart';

class Form extends StatefulWidget 

class FormState extends State<Form> 

class _FormScope extends InheritedWidget 

typedef FormFieldValidator<T> = String Function(T value);

typedef FormFieldSetter<T> = void Function(T newValue);

typedef FormFieldBuilder<T> = Widget Function(FormFieldState<T> field);

class FormField<T> extends StatefulWidget 

class FormFieldState<T> extends State<FormField<T>> 

一共涉及到五个类,三个函数,Form 其实这个类你可以理解为一个ListView的角色,它对FormField负责,管理等。_FormScope是InheritedWidget的子类,用来做数据共享的,从上往下的共享数据,Form的child最终被包装到了_FormScope中。三个函数分别实现了数据校验FormFieldValidator,数据更新FormFieldSetter,构建child widget的FormFieldBuilder,我们拓展的构建的child就是通过FormFieldBuilder函数。我们再仔细看下FormField类,源码如下

class FormField<T> extends StatefulWidget {

  /// [builder] 不能为空.
  const FormField({
    Key key,
    @required this.builder,
    this.onSaved,
    this.validator,
    this.initialValue,
    this.autovalidate = false,
    this.enabled = true,
  }) : assert(builder != null),
       super(key: key);

  /// 可选函数,数据保存时回调
  /// [FormState.save].
  final FormFieldSetter<T> onSaved;

  /// 可选函数,用于数据的校验
  final FormFieldValidator<T> validator;

  /// 构建子widget
  final FormFieldBuilder<T> builder;

  /// 可选参数,默认值
  final T initialValue;

  ///是否自动触发校验
  final bool autovalidate;

  /// 控制是否能输入,默认true
  final bool enabled;

  @override
  FormFieldState<T> createState() => FormFieldState<T>();
}

///[FormField]的当前状态。传递给[FormFieldBuilder]方法,用于构造表单字段的小部件。
class FormFieldState<T> extends State<FormField<T>> {
  ///表单数据T
  T _value;
 /// 要显示的错误信息
  String _errorText;

  /// 当前表单的值
  T get value => _value;

  /// 获取当前错误信息
  String get errorText => _errorText;

  /// 判断是否有错误
  bool get hasError => _errorText != null;

  /// 验证数据是否有效
  bool get isValid => widget.validator?.call(_value) == null;

  /// 保存数据,回调onSaved函数,并把数据传递给你
  void save() {
    if (widget.onSaved != null)
      widget.onSaved(value);
  }

  /// 重置数据
  void reset() {
    setState(() {
      _value = widget.initialValue;
      _errorText = null;
    });
  }

  /// 校验数据,主动验证,并刷新UI
  bool validate() {
    setState(() {
      _validate();
    });
    return !hasError;
  }
  /// 刷新错误信息
  void _validate() {
    if (widget.validator != null)
      _errorText = widget.validator(_value);
  }

  /// 更新当前页面数据
  void didChange(T value) {
    setState(() {
      _value = value;
    });
    ///当窗体字段更改时调用。通知所有表单字段要重新生成,如果有连动的效果,就显现出来了。
    Form.of(context)?._fieldDidChange();
  }

  /// 此方法只能由需要更新的子类调用,说了很长意思是,不然你通过它更新数据,应该调用didChange来设置数据。
  @protected
  void setValue(T value) {
    _value = value;
  }

  @override
  void initState() {
    super.initState();
    _value = widget.initialValue;
  }

  @override
  void deactivate() {
    /// 这里发现了当前Form注销掉了当前state的
    Form.of(context)?._unregister(this);
    super.deactivate();
  }

  @override
  Widget build(BuildContext context) {
    // Only autovalidate if the widget is also enabled
    if (widget.autovalidate && widget.enabled)
      _validate();
    /// 注册引用
    Form.of(context)?._register(this);
    return widget.builder(this);
  }
}

请先看注释,通过FormField构造,我们了解到,它最主要就是抽象了对数据T的初始化,校验,通知其他人我更新了,重置,保存等一系列的操作,我们应该关心的就是,如何validate,如何didChange,然后如何save数据对吧,这样我们就可以自己实现了,那么我们接下来就要开始实现了

class FieldTest extends FormField {
  FieldTest(
      {Key key,
      String initialValue,
      FormFieldSetter<String> onSaved,
      FormFieldValidator<String> validator,})
      : super(
            key: key,
            initialValue: initialValue,
            onSaved: onSaved,
            validator: validator,
            builder: (state) {
              return Container(
                child: Text(initialValue),
              );
            });
}

一个没什么交互的简版实现了,没有交互那岂不是扯的吗,表单输入怎么也得有个输入框吧,哈哈,好我们接着实现。

class FieldTest extends FormField {
  final ValueChanged<String> onChanged;
  FieldTest(
      {Key key,
      String initialValue,
      FormFieldSetter<String> onSaved,
      FormFieldValidator<String> validator,
      this.onChanged})
      : super(
            key: key,
            initialValue: initialValue,
            onSaved: onSaved,
            validator: validator,
            builder: (state) {
              return Container(
                child: Column(
                  children: <Widget>[
                    Text(initialValue),
                    TextField(
                      onChanged: onChanged,
                    )
                  ],
                ),
              );
            });
}

这次加了一个onChanged,用来监听输入框的更新,然后给父类的value赋值,然后触发相应的操作,比如校验什么的。

 builder: (state) {

              void _handleOnChanged(String _value) {
                if (state.value != _value) {
                  state.didChange(_value);
                  if (onChanged != null) {
                    onChanged(_value);
                  }
                }
              }
              
              return Container(
                child: Column(
                  children: <Widget>[
                    Text(initialValue),
                    TextField(
                      onChanged: _handleOnChanged,
                    )
                  ],
                ),
              );
            }

在builder 函数中添加一个_handleOnChanged函数,用来接管输入框的值,并更新到value上。这里解释下为什么,看FormFieldState源码应该知道,所有校验,重置,保存的操作都是T _value;这个变量,所以在输入框的实现中,我们肯定是对输入的内容做校验或者其他操作,所以这里要去更加输入的内容来更新_value的值,而更新的办法就是通过state.didChange函数。
下面来实现一个校验规则

String isValidator(value) {
  if (value == null) return "is null";
  if (value.isEmpty) return "is Empty";
  if (value.length > 6) return null;
  ///返回null说明校验通过
  return "value <= 6";
}

然后传递给它

FieldTest(
      {Key key,
      String initialValue,
      FormFieldSetter<String> onSaved,
      FormFieldValidator<String> validator = isValidator, /// 这里
      this.onChanged})

这样我们的校验规则有了,我们可以试下ok不,把组件加载到Page内

 Form(
                key: _formKey,
                child: FieldTest(
                  onChanged: (value) {
                    print("FieldTest onChanged value$value");
                  },
                ),
              ),
              RaisedButton(
                onPressed: (){
                  if(_formKey.currentState.validate()){
                    print("FieldTest验证通过");
                  }else{
                    print("FieldTest验证失败");
                  }
                },
                child: Text("校验FieldTest"),
              ),

嵌套在Form内,然后通过_formKey可以做管理,如validate()校验数据

输入23

控制台对应输出,校验一下,点击按钮打印


当我们再次输入超过校验规则的数据时,验证通过,其实就是这么的简单就实现了。在理解的基础上可以拓展更多的操作,比如显示错误信息,数据格式化,数据自动映射等等操作。接下来我们要做的就是对整个框架的拓展,拓展更多的FormField。

总结

动态表单在实际的业务开发中,有相当多的业务场景,特别是针对ToB的业务,表单的提交,校验就更别说了,越来越多,越来越复杂,如果说能有一个合适框架来减少那些本来就很简单但充斥着大量重复的操作,同样也可以解决那些负责的操作,何乐而不为呢。
项目源码:https://github.com/ibaozi-cn/flutter_dynamic_form

感谢大佬们的项目和文章

sirily11/json-textfrom
codegrue/card_settings
https://medium.com/flutter-community/flutter-how-to-validate-fields-dynamically-created-40cafca5c3cb
https://stackoverflow.com/questions/55463981/whats-the-best-way-to-dynamically-load-form-fields-in-flutter
https://book.flutterchina.club/chapter7/inherited_widget.html

查看原文

赞 1 收藏 1 评论 0

认证与成就

  • 获得 22 次点赞
  • 获得 7 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 6 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2014-11-13
个人主页被 948 人浏览