单元测试——工程师 Style 的测试方法

什么是单元测试?

测试分类

Wikipedia 对单元测试的定义:

在计算机编程中,单元测试(Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。

在实际测试中,一个单元可以小到一个方法,也可以大到包含多个类。从定义上讲,单元测试和集成测试是有严格的区分的,但是在实际开发中它们可能并没有那么严格的界限。如果专门追求单元测试必须测试最小的单元,反而容易造成多余的测试并且不易维护。换句更严谨一点的说法,我们要考虑测试的场景再去选择不同粒度的测试。

单元测试和集成测试即可以手工执行,也可以是程序自动执行。但现在一般提到单元测试,都是指自动执行的测试。所以我们下面提到的单元测试,没有特别注明,都是泛指自动执行的单元测试或集成测试。

单元测试入门

下面我们先看两个案例,感受一下单元测试到底是什么样子的。

例子 1:生命游戏单元测试

我们先看一个很简单的例子,实现一个康威生命游戏。如果不了解康威生命游戏的话,可以看 Wikipedia 的介绍。假设我们实现时定义这样的接口:

public interface Game {
    void init(int[][] shape) ;      // 初始化游戏 board 
    void tick();                    // 行进到在一个回合
    int[][] get();                  // 获取当前游戏 board
}

生命游戏有好几条规则,为了测试我们的实现是否正确,我们可以针对生命游戏的每个规则,写一个单元测试。下面测试的是复活的规则。

@Test
public void testRelive() {
    int[][] shape = {{0, 0, 1}, {0, 0, 0}, {1, 0, 1}};
    Game g = new GameImplSample(shape);
    g.tick();
    // 自己死亡,周围3个存活状态,复活
    assertEquals(1, g.get()[1][1]);
}

例子 2:订单退款集成测试

我们在看一个稍微复杂一些的例子,测试的是订单退款的过程。

@Test
public void test300_doRefundItem() {
    // 创建订单、支付,然后退款
    Order order = createOrder(OrderSource.XR_DOCTOR);
    order = fullPay(order, PayType.WECHAT_JS);
    OrderItem item = _doItemRefund(order, 1, false);

    // 检查退款中状态
    OrderWhole orderWholeRefunding = findOrderWhole(order.getOrderNo());
    isTrue(orderWholeRefunding.getRefundStatus().equals(
        OrderRefundStatus.PARTIAL_REFUNDING));
    isTrue(orderWholeRefunding.getRefunds().get(0).getStatus().equals(
        RefundStatus.REFUNDING));
    isTrue(orderWholeRefunding.getRefunds().get(0).getItemId().get().equals(
        item.getId()));

    // 构建退款的回调信息
    List<Payment> payments = findPayments(order.getId());
    List<Refund> refunds = findRefunds(order.getId());
    wxRefundNotify(payments.get(0), refunds.get(0), WxRefundStatus.SUCCESS);

    // 检查退款后状态
    OrderWhole orderWholeFinish = assertRefund(order, FULL_PAID, 
        PARTIAL_REFUND_OK, RefundStatus.SUCCESS, RefundMode.ITEM, false);
    isTrue(orderWholeFinish.getRefundFee() == item.getPaidPrice());
    isTrue(orderWholeFinish.getIncomes().stream()
        .filter(i -> i.getAmount() < 0).count() == 1);
}

单元测试执行

单元测试有很多种执行方式:
- 在 IDE 中执行
- 通过 mvn 或者 gradle 运行
- 在 CI 中执行

不论什么方式,单元测试都应该很容易就能运行,并给出一个测试结果。当然,单元测试运行速度得快,一般是在秒级的,太慢的话就不能及时获得反馈了。

为什么要写单元测试?

单元测试的好处

  • 确保代码满足需求或者设计规格。 使用单元测试来测试代码,可以通过构造数据和前置条件,确保测试覆盖到需要测试的逻辑。而手工测试或 UI 测试则无法做到,并且往往更复杂。
  • 快速定位并解决问题。 单元测试因为测试范围比较小,可以比较容易的定位到问题;而手工测试,常常需要耗费不少时间去定位问题。
  • 确保代码永远满足需求规格。 一旦需要对实现进行修改,单元测试可以确保代码的正确性,极大的降低各种修改和重构的风险。特别是避免那些在意想不到之处出现的 BUG。
  • 简化系统集成。 单元测试确保了系统或模块本身的正确性,集成时更不容易出错。
  • 提高代码质量和可维护性。 不可测试的代码,其本身的抽象性、模块性、可维护性是有些问题的。例如不符合单一职责、接口隔离等设计原则,或者依赖了全局变量。可测试的代码,往往其质量相对会高一些。
  • 提供文档和说明。 单元测试本身就是接口使用方法的很好的案例。

持续集成和持续交付

2010 年前后,大部分互联网公司的系统部署还是通过手工的方式进行的,往往要在半夜上线系统。但是之后持续集成、持续交付的理念不断推广,部署过程越来越灵活、顺畅。而单元测试则是持续集成和持续交付里重要的一环。

持续集成就是 Continuous Integration(CI),也就是指从开发上传代码、自动构建和测试、最后反馈结果的过程。

持续集成

更进一步,如果自动构建和测试后,会自动发布到测试环境或预发布环境,执行更多测试(集成测试、自动化 UI 测试等),甚至最后直接发布,那这一过程就是持续交付(Continuous Delivery,CD)。业内有不少公司,比如亚马逊、Esty,可以做到每天几十甚至成百上千次生产环境部署,就是因为有比较完善的持续交付环境。

CI 已经是互联网行业必备标准,CD 也在互联网行业有了越来越多的实践,但是如果没有单元测试这一环节,CI 和 CD 的过程是有缺陷的。

怎么写单元测试?

JUnit 简介

基本上每种语言和框架都有不错的单元测试框架和工具,例如 Java 的 JUnit、Scala 的 ScalaTest、Python 的 unittest、JavaScript 的 Jest 等。上面的例子都是基于 JUnit 的,我们下面就简单介绍下 JUnit。

  • JUnit 里面每个 @Test 注解的方法,就是一个测试。@Ignore 可以忽略一个测试。@Before、@BeforeClass、@After、@AfterClass 可以在测试执行前后插入一些通用的操作,比如初始化和资源释放等等。
  • 除了 assertEquals,JUnit 也支持不少其他的 assert 方法。例如 assertNull、assertArrayEquals​、assertThrows、assertTimeout​ 等。另外也可以用第三方的 assert 库比如 Spring 的 Assert 或者 AssertJ。
  • 除了可以测试普通的代码逻辑,JUnit 也可以进行异常测试和时间测试。异常测试是测试某段代码必须抛指定的异常,时间测试则是测试代码执行时间在一定范围内。
  • 也可以对测试进行分组。例如可以分成 contractTest 、mockTest 和 unitTest,通过参数指定执行某个分组的测试。

这里就不做过多介绍了,想了解更多 JUnit 的可以去看 极客学院的 JUnit 教程 等资料。其他的单元测试框架,基本功能都是大同小异。

使用测试 Double

狭义的单元测试,我们是只测试单元本身。即使我们写的是广义的单元测试,它依然可能依赖其他模块,比如其他类的方法、第三方服务调用或者数据库查询等等,造成我们无法很方便的测试被测系统或模块。这时我们就需要使用测试 Double 了。

如果细究的话,测试 Double 分成好多种,比如什么 Dummies、Fakes 等等。但我认为我们只要弄清两类就可以了,也就是 Stub 和 Mock。

Stub

Stub 指那些包含了预定义好的数据并且在测试时返回给调用者的对象。Stub 常被用于我们不希望返回真实数据或者造成其他副作用的场景。

我们契约测试生成的、可以通过 spring cloud stubrunner 运行的 Stub Jar 就是一个 Stub。我们可以让 Stub 返回预设好的假数据,然后在单元测试里就可以依赖这些数据,对代码进行测试。例如,我们可以让用户查询 Stub 根据参数里的用户 ID 返回认证用户和未认证用户,然后我们就可以测试调用方在这两种情况下的处理逻辑了。

当然,Stub 也可以不是远程服务,而是另外一个类。所以我们经常说要针对接口编程,因为这样我们就可以很容易的创建一个接口的 Stub 实现,从而替换具体的类。

public class StubNameService implement NameService {
    public String get(String userId) {
        return "Mock user name";
    }
}

public class UserServiceTest {
    // UserService 依赖 NameService,会调用其 get 方法
    @Inject
    private UserService userService;    

    @Test
    public void whenUserIdIsProvided_thenRetrievedNameIsCorrect() {
        userService.setNameService(new StubNameService());
        String testName = userService.getUserName("SomeId");
        Assert.assertEquals("Mock user name", testName);
    }
}

不过这样要实现很多 Stub 也是很麻烦的,现在我们已经不需要自己创建 Stub 了,因为有了各种 Mock 工具。

Mock

Mocks 指那些可以记录它们的调用信息的对象,在测试断言中我们可以验证 Mocks 被进行了符合期望的调用。

Mock 和 Stub 的区别在于,Stub 只是提供一些数据,它并不进行验证,或者只是基于状态做一些验证;而 Mock 除了可以做 Stub 的事情,也可以基于调用行为进行验证。比如说,Mock 可以验证 Mock 接口被调用了不多不少正好两次,并且调用的参数是期望的数值。

Java 里最常用的 Mock 工具就是 Mockito 了。我们来看一个简单的例子,下面的 UserService 依赖 NameService。当我们测试 UserService 的时候,我们希望隔离 NameService,那么就可以创建一个 Mock 的 NameService 注入到 UserService 中(在 Spring 里只需要用 @Mock 和 @InjectMocks 两个注解就可以完成了)。

public class UserServiceTest {
    @InjectMocks
    private UserService userService;
    @Mock
    private NameService nameService;

    @Test
    public void whenUserIdIsProvided_thenRetrievedNameIsCorrect() {
        Mockito.when(nameService.getUserName("SomeId")).thenReturn("Mock user name");
        String testName = userService.getUserName("SomeId");
        Assert.assertEquals("Mock user name", testName);
        Mockito.verify(nameService).getUserName("SomeId");
    }
}

注意上面最后一行,是验证 nameService 的 getUserName 被调用,并且参数为 "SomeId"。更多关于 Mockito 的内容,可以参考 Mockito 的文档

契约测试

契约测试会给每个服务生成一个 Stub,可以用于调用方的单元/集成测试。例如,我们需要测试预约服务的预约操作,而预约操作会调用用户服务,去验证用户的一些基本信息,比如医生是否认证等。

所以,我们可以通过传入不同的用户 ID,让契约 Stub 返回不同状态的用户数据,从而验证不同的处理流程。例如,正常的预约流程的测试用例可能是这样的。

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
@AutoConfigureStubRunner(repositoryRoot="http://<nexus_root>",
        ids = {"com.xingren.service:user-client-stubs:1.0.0:stubs:6565"})
public class BookingTest {
    // BookingService 会调用用户服务,获取医生认证状态后进行不同的处理
    @Inject 
    private BookingService bookingService;
    @Test
    public void testBooking() {
        BookingForm form = new BookingForm(
            1,                      // doctorId
            1,                      // scheduleId
            1001);                  // patientId
        BookVO res = bookingService.book(form);
        assertTrue(res.id > 0);
        assertTrue(res.payStatus == PayStatus.UN_PAY);
    }
}

注意上面的 AutoConfigureStubRunner 注解就是设置并启动了用户服务 Stub,当然在测试的时候,我们需要把服务调用接口的 baseUrl 设置为http://localhost:6565。关于契约测试的更多内容,请参考微服务环境下的集成测试探索一文。

TDD

简单说下 Test Driven Development,也就是 TDD。左耳朵耗子就写了一篇TDD并不是看上去的那么美,我就直接引用其介绍了。

其开发过程是从功能需求的test case开始,先添加一个test case,然后运行所有的test case看看有没有问题,再实现test case所要测试的功能,然后再运行test case,查看是否有case失败,然后重构代码,再重复以上步骤。

其实严格的 TDD 流程实用性并不高,左耳朵耗子本身也是持批判态度。但是对于接口定义比较明确的模块,先写单元测试再写实现代码还是有很大好处的。因为目标清晰,而且可以立刻得到反馈。

如何设计单元测试?

单元测试设计方法

单元测试用例,和普通测试用例的设计,没有太多不同,常见的就是等价类划分、边界值分析等。而测试用例的设计其实也是开发者应该掌握的基本技能。

等价类划分

把所有输入划分为若干分类,从每个分类中选取少数有代表性的数据做为测试用例。

例如,一个方法计算输入参数的绝对值的倒数,如果是输入是 0,则抛异常。那么对这个方法写测试的话,就应该有三个等价类,输入是负数、0 以及正数。所以我可以选取一个负数、一个正数以及 0 来设计三个测试用例。

再举个例子,某个方法是根据医生的认证状态,发送不同的消息。那么等价类可能有三种,未认证、普通认证但无权威认证、普通认证且权威认证,某些情况下可能还会包括无普通认证但有威认证。

边界值分析

边界值是指划分等价类后,在边界附近的一些输入数据,这些输入往往是最容易出错的。

例如,对于上面计算绝对值的倒数的例子,那么边界值就包括 Integer.min、-1、0、1、Integer.max 等。再举个例子,文本框输入范围为 1 - 255 个字符,那么等价类按输入长度划分有三类 0、1 到 255、大于 255,而边界值则包括 0、1、2、254、255、256 等。

其他类似于空数组、数组的第一个和最后一个、报表的第一行和最后一行等等,也是属于边界值,需要特别关注。

判定表法

当我们由多个输入数据时,可以将这些数据的等价类的组合以表格的形式列举出来,然后设计测试用例。下面是一个例子(没有完全列举)。

用例 医生是否设置需要确认 医生是否设置免费咨询 医生是否已经确认患者 患者是否已经完善信息 期望结果
用例A 患者可以咨询医生
用例B 患者不能咨询医生
用例C 患者可以咨询医生
用例D 患者不能咨询医生
用例E 患者不能咨询医生

其他方法

除了上面提到的几种,测试设计方法还有几种常用的:
- 场景法。场景法是根据模块实际使用的场景,例如 API 的实际调用方法、系统的实际需求场景和处理逻辑创建的用例。这种方法比较直观,并且用例贴近实际需求的,不可忽视。
- 错误推测。错误推测其实就是凭直觉,考虑最容易出错的情况来设计用例。例如,我们直到新用户、重复请求、并发、弱网、大数据量等情况都是非常容易出错的,那么可以针对性的设计用例。错误推测需要测试设计者比较熟悉业务逻辑,并且经验丰富。

其他还有因果图、正交法等方法,这里就不说了。

覆盖率

如果按照前面的用例设计方法,可能会设计出很多用例。我们不可能也没有必要把每一个用例都写成单元测试。

怎么确认用例是否足够呢?一个很重要的参考指标就是代码覆盖率。

覆盖率指标

常用的覆盖率指标有四种:
- 语句覆盖:每条语句至少执行一次。
- 分支覆盖:每个分支至少有一次为真、一次为假。
- 条件覆盖:每个分支的每个条件至少有一次为真、一次为假。
- 路径覆盖:对所有的分支、循环等可能的路径,至少都要覆盖一次。

我们以这个简单的代码为例,看看这四种覆盖率到底是什么意思。

if (a && b) {
    // X    
}
// Y
if (c || d) {
    // X
}
  • 语句覆盖。只需要一个测试用例,让 a && bc || d 都为真,系统会依次执行 X、Y、Z 三个的代码段,就能做到语句覆盖。
  • 分支覆盖。至少需要两个测试用例,让 a && bc || d 都各为真假,例如用例1 a && b 为真和 c || d 为假,用例2 则反过来,既可让两个条件分支都各为真一次,为假一次。
  • 条件覆盖。至少需要四个测试用例,条件 a 和 b 的四种组合都要执行一次,条件 c 和 d 的四种组合也都要执行一次。
  • 路径覆盖。至少需要八个测试用例,条件 a、b、c 和 d 的所有组合都要执行一次。

可以看到,要做到条件覆盖甚至路径覆盖,会需要非常多的测试用例。一般情况,对于复杂的逻辑,单元测试做到分支覆盖就不错了,必要的话再做更多完全的覆盖。

Jacoco 覆盖

Jacoco 的覆盖率略有不同,这里简单说一下。
- 指令覆盖(Instructions),覆盖所有的 Java 代码指令。
- 分支覆盖(Branches),和上面的分支覆盖基本是一样的。
- 圈复杂度覆盖(Cyclomatic Complexity),可以认为就是路径覆盖率。
- 语句覆盖(Lines),和上面的语句覆盖基本是一样的。
- 方法覆盖(Methods),覆盖所有的方法。
- 类覆盖(Classes),覆盖所有的类。

怎么写有效的单元测试?

到现在,相信大家对怎么写单元测试应该有一定概念了。但是很多人也会有疑问:
- 单元测试耗费太多时间,会不会降低生产效率?
- 单元测试会不会很难维护?比如修改代码时还总是需要修改单元测试。

关于第一个问题,相信大家应该都能理解,如果我们在开发时发现 BUG,那么解决它是很容易的;但是一旦到了集成、验收甚至上线之后,那么要解决它就要花费比较大的代价了。业界很早就有共识,并且有不少数据可以证明,有效的单元测试虽然要花费更多编码时间,但是可以很大的减少项目的集成、测试和维护成本。

注意上面提到很重要一点是,单元测试必须是有效的,如果我们发现单元测试很难维护,那往往是因为我们没有写出有效的单元测试。

不是所有的代码都需要单元测试

写单元测试我们也需要考虑投入产出比,例如下面这些情况,写单元测试的投入产出比可能会较差。
- 短期的或者一次性的项目,例如 Demo、数据更新脚本。
- 业务简单的,不含太多逻辑的模块。例如获取或者查找一个数据,或者没有分支条件的业务逻辑等。
- UI 层,相对而言比较难做单元测试,除非 UI 本身就有比较复杂的逻辑(其实某些 UI 框架也提供了单元测试工具)。

那么那些情况下要写单元测试呢?简单来说,就是两类。
- 逻辑复杂、不容易理解、容易出错的模块。例如,计算闰年的方法、订单下单等。
- 公共模块或者核心的业务模块。

即使对于需要写单元测试的模块,我们也应该关注最核心最重要的测试用例,而没必要单纯的追求覆盖率,或者追求条件覆盖甚至路径覆盖,一般做到分支覆盖就可以了。另外一个有效的方法是,对于出现的每一个 BUG,添加一个单元测试。

单元测试应该是稳定的

这里稳定的第一个含义是,单元测试不应该经常需要修改。如果单元测试经常因为底层实现逻辑的变动而需要修改,那一定不是好的单元测试。也就是说,被测单元的接口应该是稳定的、设计良好的、易于扩展的。

稳定的第二个含义是,单元测试的结果应该是稳定的。如果在不同的环境、不同的情况运行单元测试,会返回不同的结果,那就不是好的单元测试。如果测试需要依赖特定的数据、文件等,那需要有前置的初始化脚本确保依赖的数据、文件在所有环境都存在并且是一致的。

单元测试应该是灰盒测试

单元测试应该覆盖核心逻辑的各种分支、边界及异常,但是避免涉及易变的实现逻辑。也就是说,我们不应该把单元测试当成完全的白盒测试,但也不是黑盒测试,而应该把它当成介于白盒和黑盒之间的灰盒测试。

被测代码应该是抽象良好的

如果我们发现一段代码很难编写单元测试,常常是因为这段代码没有符合良好的抽象规范,比如没有使用 DI、不符合单一职责原则、或者依赖了全局的公共变量和方法等等。我们可以考虑优化这段代码,再来尝试单元单元测试。

谈谈到底什么是抽象,以及软件设计的抽象原则 介绍了软件抽象的原则,这里就不再重复了。

编码时就应该同时写好单元测试

这样我们才能在调试时就发挥单元测试的优势,对代码的任何修改都能得到即时反馈。如果是后面再补充单元测试,一方面对实现可能已经不太熟悉了,编写测试的代价更大了;另一方面,单元测试能发挥的作用也变小了。不过即使这样,对那些需要长远维护的项目,编写单元测试也还是很有用的。

单元测试的代码质量也很重要

单元测试也是代码,也是需要不断维护的。所以我们不应该随随便便的去写单元测试,而是要把他们也当成普通代码一样,要做到高质量、模块化、可维护。

为什么要写单元测试之终极原因

终极原因是,作为一名优秀的工程师,如果被 QA 和产品经理 Challenge 有 BUG,能忍吗?而我们工程师当然要用工程师 Style 的测试方法,那就是自动化的单元测试了,不是吗?

参考

Software Testing Anti-patterns

0

理解 RabbitMQ Exchange

AMQP 简介

在了解 RabbitMQ 的 Exchange(交换机)的概念之前,我们首先要对 RabbitMQ 相关的概念和名词有一个大概的了解,RabbitMQ 是 AMQP(高级消息队列协议)的标准实现:

从 AMQP 协议可以看出,Queue、Exchange 和 Binding 构成了 AMQP 协议的核心

  • Producer:消息生产者,即投递消息的程序。
  • Broker:消息队列服务器实体。
    • Exchange:消息交换机,它指定消息按什么规则,路由到哪个队列。
    • Binding:绑定,它的作用就是把 Exchange 和 Queue 按照路由规则绑定起来。
    • Queue:消息队列载体,每个消息都会被投入到一个或多个队列。
  • Consumer:消息消费者,即接受消息的程序。

Exchange 简介

那么为什么我们需要 Exchange 而不是直接将消息发送至队列呢?

AMQP 协议中的核心思想就是生产者和消费者的解耦,生产者从不直接将消息发送给队列。生产者通常不知道是否一个消息会被发送到队列中,只是将消息发送到一个交换机。先由 Exchange 来接收,然后 Exchange 按照特定的策略转发到 Queue 进行存储。Exchange 就类似于一个交换机,将各个消息分发到相应的队列中。

qq

在实际应用中我们只需要定义好 Exchange 的路由策略,而生产者则不需要关心消息会发送到哪个 Queue 或被哪些 Consumer 消费。在这种模式下生产者只面向 Exchange 发布消息,消费者只面向 Queue 消费消息,Exchange 定义了消息路由到 Queue 的规则,将各个层面的消息传递隔离开,使每一层只需要关心自己面向的下一层,降低了整体的耦合度。

理解 Exchange

Exchange 收到消息时,他是如何知道需要发送至哪些 Queue 呢?这里就需要了解 Binding 和 RoutingKey 的概念:

Binding 表示 Exchange 与 Queue 之间的关系,我们也可以简单的认为队列对该交换机上的消息感兴趣,绑定可以附带一个额外的参数 RoutingKey。Exchange 就是根据这个 RoutingKey 和当前 Exchange 所有绑定的 Binding 做匹配,如果满足匹配,就往 Exchange 所绑定的 Queue 发送消息,这样就解决了我们向 RabbitMQ 发送一次消息,可以分发到不同的 Queue。RoutingKey 的意义依赖于交换机的类型。

下面就来了解一下 Exchange 的三种主要类型:FanoutDirectTopic

Fanout Exchange

5

Fanout Exchange 会忽略 RoutingKey 的设置,直接将 Message 广播到所有绑定的 Queue 中。

应用场景

以日志系统为例:假设我们定义了一个 Exchange 来接收日志消息,同时定义了两个 Queue 来存储消息:一个记录将被打印到控制台的日志消息;另一个记录将被写入磁盘文件的日志消息。我们希望 Exchange 接收到的每一条消息都会同时被转发到两个 Queue,这种场景下就可以使用 Fanout Exchange 来广播消息到所有绑定的 Queue。

Direct Exchange

4

Direct Exchange 是 RabbitMQ 默认的 Exchange,完全根据 RoutingKey 来路由消息。设置 Exchange 和 Queue 的 Binding 时需指定 RoutingKey(一般为 Queue Name),发消息时也指定一样的 RoutingKey,消息就会被路由到对应的Queue。

应用场景

现在我们考虑只把重要的日志消息写入磁盘文件,例如只把 Error 级别的日志发送给负责记录写入磁盘文件的 Queue。这种场景下我们可以使用指定的 RoutingKey(例如 error)将写入磁盘文件的 Queue 绑定到 Direct Exchange 上。

Topic Exchange

6

Topic Exchange 和 Direct Exchange 类似,也需要通过 RoutingKey 来路由消息,区别在于Direct Exchange 对 RoutingKey 是精确匹配,而 Topic Exchange 支持模糊匹配。分别支持*#通配符,*表示匹配一个单词,#则表示匹配没有或者多个单词。

应用场景

假设我们的消息路由规则除了需要根据日志级别来分发之外还需要根据消息来源分发,可以将 RoutingKey 定义为 消息来源.级别order.infouser.error 等。处理所有来源为 user 的 Queue 就可以通过 user.* 绑定到 Topic Exchange 上,而处理所有日志级别为 info 的 Queue 可以通过 *.info 绑定到 Exchange上。

两种特殊的 Exchange

Headers Exchange

Headers Exchange 会忽略 RoutingKey 而根据消息中的 Headers 和创建绑定关系时指定的 Arguments 来匹配决定路由到哪些 Queue。

Headers Exchange 的性能比较差,而且 Direct Exchange 完全可以代替它,所以不建议使用。

Default Exchange

Default Exchange 是一种特殊的 Direct Exchange。当你手动创建一个队列时,后台会自动将这个队列绑定到一个名称为空的 Direct Exchange 上,绑定 RoutingKey 与队列名称相同。有了这个默认的交换机和绑定,使我们只关心队列这一层即可,这个比较适合做一些简单的应用。

总结

在 Exchange 的基础上我们可以通过比较简单的配置绑定关系来灵活的使用消息路由,在简单的应用中也可以直接使用 RabbitMQ 提供的 Default Exchange 而无需关心 Exchange 和绑定关系。Direct Exchange、Topic Exchange、Fanout Exchange 三种类型的交换机的使用方式也很简单,容易掌握。

0

iOS 下的图片处理与性能优化

移动开发中我们经常和多媒体数据打交道,对这些数据的解析往往需要耗费大量资源,属于常见的性能瓶颈。

本文针对多媒体数据的一种———图片,介绍下图片的常见格式,它们如何在移动平台上被传输、存储和展示,以及优化图片显示性能的一种方法:强制子线程解码。

图片在计算机世界中怎样被存储和表示?

图片和其他所有资源一样,在内存中本质上都是0和1的二进制数据,计算机需要将这些原始内容渲染成人眼能观察的图片,反过来,也需要将图片以合适的形式保存在存储器或者在网络上传送。

下面是一张图片在硬盘中的原始十六进制表示:

一张图片在计算机中的原始数据

这种将图片以某种规则进行二进制编码的方式,就是图片的格式。

常见的图片格式

图片的格式有很多种,除了我们熟知的 JPG、PNG、GIF,还有Webp,BMP,TIFF,CDR 等等几十种,用于不同的场景或平台。

图片的常见格式

这些格式可以分为两大类:有损压缩无损压缩

有损压缩:相较于颜色,人眼对光线亮度信息更为敏感,基于此,通过合并图片中的颜色信息,保留亮度信息,可以在尽量不影响图片观感的前提下减少存储体积。顾名思义,这样压缩后的图片将会永久损失一些细节。最典型的有损压缩格式是 jpg。

不同压缩比例的jpg图片

无损压缩:和有损压缩不同,无损压缩不会损失图片细节。它降低图片体积的方式是通过索引,对图片中不同的颜色特征建立索引表,减少了重复的颜色数据,从而达到压缩的效果。常见的无损压缩格式是 png,gif。

除了上述提到的格式,有必要再简单介绍下 webpbitmap这两种格式:

Webp:jpg 作为主流的网络图片标准可以向上追溯到九十年代初期,已经十分古老了。所以谷歌公司推出了Webp标准意图替代陈旧的jpg,以加快网络图片的加载速度,提高图片压缩质量。

webp 同时支持有损和无损两种压缩方式,压缩率也很高,无损压缩后的 webp 比 png 少了45%的体积,相同质量的 webp 和 jpg,前者也能节省一半的流量。同时 webp 还支持动图,可谓图片压缩格式的集大成者。

webp的体积对比

webp 的缺点是浏览器和移动端支持还不是很完善,我们需要引入谷歌的 libwebp 框架,编解码也会消耗相对更多的资源。

bitmap:bitmap 又叫位图文件,它是一种非压缩的图片格式,所以体积非常大。所谓的非压缩,就是图片每个像素的原始信息在存储器中依次排列,一张典型的1920*1080像素的 bitmap 图片,每个像素由 RGBA 四个字节表示颜色,那么它的体积就是 1920 * 1080 * 4 = 1012.5kb。

由于 bitmap 简单顺序存储图片的像素信息,它可以不经过解码就直接被渲染到 UI 上。实际上,其它格式的图片一般都需要先被首先解码为 bitmap,然后才能渲染到界面上。

如何判断图片的格式?

在一些场景中,我们需要手动去判断图片数据的格式,以进行不同的处理。一般来说,只要拿到原始的二进制数据,根据不同压缩格式的编码特征,就可以进行简单的分类了。以下是一些图片框架的常用实现,可以复制使用:

+ (XRImageFormat)imageFormatForImageData:(nullable NSData *)data {
    if (!data) {
        return XRImageFormatUndefined;
    }

    uint8_t c;
    [data getBytes:&c length:1];
    switch (c) {
        case 0xFF:
            return XRImageFormatJPEG;
        case 0x89:
            return XRImageFormatPNG;
        case 0x47:
            return XRImageFormatGIF;
        case 0x49:
        case 0x4D:
            return XRImageFormatTIFF;
        case 0x52:

            if (data.length < 12) {
                return XRImageFormatUndefined;
            }

            NSString *testString = [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, 12)] encoding:NSASCIIStringEncoding];
            if ([testString hasPrefix:@"RIFF"] && [testString hasSuffix:@"WEBP"]) {
                return XRImageFormatWebP;
            }
    }
    return XRImageFormatUndefined;
}

