复杂业务状态的处理:从状态模式到 FSM

概述

我们平常在开发业务模块时,经常会遇到比较复杂的状态转换。比如说用户可能有新注册、实名认证中、已实名认证、禁用等状态,支付可能有等待支付、支付中、已支付等状态。OA 系统里的状态处理就更多了。遇到这些处理,很多人可能不假思索的就用最直观的 if/else 或者 switch 来判断状态的方式。但其实除了这种简单粗暴的方式,我们还有其他更好的方式来处理复杂的状态转换。

状态判断

我们就以支付为例,一笔订单可能有 等待支付支付中已支付 等状态。对于 等待支付 的订单,用户可能通过第三方支付如微信支付或支付宝进行付款,支付完成后第三方支付会回调通知支付结果。我们可能会这样来处理:

    public void pay(Order order) {
        if (order.status == UNPAID) {
            order.status = PAYING;
            // 处理支付
        } else throw IllegalStateException("不能支付");
    }    
    public void paySuccess(Order order) {
        if (order.status == PAYING) {
            // 处理支付成功通知
            order.status = PAID;
        } else throw IllegalStateException("不能支付");
    }

这样看起来好像没什么问题。但是假设我们允许用户多次支付完成一笔订单,于是我们需要增加一个 部分支付 状态。订单在 部分支付 状态时,可以进行下一步的支付;订单收到支付成功通知时,根据支付金额,可能会转换到 已支付部分支付 状态。现在,我们不得不在 paypaySuccess 里处理这个状态。

    public void pay(Order order) {
        if (order.status == UNPAID || order.status == PARTIAL_PAID) {
            order.status = PAYING;
            // 处理支付
        } else throw IllegalStateException("不能支付");
    }    
    public void paySuccess(Order order) {
        if (order.status == PAYING) {
            // 处理支付成功通知
            if (order.paidFee == order.totalFee) order.status = PAID;
            else order.status = PARTIAL_PAID;
        } else throw IllegalStateException("不能支付");
    }

有了支付,我们必须也要能支持退款,那就需要增加 退款中已退款 状态,以及对应的退款操作和退款成功回调处理。

    public void refund(Order order) {
        if (order.status == PAID || order.status == PARTIAL_PAID) {
            order.status = REFUNDING;
            // 处理退款
        } else throw IllegalStateException("不能退款");
    }
    public void refundSuccess(Order order) {
        if (order.status == REFUNDING) {
            // 处理退款成功通知
            order.status = REFUNDED;
        } else throw IllegalStateException("不能退款");
    }

如果用一个有限状态机(FSM)来表示目前的状态转换,那大概是这样的:
订单支付状态机

对于状态不多、转换也不是很复杂的情况,用状态判断来处理还也算简洁明了。但一旦状态变多,操作变复杂,那么业务代码就会充斥各种条件判断,各种状态处理逻辑散落在各处。这时如果要新增一种状态,或者调整一些处理逻辑,就会比较麻烦,还很容易出错。

例如本例中,实际处理时可能还存在取消订单、支付失败/超时、退款失败/超时等情况,如果再加上物流以及一些内部状态,那处理起来就极其复杂了,而且一不小心还会出现支付失败了还能给用户退款,或者已经退款了还给用户发货等不应该出现的情况。这其实是一种坏味道,会造成代码不易维护和扩展。

设计模式之状态模式

不少人接下来可能会想到 GOF 的状态模式。对于涉及复杂状态逻辑的处理,使用状态模式可以将具体的状态抽象出来,而不是分散在各个方法的条件判断处理中,更容易维护和扩展。

状态模式一般包含三种角色,Context、State 和 ConcreteState。其中 State 是状态接口,定义了状态的操作;而 ConcreteState 则是各个具体状态的实现。它门的关系如下图所示:
2017-11-20-23-28-47

下面我们尝试用状态模式实现前面的订单状态转换。首先我们需要定义状态接口,它应该包含所有需要的操作,以及每个状态对应的实现。

abstract class OrderState {
    public abstract OrderState pay(Order order);
    public abstract OrderState paySuccess(Order order);
    public abstract OrderState refund(Order order);
    public abstract OrderState refundSuccess(Order order);
}

public class PayingOrderState implement OrderState { 
    public OrderState pay(Order order) {
        throw IllegalStateException("已经在支付中"); 
    }
    public OrderState paySuccess(Order order, long fee) {
        doPaySuccess(Order order, long fee);
        if (order.paidFee < order.totalFee) {
           order.setState(new PartialPaidOrderState());             
        } else {
           order.setState(new PaidOrderState());
        }
    }
    public OrderState refund(Order order) {
        throw IllegalStateException("尚未完成支付"); 
    }
    public OrderState refundSuccess(Order order) {
        throw IllegalStateException("尚未完成支付"); 
    }
}
public class UnpaidOrderState implement OrderState { ... }
public class PartialPaidOrderState implement OrderState { ... }
public class PaidOrderState implement OrderState { ... }
public class RefundingOrderState implement OrderState { ... }
public class RefundedOrderState implement OrderState { ... }

大家可能会注意到,不是每个状态都支持所有操作的。例如上面的实现,PayingOrderState 是不能 refund 的,PaidOrderState 是不能 Pay 的,这里我们抛出了一个 IllegalStateException 异常。当然也可以不抛异常,而是放一个空的实现。或者我们也可以定义一个 Abstract Class,把操作的默认实现都放到里面,每个状态类只需要改写自己支持的方法。

然后我们要实现 Context,也就是我们的 Order 实体,它包含了一个状态字段 state,通过 state 实现所有的状态转换逻辑。定义好了这些,支付服务的实现就很简单了。

public class Order {
    OrderState state = new UnpaidOrder();
    public void pay(long fee) {
        state.pay(fee);
    }
    public void paySuccess(long fee) {
        state.paySuccess(this, fee);        
    }
    public void refund() { ... }
    public void refundSuccess() { ... }
}

public class PaymentService {
    public void payOrder(long orderId) {
        Order order = OrderRepository.find(orderId) 
        order.pay();
        OrderRepository.save(order);
    }
}

通过状态模式,我们避免了代码里出现大量状态判断,状态转换规则的实现更加清晰。不过需要注意的是,实际上状态模式并不是很符合开闭原则(Open/Close Principle),新增一个状态时,还是可能要修改已有的其他状态的逻辑。但是和状态判断的方法比起来,已经清晰并且方便很多了。

领域驱动设计之状态建模

前面也提到了,状态模式的另外一个问题就是,实际业务里面有很多操作其实只对部分状态有效,而状态模式要求每个状态都要实现所有操作,有时候这是没有必要的。

对于这种情况,在领域驱动设计里,会更建议大家使用显式状态建模的方式。也就是把不同状态的实体,建模成不同的实体类;或者每个实体类代表一组状态。

例如,我们可以对每种状态的订单,都定义一个实体类。不过因为可能有多种状态的订单支持同样的操作,为了抽象这类操作,我们需要先定义一些接口。

public interface CanPayOrder {
    Order pay();
}
public interface CanPaySuccessOrder { ... }
public interface CanRefundOrder { ... }
public interface CanRefundSuccessOrder { ... }

public class UnpaidOrder implements CanPayOrder { ... }
public class PayingOrder implements CanPaySuccessOrder { ... }
public class PartialPaidOrder implements CanPayOrder, CanRefundOrder { ... }
public class PaidOrder implements CanRefundOrder { ... }
public class RefundingOrder implements CanRefundSuccessOrder { ... }

public class PaymentService {
    public void pay(long orderId) {
        Order order = OrderRepository.find(orderId) 
        // 转换为 CanPayOrder,如果无法转换则抛异常
        CanPayOrder orderToPay = order.asCanPayOrder();
        Order payingOrder = orderToPay.pay();
        OrderRepository.save(payingOrder);
    }
}

每种状态的实体能支持的操作,都是显式定义好的。这种方式对于操作比较多,并且很多操作只对部分状态有效的情况,能够有效避免状态模式的缺点,代码更简洁清晰。

动态语言里的状态转换

上面的例子里,UnpaidOrder 和 PartialPaidOrder 都可以进行 pay 操作。其实处理支付操作的时候,我们不需要知道它是 UnpaidOrder 还是 PartialPaidOrder,我只需要知道当前订单实体支持 Pay 操作就可以了。在 Java 这样的静态类型语言里,我们只能通过定义一些 Interface 或者 Abstract class 来处理,还是有一点点麻烦。

如果是动态类型语言,例如 Python、Ruby 或者 JavaScript 等,还可以通过 Duck Typing 来进一步简化。所谓 Duck Typing 就是:“如果有一只鸟,它走起来像鸭子,游起来像鸭子,叫起来也像鸭子,我们就叫它鸭子。” 意思就是说,我们可以忽略对象的类型,直接在运行时判断对象是否支持某种行为。

例如在 JavaScript 里,我们获取到 order 实体后,就可以通过判断是否定义了 pay 方法,然后直接调用即可,而不必了解对象到底是什么类型。

let orderToPay = order.asOrderStateEntity();
if (typeof orderToPay['pay'] === 'function') {
    orderToPay.pay();
} else {
    throw new ServiceError("该订单不能进行支付操作");
}

当然,实际会用动态语言开发这种业务系统的并不多,毕竟动态语言也会引起其他方面的一些问题。

FSM