UIImageView 的性能瓶颈

如上文所说,大部分格式的图片,都需要被首先解码为bitmap,然后才能渲染到UI上。

UIImageView 显示图片,也有类似的过程。实际上,一张图片从在文件系统中,到被显示到 UIImageView,会经历以下几个步骤:

  1. 分配内存缓冲区和其它资源。
  2. 从磁盘拷贝数据到内核缓冲区
  3. 从内核缓冲区复制数据到用户空间
  4. 生成UIImageView,把图像数据赋值给UIImageView
  5. 将压缩的图片数据,解码为位图数据(bitmap),如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。
  6. CATransaction捕获到UIImageView layer树的变化,主线程Runloop提交CATransaction,开始进行图像渲染
  7. GPU处理位图数据,进行渲染。

由于 UIKit 的封装性,这些细节不会直接对开发者展示。实际上,当我们调用[UIImage imageNamed:@"xxx"]后,UIImage 中存储的是未解码的图片,而调用 [UIImageView setImage:image]后,会在主线程进行图片的解码工作并且将图片显示到 UI 上,这时候,UIImage 中存储的是解码后的 bitmap 数据。

而图片的解压缩是一个非常消耗 CPU 资源的工作,如果我们有大量的图片需要展示到列表中,将会大大拖慢系统的响应速度,降低运行帧率。这就是 UIImageView 的一个性能瓶颈。

解决性能瓶颈:强制解码

如果 UIImage 中存储的是已经解码后的数据,速度就会快很多,所以优化的思路就是:在子线程中对图片原始数据进行强制解码,再将解码后的图片抛回主线程继续使用,从而提高主线程的响应速度。

我们需要使用的工具是 Core Graphics 框架的 CGBitmapContextCreate 方法和相关的绘制函数。总体的步骤是:

A. 创建一个指定大小和格式的 bitmap context。
B. 将未解码图片写入到这个 context 中,这个过程包含了强制解码
C. 从这个 context 中创建新的 UIImage 对象,返回。

下面是 SDWebImage 实现的核心代码,编号对应的解析在下文中:

// 1.
CGImageRef imageRef = image.CGImage;

// 2.
CGColorSpaceRef colorspaceRef = [UIImage colorSpaceForImageRef:imageRef];

size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);

// 3.
size_t bytesPerRow = 4 * width;

// 4.
CGContextRef context = CGBitmapContextCreate(NULL,
                                             width,
                                             height,
                                             kBitsPerComponent,
                                             bytesPerRow,
                                             colorspaceRef,
                                             kCGBitmapByteOrderDefault|kCGImageAlphaNoneSkipLast);
if (context == NULL) {
    return image;
}

// 5.
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);