不管是状态模式还是状态实体,多个状态之间的转换,还是分散在各个状态的实现里的。其实所有的状态转换都可以概括为:F(S, E) -> (A, S'),即如果当前状态为S,接收到一个事件E,则执行动作A,同时状态转换为S‘。

Akka 里面提供了一个有限状态机的框架叫 FSM,通过 Scala 语言的模式匹配及其他一些强大特性,可以把状态转换和业务处理逻辑分离开来。具体我就不细说了,我们也没有在实际开发中使用过。但我们可以感受一下:

class OrderFSM extends FSM[State, Data] {
    startWith(Unpaid)                       // 开始时的状态是 Unpaid    
    when(Unpaid) {
        case Event(Pay, data) ⇒         // Unpaid 状态时,如果收到事件 Pay,则进行支付,状态转换为 Paying
            doPay(data)        
            goto(Paying)
    }
    when(Paying) {                            // Paying 状态时,如果收到事件 PaySuccess,则进行支付成功处理,通过根据支付金额,转换为 Paid 或者 PartialPaid 状态
        case Event(PaySuccess(fee), data) ⇒ 
            doPaySuccess(data, fee)
            if (fee+data.paidFee == data.totalFee) goto(Paid) 
            else goto(PartialPaid)
    }
    // ...
}

当然,FSM 的功能远不止此,实际实现也可能会更复杂。Java 里面也有一个有限状态机的实现,叫 Squirrel,不过由于 Java 语言的限制,使用起来没有 Akka FSM 那么优雅。这里就不深入研究了,感兴趣的同学可以去了解下。

总结

本文简单介绍了业务系统中,处理复杂状态逻辑的几种方法。除了极其简单的情况,大家应该尽量避免使用状态判断的方式,使用状态模式或者状态建模,可以很有效的提高代码的维护性和扩展性。最后也简单介绍了动态语言对状态建模的一些优化,以及 FSM 框架。

1+

聊聊移动端跨平台数据库 Realm

开发杏仁 App 的过程中,我们在相对独立的模块试水了当前非常流行的移动端数据库:Realm,有挑战也有惊喜。下面以 iOS(Object-C) 平台为例,简单介绍下 Realm 的基本使用,并且总结下心得。

什么是Realm

Realm

Realm 是一个针对移动端开发的、跨平台、跨语言数据存储方案。它上手方便,性能强大,功能丰富而且还在不断更新。Realm 在语言上支持 JavaJS.NETSwiftOC,基本覆盖了当前移动端的所有场景。

目前,Realm 已经完全开源,并且有很多三方的插件可以使用,生态已经相对比较成熟了。

配置

Realm 的配置比较简单,升级和数据迁移都很直观,不过需要注意:

  • 每次对数据库表有更新都必须手动增加版本号,不然会闪退。
  • 升级表(增加、删除字段或表)不需要手写迁移代码;如果有数据迁移、修改字段名、合并字段、数据升级等高级操作,则在 block 中写相关代码,具体可参照文档。
  • 一般来说要写全递归升级的所有版本分支,做好每个版本的清理工作,以免发生意外(比如版本2比版本1删除了字段A,版本3又添加回来,用户如果直接从版本1升级到版本3,则会有脏数据)。

启动Realm的基本流程:

    // 1.配置数据库文件地址
    NSString *filePath = path;

    RLMRealmConfiguration *config = [RLMRealmConfiguration defaultConfiguration];
    config.fileURL = [[[NSURL URLWithString:filePath] URLByAppendingPathComponent:@"YF"] URLByAppendingPathExtension:@"realm"];

    // 2. 加密
    config.encryptionKey = [self getKey];

    // 3. 设置版本号(每次发布都应该增加版本号)
    config.schemaVersion = 1;

    // 4. 数据迁移
    config.migrationBlock = ^(RLMMigration *migration, uint64_t oldSchemaVersion) {
        if (oldSchemaVersion < 1) {
            // do something
        }
    };

    // 5. 设置配置选项
    [RLMRealmConfiguration setDefaultConfiguration:config];

    // 6. 启动数据库,完成相关的配置
    [RLMRealm defaultRealm];

建表

直接继承 RLMObject 的类将会自动创建一张表,表项为这个类的属性。需要注意几点:

  • Number,BOOL 等类型要用 Realm 指定的格式。
  • Array 要用 RLMArray 容器,来表示一对多的关系。
  • 没有入库前,可以像正常 Object 一样操作;但是入库后或者是 query 出来的对象,则要按照 Realm 的规范处理。这点是刚上手时需要特别注意的。

一个典型的 Realm 对象:

@interface YFRecommendHistoryObject : RLMObject

@property (nonatomic) NSNumber<RLMInt> *recommendId;
@property (nonatomic) NSNumber<RLMInt> *patientId;

@property (nonatomic) NSString *patientName;
@property (nonatomic) NSNumber<RLMInt> *created;

@property (nonatomic) NSString *createdTimeString; // yyyy-MM-dd hh:mm


@property (nonatomic) NSNumber<RLMInt> *count;
@property (nonatomic) NSNumber<RLMInt> *totalPrice; ///< 总价,分


@property (nonatomic) NSString *totalPriceString; /// ¥xxxx.xx
@property (nonatomic) RLMArray<YFRecommendDrugItem> *items;

@end

所有被 Realm 管理的对象都是线程不安全的,绝对不可以跨线程访问对象,也不要将 Realm 对象作为参数传递。推荐的做法是每个线程甚至是每次需要访问对象的时候都重新 query。

PS:最新版本的 Realm 提供了一些跨线程传递对象的途径(RLMThreadSafeReference)。

增删查改

入库:

// 创建对象
Person *author = [[Person alloc] init];
author.name    = @"David Foster Wallace";

// 获取到Realm对象
RLMRealm *realm = [RLMRealm defaultRealm];

// 入库
[realm beginWriteTransaction];
[realm addObject:author];
[realm commitWriteTransaction];

另外对一对一关系或者一对多关系的赋值也相当于入库。

对被管理对象的所有赋值,删除等操作都必须放到 begin-end 对当中,否则就是非法操作,直接闪退。

// 删除对象
[realm beginWriteTransaction];
[realm deleteObject:cheeseBook];
[realm commitWriteTransaction];

需要注意的是如果一个对象从数据库中被删除了,那么它就是非法对象了,对其的任何访问都会导致异常。

查询的话语法和 NSPredicate类似。

// 使用 query string 来查询
RLMResults<Dog *> *tanDogs = [Dog objectsWhere:@"color = 'tan' AND name BEGINSWITH 'B'"];

// 使用 Cocoa 的 NSPredicate 对象来查询
NSPredicate *pred = [NSPredicate predicateWithFormat:@"color = %@ AND name BEGINSWITH %@",
                                                     @"tan", @"B"];
tanDogs = [Dog objectsWithPredicate:pred];

通知

不要手动去管理数据库对象更新的通知,Realm 会自动在 Runloop 中同步各个线程的数据,但是同步的时机是无法预料的,应该使用 Realm 框架自带的通知系统。

对集合对象的通知:

self.notificationToken = [[Person objectsWhere:@"age > 5"] addNotificationBlock:^(RLMResults<Person *> *results, RLMCollectionChange *changes, NSError *error) {
    if (error) {
      NSLog(@"Failed to open Realm on background worker: %@", error);
      return;
    }

    UITableView *tableView = weakSelf.tableView;

    // 在回调中更新相关UI
    [tableView beginUpdates];
    [tableView deleteRowsAtIndexPaths:[changes deletionsInSection:0]
                     withRowAnimation:UITableViewRowAnimationAutomatic];
    [tableView insertRowsAtIndexPaths:[changes insertionsInSection:0]
                     withRowAnimation:UITableViewRowAnimationAutomatic];
    [tableView reloadRowsAtIndexPaths:[changes modificationsInSection:0]
                     withRowAnimation:UITableViewRowAnimationAutomatic];
    [tableView endUpdates];
  }];

Realm 在 OC 中实现响应式主要靠这种方式。在其它语言平台上(JS,JAVA,Swift),Realm 对响应式编程的支持要更好一些。

对普通 object 的通知:

RLMStepCounter *counter = [[RLMStepCounter alloc] init];
counter.steps = 0;
RLMRealm *realm = [RLMRealm defaultRealm];
[realm beginWriteTransaction];
[realm addObject:counter];
[realm commitWriteTransaction];

__block RLMNotificationToken *token = [counter addNotificationBlock:^(BOOL deleted,
                                                                      NSArray<RLMPropertyChange *> *changes,
                                                                      NSError *error) {
    if (deleted) {
        NSLog(@"The object was deleted.");
    } else if (error) {
        NSLog(@"An error occurred: %@", error);
    } else {
        for (RLMPropertyChange *property in changes) {
            if ([property.name isEqualToString:@"steps"] && [property.value integerValue] > 1000) {
                NSLog(@"Congratulations, you've exceeded 1000 steps.");
                [token stop];
                token = nil;
            }
        }

    }
}];

关于通知的使用,有两点需要注意:

  • 必须保持对 token 的引用,token 被释放,则通知失效(在被释放前最好按规范 stop 掉 token)。
  • object 通知并不监听得到 object 的 RLMArray 等集合属性成员的变化,需要另外处理。

一些总结

Realm 真的好用吗?相对于陈旧的 sqlite 和学习曲线陡峭的 CoreData,Realm 还是一个不错的选择。但是就目前的版本来说,还存在一些局限性。

先说说优点:

  1. 简单:光速上手(然后光速踩坑,APP 狂闪不止)。这个确实是小团队福音,不需要学习曲线陡峭的 CoreData,甚至不用写 sql,大家简单阅读下文档,就可以在实际项目中开用了。升级、迁移等都有非常成熟的接口。

  2. 性能优秀:简单看过原理,相比于传统数据库链接 - 查询 - 命中 - 内存拷贝 - 对象序列化的复杂过程,Realm 采用基于内存映射的 Zero-Copy 技术,速度快一个数量级。而且内部采用了类似 git 的对象版本管理机制,并发的性能和安全性也不错。

  3. 线程安全:Realm 拒绝跨线程访问对象,同时,在不同线程中进行增删查改都是绝对安全的。安全的代价是代码上的不便,下面会讲。

  4. 跨平台:iOS 和安卓两边可以共用一套存储方案,Realm 数据库则方便地在不同平台中进行迁移。对于 RN 开发来说,Realm更是数据库方案的首选,真正做到 write once run everywhere。

  5. 响应式:Realm 的查询结果是随数据库变化实时更新的(要求对象在 Run Loop 线程中),配合KVO或者Realm 自带的 ObjectNotification,可以轻松构建即时反映数据变化的响应式 UI,这点和目前主流的响应式框架(ReactNative,ReactiveCocoa)应该是天作之和了。但是要求使用者改变思路,不然会出现很多诡异的 bug。

  6. ORM:虽然 Realm 自己号称是『为移动开发者定制的全功能数据库』,但是其中确实包含 ORM 的很多特性,不用手动写中间层了,取出来就是新鲜活泼的对象,everyone is happy。

再说说坑:

  1. 无法多线程共享数据库对象:这是 Realm 设计的要求,跨线程访问的话,只能自己重新 query 出来。前面说的上手快,踩坑也快,就是因为这个:异步的 Block、网络接口的回调、从不同线程发出的通知都会触发这个访问异常。ORM 出来的是对象最关键的还是思维方式的转换,虽然是 ORM,但毕竟还是是个数据库,该 query 的地方,还是不要偷懒。

  2. 数据库对象管理:这里有很多坑,比如访问一个被删除的对象时,会直接异常;数据库被 close 后,所有查询出的 object 都无法使用;修改被管理对象属性,必须在指定 block 或者数据库事物集中完成,相当于入库。而 Realm 对象和普通对象是没有任何区别的,所以使用 Realm 的一个重要原则是:不要将数据库对象作为参数传递

  3. 对代码的侵蚀严重:所有的的数据库对象要继承指定的类(没法继承自己的基类了),增删改查,对查询结果处理都有特殊的语法要求,这使得在旧项目中引入 Realm 或者放弃使用将 Realm 从项目中剥离都面临很大的成本。

  4. 静态库大、版本尚未稳定:引入这样一个三方静态库会增加 App 体积,目测大了 1M 至少了。另外 Realm 目前还不是很稳定,之前测试新出的 ObjectNotification 功能,居然会出现偶尔拿不到回调的 bug,相比于成熟的 sqlite 方案,还不是很放心。

以上介绍了下 Realm Database 的基本情况,主要场景是移动端的本地存储。其实 Realm 还有提供 Realm Platform 产品,提供移动响应式后台解决方案,有兴趣的团队可以了解下。

总而言之,Realm 还是一个值得尝试的存储方案。如果你追求快速部署、优秀前卫的特性、以及跨平台,Realm 是你的首选;如果你追求稳定,而已有的项目庞大成熟,可以选择暂时观望技术的新进展,谨慎选择。

3+

喜欢该文章的用户:

  • avatar

苹果在医疗健康领域的三个 Kit

Apple Watch Series 2 发布时,苹果对它进行了重新定位,聚焦在 Fitness 领域。这个转型显然获得了成功,虽然没有公布过官方的销量数据,但根据外界的普遍预测,Apple Watch 今年的出货量已经达到了 1500-2000 万之间。

其实,苹果对于健康领域的兴趣,不仅仅停留在硬件层面上。在软件层面上,近年来也动作颇多。iOS 8 发布时,系统内置了一个名为「健康」的原生应用,并同时发布了面向开发者的 HealthKit;2015 年春季发布会,苹果发布了 ResearchKit;而在去年的 2016 春节发布会上,苹果不仅进一步加强了 ResearchKit,还推出了全新的 CareKit。

三个 Kit,不再傻傻分不清楚

Artboard

HealthKit、ResearchKit、CareKit,这三个 Kit 面向的对象是开发者,它们的存在,大大降低了开发者的门槛。现在,医院里的医生、大学里的医学教授、国际医学中心的科研小组,拥有丰富的临床知识和经验却不一定会编程的他们,现在也有可能做出一个自己的 app 了。三者作为一个有机的整体,相辅相成,不断扩展着健康管理的方式和边界

那么,具体到每一个 Kit,它们是如何各司其职的呢?

HealthKit:提供应用间的健康数据分享标准

HealthKit 作为最早推出的开发套件,实际上也是苹果整个医疗健康产业的基石。试着去打开你手机上的「健康」应用,你可以看到各类健康相关的指标,从基本的健康数据,到生殖健康,再到化验结果、营养摄入……大大小小总共涵盖了数十种指标和体征数据。之所以说 HealthKit 是后面一系列事情的基石,正在于它定义了数据的类型和互相通信的标准。现在,你可以通过将各类第三方健康应用,如 Keep、乐动力、Runtastic,甚至微信运动,它们都可以通过 HealthKit 互相交换、读取、写入健康相关的数据。

image

乍一看这好像也不算什么特别大的事情,实际上如果对医疗行业有稍微深入的认识,你就可以揣测出苹果未来的伟大愿景。现在你去医院看病的时候,基本上所有的病历、开药都已经完全电子化了,没错,医疗健康行业现在可以说是一个信息化的行业,但是,却是一个高度碎片信息化的行业。

如何理解高度碎片化?非常简单,你在一个医院做的检查,得到的化验结果,跑到另一家医院,他们的系统里就没法调取你的化验数据,很多时候你需要重新再来一次。所以,国内外的一些医疗信息化标准的最高等级目前都是七级,都要求实现区域化的医疗信息共享。的确在部分地区形成了区域互通的医疗信息共享,然而,如果想进一步将北京和天津两地的医疗数据打通,那么又是一件麻烦事。每一家医院存储和记录医疗数据的方式、格式都非常不同,仅仅将这些数据形成一套统一可交换的规范协议,就需要花费不少的时间去改造。何况,这里提到的碎片化还只是不同机构间的数据无法互通,另一个更大的问题在于,由于患者的健康数据无法连贯保存,无法形成完整的数据轨迹。

显然,这种方式的变革太慢,苹果正是希望通过 iOS 在全球的影响力和普及程度,从数据的角度入手,首先解决医疗中的老大难问题:形成一套可互通互换的数据格式协议。现在,或许你看到的只是应用和应用间的数据共享,但未来呢,应用和可穿戴硬件之间,和医疗器械之间,和医疗信息系统之间……苹果的 HealthKit,就是连接这一切的标准中间件

ResearchKit:面向科研人员的数据搜集与疾病研究

在 HealthKit 打下数据互通的基本上,进一步的就是应用层面的问题了。有了这些健康数据,我们可以怎么使用它们?ResearchKit 是苹果最先想到的一个大规模应用场景。

在谈 ResearchKit 之前,或许你有必要了解一下临床科研项目的一些基本背景。事实上,世界上目前有许多罕见病和慢性病,我们并没有找到其发病的根本原因,对发病的机理也一无所知,甚至有治疗手段的有效性,也有待考证。没错,临床科研高度依赖试验,只有通过大量的患者入组参与临床科研,并通过有效的实验设计,收集大量的数据反馈,才能最终验证治疗方案的有效性和可靠性。

researchkit

然而,在现实情况里,有许多非常现实的问题:上哪找到足够多的愿意入组的患者?患者入组后,如何高效合理地开展试验?要知道,临床科研的合规性要求非常之高,哪怕只是错过一次治疗或者数据反馈,这一组「样本」可能就失去可靠性了。所以,临床科研项目往往面临着找患者难、找到患者之后实施更难的窘境。一次有效的临床科研项目,往往需要投入巨大的时间成本,耗费大量的财物跟踪患者的治疗和数据收集,而我们谈论的,可能只是几百个患者样本的试验而已。

ResearchKit 就是希望帮助临床科研人员解决这个问题。患者可以通过 iPhone 随时参与某一项罕见病或慢性病的科研项目,期间只要定期完成一些数据反馈和问卷调查,科研人员手中就能收获大量的数据。整个科研项目的流程都被简化了,现在患者只需要下载一个 App,首次进入时通过电子签名的方式确认参与项目并同意数据的匿名分享,之后定时打开 App,不管是输入自己的指标,还是回答问卷,或者完成一些小的测试,都可以随时随地在手机上完成。

apple-researchkit-1104340-TwoByOne

CareKit:以患者为中心的医疗干预和随访

ResearchKit 只是数据应用的一个案例,显然苹果并不满足于此。在科研人员之外,去年推出的 CareKit 则是面向患者个体的健康服务框架。和科研数据收集的导向不同,CareKit 的整体是以患者为中心的。通过 CareKit,患者可以随时与自己的医生共享自己的健康数据,并一对一地收到医生的反馈建议和治疗方案的调整,从而实现远程的医疗干预和随访。

Slice_1

不要小看了医疗干预和随访这件事情。虽然现代医疗越来越强调以患者为中心这个概念,然而很多并没有落到实处。许多人在看完《北京遇上西雅图》之后会开玩笑地说,中国医生的水平比国外医生高多了,因为中国医生一天看的患者数量,可能是国外医生一年的患者量。这句话其实并不全对,看病并不仅仅只是五分钟的面诊,而是一个动态调整的过程。实际上门诊和医生见面,仅仅只是治疗的开始,医生在通过面对面接触,得到化验结果的前提下,给你做出的治疗方案和开具的处方药物,并不是一个结果,而是需要后续根据病情的发展和治疗的效果,随时做出动态的调整。

对于医生来说,如果患者做完手术后出院,再也不和医生联系了,其实医生也并不清楚患者的术后情况和治疗效果,由于缺少信息的正向反馈,对自身临床水平的提高也不利。患者端则更好理解,如果医生能够持续地跟进治疗,患者的依从性和健康状况,也会有更好的改善。然而,我国由于医患数量的严重不对等,导致很多医生没有时间和精力来做好随访这件事情,而很多医院本身也不重视随访工作,或者工作方式还非常原始——不要怀疑,很多医院甚至还靠向患者写信,来收集患者出院的康复情况。

那么 CareKit 想做的事情就很好理解了,和 ResearchKit 的核心一致,本质上都是简化数据流通的过程,现在患者可以通过手机将健康数据实时共享给自己的医生,从而收获医生的建议和反馈,动态地调整治疗方案,对于医患双方来说,都大有禆益

ResearchKit 已取得的成果

前面提到了 ResearchKit 是面向科研人员的大数据收集工具,也是苹果在健康数据应用层面的第一次尝试,自从前年推出以来也有两年的时间了,这两年里成果如何呢?

Research-Kit-Apps-640x360

mPower 为例,它是由罗彻斯特大学、赛智生物网络共同推出的一款 App,主要研究的疾病是帕金森症,评估患者的情况并不难,只需要一些简单的小测试,让患者动动手脚,回答一些简单的问题,就能了解治疗对患者病情的改善程度。目前已经有超过 10000 人入组参与,其中 93% 的人从来没有参与过任何临床科研项目,不要小看了这一万人,这已经是帕金森症的临床研究中,有史以来规模最大的一次。在传统的研究中,一次试验能有几百个入组患者,已经是相当不错的成果了。通过 iPhone 的陀螺仪等功能,mPower 收集了患者一系列的数据,包括灵巧性、平衡能力、走路姿态和记忆水平,从而分析睡眠、运动和心情对疾病的正负面影响。

不仅如此,iPhone 的参与,还让许多临床项目融入了科技的元素。举例来说,小儿自闭症一直是另人困扰的问题,而许多家长往往在小孩已经好几岁时才发现,这时候治疗已经有些迟了。事实上已经有大量的研究证明表明,如果能尽早介入对自闭症儿童的干预治疗,儿童在成年后会有更好的智商和社交能力。Autism & Beyond 就可以使用 iPhone 的前置摄像头,通过对人脸的智能识别,甚至可以识别 18 个月大的婴幼儿的情绪反应,从而尽可能早地筛选出小儿自闭症患者,并进行干预治疗。利用 ResearchKit,App 仅仅推出一个月的时间,就比这个项目之前九个月通过各种渠道募集到的入组实验儿童都要多

Slice_2

苹果一度说的 There is an app for that,现在对于各类罕见病和慢性病来说,都是 There is an app for the disease。从之前提到的帕金森症和小儿自闭症,到癫痫、哮喘、脑震荡、慢性阻塞性肺病、糖尿病、乙肝、黑色素瘤、产后抑郁、睡眠健康等等,你都可以找到一款 App,主动共享你的健康数据,从而帮助临床科研人员更好地推动疾病的治疗发展。

这些疾病或许对你我来说并不陌生,但在科研人员眼里,还有太多他们想了解的谜团没有解决,例如产后抑郁和基因到底有没有关联,脑震荡长期来看对生活质量的影响,哮喘的个体化治疗方案等等,我们贡献的可能只是一点点匿名的数据,但所有人加起来,科研人员一下子能得到的数量据是以前的几十倍,甚至在糖尿病方面,几乎已经确认存在着数类 II 型糖尿病的亚型,而运动对这些亚型的治疗有着显著的影响。

而最棒的是,不仅仅只是那些患者,我们每一个普通人,都可以参与到推动医疗健康发展的进程中来。即使你一切健康,也可以下载诸如 mPower 这样的 App,并以一名健康者的身份共享自己的数据,从而帮助科研人员获取健康情况下的对照组

Researchkit__1_

CareKit 能做什么

前面提到了,CareKit 是在数据应用层面上,面向患者个体和医疗服务者之间的通道,是一套以患者为中心的医疗干预和随访。通过 CareKit,你可以随时与自己的医生分享自己的健康数据,医生在远程能够实时地给出建议和治疗方案调整。实际上,它主要有四个模块:

  • 健康卡:帮助患者执行和监控自己的治疗方案,例如通知你吃药的时间和剂量,该做什么理疗和运动,一些相关的运动数据还可以直接通过 Apple Watch 或者 iPhone 的传感器收集后直接上传给你的医生;
  • 症状和测量追踪器:患者可以记录自己不同时间段的情况,如睡眠质量、血糖血压情况,或者可以定时填写打分量表评估自己的心情或是疼痛水平,或者拍照记录伤口的愈合情况等等;
  • 透视仪表盘:通过图表的形式,直观地展示不同治疗方案的有效性和数据变动情况,以让患者直接地了解该疗程的效果是否达到;
  • 连接模块:患者可以直接与自己的医生在线沟通、共享数据。

image1458596369787

CareKit 以患者为中心正体现在:通过外界力量督促患者执行治疗方案、以患者可以理解的方式呈现数据和进展、针对患者的个体情况提供个性化的指导和建议、对接医生实时提供干预和调整。从前,你很可能需要随身携带笨重的设备全天候地记录数据,然后定期地回到医院、找到医生,现在,你只需要一部手机,就可以随时记录情况、分享数据、联系医生。

一个可以肯定的事实是,CareKit 相比 ResearchKit,会更快更大范围地普及,除了原有的一些 ResearchKit 项目会推出相对应的面向个人的基于 CareKit 的医疗服务外,德州休斯顿医学中心也正在研发一款基于 CareKit 的医患沟通 App;贝斯以色列女执事医疗中心则会面向慢性病患者,推出一套基于 CareKit 的家庭监控方案;One Drop 则正在开发一款糖尿病管理工具,不仅可以记录在不同血糖情况下的疼痛、晕眩和饥饿感,还可以实时地将这些数据共享和你的医生和家人。

苹果在健康产业的下一步

看到这里,你应该已经有了一个大概的认识:HealthKit 是一切的基石,它负责打通数据层面的对接和互通,ResearchKit 和 CareKit 则是两个数据应用层面的具体场景和案例,前者面向科研人员的大数据收集和分析,后者则是面向患者个人的健康管理和个体化治疗

那么,苹果在健康产业的下一步会是什么?

从目前来看,苹果的整个切入点还是在数据层面,的确在医疗体系里,数据标准化是一个很复杂的话题,即使是苹果目前做的,也仅仅只是部分最简单的数据的标准化,如化验结果、生理指标等等,但是苹果显然不会止步于此,毕竟要想在更多健康应用的场景里扩展可能性,就需要接入和统一更多的数据,包括患者的用药情况、手术情况、基因分子情况等等,这些数据的标准和结构化则更加棘手和复杂,显然苹果也在谨慎地调研。

此外,在数据层面来看,除了扩展数据的丰富程度之外,健康数据的安全性也是一个不容忽视的话题。如果我们的手机上存储了我们每个人最为重要的健康指标和数据,这些数据的隐私和保密就显得尤为重要。

在数据标准化的前提下,苹果当然是希望接入更多的健康场景和应用领域。未来,一定会有更多的可穿戴设备和医疗器械和 HealthKit 对接,随着检测仪器的小型化和便携化,未来化验和检查中心,甚至也可能完全消失,人们直接通过便携式的设备,即可通过标准的 HealthKit 协议互通数据,足不出户也能完全数据的采集。而这些数据,除了目前的临床科研和随访之外,在区域卫生预警、持续化健康管理、个体化治疗方案等等领域内,都有着广阔的前景和想像空间。

从某种程度上,健康领域的这些野心,体现的也是苹果所特有的一种文化:即希望赋予普通人以工具和能力,以积小成多的方式,发挥出协作和团体的力量。它或许没有神秘的实验室,没有令人眼前一亮的未来感,也没有太多的精英式的先导者,但正是凭借着这种文化和理念,苹果在设备和硬件之外,如此变革了多媒体内容产业、软件生态产业和教育产业,在不远的未来,医疗健康产业或许亦如是。

0

响应式编程(下):Spring 5

引子:被誉为“中国大数据第一人”的涂子沛先生在其成名作《数据之巅》里提到,摩尔定律、社交媒体、数据挖掘是大数据的三大成因。IBM 的研究称,整个人类文明所获得的全部数据中,有 90% 是过去两年内产生的。在此背景下,包括NoSQL、Hadoop、Spark、Storm、Kylin在内的大批新技术应运而生。其中以 RxJavaReactor 为代表的响应式(Reactive)编程技术针对的就是经典的大数据4V定义(Volume,Variety,Velocity,Value)中的 Velocity,即高并发问题,而在刚刚发布的 Spring 5 中,也引入了响应式编程的支持。我将分上下两篇与你分享与响应式编程有关的一些学习心得。本篇是下篇,对刚刚发布的 Spring 5 中有关响应式编程的支持做一些简单介绍,并详解一个完整的 Spring 5 示例应用。

1. Spring 5 中的响应式编程

作为 Java 世界首个响应式 Web 框架,Spring 5 最大的亮点莫过于提供了完整的端到端响应式编程的支持。

图片出处:Spring Framework Reference Documentation

左侧是传统的基于 Servlet 的 Spring Web MVC 框架,右侧是 5.0 版本新引入的基于 Reactive Streams 的 Spring WebFlux 框架,从上到下依次是 Router Functions,WebFlux,Reactive Streams 三个新组件。

  • Router Functions: 对标 @Controller、@RequestMapping 等标准的 Spring MVC 注解,提供一套函数式风格的 API,用于创建 Router,Handler 和 Filter。
  • WebFlux: 核心组件,协调上下游各个组件提供响应式编程支持。
  • Reactive Streams: 一种支持背压(Backpressure)的异步数据流处理标准,主流实现有 RxJava 和 Reactor,Spring WebFlux 默认集成的是 Reactor。

在Web容器的选择上,Spring WebFlux 既支持像 Tomcat,Jetty 这样的的传统容器(前提是支持 Servlet 3.1 Non-Blocking IO API),又支持像 Netty,Undertow 那样的异步容器。不管是何种容器,Spring WebFlux 都会将其输入输出流适配成 Flux<DataBuffer> 格式,以便进行统一处理。

值得一提的是,除了新的 Router Functions 接口,Spring WebFlux 同时支持使用老的 Spring MVC 注解声明Reactive Controller。和传统的 MVC Controller 不同,Reactive Controller 操作的是非阻塞的ServerHttpRequest 和 ServerHttpResponse,而不再是 Spring MVC 里的 HttpServletRequest 和 HttpServletResponse。

2. 实战

下面我将以一个简单的 Spring 5 应用为例,介绍如何使用 Spring 5 快速搭建一个响应式Web应用(以下简称 RP 应用)。

2.1 环境准备

首先,从 GitHub 下载我的这个示例应用,地址是https://github.com/emac/spring5-features-demo

然后,从 MongoDB 官网下载最新版本的MongoDB,然后在命令行下运行 mongod & 启动服务。

现在,可以先试着跑一下项目中自带的测试用例。

./gradlew clean build

2.2 依赖介绍

接下来,看一下这个示例应用里的和响应式编程相关的依赖。

compile('org.springframework.boot:spring-boot-starter-webflux')
compile('org.springframework.boot:spring-boot-starter-data-mongodb-reactive')
  • spring-boot-starter-webflux: 启用 Spring 5 的 RP(Reactive Programming)支持,这是使用 Spring 5 开发 RP 应用的必要条件,就好比 spring-boot-starter-web 之于传统的 Spring MVC 应用。
  • spring-boot-starter-data-mongodb-reactive: Spring 5 中新引入的针对 MongoDB 的 Reactive Data 扩展库,允许通过统一的 RP 风格的API操作 MongoDB。

2.3 第一种方式:MVC 注解

Spring 5 提供了 Spring MVC 注解和 Router Functions 两种方式来编写 RP 应用。首先,我先用大家最熟悉的MVC注解来展示如何编写一个最简单的 RP Controller。

示例代码

@RestController
public class RestaurantController {

    /**
     * 扩展ReactiveCrudRepository接口,提供基本的CRUD操作
     */
    private final RestaurantRepository restaurantRepository;

    /**
     * spring-boot-starter-data-mongodb-reactive提供的通用模板
     */
    private final ReactiveMongoTemplate reactiveMongoTemplate;

    public RestaurantController(RestaurantRepository restaurantRepository, ReactiveMongoTemplate reactiveMongoTemplate) {
        this.restaurantRepository = restaurantRepository;
        this.reactiveMongoTemplate = reactiveMongoTemplate;
    }

    @GetMapping("/reactive/restaurants")
    public Flux<Restaurant> findAll() {
        return restaurantRepository.findAll();
    }

    @GetMapping("/reactive/restaurants/{id}")
    public Mono<Restaurant> get(@PathVariable String id) {
        return restaurantRepository.findById(id);
    }

    @PostMapping("/reactive/restaurants")
    public Flux<Restaurant> create(@RequestBody Flux<Restaurant> restaurants) {
        return restaurants
                .buffer(10000)
                .flatMap(rs -> reactiveMongoTemplate.insert(rs, Restaurant.class));
    }

    @DeleteMapping("/reactive/restaurants/{id}")
    public Mono<Void> delete(@PathVariable String id) {
        return restaurantRepository.deleteById(id);
    }
}

可以看到,实现一个 RP Controller 和一个普通的 Controller 是非常类似的,最核心的区别是,优先使用 RP 中最基础的两种数据类型,Flux(对应多值)和 Mono(单值),尤其是方法的参数和返回值。即便是空返回值,也应封装为 Mono<Void>。这样做的目的是,使得应用能够以一种统一的符合 RP 规范的方式处理数据,最理想的情况是从最底层的数据库(或者其他系统外部调用),到最上层的 Controller 层,所有数据都不落地,经由各种 FluxMono 铺设的“管道”,直供调用端。就像农夫山泉那句著名的广告词,我们不生产水,我们只是大自然的搬运工。

单元测试

和非 RP 应用的单元测试相比,RP 应用的单元测试主要是使用了一个 Spring 5 新引入的测试工具类,WebTestClient,专门用于测试 RP 应用。

@RunWith(SpringRunner.class)
@SpringBootTest
public class RestaurantControllerTests {

    @Test
    public void testNormal() throws InterruptedException {
        // start from scratch
        restaurantRepository.deleteAll().block();

        // prepare
        WebTestClient webClient = WebTestClient.bindToController(new RestaurantController(restaurantRepository, reactiveMongoTemplate)).build();
        Restaurant[] restaurants = IntStream.range(0, 100)
                .mapToObj(String::valueOf)
                .map(s -> new Restaurant(s, s, s))
                .toArray(Restaurant[]::new);

        // create
        webClient.post().uri("/reactive/restaurants")
                .accept(MediaType.APPLICATION_JSON_UTF8)
                .syncBody(restaurants)
                .exchange()
                .expectStatus().isOk()
                .expectHeader().contentType(MediaType.APPLICATION_JSON_UTF8)
                .expectBodyList(Restaurant.class)
                .hasSize(100)
                .consumeWith(rs -> Flux.fromIterable(rs.getResponseBody())
                        .log()
                        .subscribe(r1 -> {
                            // get
                            webClient.get()
                                    .uri("/reactive/restaurants/{id}", r1.getId())
                                    .accept(MediaType.APPLICATION_JSON_UTF8)
                                    .exchange()
                                    .expectStatus().isOk()
                                    .expectHeader().contentType(MediaType.APPLICATION_JSON_UTF8)
                                    .expectBody(Restaurant.class)
                                    .consumeWith(r2 -> Assert.assertEquals(r1, r2));
                        })
                );
    }
}

创建 WebTestClient 实例时,首先要绑定一下待测试的 RP Controller。可以看到,和业务类一样,编写 RP 应用的单元测试,同样也是数据不落地的流式风格。

2.4 第二种方式:Router Functions

接着介绍实现 RP 应用的另一种实现方式 —— Router Functions。

Router Functions 是 Spring 5 新引入的一套 Reactive 风格(基于 Flux 和 Mono)的函数式接口,主要包括RouterFunctionHandlerFunctionHandlerFilterFunction,分别对应 Spring MVC 中的 @RequestMapping@ControllerHandlerInterceptor(或者 Servlet 规范中的 Filter)。

和 Router Functions 搭配使用的是两个新的请求/响应模型,ServerRequestServerResponse,这两个模型同样提供了 Reactive 风格的接口

示例代码

自定义 RouterFunction 和 HandlerFilterFunction
@Configuration
public class RestaurantServer implements CommandLineRunner {

    @Autowired
    private RestaurantHandler restaurantHandler;

    /**
     * 注册自定义RouterFunction
     */
    @Bean
    public RouterFunction<ServerResponse> restaurantRouter() {
        RouterFunction<ServerResponse> router = route(GET("/reactive/restaurants").and(accept(APPLICATION_JSON_UTF8)), restaurantHandler::findAll)
                .andRoute(GET("/reactive/delay/restaurants").and(accept(APPLICATION_JSON_UTF8)), restaurantHandler::findAllDelay)
                .andRoute(GET("/reactive/restaurants/{id}").and(accept(APPLICATION_JSON_UTF8)), restaurantHandler::get)
                .andRoute(POST("/reactive/restaurants").and(accept(APPLICATION_JSON_UTF8)).and(contentType(APPLICATION_JSON_UTF8)), restaurantHandler::create)
                .andRoute(DELETE("/reactive/restaurants/{id}").and(accept(APPLICATION_JSON_UTF8)), restaurantHandler::delete)
                // 注册自定义HandlerFilterFunction
                .filter((request, next) -> {
                    if (HttpMethod.PUT.equals(request.method())) {
                        return ServerResponse.status(HttpStatus.BAD_REQUEST).build();
                    }
                    return next.handle(request);
                });
        return router;
    }

    @Override
    public void run(String... args) throws Exception {
        RouterFunction<ServerResponse> router = restaurantRouter();
        // 转化为通用的Reactive HttpHandler
        HttpHandler httpHandler = toHttpHandler(router);
        // 适配成Netty Server所需的Handler
        ReactorHttpHandlerAdapter httpAdapter = new ReactorHttpHandlerAdapter(httpHandler);
        // 创建Netty Server
        HttpServer server = HttpServer.create("localhost", 9090);
        // 注册Handler并启动Netty Server
        server.newHandler(httpAdapter).block();
    }
}

可以看到,使用 Router Functions 实现 RP 应用时,你需要自己创建和管理容器,也就是说 Spring 5 并没有针对 Router Functions 提供 IoC 支持,这是 Router Functions 和 Spring MVC 相比最大的不同。除此之外,你需要通过 RouterFunction 的 API(而不是注解)来配置路由表和过滤器。对于简单的应用,这样做问题不大,但对于上规模的应用,就会导致两个问题:1)Router 的定义越来越庞大;2)由于 URI 和 Handler 分开定义,路由表的维护成本越来越高。那为什么 Spring 5 会选择这种方式定义 Router 呢?接着往下看。