// 6.
CGImageRef newImageRef = CGBitmapContextCreateImage(context);

// 7.
UIImage *newImage = [UIImage imageWithCGImage:newImageRef
                                        scale:image.scale
                                  orientation:image.imageOrientation];

CGContextRelease(context);
CGImageRelease(newImageRef);

return newImage;

对上述代码的解析:

1、从 UIImage 对象中获取 CGImageRef 的引用。这两个结构是苹果在不同层级上对图片的表示方式,UIImage 属于 UIKit,是 UI 层级图片的抽象,用于图片的展示;CGImageRef 是 QuartzCore 中的一个结构体指针,用C语言编写,用来创建像素位图,可以通过操作存储的像素位来编辑图片。这两种结构可以方便的互转:

// CGImageRef 转换成 UIImage
CGImageRef imageRef = CGBitmapContextCreateImage(context);
UIImage *image = [UIImage imageWithCGImage:imageRef];

// UIImage 转换成 CGImageRef
UIImage *image=[UIImage imageNamed:@"xxx"];
CGImageRef imageRef=loadImage.CGImage;

2、调用 UIImage 的 +colorSpaceForImageRef: 方法来获取原始图片的颜色空间参数。

什么叫颜色空间呢,就是对相同颜色数值的解释方式,比如说一个像素的数据是(FF0000FF),在 RGBA 颜色空间中,会被解释为红色,而在 BGRA 颜色空间中,则会被解释为蓝色。所以我们需要提取出这个参数,保证解码前后的图片颜色空间一致。