自定义 HandlerFunction
@Component
public class RestaurantHandler {

    /**
     * 扩展ReactiveCrudRepository接口,提供基本的CRUD操作
     */
    private final RestaurantRepository restaurantRepository;

    /**
     * spring-boot-starter-data-mongodb-reactive提供的通用模板
     */
    private final ReactiveMongoTemplate reactiveMongoTemplate;

    public RestaurantHandler(RestaurantRepository restaurantRepository, ReactiveMongoTemplate reactiveMongoTemplate) {
        this.restaurantRepository = restaurantRepository;
        this.reactiveMongoTemplate = reactiveMongoTemplate;
    }

    public Mono<ServerResponse> findAll(ServerRequest request) {
        Flux<Restaurant> result = restaurantRepository.findAll();
        return ok().contentType(APPLICATION_JSON_UTF8).body(result, Restaurant.class);
    }

    public Mono<ServerResponse> findAllDelay(ServerRequest request) {
        Flux<Restaurant> result = restaurantRepository.findAll().delayElements(Duration.ofSeconds(1));
        return ok().contentType(APPLICATION_JSON_UTF8).body(result, Restaurant.class);
    }

    public Mono<ServerResponse> get(ServerRequest request) {
        String id = request.pathVariable("id");
        Mono<Restaurant> result = restaurantRepository.findById(id);
        return ok().contentType(APPLICATION_JSON_UTF8).body(result, Restaurant.class);
    }