颜色空间的对比

CoreGraphic中支持的颜色空间类型:

颜色空间

3、计算图片解码后每行需要的比特数,由两个参数相乘得到:每行的像素数 width,和存储一个像素需要的比特数4。

这里的4,其实是由每张图片的像素格式像素组合来决定的,下表是苹果平台支持的像素组合方式。

像素组合

表中的bpp,表示每个像素需要多少位;bpc表示颜色的每个分量,需要多少位。具体的解释方式,可以看下面这张图:

像素组合

我们解码后的图片,默认采用 kCGImageAlphaNoneSkipLast RGB 的像素组合,没有 alpha 通道,每个像素32位4个字节,前三个字节代表红绿蓝三个通道,最后一个字节废弃不被解释。

4、最关键的函数:调用 CGBitmapContextCreate() 方法,生成一个空白的图片绘制上下文,我们传入了上述的一些参数,指定了图片的大小、颜色空间、像素排列等等属性。

5、调用 CGContextDrawImage() 方法,将未解码的 imageRef 指针内容,写入到我们创建的上下文中,这个步骤,完成了隐式的解码工作。

6、从 context 上下文中创建一个新的 imageRef,这是解码后的图片了。

7、从 imageRef 生成供UI层使用的 UIImage 对象,同时指定图片的 scaleorientation 两个参数。

scale 指的是图片被渲染时需要被压缩的倍数,为什么会存在这个参数呢,因为苹果为了节省安装包体积,允许开发者为同一张图片上传不同分辨率的版本,也就是我们熟悉的@2x,@3x后缀图片。不同屏幕素质的设备,会获取到对应的资源。为了绘制图片时统一,这些图片会被set自己的scale属性,比如@2x图片,scale 值就是2,虽然和1x图片的绘制宽高一样,但是实际的长是width * scale

orientation 很好理解,就是图片的旋转属性,告诉设备,以哪个方向作为图片的默认方向来渲染。

通过以上的步骤,我们成功在子线程中对图片进行了强制转码,回调给主线程使用,从而大大提高了图片的渲染效率。这也是现在主流 App 和大量三方库的最佳实践。

总结

总结一下本文内容:

  • 图片在计算机世界中被按照不同的封装格式进行压缩,以便存储和传输。
  • 手机会在主线程中将压缩的图片解压为可以进行渲染的位图格式,这个过程会消耗大量资源,影响App性能。
  • 我们使用 Core Graphics 的绘制方法,强制在子线程中先对 UIImage 进行转码工作,减少主线程的负担,从而提升App的响应速度。

和 UIImageView 类似,UIKit 隐藏了很多技术细节,降低开发者的学习门槛,但另一方面,却也限制了我们对一些底层技术的探究。文中提到的强制解码方法,其实也是 CGBitmapContextCreate 方法的一个『副作用』,属于比较hack方式,这也是iOS平台的一个局限:苹果过于封闭了。

用户对软件性能(帧率、响应速度、闪退率等等)其实非常敏感,作为开发者,必须不断探究性能瓶颈背后的原理,并且尝试解决,移动端开发的性能优化永无止境。

0

不懂产品的研发,不是好 CTO

在做产品经理之前,我是传说中第三种人类:程序媛。曾经,被产品经理虐过,吐槽过产品经理;现在,也虐过研发同学,也被研发同学吐槽过。因为做过研发也做过产品,可谓知己知彼,深刻的体会到双方沟通中的痛点,希望本文能够帮助研发同学更好的和产品经理沟(si)通(bi)。

什么是产品?

产品是指能够供给市场,被人们使用和消费,并能满足人们某种需求的任何东西,包括有形的物品、无形的服务、组织、观念或它们的组合。

比如,手机满足人们通讯需求,是有形的硬件产品;微信满足人们社交需求,是无形的软件产品;家政服务满足人们清洁需求,是无形的服务产品……

其实,简单来说,产品就是能满足人们某种需求的东西。

什么是好产品?

从以下三组图片中,选择你认为好的产品。

幻灯片02

幻灯片03

幻灯片04

第一组,相信大家都会选择小米遥控器。因为相比传统的遥控器,小米遥控器设计简洁,按键功能一目了然,不用看说明书就知道如何使用。

第二组,每个人的选择都不一样。对于普通用户来说,美图秀秀是个好产品,好用、容易上手,而 PS 完全不知道从何下手。但对于专业设计师来说,美图秀秀就是个渣渣,根本满足不了工作需求。

第三组,大家可能没怎么接触过,通过这两款产品可以实时查询公交的位置,还有几分钟到达相应的站点。仅凭一个界面,有些人会觉得车来了是好产品,因为界面干净、整洁、交互也特别好,而上海公交查询体验特别差。但事实上,我把车来了这个 App 卸载了,却一直在使用上海公交查询,因为它提供的数据非常准确。对我来说,这两个产品都算不上是好产品。

所以,好的产品必须同时具有以下三点:

  • 有用
  • 能用
  • 好用

image

人人都是产品经理?伪命题!

每次看到这句话我就特别想翻白眼,甚至想学罗胖来一句,我呸!

这是大家眼中的产品经理:

幻灯片07

你是想卖一辈子糖水,还是跟着我们改变世界