    public Mono<ServerResponse> create(ServerRequest request) {
        Flux<Restaurant> restaurants = request.bodyToFlux(Restaurant.class);
        Flux<Restaurant> result = restaurants
                .buffer(10000)
                .flatMap(rs -> reactiveMongoTemplate.insert(rs, Restaurant.class));
        return ok().contentType(APPLICATION_JSON_UTF8).body(result, Restaurant.class);
    }

    public Mono<ServerResponse> delete(ServerRequest request) {
        String id = request.pathVariable("id");
        Mono<Void> result = restaurantRepository.deleteById(id);
        return ok().contentType(APPLICATION_JSON_UTF8).build(result);
    }
}

对比前面的 RestaurantController,由于去除了路由信息,RestaurantHandler 变得非常函数化,可以说就是一组相关的 HandlerFunction 的集合,同时各个方法的可复用性也大为提升。这就回答了上一小节提出的疑问,即以牺牲可维护性为代价,换取更好的函数特性。

单元测试

@RunWith(SpringRunner.class)
@SpringBootTest
public class RestaurantHandlerTests extends BaseUnitTests {

    @Autowired
    private RouterFunction<ServerResponse> restaurantRouter;

    @Override
    protected WebTestClient prepareClient() {
        WebTestClient webClient = WebTestClient.bindToRouterFunction(restaurantRouter)
                .configureClient().baseUrl("http://localhost:9090").responseTimeout(Duration.ofMinutes(1)).build();
        return webClient;
    }
}

和针对 Controller 的单元测试相比,编写 Handler 的单元测试的主要区别在于初始化 WebTestClient 方式的不同,测试方法的主体可以完全复用。

3 小结

到此,有关响应式编程的介绍就暂且告一段落。回顾这两篇文章,我先是从响应式宣言说起,然后介绍了响应式编程的基本概念和关键特性,并且详解了 Spring 5 中和响应式编程相关的新特性,最后以一个示例应用结尾。希望读完这些文章,对你理解响应式编程能有所帮助。

4 参考

0

响应式编程(上):总览

引子:被誉为“中国大数据第一人”的涂子沛先生在其成名作《数据之巅》里提到,摩尔定律、社交媒体、数据挖掘是大数据的三大成因。IBM 的研究称,整个人类文明所获得的全部数据中,有 90% 是过去两年内产生的。在此背景下,包括 NoSQL、Hadoop、Spark、Storm、Kylin 在内的大批新技术应运而生。其中以 RxJavaReactor 为代表的响应式(Reactive)编程技术针对的就是经典的大数据 4V 定义(Volume,Variety,Velocity,Value)中的 Velocity,即高并发问题,而在刚刚发布的 Spring 5 中,也引入了响应式编程的支持。我将分上下两篇与你分享与响应式编程有关的一些学习心得。本篇是上篇,以 Reactor 框架为例介绍响应式编程的几个关键特性。

1. 响应式宣言

敏捷宣言一样,说起响应式编程,必先提到响应式宣言。

We want systems that are Responsive, Resilient, Elastic and Message Driven. We call these Reactive Systems. - The Reactive Manifesto

图片出处:The Reactive Manifesto

不知道是不是为了向敏捷宣言致敬,响应式宣言中也包含了 4 组关键词:

  • Responsive:可响应的。要求系统尽可能做到在任何时候都能及时响应。
  • Resilient:可恢复的。要求系统即使出错了,也能保持可响应性。
  • Elastic:可伸缩的。要求系统在各种负载下都能保持可响应性。
  • Message Driven:消息驱动的。要求系统通过异步消息连接各个组件。

可以看到,对于任何一个响应式系统,首先要保证的就是可响应性,否则就称不上是响应式系统。从这个意义上来说,动不动就蓝屏的 Windows 系统显然不是一个响应式系统。

PS: 如果你赞同响应式宣言,不妨到官网上留下的你电子签名,我的编号是 18989,试试看能不能找到我。

2. 响应式编程

In computing, reactive programming is an asynchronous programming paradigm concerned with data streams and the propagation of change. - Reactive programming - Wikipedia