在每个产品经理内心深处都有一个改变世界的梦想。

在父母眼中,产品经理就是公司的核心,不可或缺。

在研发眼中,产品经理就是一个打杂小妹,随叫随到。

在设计眼中,产品经理就是山顶洞人,完全没有审美眼光。

逻辑不如研发,文案不如运营,审美不如设计,视野不如老板。

为什么需要产品经理?

存在即是合理。

老板表示需要一个背锅的:遇到不好用的 App,大家第一反应都是什么 SB 功能,SB 产品经理,但是从来没有人骂 SB 老板;用户吐槽产品很难用,设计可以说这锅我不背,产品经理设计的流程就是这样的;项目进度延迟,研发可以说这锅我不背,产品经理需求变更频繁……

任何人都可以甩锅,唯独产品经理不可以,任何与产品相关的锅产品经理都要背,这些都是产品经理的责任,不可推卸。

研发、设计表示需要一个打杂的:缺文案,产品经理帮你写;缺数据,产品经理帮你整理;需求开发好了,产品经理还要帮忙验收测试……

市场、用户表示需要一个翻译:例如,如何向用户解释什么是 2G、3G、4G 网络。如果直接告诉用户 2G 是数字网络、3G 是高速 IP 数据网络、4G 是全 IP 数据网络,用户肯定一脸懵逼:什么鬼?但如果告诉用户 2G 可以看文字、3G 可以看图片、4G 可以看小电影,用户立马就明白了。

怎样才能成为产品经理?

在大部分人眼里,只要会吐槽就可以做产品经理。

吐槽吐的专业,可以成为评论家;吐槽吐的幽默,可以成为段子手;吐槽吐的群情激奋,可以成为意见领袖;但发现问题仅仅只是产品经理工作中的一小部分。一个优秀的产品经理,必须会分析并解决问题。

我们来看一下产品经理的关键词:

幻灯片11

是不是有种产品经理从入门到放弃的感觉?其实,将这些关键词整理一下,可以分成以下几个维度:

1.人

幻灯片13

实现一个产品功能,产品经理需要全程跟进,在这个过程中,就免不了在多个部门之间沟通协调。

例如,老板想要增加某个功能,那么产品经理首先需要和老板沟通,了解增加该功能的原因,然后简单评估一下需求的优先级,并给出一个合理的解决方案。找设计沟通UI设计及交互逻辑;拿着做好的设计稿找到研发,确定开发计划及上线时间;功能上线后,向市场、客服部门的同事做培训,推广产品功能;产品功能推向市场后,收集用户反馈,处理用户遇到的问题;功能稳定后,采集数据,分析数据,持续运营产品;最后,还需要向老板及相关部门汇报产品数据,复盘总结。

2.能力

幻灯片14

在图中列出的产品经理相关能力中,最核心的能力是学习能力、独立思考、沟通协调能力。这里的沟通能力,并不是指性格开朗、热情大方、幽默风趣这些一般人理解的沟通能力,而是把细节说清楚,专业。把细节描述清楚,没有歧义,双方能达成共识;工作态度专业认真,不过于情绪化。而协调指的是协调所有可用资源,让事情朝着你想要的方向前进。

3.工具&方法&输出

幻灯片16

工具、方法、输出都是手段,不是目的。学以致用才是学习的目的,学了不会用等于白学。

而在实际工作中,光会这些还不够,必须把所有这些都揉碎、重组、融入到工作的每个细节中。

左边是大家看到的路径,右边是产品经理考虑的路径。

幻灯片23

左边是大家看到的需求,右边是产品经理面对的需求。

幻灯片24

左边是大家的工作流程,右边是产品经理的工作流程。

幻灯片25

以杏仁医生App药品登记需求为例,下图是用户和研发看到的。

幻灯片26

而实际上,下图是我考虑的内容。

幻灯片27

看到这里,你还认为产品经理门槛很低吗?人人都是产品经理吗?

产品经理不生产需求,只是需求的搬运工

介绍完产品经理,我们要介绍产品的另一个关键词:需求。产品经理不生产需求,只是需求的搬运工。有人可能要说反对意见,乔布斯不就生产了需求吗?其实乔布斯也没有生产需求。

需求就像煤矿一样,有的随意散落在地表,大家都能看见;可绝大部分有价值的需求就像埋在地底下的煤矿一样,需要专业的人员去挖掘,去采集,去清洗。而产品经理就是这个挖煤的矿工,优秀的产品经理能挖掘到人性最深层的需求。

按照马斯洛需求层次理论,饿了么满足了用户的生理需求,在家也能吃上全国各地的饭菜;支付宝满足了用户的安全需求,给用户提供了资金安全保障;微信满足了用户的社交需求,让用户低成本的与朋友、亲人、同事交流;知乎满足了用户的尊重需求,让用户展示自己出众的知识技能,得到大家的赞赏;得到满足了用户自我实现需求,营造一种充实感,让用户感觉自己每天都在进步……

image__1_

需求需要符合这四个条件:

  • 特定的人
  • 特定的情况下
  • 特定的问题
  • 可以被解决

概括下来就两个字:场景

之前有同事提出,杏仁医生 App 中的药品登记模块需要增加一个批量登记的功能。咋一看是不是很合理?但仔细一想,什么场景下医生会需要批量登记呢?

事实上,并没有这样的场景。医生开药的场景大部分都是先搜索药品,如果没有搜索到合适的药品,就顺手登记一下。医生并不会用小本本记下缺哪些药品,然后批量登记。因此,批量登记功能是一个伪需求。

把问题放到一个具体的场景中可以过滤掉非常多的伪需求、不合理的需求。

需求从哪里来?

1.公司外部

公司外部的需求主要来自用户、合作伙伴、竞争对手。用户经常抱怨某个功能不好用,用户反馈其实就是需求;合作伙伴在和我们合作的过程中,肯定会产生一些新的想法,这些也是需求;竞争对手推出了一些好用的功能抢走了很多用户,为了抢夺市场,也会产生需求。

2.公司内部

公司内部的需求主要来自公司战略、产品规划、内部人员。每年公司都会制定公司一整年的战略计划,产品总监会根据这些战略制定每个季度的产品规划,产品经理再将这些产品规划分解成多个产品功能。在产品迭代的过程中,老板、市场、运营会提各种各样的需求,研发、产品经理也会从技术、产品层面提出需求。

怎么定优先级?

1.刚性

指这个需求是否迫切,实现了这个需求,是不是能立马解决困扰用户很久的问题。

2.广度&频率

广度指这个需求会影响多少用户,多少核心用户。频率指用户使用这个功能的频率。

3.替代方案

指解决这个需求,是否有替代方案。

按照以上原则,规划电商模块功能。第一个版本必须要实现商品展示、商品详情、购物车、下单结算、支付功能,因为这是用户购买过程中最核心的功能。而售后退款相关流程可以放到后续版本,因为在初期售后退款功能并不是很迫切,只影响部分用户,且存在替代方案——人工处理的方式。当用户规模达到一定程度后,再做相关功能的开发。

满足需求的方法?

相信大家都去海底捞吃过火锅,每次去总是人满为患,假设你是海底捞的店长,你怎么解决海底捞排队的问题?满足用户快速吃饭的需求。

1.改变现状

我们能想到最直接的办法,就是扩大门店,增加更多的桌子和服务员。但性价比很低,一是扩张需要成本,需要额外负担装修成本、服务员工资;二是在非吃饭高峰期造成资源浪费,大面积的区域没有使用,部分服务员无事可做。

2.降低期望

在海底捞排队取号时,通常服务员都会告诉你前面还有多少桌,并告诉你大概的排队时间。但这个预估的排队时间通常都比实际等待时间要长一倍到两倍,虽然会吓跑一部分顾客,但留下来排队的顾客体验会好很多。本来以为要排 1 个小时,实际上只排了半个小时,内心会有小小的惊喜:海底捞效率还挺高的。

3.转移需求