在上述响应式编程(后面简称 RP)的定义中,除了异步编程,还包含两个重要的关键词:

  • Data streams:即数据流,分为静态数据流(比如数组,文件)和动态数据流(比如事件流,日志流)两种。基于数据流模型,RP 得以提供一套统一的 Stream 风格的数据处理接口。和 Java 8 中的 Stream API 相比,RP API 除了支持静态数据流,还支持动态数据流,并且允许复用和同时接入多个订阅者。
  • The propagation of change:变化传播,简单来说就是以一个数据流为输入,经过一连串操作转化为另一个数据流,然后分发给各个订阅者的过程。这就有点像函数式编程中的组合函数,将多个函数串联起来,把一组输入数据转化为格式迥异的输出数据。

一个容易混淆的概念是响应式设计,虽然它的名字中也包含了“响应式”三个字,但其实和 RP 完全是两码事。响应式设计是指网页能够自动调整布局和样式以适配不同尺寸的屏幕,属于网站设计的范畴,而 RP 是一种关注系统可响应性,面向数据流的编程思想或者说编程框架。

特性

从本质上说,RP 是一种异步编程框架,和其他框架相比,RP 至少包含了以下三个特性:

  • 描述而非执行:在你最终调用 subscribe() 方法之前,从发布端到订阅端,没有任何事会发生。就好比无论多长的水管,只要水龙头不打开,水管里的水就不会流动。为了提高描述能力,RP 提供了比 Stream 丰富的多的多的API,比如 buffer()merge()onErrorMap() 等。
  • 提高吞吐量:类似于 HTTP/2 中的连接复用,RP 通过线程复用来提高吞吐量。在传统的Servlet容器中,每来一个请求就会发起一个线程进行处理。受限于机器硬件资源,单台服务器所能支撑的线程数是存在一个上限的,假设为T,那么应用同时能处理的请求数(吞吐量)必然也不会超过T。但对于一个使用 Spring 5 开发的 RP 应用,如果运行在像 Netty 这样的异步容器中,无论有多少个请求,用于处理请求的线程数是相对固定的,因此最大吞吐量就有可能超过T。
  • 背压(Backpressure)支持:简单来说,背压就是一种反馈机制。在一般的 Push 模型中,发布者既不知道也不关心订阅者的处理速度,当数据的发布速度超过处理速度时,需要订阅者自己决定是缓存还是丢弃。如果使用 RP,决定权就交回给发布者,订阅者只需要根据自己的处理能力问发布者请求相应数量的数据。你可能会问这不就是 Pull 模型吗?其实是不同的。在 Pull 模型中,订阅者每次处理完数据,都要重新发起一次请求拉取新的数据,而使用背压,订阅者只需要发起一次请求,就能连续不断的重复请求数据。

适用场景

了解了 RP 的这些特性,你可能已经猜想到 RP 有哪些适用场景了。一般来说,RP 适用于高并发、带延迟操作的场景,比如以下这些情况(的组合):

  • 一次请求涉及多次外部服务调用
  • 非可靠的网络传输
  • 高并发下的消息处理
  • 弹性计算网络

代价

Every coin has two sides.

和任何框架一样,有优势必然就有劣势。RP 的两个比较大的问题是:

  • 虽然复用线程有助于提高吞吐量,但一旦在某个回调函数中线程被卡住,那么这个线程上所有的请求都会被阻塞,最严重的情况,整个应用会被拖垮。
  • 难以调试。由于 RP 强大的描述能力,在一个典型的 RP 应用中,大部分代码都是以链式表达式的形式出现,比如flux.map(String::toUpperCase).doOnNext(s -> LOG.info("UC String {}", s)).next().subscribe(),一旦出错,你将很难定位到具体是哪个环节出了问题。所幸的是,RP 框架一般都会提供一些工具方法来辅助进行调试。

3. Reactor 实战

为了帮助你理解上面说的一些概念,下面我就通过几个测试用例,演示 RP 的两个关键特性:提高吞吐量和背压。完整的代码可参见我 GitHub 上的示例工程

提高吞吐量

    @Test
    public void testImperative() throws InterruptedException {
        _runInParallel(CONCURRENT_SIZE, () -> {
            ImperativeRestaurantRepository.INSTANCE.insert(load);
        });
    }

    private void _runInParallel(int nThreads, Runnable task) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(nThreads);
        for (int i = 0; i < nThreads; i++) {
            executorService.submit(task);
        }
        executorService.shutdown();
        executorService.awaitTermination(1, TimeUnit.MINUTES);
    }

    @Test
    public void testReactive() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(CONCURRENT_SIZE);
        for (int i = 0; i < CONCURRENT_SIZE; i++) {
            ReactiveRestaurantRepository.INSTANCE.insert(load).subscribe(s -> {
            }, e -> latch.countDown(), latch::countDown);
        }
        latch.await();
    }

用例解读:

  • 第一个测试用例使用的是多线程 + MongoDB Driver,同时起 100 个线程,每个线程往 MongoDB 中插入 10000 条数据,总共 100 万条数据,平均用时15秒左右。
  • 第二个测试用例使用的是 Reactor + MongoDB Reactive Streams Driver,同样是插入 100 万条数据,平均用时不到 10 秒,吞吐量提高了
    50%!

背压

在演示测试用例之前,先看两张图,帮助你更形象的理解什么是背压。

图片出处:Dataflow and simplified reactive programming

两张图乍一看没啥区别,但其实是完全两种不同的背压策略。第一张图,发布速度(100/s)远大于订阅速度(1/s),但由于背压的关系,发布者严格按照订阅者的请求数量发送数据。第二张图,发布速度(1/s)小于订阅速度(100/s),当订阅者请求100个数据时,发布者会积满所需个数的数据再开始发送。可以看到,通过背压机制,发布者可以根据各个订阅者的能力动态调整发布速度。

    @BeforeEach
    public void beforeEach() {
        // initialize publisher
        AtomicInteger count = new AtomicInteger();
        timerPublisher = Flux.create(s ->
                new Timer().schedule(new TimerTask() {
                    @Override
                    public void run() {
                        s.next(count.getAndIncrement());
                        if (count.get() == 10) {
                            s.complete();
                        }
                    }
                }, 100, 100)
        );
    }

    @Test
    public void testNormal() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(1);
        timerPublisher
                .subscribe(r -> System.out.println("Continuous consuming " + r),
                        e -> latch.countDown(),
                        latch::countDown);
        latch.await();
    }

    @Test
    public void testBackpressure() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<Subscription> timerSubscription = new AtomicReference<>();
        Subscriber<Integer> subscriber = new BaseSubscriber<Integer>() {
            @Override
            protected void hookOnSubscribe(Subscription subscription) {
                timerSubscription.set(subscription);
            }

            @Override
            protected void hookOnNext(Integer value) {
                System.out.println("consuming " + value);
            }

            @Override
            protected void hookOnComplete() {
                latch.countDown();
            }

            @Override
            protected void hookOnError(Throwable throwable) {
                latch.countDown();
            }
        };
        timerPublisher.onBackpressureDrop().subscribe(subscriber);
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                timerSubscription.get().request(1);
            }
        }, 100, 200);
        latch.await();
    }

用例解读:

  • 第一个测试用例演示了在理想情况下,即订阅者的处理速度能够跟上发布者的发布速度(以 100ms 为间隔产生 10 个数字),控制台从 0 打印到 9,一共 10 个数字,和发布端一致。
  • 第二个测试用例故意调慢了订阅者的处理速度(每 200ms 处理一个数字),同时发布者采用了 Drop 的背压策略,结果控制台只打印了一半的数字(0,2,4,6,8),另外一半的数字由于背压的原因被发布者 Drop 掉了,并没有发给订阅者。

4 小结

通过上面的介绍,不难看出 RP 实际上是一种内置了发布者订阅者模型的异步编程框架,包含了线程复用,背压等高级特性,特别适用于高并发、有延迟的场景。

下篇我将对刚刚发布的 Spring 5 中有关响应式编程的支持做一些简单介绍,并详解一个完整的 Spring 5 示例应用,敬请期待。

5 参考

0

Web 与 App 数据交互原理和实现

背景

点击图片查看大图已经成了用户浏览页面时的一种习惯,原生 App 往往都实现了这些图片处理功能(点击查看、缩放、保存、滑动浏览等)。对于 Web 页面,为了更好的体验,一般开发者都会自己实现或是使用三方的图片处理框架。但如果一个 Web 页面能在一些特定的原生 App 中打开,那完全可以让 App 去代理处理这些图片。毕竟原生 App 的体验会更好,而且同一个 App 内点击原生图片和 Web 里面的图片,体验应该是一致的。

所以需求很简单,就是Web 页能直接调用原生的图片显示功能嘛!

交互原理

这个需求背后要解决的问题,实际上是要通过 Web 与原生的交互,让 Web 把图片资源交给原生应用去处理。

iOS7 之后苹果推出 JSCore,通过获取 Web 上下文环境,实现了 App 可以直接调用 JS 方法,或者将 block 赋值给 JS 方法来实现 Web 调用 App 并传值。所以在不考虑安卓的情况下,可以通过 JSCore 实现 Web 与 App 交互。

JSCore

App 端

// 获取JS上下文
 JSContext *jsContext;
-(void)WebViewDidFinishLoad:(UIWebView *)webView {
  jsContext = [_webview valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
}

// App 调 Web insertText 事件
JSValue *funcValue = jsContext[@"insertText"];
[funcValue callWithArguments:@[@"hello web!"]];

// Web 调 App,App注册事件名为callNative
jsContext[@"callNative"] = ^(NSString*str){
  NSLog(str);
};

Web 端

// 接收 App 调用
function insertText(text) {
  ...
}

// 调用 App
btnNode.onclick = () => {
  callNative('hello app!');
}

这种方式使用简单,但大多数情况需要兼容 iOS 与安卓,所以需要找到更合适的方式。

传统方式

苹果推出 JSCore 以前,App 调用 Web 只能通过 WebView 执行 JSString 来实现。而 Web 没法直接调用 App,只能触发特定链接,让 App 在 WebView 代理方法中捕获到这特定链接,从而执行相应操作,间接实现 Web 调 App。这种传统方式也适用于安卓端的的实现。

App 调用 Web

[_webView stringByEvaluatingJavaScriptFromString:jsString];

这里的 JSString 是一个 JS 方法的调用,这个方法能给 Web 传值的前提是在 Web 端定义一个全局方法如:

function handlerMessageFromApp(data) {
  ...
}

那 App 端需要去拼接出这个 JSString 然后再调用 Webview 的 stringByEvaluatingJavaScriptFromString 方法:

NSString* jsString = [NSString stringWithFormat:@"handlerMessageFromApp('%@');", messageJSON];

[_webView stringByEvaluatingJavaScriptFromString:jsString];

App 调用 Web 就是利用这种 Webview 去执行一个 JS 方法的方式去实现。JS 方法可以直接返回值给 App,但通常情况下 Web 端收到 App 的消息会去进行一些异步操作,这样在 handlerMessageFromApp 中直接返回就不太合适了。此时需要有另外一个流程去保证 Web 端把消息传递给 App,也就是下面会说的 Web 调 App。

Web 调用 App

Web 调 App 则需要双方事先沟通定好协议。比如如果要点击 Web 页面链接跳转到 App 主页,那可以将协议的名称定为 xr://home,HTML 内容为 <a href="xr://home">查看主页>></a>,点击链接就会发生 url 的改变,同时原生 WebView 的代理方法也会被触发。通过在代理方法中监听 url 的变化实现约定的协议。

// 在 webview 代理方法中处理
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
    NSURL *url = [request URL];
    if  (url == "xr://home") {...}
}

如果基于这种方式让 Web 给 App 传值可以有两种方式:
1. 直接在协议后面拼接参数如 xr://message?name=carson,这种方式适合于简单的值传递,但对于复杂结构的数据传递这种方式不合适。
2. Web 端 与 App 端定好消息协议如:xr://message,Web 端要发送消息给 App 端时触发该协议,同时将要传递的值放在页面上,App 端监听到该协议变化时从 Web 页面上取值。这种方式适合传递复杂数据。

现实开发中的传值往往都是复杂结构的,所以我们选择第2种方式去完成 Web 端调用 App 端。

function getMessageFromWeb() {
  return messageObject;
}

基于前面的经验在 Web 端实现方法 getMessageFromWeb,这个方法负责返回要传递给 App 的值。

id messages = [_webView stringByEvaluatingJavaScriptFromString:@"getMessageFromWeb()"];

当特定的消息协议被触发时 App 通过 Webview 执行带有返回值的 JS 方法拿到数据,这就间接实现 Web 调用 App 并传值的原理。

但这种方式 Web 端每次调用时都需要触发特定协议,同时将全局变量 messageObject 赋值。我们往往希望有一个抽象层来做这些事情,每当调用的时候作为调用方最好是能一个方法传递消息名称与消息内容就行了,接收方也只需要按消息名称接收消息内容。Web 端与 App 端都应该具备这样一个抽象层,这样具体的两端交互就简化成了一端只管调用另一端只管接收。有了这样一个抽象层再来看 Web 端调 App 端就好比:

  1. W 委托 B 发消息给 A 说有包糖要给他,此时 W 已经把糖交给了 B。
  2. B 发出通知给 A。
  3. A 收到通知后跑过来从 B 这里拿走了糖。

这样一个单向的 W 把消息发送给 A 就完成了,原理还是基于前面 Web 调用 App 的原理。因为有 B 的存在此时 W 要做的事情少了很多。

完整的调用