现在海底捞的做法就是在排队区域准备一些小零食、水果和小游戏,让顾客一边吃东西玩游戏,一边排队。一方面通过零食和小游戏分散顾客的注意力,缓解顾客排队等候的暴躁情绪;另一方面通过零食和小游戏将大部分顾客牢牢的锁在海底捞,提高订单转化率。

研发与产品的爱恨情仇,本是同根生相煎何太急

经常会在网上看到程序员吐槽产品经理的各种段子、漫画和文章,平时工作中也遇到不少。分析下来,矛盾的根源在于:

  • 频繁变更需求
  • 低估研发成本
  • 需求变更没有通知到位

产品经理防“群殴”指南

image__2_

需求频繁变更,其实说明产品经理在分析需求时缺乏思考,没有多想几个为什么。

不要用战术上的勤奋掩饰战略上的懒惰。

经常有朋友问我:做产品经理和做研发有什么区别?

最大的区别在于研发 80% 的时间用于执行,而产品 80% 的时间用于思考和沟通。

1.考虑未来三个月内的扩展性

在做一些比较大的功能模块时,不能只考虑第一个版本的规划,还需要考虑未来两到三个版本的功能。可以将版本规划提前告知研发团队,让他们了解未来产品要做成什么样子,哪些功能必须要做,哪些功能可能要优化。这样研发同学就可以提前做相关技术调研,规划技术架构,提高代码的可扩展性。

2.考虑常见的特殊情况

最常见的情况,调后台接口,展示列表数据。此时就要考虑很多种特殊情况:

  1. 没有网络
  2. 网络条件不好
  3. WiFi网络环境和非WiFi网络环境
  4. 没有数据
  5. 少量数据
  6. 大量数据

再比如,电商业务中最常见的订单,需要梳理整个过程中的状态,并画出各个状态之间的转化示意图。只有把所有状态都列清楚,才能确认逻辑是否完善,让研发能更好的实现订单流程。

3.不要想满足所有人的需求

相信大家也知道夫妻骑驴的故事,在这个故事中,无论这对夫妻采用何种方案,总会有批评的声音。

image__3_

做产品也是同样的道理。比如,针对患教资料是否显示原创医生的个人信息。部分医生认为应该显示,理由一:这是医生花费大量时间和精力编写的文章,要尊重医生的知识产权;理由二:通过分享患教资料扩大知名度,让更多的陌生患者添加自己;另一部分医生却强烈要求隐藏原创医生的个人信息,这部分医生认为自己分享了其他医生写的患教资料,相当于向自己的患者介绍其他医生,为其他医生做推广。

无论是否显示原创医生的个人信息,总会有部分医生不满。你不可能满足所有用户的需求,在这种情况,坚持做自己认为对的事情就好。

4.MVP( Minimal Viable Product 最小可行性产品)

产品迭代路径应该按照 MVP 原则,先向用户提供能用的产品,再根据用户需求,逐步提供更好用的产品。而不是一上来,就规划一个非常完美的方案,然后花上好几个月甚至一两年来实现,说不定等你实现所谓“完美”的产品,用户需求早已发生翻天覆地的变化。

image__4_

5.适当了解一些技术,适当看一些技术文档

在做患者端小程序前,先查看小程序接口文档,了解以下问题:

  1. 小程序能实现是否支持语音、视频、扫一扫功能
  2. 小程序和公众号如何相互跳转
  3. 小程序通知下发机制
  4. 如何配置 webview 业务域名
  5. webview 组件如何实现微信支付

这样才能规划小程序做哪些功能;如何实现小程序和公众号之间相互引流;如何通知用户;遇到非业务域名如何处理;如何在 webview 中调用小程序微信支付。

之前听过一句玩笑话。

产品经理懂技术等于流氓会武功?

流氓会武功的话,对付一般的小警察还是很有优势的;但如果流氓因为自己会武功,而无法无天,自以为是;等遇到狙击手的时候,直接一枪就被放到了好吗。

同样的道理,产品经理不能仗着自己懂一点技术,就对研发同学指手画脚。懂技术是为了排除需求中的隐患,更好的和研发沟通,提升工作效率。了解一些基础技术架构即可,不要沉迷于技术实现,否则,就是本末倒置。

6.重视文档更新

这个不解释。

研发防“入坑”指南

每次版本总结,总是会有研发表示被产品经理“坑”惨了:需求变更太频繁,需求不靠谱,需求解读错误,需求不明确……除去市场、产品本身存在的问题,大部分问题其实是沟通问题。

1.正确看待需求变更

一提到需求变更,一些研发会皱着眉头改代码,心里 mmp ,怎么又 TM 改需求啊;一些研发会让产品经理签字画押,作为日后打脸的呈堂证供;还有一些研发会直接拒绝,要改你来改啊,老子就不改……但事实上,业务稍微复杂一点的产品都会有需求变更,要求产品没有需求变更,相当于要求研发写的代码没有 bug 。我相信没有一个研发敢摸着良心保证自己的代码没有一个 bug 。

从另一个角度思考,需求变更其实是一件好事。正因为有需求变更,才能体现研发的价值:如果没有需求变更,80% 的程序员都要失业了。正因为有需求变更,研发的工作更具有挑战:如何设计更合理的架构,覆盖更多的业务场景,支持更复杂的业务流程。

2.先考虑需求是不是合理,是不是有更好的解决方案

看到这里,有部分研发同学认为,我为什么要考虑这些,这是产品经理的工作,不是研发的工作。这的确不是研发的工作,但实现这些需求却是研发的工作。为了避免自己被“坑”,如果有更好的解决方案,完全可以提出来,和产品经理一起讨论。

也许你们会讨论出一套性价比更高的方案:一方面满足产品需求,一方面减少自身的开发工作量。也许你们讨论过程中发现遗漏了某个分支的处理流程:一方面发现了产品设计的漏洞,一方面减少了 bug 数量提高代码质量。

3.有疑问,有困难一定要尽早提出,不要自己一个人默默的解决

例如,某个功能实现起来有技术难度,此时,研发和产品经理可以沟通讨论出更好的方案;某个模块功能太多研发时间太长,此时,产品经理可以根据实际项目情况,砍掉部分优先级低的需求,或者申请适当延长研发周期。千万不要自己一个人死扛,默默的处理遇到的所有问题,最终产品验收时,发现偏离了产品需求,吃力不讨好。

在每个版本开发前,产品经理都会先和各组研发 leader 进行小范围的需求评审,之后和后台研发同事进行第二轮需求评审,最后和所有相关研发同事进行最终需求评审。在这个过程中,产品经理逐步完善产品设计。前期沟通的越清楚,后期沟通的成本就越小,产品需求变更的情况也越少。

4.产品经理做的不好的地方,可以当面指出

在产品研发的过程中,产品经理接触最多的就是研发,听到最多的就是研发同事对产品经理的吐槽与抱怨。吐槽需求不靠谱,吐槽产品原型有漏洞,吐槽产品经理低估研发成本……吐槽产品经理也成为研发日常工作的一部分,我也相信这些槽点三天三夜也吐不完。

面对这些吐槽与抱怨,任何一个有职业素养的产品经理都会认真的听取意见。毕竟产品经理的部分工作就是收集用户建议、跟踪用户反馈、持续提高用户体验,而研发其实是产品经理最重要的用户。

没有什么事情,是一顿火锅解决不了的。如果有,那就两顿!

相信大家都知道瞎子和跛子的故事:一个瞎子和一个跛子,被困在火场中,眼看着要被烧死了,瞎子背起跛子,跛子指路,终于从大火中死里逃生。

如果说创业就像在火场中逃生,九死一生。那么产品经理就是一个跛子,能看见前进的方向,但没有行动能力;研发是一个瞎子,有行动能力,但看不见前进的方向;如果研发和产品经理之间相互猜忌,埋怨,最终可能是死路一条。只有两者相互信任,才有可能活着走出火场。

最后,希望研发同学和产品经理能够相互信任、和谐沟通,一起愉快的玩耍。

0