上面的过程只是最简单的情况,是一个单向的,正常情况下 W 给 A 送了包糖或许还想知道 A 什么时候吃完,吃完了 W 可以再去买。

  1. W 委托 B 发消息给 A 说有包糖要给他,此时 W 已经把糖交给了 B。
  2. B 发出通知给 A。
  3. A 收到通知后跑过来从 B 这里拿走了糖。
  4. A 吃完糖后再通知到 B 说他吃完了。
  5. B 最后通知到 W,W 再去买糖。

步骤4与步骤5代表 App 回调 Web。W 最后再买糖相当于回调函数的实现,这是在 W 送糖的时候就已经决定的事情。 因为整个一去一回的过程并非同步,所以这个地方就需要处理好这样一个映射:消息 —> 回调处理函数。也就是说每条发送的消息对应上各自的回调处理函数,这就需要 B 去维护这样一个映射关系。B 给要发送的消息加上 callbackId,同时以 { callbackId: callbackHandler } 的方式将回调处理函数存储起来。A 收到了有 callbackId 的消息再响应时又回继续带上这个 callbackId,最后 B 按照 callbackId 找到回调处理函数去执行。这样才是一个完整的带有回调处理的 Web 调用 App 并传值。

所以关键任务是去实现 B 这样一个抽象层,B 的任务很重,它是一个 Web 端与 App 端交互的桥梁,它要具备发送消息提示、存储消息内容、存储消息回调、 接收消息、执行消息回调等功能。

实现这样一个抽象层并不算太难,但重复造轮子的事就不做了,前面描述的过程也正是参照第三方库 WebViewJavascriptBridge 的实现,B 也正是这个框架的核心 bridge,其整个实现基于观察者模式,在最基本的原理上加上了一些封装,抽象出一层 bridge 负责两端的交互,最终暴露给开发者的只有简单的 API(初始化、注册、调用)。

正如 bridge 这个名字一样,它起着桥梁作用,实现了两端的数据交互。这两个 bridge 基本实现了相同的功能,唯一的区别在于 Web 端这边的 bridge 没法直接发送消息内容,只能告诉 App 端有消息。

应用

回到最开始的需求,还是在 WebViewJavascriptBridge 的基础上去实现让 App 去处理 Web 里的图片。做过图片浏览的应该都知道,实现图片浏览需要提供所有图片的数组 array 以及当前点击图片的 index。所以给 img 标签绑定点击事件,再获取所有图片的数组,最后利用 JSBridge 传给 App 即可。至于第三方库的接入可以去查看官方文档。

App 端注册 handler,用于处理接收图片:

[self.bridge registerHandler:@"previewImage" handler:^(id data, WVJBResponseCallback responseCallback) {
    // 处理图片 data
}]

相应的 Web 端要去 callHandler:

// setup 去触发 App 将 bridge 注入 window
setupWebViewJavascriptBridge(function(bridge) { 
  bridge.callHandler('previewImage', { urls: [ /* ... */ ], index: 0 });
}

就这么简单的一端管接收另一端管调用就完成了 Web 将图片传给原生。
熟悉微信网页开发的应该知道微信也提供了相关功能,微信中的网页可以利用微信的 JSSDK 使用原生提供的一整套图片处理功能:相册、相机、裁剪、上传、下载、图片浏览...


App 与 Web 交互的场景还有很多,比如 Web 页自定义分享、控制 App 页面跳转,随着小程序的出现 Web 端与 App 的交互也是更加有了深度。总之原理不难,掌握了原理才能做出更好更多的事情。

1+

iOS 屏幕适配浅谈

前端开发的屏幕适配其实算是基本功,每个码农在长期实践中都有自己的总结。

在 iOS 平台上,苹果爸爸对适配的支持个人感觉很不人性化,提供了 autoLayout、sizeClass 等技术,感觉没有前端类似 flexBox 这样的技术来得灵活。像是点歪了技能树,过于重视使用 xib 配置 UI,但很多码农还是习惯纯代码编程。Cocoa 没有 css 这样的纯布局文件,导致很多时候我们将布局、UI 和逻辑写在一起,十分混乱、冗长。

下面简单介绍下在实践中适配屏幕的方向思路,抛砖引玉。

从设计到代码:沟通与标准

App 的 UI 界面是由设计人员(产品,UI)绘制的,然后由开发实现,双方要有良好的沟通,并且把设计内容标准化、文档化。

对设计方来说,适配的规则总是在设计师心中的,是按比例的缩放,还是固定的间距,是公用一套规则,还是在大屏下有特殊的布局,都需要有明确方式传达给耿直的码农们。

良好的设计文档是沟通第一步

一般常见的布局方式有:

  • 固定间距:在不同尺寸下,间距总是固定。
  • 流式布局:文字,图片等在不同屏幕下流式排布,比如大屏下一行显示四张图片,小屏一行三张,图片尺寸固定。
  • 比例放大:间距,文字大小,图片大小等比例放大。
  • 保持比值:两个UI元素或者图片的长宽等属性保持一定的比值。
  • 对齐:元素间按某个方向对齐。

设计师需要将这些布局规则标注清楚,有利沟通,也方便日后追溯。

对于一些通用 UI 组件,要进行标准化,设计上有利于 app 风格统一,实现上也方便开发进行封装。

平面设计要标准化

UI的搭建:xib VS 纯代码

苹果一直用xib来标榜他们家 App 开发简单易上手:将各种你需要的东西往屏幕上一拖一放,一个UI界面就搞定了,这很 cool 不是嘛!

xib 的优点显而易见:

  • 易上手、可视化,所见即所得
  • 减少代码量
  • 快,适合小 app 快速开发

但是在我们的实际项目中,是不推荐使用 xib 的。

首先,xib 本身过于笨拙,只能搭建一些简单的 UI,动态性很差,难以满足app复杂的UI交互需求。

其次,做过性能优化的同学都知道,xib(or StoryBoard)的性能是很差的,相对于用纯代码 alloc 的组件来说,xib 加载慢,而且会占用 app 包的体积。不仅仅是app的性能,使用老 mac 打开较大的 xib 文件,有时候会卡的你怀疑人生,严重影响开发效率(心情)。

除此以外,对于团队协作来说,xib 也不是一个好选项:阅读困难,无法在 git 上查看历史改动,容易造成冲突,造成冲突后难以解决,元素通过 outlets 与代码的链接难以维护,容易在改动中造成错漏等等。

另外,对于我这种中途转到前端的工程师来说,对一切在 IDE 界面上配置的东西都有种迷之不信任,感觉不如一行行黑底白字的代码来的靠谱。

当然我们不是完全禁用了 xib,用代码码 UI 的缺点也很明显:繁琐,代码量大。因此对一些元素较多,又比较固定的 UI 组件,我们可以用 xib 来减少代码量:

固定的UI组件可以使用xib

针对UI代码繁琐,重复编码多的情况,我们可以通过适当封装(UI 工厂类),组织结构(MVC,分离 UI 代码)等手段,清晰逻辑。

// label 工厂方法
+ (UILabel *)labelWithFont:(UIFont *)font
                     color:(UIColor *)
                      text:(NSString *)text
             attributeText:(NSAttributeString *)attributeText
                 alignment:(NSTextAlignment)alignment;

布局:返璞归真

从 iOS7 开始苹果在 Cocoa 平台引入 AutoLayout 进行 UI 的基本布局。但是 AutoLayout 非常反人类,不仅代码繁琐而且使用不灵活限制很多。

比如我想要把三个元素等间距地展示在屏幕上,用 AutoLayout 写完基本蛋都碎了,更别说动态地在两套布局间切换这种高级需求。

后来苹果推出 sizeClass,试图解决多套布局的问题,但是仍然没有触及到码农的痛点,而且依赖 xib 使它泛用性不好。

看起来很美好的sizeClass

一段典型的AutoLayout代码如下所示:

    _topViewTopPositionConstraint = [NSLayoutConstraint
                                     constraintWithItem:_topInfoView
                                     attribute:NSLayoutAttributeTop
                                     relatedBy:NSLayoutRelationEqual
                                     toItem:self.view
                                     attribute:NSLayoutAttributeTop
                                     multiplier:1.0
                                     constant:self.navigationController.navigationBar.frame.size.height + self.navigationController.navigationBar.frame.origin.y];

    [self.view addConstraint:topViewLeftPositionConstraint];

    (这里省略上述类似结构*4)

上面省略了很多代码,实际上一页都放不下。它干了什么呢,只是将一个元素紧贴屏幕上边缘放置。项目中我们会使用三方 autoLayout 的封装:PureLayout ,简化代码,也有其它实用功能,。

AutoLayout 比较适合:

  • 基本的对齐(上下左右对齐,居中对齐等)
  • 固定的布局,固定的间距,动态性不高的页面
  • 简单且数量较少的 UI 元素

不擅长:

  • 比例布局
  • 动态性较强的页面局部
  • 不同屏幕大小比例的适配
  • 复杂的UI

另外有一点,autoLayout 对性能是有损耗的,所以对性能有要求的场景,比如列表中的 cell,我们会用代码计算 frame,提高滑动帧率。

所以在实际工程中,需要来选择布局方式。

下面是 app 中首页新闻 Feeds 的布局代码片段:

- (void)layoutSubviews {

    [super layoutSubviews];

    CGFloat cellWidth = CGRectGetWidth(self.bounds);
    CGFloat currentY = 0.f;

    // 0.content
    CGFloat cellHeight = CGRectGetHeight(self.bounds);
    CGFloat contentHeigth = cellHeight - kCellPaddingHeight;
    _mainContentView.frame = CGRectMake(0, 0, cellWidth, contentHeigth);

    // 1. topic
    CGFloat topicLabelWidth = [_topicLabel.text boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:_topicLabel.font} context:nil].size.width;

    CGFloat topicLabelHeight = [@"测高度" boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:_topicLabel.font} context:nil].size.height;

    CGFloat topicLogoLeftPadding = 3.f;
    CGFloat topicLogoWidth = 10.f;
    CGFloat topicLeftPadding = 13.f;

    _topicView.frame = CGRectMake(topicLeftPadding, currentY + kTopicUpPadding, topicLogoWidth + topicLogoLeftPadding + topicLabelWidth, topicLabelHeight);
    _topicLogo.frame = CGRectMake(topicLabelWidth + topicLogoLeftPadding, CGRectGetHeight(_topicView.frame) / 2.0 - topicLogoWidth / 2.0, topicLogoWidth, topicLogoWidth);
    _topicLabel.frame = CGRectMake(0, 0, topicLabelWidth, topicLabelHeight);

    (省略大量代码……)

    // 10._sourceLabel
    CGSize sourceSize = [_sourceLabel.text boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:_sourceLabel.font} context:nil].size;

    _sourceLabel.frame = CGRectMake(kEdgeHorizontalPadding, currentY + kLeadingUpPading, sourceSize.width, sourceSize.height);
}

可以看到,为了确定每个元素的位置,我们需要进行大量的计算,代码可读性也不好,繁琐难读。如果引入动态性,比如不同屏幕字体大小改变,元素大小按比例扩大等,则计算量又要上一个数量级。

动态布局:清晰独立

UI界面是动态的,在不同状态,不同尺寸或者手机的横竖屏情况下,我们往往需要在多套布局方案中切换,或者对布局进行微调。如果使用xib布局的话,可以使用 SizeClass + AutoLayout 的方案;如果是代码实现的页面,则没有官方提供的工具,只能用逻辑去判断。

一般来说,我们写复杂的UI页面,需要遵循两个原则:

  • UI 布局代码,要清晰:这是最重要的,要一眼就知道在调整那一块,怎么调整,如果不能,适当拆分,优化命名。
  • 布局代码要和业务逻辑独立:在一些常用设计模式下,我们会将 UI 和数据模型解耦,在 UI 内部,同样要将交互,配置这些逻辑和布局解耦,独立出类似前端 css 这样的纯布局文件。

将布局代码提炼出来,在不同尺寸下调用不同的实现:

    if (IS_IPHONE_6){  
        self.layout = [MyLayout iPhone6Layout];
    }else if (IS_IPHONE_6_PLUS){  
        self.layout = [MyLayout iPhone6PlusLayout]; 
    }

    // 实现小屏幕布局
    + (MyLayout *)iPhone6Layout {...}
    // 实现大屏幕布局
    + (MyLayout *)iPhone6PlusLayout {...}

字体适配:字体集

在开发中我们经常会遇到需要动态设置字体的情况:

  • 不同屏幕尺寸,或者横竖屏,需要展示不同的字体大小。
  • 为用户提供了文章调节字体选项。
  • App 的不同语言版本,需要显示的字体不一样。

字体大小调节

较为简单的做法是用宏或者枚举定义字体参数,针对不同尺寸的屏幕,我们拿到不同的值:

#ifdef IPHONE6
#define kChatFontSize 16.f
#else IPHONE6Plus
#define kChatFontSize 18.f
#endif

在对一些旧代码做字体适配扩展的时候,直接修改源码改动太多,容易混乱,可以采用 runTime 方法 hack Label 等控件的展示,替换原有的 setFont 方法:

+ (void)load{  

    Method newMethod = class_getClassMethod([self class], @selector(mySystemFontOfSize:));  
    Method method = class_getClassMethod([self class], @selector(systemFontOfSize:));  
    method_exchangeImplementations(newMethod, method);  
}  

+ (UIFont *)mySystemFontOfSize:(CGFloat)fontSize{  
    UIFont *newFont=nil;  
    if (IS_IPHONE_6){  
        newFont = [UIFont adjustFont:fontSize * IPHONE6_INCREMENT];  
    }else if (IS_IPHONE_6_PLUS){  
        newFont = [UIFont adjustFont:fontSize * IPHONE6PLUS_INCREMENT];  
    }else{  
        newFont = [UIFont adjustFont:fontSize];  
    }  
    return newFont;  
}  

以上套路缺点显而易见:不够灵活,将逻辑分散,不便于维护,扩展性也不好。

一种比较好的实践是引入字体集(Font Collection)的概念,什么是字体集呢,我们在用 Keynote 或者 Office 的时候,软件会提供一些段落样式,定义了段落、标题、说明等文字的字体,我们可以在不同的段落样式中切换,来直接改变整个文章的字体风格。

Keynote中的字体集

听上去和我们的需求是不是很像呢,我们在代码中也是做类似的事情,将不同场景下的字体定义到一个 Font Collection 中:

@protocol XRFontCollectionProtocol <NSObject>

- (UIFont *)bodyFont; // 文章
- (UIFont *)chatFont; // 聊天
- (UIFont *)titleFont; // 标题
- (UIFont *)noteFont; // 说明
......
@end

不同的场景,灵活选择不同的字体集:

+ (id<XRFontCollectionProtocol>)currentFontCollection {

#ifdef IS_IPhone6
    return [self collectionForIPhone6];
#elif IS_IPhone6p
    return [self collectionForIPhone6Plus];
#endif
    return nil;
}

// set font
titleLabel.font = [[XRFontManager currentFontCollection] titleFont];

适配新的屏幕或者场景,我们只需要简单地增加一套字体集就好了,可以很方便的管理 app 中的字体样式,做动态切换也很简单。


总结来说,用代码在一个尺寸实现设计稿是比较简单的,但是要在各种尺寸下忠实反应设计的想法需要合理的代码设计以及一定的代码量。

UI 的还原其实也是大前端开发非常重要的部分,作为程序员,往往重视代码的稳定,业务的正常使用而忽略软件界面这个同样重要的用户体验因素。设身处地地想,如果设计看到自己精心调配的比例、字体、色号在不同尺寸手机上显示得歪七倒八,一定会气的要死吧。

1+

工程师成长的必备技能

经常听到有些工作1、2年的工程师说,觉得很迷茫,不知道该学些什么,想让我给一些建议。所以我总结了几个我认为对工程师很重要,越早学会越有用的知识或者技能。不知道学什么的时候,学这些总是没错的:)

基础知识

不管你是哪个方向,前端、后端、运维、QA、数据甚至AI等,基础的重要性再怎么强调都不为过。原因很简单,因为基础决定了一个工程师未来能达到的高度

就像建高楼一样,越高的楼,地基肯定需要打得越深。有些人会觉得平常工作里用不到那些知识,于是忽略了。他们按部就班的做一些日常工作,可能也没什么问题。但是当他们想要进一步提升自己的时候,就会发现好像有个天花板,怎么也突破不了。如果意识不到这一点,那可能一辈子就是一个平庸的工程师了。所以说,基础知识就是一个工程师的内功。内功不行,修炼再多的招式,也不可能成为高手。基础也会影响一个人分析问题解决问题的能力。基础不好的人,遇到问题可能都不知道去搜索什么关键词。

比如说性能优化,如果要做到极致,那就需要对硬件(如CPU、缓存结构、内存性能等)、系统(如进程、线程、内存分配、系统调用等)、网络(如TCP重传、拥塞控制、IP路由等)、数据结构、编程语言等都非常了解。再比如说 AI,数学和算法不好也能用 AI 做一些东西,但真的要训练一个好用的模型,基本的数学和算法知识就必不可少了。再比如系统分析和设计,编程功底不足、逻辑能力和抽象能力锻炼不够的人,设计出来的系统恐怕也是惨不忍睹的。

所以我建议大家可以把大学里对应专业的基础课程,找一些更好更深入的教材再好好的看看。比如对开发来讲,程序设计、数据结构和算法、操作系统、数据库、计算机网络、软件工程等等都应该看看。这些方面有很多经典的书可以参考,比如《算法导论》、《TCP/IP详解》、《深入理解计算机系统》、《计算机程序的构造和解释》、《代码大全》等等。如果有志于数据分析、数据挖掘、AI 方向的,数学、统计分析等也应该看看。我刚毕业那几年也算看了一些,但现在常常觉得还是不够,又没有时间再去重新学习基础内容。所以说,趁年轻有时间的时候,一定要多投入一些时间把基础打扎实了。

学习基础的时候,也不要死抠细节。更重要的是理解它的原理,学习解决问题的思路。比如,学习数据结构里的树,不是要你把这些它们的翻转、遍历算法倒背如流,而是要理解他们的原理、优缺点,知道在哪些情况下适用或者不适用,为什么数据库用B+树等等。当然,学习的时候动手写写这些算法,还是有助于理解的。

不过学习基础知识是很枯燥的事情,短期内也不会有明显效果,所以更需要耐心和坚持。基础扎实了,接下来根据发展方向的不同,就可以自己选择性的去学习了。

时间管理

这是我希望当年我刚毕业就学会的技能,可惜过了好几年我才明白它的重要性。很多人不会管理自己的时间和精力,白白浪费了不少大好时光。

时间管理最有用的我认为就是《高效能人士的七个习惯》里提到的“要事第一”原则,它的核心就是四象限。顺便提一下,史蒂芬·柯维的这本书,很值得一看,我看过好几遍,获益良多。

四象限.001

第四象限,不重要不紧急。 比如上网、游戏、购物、聊天、看朋友圈等。这些事情偶尔作为生活调节没有问题,但切记不可沉溺。有些人总抱怨自己没有时间学习,其实可以看一下,自己是不是花了太多时间在这一象限的事情上。

第三象限,紧急不重要。 比如不速之客、不重要的会议、日常的琐碎工作。对这些事,先考虑是否可以拒绝?是否值得自动化?或者是否有其他方式优化流程?如果都不是,那就尽快做掉。

第二象限,重要不紧急。 比如自动化或者改进效率的事情、工作规划、回顾总结、学习分享等等。这是最重要的象限(注意,最重要的不是第一象限),我们应该把大部分时间花在这里。这里的事情,大部分是着眼于未来,可以提升自己能力、改进工作效率的事情,但往往由于不是那么急迫,会被人忽视。

第一象限,紧急且重要。 比如线上故障、Deadline 等。这些都是要立即处理的事情。但其实这里的很多内容是从第二象限转变过来的,例如本来一个任务不紧急,但一拖再拖就变成紧急任务了。又比如如果我们把监控、稳定性做好的话,那线上故障就会减少。所以对待这个象限的原则是,尽量想办法减少这个象限的内容。

每次遇到事情,考虑一下它是哪个象限的,再决定如何处理。总之它给我们不少启示:

  • 把自己的时间花在重要不紧急的事情(第二象限)上。
  • 学会取舍。有的事情不一定要做,或者不一定现在要做。
  • 学会协作。有的事情不一定要自己做,可以找人一起帮忙。
  • 不要拖延。拖延会把不紧急的事情变成紧急的事情。
  • 不要钻牛角尖,把时间花在没有结果的事情上。

除了四象限,常用的时间管理方法还有 GTD 和番茄工作法。这些大家都应该了解下,但是也不用生搬硬套,而是要最终建立一套自己的工作方法。可以借助一些工具(比如我用的 Doit.im),学会规划好自己每天、每周以及更长期的任务。

项目管理

项目管理是一个很大的话题,很多人觉得项目经理才需要学这个,但其实对于初级工程师,了解一些项目管理的原则和方法,是很有帮助的。

比如说任务分解,看上去是很简单的事情,但要做好并不容易。任务分解的一个基本原则是 MECE 原则(相互独立、完全穷尽),也就是任务之间不能有重叠,并且不能遗漏。对于开发任务,按照垂直的功能去划分任务(即分成一个个完全独立的小功能),按照水平的分层去划分任务(即分成前端、服务层、数据层等),会有很不相同的效果。但怎么分解好,也是要具体问题具体分析,不能一概而论。

其实我们做任何事情,都需要在不同维度上进行任务分解。初级工程师应该要知道如何去分解自己的工作。比如以前我还用 C 和 C++ 的时候,见过不少人喜欢一下子先把代码都写完,然后再编译和调试。结果一编译,出现无数个编译错误,花费大量的时间修改和调试。现在 IDE 比较先进,这种情况不多见了,但很多人还是习惯先写好很多代码,再去调试。常常调试、修BUG的时间比写代码的时间还多很多。但其实我们可以把任务继续进行分解,例如 2 天的任务,我们可以把它分解成更小的模块、类甚至方法,每次实现了一部分,就去测试一下,或者写个单元测试确保这部分代码正确了,再去做下一个小任务。TDD 也是这种理念,不过它把写单元测试放到前面去了。并且认真规划的话,大部分情况都可以确保完成每个小任务后,应用功能还是可用的,随时可以提交代码。而不是要花上一个礼拜,等整个功能全部完成才提交代码。

再比如风险管理。有风险意识的工程师,会在开始任务前,仔细考虑可能遇到的各种问题,采取一定预防措施,做好沟通工作,并考虑好对策。而不是等到问题出现就手足无措,很多延迟和返工就是因为这类原因出现的。

为什么优秀工程师效率会比一般工程师高很多?原因之一就是优秀工程师会用项目管理的思维去规划他们工作,采用最合适的方式去完成任务。所以初级工程师应该了解一些项目管理知识,包括WBS、MECE 原则、各种估算方法、风险管理等。这些对自己当前的工作也好,对未来承担更重要的职责也好,都有很大的帮助。

持续学习

软件行业的知识和技术日新月异,作为一个软件工程师,要不想自己被淘汰,就只能不停的学习。

十年前我刚毕业的时候,网上的资料很少,开源代码更少,要学东西只能自己去买书然后边看边实践。现在不一样了,只要不是太冷门的内容,都能在网上找到各种教程、文章甚至还有开源代码,学起来方便多了。

对于工程师来讲,学习需要既有一定的广度,同时也要在某个特定方向有足够的深度。广度方面,可以定期学习一些新的知识,关注行业的最新动向。至于深度,那还是要去看书、看代码、动手实践。对于开发工程师,可以多阅读一些优秀开源项目的代码,也可以上 Leetcode,Topcoder 之类网站刷刷题,对自己的功力提升有非常大的帮助。

然后就是尝试多写、多分享。一个知识,当我们尝试要去把它写成文章的时候,常常会发现我们对它还有很多不了解的地方。特别是要用通俗易懂的方式把它写下来,其实非常难,需要我们对这个知识理解的很深刻。这也是我们杏仁要求每个工程师都去写知识库,并且鼓励大家做分享的原因。前面的一篇文章,《思维阅读法》也提到了分享的重要性。

除了自己专业的知识,大家也不妨多了解些其他专业和行业,比如经济、历史、财务、运营等方面的内容。学习多种思维模型,避免手里只有一把锤子,然后把所有问题都看成是钉子。

锻炼身体

最后再加一个彩蛋。很多工程师都有颈椎、腰椎的问题,很是痛苦。我也是,有段时间脖子疼腰疼,特别不好受。以前虽然也运动或跑步,但很不规律。最近几年开始健身跑步,一周保证至少两次,颈椎和腰痛都好了很多。平常注意坐姿,劳逸结合,坚持锻炼,不仅可以免受很多痛苦,精力也会更充沛。


当然想要成长为大牛,光靠这里说的这些还是不够的,还有很多东西需要不断的学习和磨练,比如沟通协作、系统设计、产品思维等等。但我认为上面几点,是最核心最基础的,可以说是“元”技能,越早学会这些“元”技能,能够更好学习其他知识和技能,更快突破自己。

0

四维阅读法 – 我的高效学习“秘技”

引言

我常常不自觉地把学习、工作中遇到的一个技术点钻成了无底洞,通过互联网一下子能获取无穷的信息和书籍,查询一个关键词的时候会遇到更多相关的关键词、畅游知识的海洋无比满足,同时也如深处泥淖一般无法自拔、常常迷失了目标而走了不必要的弯路;于是在最近的学习过程中整理出这套实践方法,作为阅读的指导以防迷路走失。

日常阅读时常常在想:有没有更好的方法和工具来帮助自己更有效、更高效、更系统地去阅读和做笔记?也一直在搜寻各种效率工具和APP,但始终没有找到某个能满足我所有需求的工具:思维导图不方便深入讨论细节、Markdown不方便构建层次结构、UML图不方便书写文档、Code能容纳的信息就更少。最后得出结论:单单从某个维度去穿透阅读无法达到系统学习的目的,必须综合运用多个维度的方法和工具。于是就有了:四维阅读法。

四维阅读法包含四个维度,分别是:

  • 广度 Breadth,使用思维导图工具输出成果,如 MindeNode
  • 深度 Depth,使用文档工具输出成果,如 Markdown
  • 形象 Imagine,使用图表工具输出成果,如 UML
  • 实践 Practice,使用项目和代码来输出成果、并分享自己学到的知识

四个维度的方法和使用务必坚持分治思想和职责单一的原则,每个维度只做自己领域内擅长的事情,最终组织成系统的知识结构体系。下面将从四个投射问题引出四个维度的解决方案。

第一维度,广度穿透

拖延症与行动力的问题

不只是阅读,现在生活遇到的难题是常常拖延,以至于到最后的dead-line时刻才匆匆忙忙去完成一件事情,不仅精神压力大事情完成得也不够理想,这是问题的症结所在。