技术选型的艺术

什么是技术选型

技术选型对于广大程序员,特别是互联网公司的技术负责人或者架构师来说,一定不陌生。小到日常开发中的一个工具库的选择,大到整个系统语言、架构层面的选择,都是技术选型的范围。今天我们就简单聊聊技术选型。

一般而已,我们会碰到的技术选型,可以分为以下几类:

  • 基础设施选型:云平台或IDC、编程语言、数据库等。
  • 框架和库的选型:前后端的开发框架、核心类库等。
  • 中间件选型:负载平衡、消息中间件、缓存中间件等。
  • 第三方服务选择:第三方的推送、短信等。

实际上,我们常常面临的不是单个技术的选型,而是对于一个项目所涉及的一整套的技术、方案、规范或者产品的选型。这就需要我们更仔细的去权衡各种技术、各种组合的利弊,作出取舍。

技术选型,其实本质上就是一种架构决策。《系统架构》这本书里将架构决策总结成了六种模式,包括选项、筛选、指派、分区、排列和连接。而技术选型明显属于其中的选项(Decision-Option)模式,也即每个决策都是一套离散选项,从中选择一个合适的。

影响技术选型的因素

项目因素

首先考虑的是项目。

我们要明确项目本身的性质,包括项目的规模、重要程度、时间要求等等。如果是一个小规模的实验性项目,那么尝试一些新技术也未尝不可。如果时间要求非常紧,那可以考虑基于开源或商用的程序修改。淘宝当年上线时候,就是购买了一个商用程序后修改的。另外,项目的成本和预算也是必须要考虑的。如果预算足够,我们也可以考虑购买商用程序或者第三方服务。

项目的需求也会影响甚至限制技术的选型。特别要注意的是那些非功能性的需求,例如对并发性、实时性、可用性、数据一致性、安全性等方面的需求,往往对技术方案和选型有很大影响。例如支付相关的应用,对数据的一致性有非常高的要求,那核心的支付数据的存储,就会倾向选择 Oracle、PostgreSQL 这种强一致性的数据库,而不会去选 MongoDB(虽然据说马上也要支持事务了)。

团队因素

其次要考虑的是团队,也就是说人的因素。

技术选型一定要考虑当前团队人员的技术组成。对于一些比较基础的技术的选型,比如语言和框架、数据库等等,往往最合适的选择就是团队最熟悉的技术。如果打算选择不同的技术的话,那就要考虑下团队是否有人能够 Hold 住了。另一个必须要考虑的是招聘,如果使用的是比较小众的技术,那么当需要扩充团队的时候,招聘人员会比较困难。我们杏仁在这方面就取了一个巧,最开始我们用的是基于 JVM 的 Scala 语言,但是我们招聘的时候,都是打的 Java 工程师的幌子。

团队的发展时期对于技术选型也会有一定影响。对于早期团队,大多数员工都是喜欢创新、愿意承担风险,那么可以选择一些相对较新、有挑战的技术;团队发展到一定阶段,那就要开始考虑团队效率、开发规范等因素,此时往往会选择一些比较大众的、经过验证的技术。

我加入现在的公司时,我们正好开始转型。原有的产品是基于 Web 的网站,我们打算开发一个移动 APP。我们原先后端应用使用的是 Scala 语言和 Play 框架,当时面临的一个问题是,新的产品后端应该选择什么技术?虽然也考虑过 Java,但因为时间很紧,我们还是选择了团队最熟悉的 Scala。但后面我们开始服务化的时候,就选择了 Java。

还有一点就是,虽然技术选型需要考虑团队人员的喜好,但千万不要因为某几个人的个人喜好,来决定技术的选型。还是通过细致的分析和实验来进行选型。而决策者也需要看的更长远一些,推动团队技术向前发展。

技术因素

技术选型当然也必须考虑技术因素,例如技术特性(易用性、可维护性、可扩展性、性能等)、技术成熟度、社区活跃度、架构匹配和演化等等。

技术特性往往是大家都会去关注的,相信不必多说。但是注意不要只是浏览网上看到的各种分析,对于重要的特性,还是需要去亲自实验一下或者做一些测试,有第一手的感觉。另外,这些特性往往不可兼得,所以我们需要对照项目因素和团队因素来进行取舍。

技术的发展有其内在趋势,Gartner 每年都会发布一个 Hyper Cycle,标记各种技术的发展阶段;另外一个很好的参考是 ThoughtWorks 的技术雷达。我们可以根据项目、团队等因素去选择不同成熟度的技术和产品。但是对于核心项目,最好还是选择足够成熟的技术,切忌盲目追求新技术。

对于比较重要的应用,我们一般会选择比较大众的技术,因为使用的人多了,也从某种程度上说明这个产品是比较优秀的,并且招聘也会更容易些。这方面可以参考的有 Github 的 star 数、Google Trends、百度指数等等。社区活跃度也是必须要考虑的因素,你一定不会想用一个没有人维护,提了问题也没人回复的技术产品吧。

最后还需要考虑技术产品和当前架构的匹配程度。一个团队的技术栈太过散乱,对开发和运维会是很大的压力,甚至影响系统的稳定性。例如我们当前的数据库使用的是 MySQL,而一个技术产品如果要求必须引入 MongoDB,那我一定会多想一下,我们多维护一个 MongDB 是不是值得。另外,如果大家对自己架构的演化有一定规划,那也要考虑引入的新的技术产品和未来的架构是否能够匹配。

其他因素

最后,可能还会有一些额外的因素要考虑。比如许可协议的问题,前段时间闹得沸沸扬扬的 React Native 许可协议事件,相信大家记忆犹新。还有一些公司,可能对于使用第三方的程序或者服务,有一些原则甚至规范,那也一定要注意参考。另外,在稍大一些的公司里可能还会涉及一些派系之争,这里就不多说了。

如何进行技术选型

我们上面已经列出了很多技术选型需要考虑的因素,那么到底如何进行技术选型呢?大致上可以分为以下几个步骤:

  1. 首先要明确选型的需求和目的,最好能列出必须要考虑的各种因素以及评判标准。
  2. 然后就可以开始寻找候选技术或产品了。这时范围可以尽量广一些,搜集尽可能多的候选技术和产品。
  3. 其次就可以进行初步筛选。把一些由于各种限制无法选择的、或者明显不可能的技术或产品排除,筛选出 3 个左右备选方案。
  4. 再然后就要做一些详细的调查和分析了。可以列一个表格,把备选方案、以及需要考虑的因素放到表格的横向和纵向中去,一个个进行评估的分析。此时可能会需要做一些小 Demo 验证可行性,或者做一些性能测试、压力测试等等。必要的话可以在表格里给每一个因素打分。
  5. 最后,对分析结果进行评审,作出最后决定。

技术选型分析表,每一格里可以有具体的打分,也可以有优势劣势的评价。

候选A 候选B 候选C
团队
技术成熟度
性能
架构一致性
...

当然,小的不太重要的技术选型也不一定要这么麻烦,而重要的技术选型则可能要反复各个步骤多次。

技术选型的几个注意点

  • 一定要进行可行性分析,如果不太确定,做个 Demo 验证一下。如果项目进行到一半,发现原来设想的某个方案不可行,那会是非常痛苦和浪费时间的事情。
  • 不要有思维定势,习惯性的使用某些技术,比如服务化就用 Dubbo、缓存就用 Redis,具体问题要具体分析。也不要赶时髦,在重要的项目上使用太新的不够成熟的技术。
  • 随着业务发展,很多架构需要不断升级,所以一定要考虑未来如果要替换某项技术,是否会很麻烦。可以选择符合一些标准的技术或产品,或者在应用中部署一个适配层,方便未来适配其他技术。
  • 架构应该尽可能统一,一个领域避免引入太多相同功能的技术产品。

总结

最后,大家其实会发现,技术选型既是一种科学、又是一种艺术,有时候并没有对错之分。最后面临两难选择的时候,还是需要决策人拿出勇气,拍板决定,坚定的去推进。

0