读吴伯凡《拖延症,为什么成了经久不衰的话题?》后受到启发:拖延症是一个伪命题和借口--拖延症是你不想做某一件事情的借口,如果你真的想做某件事你根本就不会拖延而是想尽办法去达成目标,所以需要治疗的不是拖延症,而是去直面你需要去解决和完成的问题和事情。从医学角度来解释一下“拖延症”为什么如此难以战胜:不想做的事情会触发大脑神经的痛感中心,让你感觉痛苦、难熬,而我们去找寻所谓解决“拖延症”的解决方案、想一劳永逸地解决“拖延症”的问题,去读各种分析“拖延症”的文章和鸡汤,心灵得到了慰藉,痛感消失,最后便对“拖延症”不了了之,之后当你遇到另一件不想做的事情的时候,“拖延症”又复发了,于是又去寻找解药走上了老路,如此循环无解。所以明白了吗?“拖延症”的解药是一种“安全无效药”,本质上不过是一种止痛药罢了。人生中会遇到无数你不喜欢不想做但是需要你去做的事,这个时候会本能的逃避和拖延,但要做事就必须面对、要做事就必须有勇气和决心。别无它法。

广度穿透

广度穿透的目标是:构建知识的层次结构、思路,输出思维地图、任务清单,并帮助解决拖延症和行动力的问题。所以这个侧重点和主要职责是--分类,而不涉及具体、细节问题的描述或者讨论。把这份地图当成必达使命去执行和完成,最终完成系统阅读和学习。

思维地图是解决方案、也是算法,它让自己能够随时意识到自己所在的位置和上下文,它是一张准确精细的地图,给自己的行动提供方向;它是撒出去的网,给自己的决策提供收放依据。

工具

思维导图工具 MindNode。

示例--读《Java并发编程实战》:

breadth

第二维度,深度穿透

虚无的知识网

通过第一步广度优先地梳理,已建立完整的知识网,第二步需要深入细节的描述和讨论来详实和支撑知识网。

深度穿透

深度穿透,是通过逻辑力和表达力来穿透概念和知识点。描述清楚是基本要求,逻辑严谨是深度要求。

工具

Markdown书写工具。

示例--读“Akka官方文档”笔记:

depth

第三维度,形象穿透

混沌的知识网

一个需要用千言万语才能描述和解释清楚的问题,往往用一张图表就能完美清楚地说明。语言使用的是逻辑力量,而图标使用的是想象力:将逻辑形象化。

一个复杂的问题即使能够用语言描述清楚,在阅读者的大脑中仍旧是一些馄饨、抽象的概念。阅读者的想象力与书写者的表达力是正相关的,表达力不足,想象力和理解力也将受限。所以,需要同时调动表达力和想象力来相互助益。

形象穿透

形象穿透,是将复杂、抽象概念形象化、具体化的过程,帮助理解和记忆概念和知识。简单具体是形象穿透的第一原则。在形象化过程中可以使用类比,比如并发问题可以类比成“一团杂乱的线团”,它和并发问题一样让人心烦意乱而且无从下手,所以在描述清楚并发问题后配上这样一张图片对阅读者会有很大的启发作用。

这一维度的灵感起源于:Google图片搜索学习法。遇到一下子无法理解的概念,Google搜索关键字也无法找到满意的解答,这时切换到Google图片搜索,往往能得到一目了然的图解,顺着图片查看出处网页就能找到更优秀的资源。

形象穿透常常有两种方案:一是网上搜图片、二是自己动手画图。面对复杂陌生的概念和知识,即使能找到完美的图解,仍然建议自己动手画一画,这样能够帮助深度理解和记忆。

工具

图表工具 draw.io。

示例--“Java的锁”图解:

imagine

第四维度,实践穿透

纸上得来终觉浅,绝知此事要躬行

阅读得来的知识,在消化和透彻理解之后,内心会有非常强烈的满足和快感,这是“引诱”我们继续深入阅读的“糖衣炮弹”,要十分警惕这种感觉。把它当成一个信号:一个警醒自己沉淀下来、继续虚心前行的信号。否则会沉沦于快乐的舒适区中,忘记自己学习的初衷和目标,飘飘然而遗忘自己所在的位置。所以,问题的关键在于,如何沉淀下来?如何将读到的知识变成自己的知识(或者说思想)?

古人云:读万卷书行万里路。在获得足够的知识之后,应该马上到实践中去应用、验证你所得到的知识。于是知识就变成了你人生中实实在在的经验和阅历,而不再是虚无抽象的概念了。

那么,要如何去实践呢?

实践穿透

个人总结下来有三种非常有效的路径:

1. 运用到工作中去

如果你学习的知识正好工作上能够用到,那就再好不过了。以工作为战场去应用和检验自己学到的新技能,既巩固了知识、更使所学产生价值,实在是一举多得。

2. 贡献开源代码

工作中有施展空间当然是非常幸运的,然而更多时候,自己的学习和研究是无法马上应用到实际工作中的:商用的前提是稳定,应用新技术和方案带来的变化,其影响和风险是很难量化和估计的,所以往往条件不成熟。这种情况,就要另辟蹊径,在工作之外寻找空间去实践。

我想,再没有比开源社区更好的第二空间了吧,在社区能去做自己感兴趣的项目,能为开源项目贡献力量,更能遇到志同道合的伙伴;我相信,有团队的帮助,在学习的路上你将走得更远。

3. 去分享

大家可能往往觉得实践就只是去应用知识,其实不然,实践还有一个非常重要的方面,就是去分享知识。去分享你就会发现:原来我并没有自己想象中那么理解这个概念?原来要将知识解释清楚这么难?原来并不是只有我一个感兴趣这个领域?原来这个概念还能这么理解?......分享的过程,你能更深刻地理解知识,能找到知己,能锻炼自己的表达。

去分享,你也可以成为一个布道者。

工具

能帮助你更好地去完成实践的工具就是一些方法层面的实用工具了。一是使用项目管理的方法,推荐阅读《一页纸项目管理》;二是懂得取舍和决策,推荐阅读《简约之美--软件设计之道》。

总结

从算法的角度来总结一下四维阅读就是:

  1. 广度优先建立学习的地图
  2. 在学习地图上有取舍地深度阅读
  3. 根据前两步的成果运用形象表达加深理解
  4. 去实践
  5. 迭代学习:实践中遇到问题?如果是方向错误,则回到第1步修正学习地图;如果是知识概念没有理解或理解错误,则回到第2步继续深度阅读

阅读中往往很容易就忘记了阅读的目的,阅读,最终还是为了解决实际问题、内心的疑惑。但阅读过程中会遇到目标之外的海量信息、知识和更多的疑惑,阅读的航道很容易偏离目标,导致目标的拖延。所以阅读的过程中,需要不断的确认和修正自己的航道。理性阅读,需要更多的克制和坚持。请明确目标,理性决策,正确前进。

0

一个创业公司的容器化之路(三) – 为什么要用容器

创业公司应该使用容器的4个原因

前两篇文章(一个创业公司的容器化之路(一) - 容器化之前一个创业公司的容器化之路(二) - 容器化)我们介绍了杏仁架构的发展历程。我们再回顾一下第一篇开头提出的三个初创公司的技术挑战,

  1. 如何快速、低成本的搭建系统,同时确保安全稳定?
  2. 如何快速的构建和发布应用,满足业务需求?
  3. 如何提高团队开发效率,确保开发质量?

杏仁通过容器化来应对这几个挑战,也取得了一些成就,但也说不上完美的解决。毕竟我们规模还小,很多方案也只是在我们这个规模刚好够用。不过在这个过程里,我们还是深刻体会到了容器化的价值。正如我介绍容器的时候说的,容器会和集装箱一样,成为一个标准化的基础设施,对上层应用产品革命性的影响。

很多大厂其实一、两年前就开始做容器方面的尝试。但现在,我们认为其实初创公司也应该考虑应用容器了,原因有以下几点。

1. 容器生态已经成熟,容器服务和容器云可以大幅降低容器使用成本。

去年容器编排还不能说很成熟,例如 Kubernetes 还存在不少缺陷。但经过一年时间狂飙猛进,Kubernetes 今年已经很成熟了,有不少企业已经在生产环境使用了 Kubernetes。部署和运维也比之前简单多了。

另一方面,各大公有云也都推出了容器服务,还有不少独立的容器云公司。如果你是用公有云的话,推荐直接使用相应的容器服务,可以快速的搭建系统,大幅降低运维成本,提高效率。

并且这会带来一个额外的好处,因为这些容器服务都是基于 Kubernetes 的,而容器本身就是一个标准化的东西,使用容器服务反而可以降低对公有云服务的依赖。在公有云的容器服务之间迁移,或者后期打算自建然后迁移,都不是很难的事情,

2. 容器使创业公司可以轻松的应用行业最佳实践,创建优质应用。

几年前有人提出了一个十二要素应用的最佳实践,我认为是一个很好的标准。

下面列表对这十二条实践进行了分析,我分成了三种情况,分别用三颗星、两颗星和一颗星表示。

  • 三颗星:没有容器实践起来很困难,容器环境下可以很容易实现。
  • 两颗星:没有容器也可以实践,但在容器环境下可以做得更好或更轻松。
  • 一颗星:没有容器也可以实践,容器环境里也能支持。
最佳实践 支持程度 说明
基准代码:一份基准代码,多份部署。 ★★ 不仅一份代码多份部署,容器能做到一份镜像,多分部署。
依赖:显式声明依赖关系。 ★★★ 容器通过 Dockerfile 管理内部依赖,实现自包含;容器编排管理服务依赖。
配置:在环境中存储配置。 ★★ 容器通过环境变量传递配置;容器编排能提供标准的配置工具。
后端服务:把后端服务当作附加资源。
构建,发布,运行:严格分离构建和运行。 ★★ 通过 docker build 构建,通过 docker run 运行,严格分离。
进程:以一个或多个无状态进程运行应用。 容器对无状态应该可以完美支持,有状态应用现在也能很好支持。
端口绑定:通过端口绑定提供服务。 ★★ 容器编排可以处理服务依赖和绑定,应用不再需要关心。
并发:通过进程模型进行扩展。 ★★★ 容器编排可以很轻松的扩容。
易处理:快速启动和优雅终止可最大化健壮性。 ★★★ 容器编排可以支持快速启动、优雅终止,具有自愈能力。
开发环境与线上环境等价:尽可能的保持开发,预发布,线上环境相同。 ★★★ 容器的可移植性和自包含,能做到随处运行,但应用的环境依然保持一致。
日志:把日志当作事件流。 ★★ 容器和容器编排可以很好的支持日志。
管理进程:后台管理任务当作一次性进程运行。 容器编排提供了多种方式可以运行一次性任务。

所以可以看到,在容器环境下,可以很轻松的实践这十二要素,从而使得应用更容易开发、维护和部署。

3. 容器使创业公司一开始就可以以极低的成本应用微服务架构。

这张图很多人应该看到过,是 Martin Fowler 提出的关于微服务和单体应用的生产率趋势的比较。

productivity.png-102.3kB

这张图要表达的意思是,如果团队使用微服务,开始可能需要花费更多的精力来搭建基础设施和架构,但是生产率可以一直维持在比较高的水平。而在创业公司,我们一般会为了速度,采用 Quick & Dirty 的方案,也就是创建一个单体应用。一开始这样会很快,但问题是,单体应用会越来越庞杂、越来越难以维护,此时就会严重影响团队生产率。

当生产率下降到一定程度时,团队会开始考虑服务化(包括我们杏仁也是这么做的)。但在服务化的过程中,会消耗更多的人力和资源,严重影响生产率。服务化完成之后,生产率会有一定提升,但也很难一下子达到微服务的状态,需要不断的重构并拆分服务。所以在互联网,其实大部分稍微有点规模的公司的生产率曲线是这样的:

productivity2.png-51.9kB

但是在容器环境下,一开始就使用微服务的话,其实生产率并不会比单体应用低多少。

productivity3.png-58.6kB

这是因为容器已经提供了微服务所需要的很多基础设施,包括服务注册和发现、服务依赖和生命周期管理、负载平衡等,而且健壮性和资源利用率也会更高。如果是直接使用容器云,则运维的成本也很低,只需要关注微服务的业务架构就可以了。

所以在容器环境下,我们没有理由再去创建一个单体应用,一切都是微服务,只要业务架构设计合理,研发团队可以更专注于业务开发,生产率可以一直保持在较高水平。

4. 容器使创业公司降低运维成本,提升运维效率,轻松实践 Devops。

容器环境下,很多以前的日常操作都自动化或至少半自动化了,比如比如部署、发布新应用、扩容等等,都可以很快速的响应。容器编排的自愈能力,即使除了问题,也可减少人工干预。

所以容器环境下的运维是幸福的,不用在苦逼的响应各种需求,可以吃着火锅唱着歌把事儿做了。大大加强了运维的幸福感,终于运维同学也可以开开心心的去谈恋爱了。

而开发也可以对自己的应用有更多的掌控。除了能快速频繁的部署,也可以从日志、监控中得到数据和反馈,出现问题也可以通过工具一定程度上去进行调试。

容器即未来

所以,正如上一篇说的,基于容器,会逐步建议一套完整应用架构体系,而这套体系会带来革命性的改变。所以,容器即未来,而未来已来到眼前。

容器容器容器

0