koa中间件源码解析

 

  • Koa是现在最流行的基于Node.js平台的web开发框架;
  • 中间件是 Koa 中一个非常重要的概念,利用中间件,可以很方便的处理用户的请求,比如日志,参数解析,权限控制等;
  • 中间件格式为一个高阶函数,一般来说外部的函数接收一个 options 参数,这样方便中间件提供一些配置信息;
  • 内部函数是一个async函数,接收两个参数ctx,next;
  • ctx为请求上下文,调用next将请求扔给下一个中间件处理;
  • 通常将404中间件放在最后,处理所有未知请求

常见的koa中间件实例

// log info 
const log =(level='debug')=>{
    return async (ctx,next) => {
       console.debug(`request url is ${ctx.url}`);
       next() // next() 将请求丢给下一个中间件
        
    };
};


// 404 page
const page404 = () => {
    return async (ctx) => {
        ctx.body = {code: 404, msg: 'not found'}; 
       // 没有调用next ,请求在此返回数据
    }
};

如何使用中间件

const Koa = require('koa');
const app = new Koa();
app.use(log('debug')); // 调用app.use添加中间件
app.use(page404())
app.listen(3000)
  • koa 实例有如下属性
    this.proxy = false;
    this.middleware = []; //存放中间件的数组
    this.subdomainOffset = 2;
    this.env = process.env.NODE_ENV || 'development';
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);

 

  • app.use 将async function 添加到 middleware数组中
use(fn) {
   
    this.middleware.push(fn); //添加到 middleware数组中
    return this;
  }
  • app.listen 创建http服务,并且调用callback函数
listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    //http.createServe回调函数接收两个参数 request和response
    return server.listen(...args);
  }
  • callback回调,将请求交给第一个中间件处理
callback() {
    const fn = compose(this.middleware);  
    //compose 将中间件组合起来,并且返回第一个中间件

    ...

    const handleRequest = (req, res) => {
      // 将req和res封装到ctx中
      const ctx = this.createContext(req, res); 
      // fun 为第一个中间件,最先处理ctx上下文
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

  • 通过函数 compose 将中间件组合起来,并且返回第一个中间件
 function compose (middleware) {
  


  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0) // 返回第一个中间件处理
    function dispatch (i) {
      if (i <= index) 
      // 如果同一个中间件 多次调用 next ,抛出错误
      return Promise.reject(new Error('next() called multiple times'))
      
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve() 
    // 所有中间件都处理完毕 ,返回。
      try {
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1) 
// 当中间件调用next()时 ,交给下一个中间件处理
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

总结:koa中间件处理流程大致如下

 

  • app = new Koa()生成koa实例
  • app.use 将中间件添加到middleware数组里
  • compose函数将中间件组合起来,并且返回第一个中间件
  • app.listen创建服务,同时调用app.callback()
  • app.callback()将请求交给compose函数返回的第一个中间件处理
  • 调用next将请求扔给下一个中间件处理
  • 最后一个中间件处理后,将数据返回

 

 

 

 

 

 

 

 

 

 

Appium环境搭建介绍

本文主要介绍在mac环境下搭建appium,window版只能执行Android自动化,不能执行IOS

一、安装软件

1. 安装brew,在终端下执行:

/usr/bin/ruby -e “$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)”

2. 安装libimobiledevice,在终端下执行:

brew install libimobiledevice –HEAD

3. 安装carthage,在终端下执行:

brew install carthage

4. 安装node,在终端下执行:

brew install node

5.安装ios-deploy

npm install -g ios-deploy

6. 安装jdk,可自行下载,建议1.8版本的jdk

7、安装Android SDK,可自行下载最新版,用于Android自动化,如果只跑IOS可不安装

8. 配置环境变量,在终端下编辑/etc/profile文件

vi /etc/profile

内容如下:

export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_40.jdk/Contents/Home

export ANDROID_HOME=/Users/zhanghao/android-sdk

export NODE_PATH=/usr/local/lib/node_module

export PATH=$JAVA_HOME/bin:$JAVA_HOME/jre/bin:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools:$PATH

然后保存,保存后在终端下再输入source /etc/profile

9. 安装appium-doctor,在终端下执行:

npm install -g appium-doctor

10. 安装appium,可在官网下载最新版,建议安装desktop版本

官网:https://github.com/appium/appium-desktop/releases

image2018-10-10_21-50-0.png

11. 检查环境,在终端下执行以下命令

appium-doctor

正确结果如图:

image2018-10-10_21-39-35.png

二、配置WebDriverAgent

1、执行脚本

进入目录:cd /Applications/Appium.app/Contents/Resources/app/node_modules/appium/node_modules/appium-xcuitest-driver/WebDriverAgent

创建目录:mkdir -p Resources/WebDriverAgent.bundle

执行: ./Scripts/bootstrap.sh

2、编译WebDriverAgent

1)appium是通过手机上WebDriverAgentRunner,来运行测试的,没有这个在真机上没有办法测试(模拟器上需要这个,不过会自动安装)

2)用Xcode打开WebDriverAgent,并且编译(编译之前需要一些设置)

进入目录:cd /Applications/Appium.app/Contents/Resources/app/node_modules/appium/node_modules/appium-xcuitest-driver/WebDriverAgent

选中WebDriverAgent.xcodeproj 文件,用xcode打开,并做如图设置。

这时候,会在手机上安装 WebDriverAgentRunner 的app。

请注意手机,如果提示是不收信任的开发者,请在设置-通用-设备管理(描述文件)信任你的apple id就可以了。

至此,Appium环境搭建就结束了,可以执行自动化验证下环境是否OK

用canvas实现一个粒子系统

工作需要实现一些酷炫的烟花特效,本来想上github上当个勤奋的搬运工,无奈没有找到专门的粒子特效库,只有一些零散的文章,于是准备自己实现一个。

写之前呢首先要了解粒子系统的基本概念,有两篇文章对粒子系统描述的比较清楚,一篇是:维基百科/粒子系统 ,另外一篇是unreal的游戏引擎文档粒子系统的关键概念 粒子系统表示三维计算机图形学中模拟一些特定的模糊现象的技术,而这些现象用其它传统的渲染技术难以实现的真实感的游戏图形。经常使用粒子系统模拟的现象有火、爆炸、烟、水流、火花、落叶、云、雾、雪、尘、流星尾迹或者象发光轨迹这样的抽象视觉效果等等。

好了,知道了粒子系统的概念,那开始动手做吧,首先我们可以把粒子系统抽象成三个类

1. 粒子:包含粒子的宽、高、形状、颜色、大小等属性
2. 发射器:用来维护单个粒子的轨迹、状态变化、颜色变化等。
3. 粒子系统:用来维护多个粒子的数量、状态等。

首先,我们来实现第一个类:粒子。(点击看效果

// 粒子
class Particle {
constructor(option) {
if(!option.ctx) {
console.error('ctx must be defined');
return;
}
this.ctx = option.ctx;
this.color = option.color || 'black';
this.x = option.x || 0;
this.y = option.y || 0;
this.radius = option.radius || 20;
}
render(option) {
Object.keys(option).forEach( key => {
this[key] = option[key];
});
ctx.beginPath();
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = this.color;
ctx.fill();
}
}


let canvas = document.getElementById('particle-system');
let ctx = canvas.getContext('2d');
let particle = new Particle({ctx})
canvas.addEventListener('click', (e) => {
ctx.clearRect(0,0, canvas.width, canvas.height);
particle.render({x: e.clientX, y: e.clientY})
})

接下来我们实现发射器来让小球动起来(点击看效果

// 粒子
class Particle {
constructor(option) {
if(!option.ctx) {
console.error('ctx must be defined');
return;
}
this.ctx = option.ctx;
this.color = option.color || 'black';
this.x = option.x || 0;
this.y = option.y || 0;
this.radius = option.radius || 20;
}
render(option) {
Object.keys(option).forEach( key => {
this[key] = option[key];
});
ctx.beginPath();
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = this.color;
ctx.fill();
}
}


// 发射器
class Emitter {
constructor({ctx, x, y, id}) {
this.particle = new Particle({ctx, x, y});
let offset = Math.random()*7 + 3; //3-10随机
let radian = Math.random() * 2 * Math.PI;
this.x = x;
this.y = y;
this.radius = Math.random()*10 + 10; // 10-20随机
this.hue = Math.random() * 200 - 100;
this.color = `hsla(${this.hue}, 100%, 50%, 1)`;
this.offsetX = offset * Math.cos(radian);
this.offsetY = offset * Math.sin(radian);
this.id = id;
}
updateX() { this.x += this.offsetX; }
updateY() { this.y += this.offsetY; }
updateColor() {
this.hue -= 0.5;
this.color = `hsla(${this.hue}, 100%, 50%, 1)`;
}
updateRadius() {
this.radius = this.radius*0.98;
}

update() {
this.updateX();
this.updateY();
this.updateColor();
this.updateRadius();
this.particle.render({
x: this.x,
y: this.y,
color: this.color,
radius: this.radius,
});
}
}

let canvas = document.getElementById('particle-system');
let ctx = canvas.getContext('2d');
let emitters = []
canvas.addEventListener('click', (e) => {
let emitter = new Emitter({ctx, x: e.clientX, y: e.clientY});
emitters.push(emitter);
});
let loop = () => {
window.requestAnimationFrame(() => {
ctx.clearRect(0,0, canvas.width, canvas.height);
emitters.forEach(emitter => {
emitter.update();
})
loop();
})
}
loop();

最后一步,我们来实现一个粒子系统来维护发射器的数量和生命周期(点击看效果

// 粒子
class Particle {
constructor(option) {
if(!option.ctx) {
console.error('ctx must be defined');
return;
}
this.ctx = option.ctx;
this.color = option.color || 'black';
this.x = option.x || 0;
this.y = option.y || 0;
this.radius = option.radius || 20;
}
render(option) {
Object.keys(option).forEach( key => {
this[key] = option[key];
});
ctx.beginPath();
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = this.color;
ctx.fill();
}
}


// 发射器
class Emitter {
constructor({ctx, x, y, id}) {
this.particle = new Particle({ctx, x, y});
let offset = Math.random()*7 + 3; //3-10随机
let radian = Math.random() * 2 * Math.PI;
this.x = x;
this.y = y;
this.radius = Math.random()*10 + 10; // 10-20随机
this.hue = Math.random() * 200 - 100;
this.color = `hsla(${this.hue}, 100%, 50%, 1)`;
this.offsetX = offset * Math.cos(radian);
this.offsetY = offset * Math.sin(radian);
this.id = id;
}
updateX() { this.x += this.offsetX; }
updateY() { this.offsetY += 0.5 ; this.y += this.offsetY; }
updateColor() {
this.hue -= 0.5;
this.color = `hsla(${this.hue}, 100%, 50%, 1)`;
}
updateRadius() {
this.radius = this.radius*0.97;
}

update() {
this.updateX();
this.updateY();
this.updateColor();
this.updateRadius();
this.particle.render({
x: this.x,
y: this.y,
color: this.color,
radius: this.radius,
});
}
}


// 粒子系统
class ParticleSystem {

constructor({ctx, width, height }) {
this.ctx = ctx;
this.particleNu = Math.random() * 20 + 30; // 30~50
this.width = width;
this.height = height;
this.requestId = 0;
}
addParticle(x, y) {
for(let i=0; i< this.particleNu; i++) { let id = ParticleSystem.emitterOffset; let emitter = new Emitter( {ctx: this.ctx, x, y, id}) ParticleSystem.emitters.set(id, emitter); ParticleSystem.emitterOffset++; } } nextTick() { if(ParticleSystem.emitters.size === 0) { return; } ctx.clearRect(0,0, canvas.width, canvas.height); ParticleSystem.emitters.forEach(emitter => {
if(emitter.x < 0 || emitter.y < 0 || emitter.x > this.width || emitter.y > this.height) {
ParticleSystem.emitters.delete(emitter.id);
return;
}
emitter.update();
})
this.requestId = window.requestAnimationFrame(() => {
this.nextTick();
});
}
burst(x, y) {
this.addParticle(x, y);
window.cancelAnimationFrame(this.requestId);
this.nextTick()
}
}


ParticleSystem.emitters = new Map();
ParticleSystem.emitterOffset = 0;

let canvas = document.getElementById('particle-system');
let ctx = canvas.getContext('2d');
let particleSystem = new ParticleSystem({
ctx,
width: canvas.width,
height: canvas.height
})
canvas.addEventListener('click', (e) => {
console.log(e);
particleSystem.burst(e.clientX, e.clientY);
})

大功告成,是不是很简单。后续我们还可以继承我们的类来进行扩展,比方重写particle的render方法来把小球变成雪花、五角星,给Emitter类添加新的方法来给小球的轨迹增加重力、加速度、旋转等物理效果,还可以修改ParticleSystem的逻辑来将目前的爆炸效果改成下雨、云雾等效果,限制我们的只是我们的想象力。

kubernetes基于filebeat日志的动态采集

背景需求

当业务迁移到kubernetes中以后,由于服务多副本可能存在不同的机器上面,在传统日志落盘宿主机文件会造成同个服务日志散落在不同机器上面,这就需要考虑如何把每个副本日志汇总在一起提供给开发人员查看,对于传统日志采集器会将其部署在Pod中以sidecar方式存在采集某个目录下的日志文件,但是会带来如下问题:

  1. 需要对kubernetes的yaml文件进行单独配置,略显繁琐;增加配置复杂度
  2. 需要被采集日志的每个Pod都要配置,依赖中;不便于管理以及升级维护

对于熟悉Prometheus的开发者而言,我们知道它提供SD(service discovery)的工作,只需要在每个service注明annotations需要被采集的端口、metrics接口、是否采集等字样标注即可。对于日志系统,保证日志稳定性、低延迟的同时也可以简化配置项、带来如下的好处:

  1. 每个pod通过annotations来选择是否需要被日志采集,不需要额外繁杂配置
  2. 支持非json输出以及json decode

基于以上需求考虑,我们可在每个Node节点通过Daemonset方式运行filebeat插件做日志采集.

架构

采集原理介绍:

在进行日志收集的过程中, 我们采用filebeat, 因为它足够轻量级, 内存占用不到30M左右,同时filebeat 6.0以上支持 add_kubernetes_metadata插件为日志添加kubernetesmetadata。其原理是扫描docker container /var/lib/docker/container/*/*.log日志的同 时,会通过container id去kubernetes api匹配对应的pod为每条日志打上kubernetes pod相关的标签,然后我们可以利用这些标签信息做一些日志处理。

原始数据输出格式,多了一个kubernetes 的filed:

{
"@timestamp": "2017-10-11T21:28:55.426Z",
"@metadata": {
"beat": "filebeat",
"type": "doc",
"version": "6.0.0-rc1"
}, "beat": {
"name": "filebeat-filebeat-dwjf3",
"hostname": "filebeat-filebeat-dwjf3",
"version": "6.0.0-rc1"
},
"kubernetes": {
"container": {
"name": "filebeat"
}, "pod": {
"name": "filebeat-filebeat-dwjf3"
},
"namespace": "default",
"labels": {
"release": "filebeat",
"app": "filebeat",
"controller-revision-hash": "4244555777",
"pod-template-generation": "1"
} },
"source":"/var/lib/docker/containers/0caeb502469d7f0dfb90c4154294f95f2f9dd206a21cc003f118353d0d6fad75/0caeb502469d7f0dfb90c4154294f95f2f9dd206a21cc003f118353d0d6fad75-json.log","offset":13157,"message": "{\"log\":\"2017/10/11 21:28:50.328022 metrics.go:39: INFO Non-zero metrics in the last 30s: beat.memstats.gc_next=14161552 beat.memstats.memory_alloc=8355272 beat.memstats.memory_total=415714016 filebeat.events.active=4117 filebeat.events.added=90167 filebeat.events.done=86050 filebeat.harvester.open_files=34 filebeat.harvester.running=34filebeat.harvester.started=34 libbeat.output.events.acked=86016x1x`libbeat.output.events.active=2048 libbeat.output.events.batches=43 libbeat.output.events.total=88064 libbeat.output.type=console libbeat.output.write.bytes=39752840 libbeat.pipeline.clients=1 libbeat.pipeline.events.active=4117 libbeat.pipeline.events.filtered=34libbeat.pipeline.events.published=90132libbeat.pipeline.events.total=90167 libbeat.pipeline.queue.acked=86016registrar.states.current=34registrar.states.update=86050registrar.writes=44\\n\",\"stream\":\"stderr\",\"time\":\"2017-10-11T21:28:
50.32829028Z\"}"
}

filebeat daemonset 配置:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: filebeat-config
  namespace: kube-system
  labels:
    k8s-app: filebeat
data:
  filebeat.yml: |-
    filebeat.config:
      inputs:
        # Mounted `filebeat-inputs` configmap:
        path: /usr/share/filebeat/inputs.d/*.yml
        # Reload inputs configs as they change:
        reload.enabled: true
      modules:
        path: /usr/share/filebeat/modules.d/*.yml
        # Reload module configs as they change:
        reload.enabled: true
    output.kafka:
      hosts:
        10.100.7.203:9092
        10.100.8.49:9092
        10.100.7.88:9092
      version: 0.9.0.1
      topic: 'filebeat-kubernetes-palfish'
      partition.round_robin:
        reachable_only: false
      compression: snappy
    spool_size: 2048
    idle_timeout: 5s
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: filebeat-inputs
  namespace: kube-system
  labels:
    k8s-app: filebeat
data:
  kubernetes.yml: |-
    - type: docker
      # filebeat 7.0
      combine_partials: true
      containers.ids:
        "*"
      encoding: plain
      fields_under_root: true
      ignore_older: 0
         close_older: 1h
      scan_frequency: 10s
      harvester_buffer_size: 16384
      max_bytes: 10485760
      tail_files: true
      backoff: 1s
      max_backoff: 10s
      backoff_factor: 2
      # comment(wangyichen),pod templateannotatioES.
      # filebeat.harvest: Only harvest pods that have a value of `true`
      # filebeat.index: Set the index value stored in elasticsearch
      processors:
        - add_kubernetes_metadata:
            in_cluster: true
            include_annotations:
              "filebeat.harvest"
              "filebeat.index"
        # comment(wangyichen): drop_eventdrop_fields drop_eventkubernetes.annotations.
        - drop_event:
            when:
              not:
                equals:
                  kubernetes.annotations.filebeat.harvest: "true"
        - decode_json_fields:
            fields: ["message"]
            target: ""
            overwrite_keys: true
            when:
              regexp:
                message: "^{.*}$"
        - rename:
             fields:
               - from: "message"
                 to: "logs"
             when:
               not:
                 regexp:
                   message: "^{.*}$"
        - drop_fields:
            fields:
              "beat"
              "@metadata"
                “kubernetes.labels"
              "prospector"
              "input"
              "offset"
              "stream"
              "source"
---
apiVersion: extensions/v1beta1
kind: DaemonSet
metadata:
     name: filebeat
  namespace: kube-system
  labels:
    k8s-app: filebeat
spec:
  template:
    metadata:
      labels:
        k8s-app: filebeat
    spec:
      serviceAccountName: filebeat
      terminationGracePeriodSeconds: 30
      containers:
        - name: filebeat
          image: wangyichen/filebeat:6.3.1
          args: [
            "-c""/etc/filebeat.yml","-e", ]
          securityContext:
            runAsUser: 0
          resources:
            limits:
              memory: 200Mi
            requests:
              cpu: 100m
              memory: 100Mi
          volumeMounts:
            - name: config
              mountPath: /etc/filebeat.yml
              readOnly: true
              subPath: filebeat.yml
            - name: inputs
              mountPath: /usr/share/filebeat/inputs.d
              readOnly: true
            - name: data
              mountPath: /usr/share/filebeat/data
            - name: varlibdockercontainers
              mountPath: /var/lib/docker/containers
              readOnly: true
      volumes:
        - name: config
          configMap:
            defaultMode: 0600
            name: filebeat-config
        - name: varlibdockercontainers
          hostPath:
            path: /data/data/docker/containers
        - name: inputs
          configMap:
            defaultMode: 0600
            name: filebeat-inputs
        # We set an `emptyDir` here to ensure the manifest will deploy correctly.
        # It's recommended to change this to a `hostPath` folder, to ensure internal data
        # files survive pod changes (ie: version upgrade)
        - name: data
          emptyDir: {}
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: filebeat
subjects:
  - kind: ServiceAccount
    name: filebeat
    namespace: kube-system
roleRef:
  kind: ClusterRole
  name: filebeat
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
  name: filebeat
  labels:
    k8s-app: filebeat
rules:
  "" indicates the core API group
  - apiGroups: [""]
    resources:
      - namespaces
      - pods
    verbs:
      - get
      - watch
      - list
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: filebeat
  namespace: kube-system
  labels:
    k8s-app: filebeat
---

业务使用

需要在被采集的pod template 中添加如下annotation字段

  • filebeat.harvest: “true” #只有当此annotation 设置为“true”时,日志才会被采集。
  • filebeat.index: “account” # 根据这个值定义我们在日志最终输出到ES中哪个索引,最终的名称是kubernetes-account-%{+YYYY.MM.dd}”
栗子如下:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: account
  namespace: base-platform
  labels:
    app.kubernetes.io/name: account
    app.kubernetes.io/component: base-platform
    app.kubernetes.io/part-of: account
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      product: base-platform
      app: account
      env: production
  template:
    metadata:
      annotations:
        filebeat.harvest: "true"
        filebeat.index: "account"
      labels:
        product: base-platform
        app: account
        env: production
    spec:
      imagePullSecrets:
        - name: docker-secret
      dnsConfig:
        options:
          - name: ndots
            value: '2'
          - name: timeout
......

存在的问题

Docker json-file driver splits log lines larger than 16k bytes
# https://github.com/moby/moby/issues/32923

在docker1.26版本以后,每行日志大小不超过16K,否则将被截断放入下一行,这将导致超过16K的日志不完整。filebeat master代码已经实现了日 志的拼接,将在7.0得到支持。https://www.elastic.co/guide/en/beats/filebeat/master/filebeat-input-docker.html

Android APP集成FCM介绍

1. 集成需求介绍

安卓系统的APP,如果涉及推送系统,一般会基于Socket在后台维持一个与APP服务器通信的长连接。但是这个长连接不稳定,当APP退到后台运行以后,系统会关闭APP并收回分配的资源,Socket长连接会被断掉,从而使APP失去与服务器的通信联系。

APP与服务器的通信断开之后,APP就不再能收到服务器发出的通知,导致用户不能及时收到相应的通知而错失及时处理问题的机会。

类似苹果推送,在安卓手系统上,谷歌提供推送服务FCM(Firebase Cloud Messaging)。FCM的运行依赖谷歌提供的Google Play Services服务,但是因为网络问题谷歌的服务无法在国内使用,中国大陆手机厂商都会把安卓手机上的谷歌相关的服务移除,结果在大陆地区就没有类似苹果设备统一的推送服务可用。

大陆地区有很多第三方的推送服务商,提供的服务大致分为两种,一种是手机厂商自己提供的推送服务,例如华为和小米;另一种是非手机厂商提供的消息推送服务,例如百度推送、极光推送、腾讯信鸽等,比较杂乱,效果也众说纷纭。

大陆地区有一个叫做统一推送联盟的组织,2017年由中国信息通信研究院泰尔终端实验室发起(包含:华为、小米、vivo、OPPO、三星等手机厂商;百度、阿里、腾讯、奇虎科技等互联网企业;个推、极光等第三方推送商),意在构建一个国内可用的,统一的推送服务入口,逐步解决国内安卓推送碎片化的局面。效果如何,需要持续关注。

2. FCM原理介绍

FCM集成的实现包括用于发送和接收的两个主要组件:

  • 一个受信任的环境,例如向用户APP发送消息的后台服务;
  • 一个接收消息的客户端应用;

FCM提供了连接两个组件的服务:

  • FCM的消息服务,连接用户消息服务器和用户设备。提供了HTTP和XMPP API供用户服务器管理和发送消息,还提供了Admin SDK支持开发一个基于移动设备的消息管理程序;

  • Google Play services,用于接收FCM消息服务发来的消息,分发到客户端SDK进行处理;

运行集成FCM的客户端的移动设备上需要运行安卓4.0及以上版本系统,并安装运行Google Play services服务15.0.0或者更高版本。Google Play services服务在终端设备上是常驻的,开启后不会被系统自动关闭,这样就能保证客户端在有网络连接的情况下,随时收到来自FCM消息服务器的通知和消息。如果移动终端没有安装Google Play services或者因为网络原因无法连接到谷歌的FCM消息服务器,就不能正常地接收到消息。

FCM的主要功能 功能描述
发送通知消息或数据消息 发送向用户显示的通知消息,或者发送数据消息并完全确定应用代码中会发生的情况
通用消息定位 使用以下三种方式中的任意一种将消息分发到客户端应用:分发至单一设备、分发至群组设备、分发至订阅特定主题的设备。
从客户应用发送消息 通过FCM可靠而省电的连接通道,将确认消息、聊天消息及其他消息从设备发回至服务器

FCM可以发送通知类消息和数据消息两种,这两种消息的有效负载上线均为4KB,数据中的Token是指后面要说到的注册令牌。

  • 通知类消息负载
{
  "message":{
    "token":"...",
    "notification":{
      "title":"Portugal vs. Denmark",
      "body":"great match!"
    }
  }
}
  • 数据类消息负载
{
  "message":{
    "token":"...",
    "data":{
      "Nick" : "Mario",
      "body" : "great match!",
      "Room" : "PortugalVSDenmark"
    }
  }
}

3. FCM和APP内推送的协调

应用在后台运行时,通知类消息负载会被传递到通知面板;应用在前台运行时,通知类型的消息负载不会被传递到通知面板,APP可以在FirebaseMessagingService中定义的回调函数onMessageReceived中处理接收到的消息负载,包括通知类型负载和数据类型的负载;数据类型的消息负载不论在前后台运行,都可以在这里接收处理。

如果希望对FCM展示的通知有点击效果,例如点击某个通知后可以调起某个APP内的界面或者功能,可以给消息同时加上通知类型负载和数据类型负载,通知类型负载会被显示在通知面板中,数据类型的负载可以在点击事件后,在启动器Intent的extras中获取处理。

集成FCM就要在APP内的推送和FCM之间就需要做一个协调,避免出现一个用户通知在移动设备上被展示两遍的现象,一遍来做FCM,一遍来自APP内推送通道。

建议的方式是,只对通知面板的显示做协调处理,对APP内功能性的弹出式提醒或或静默式的触发操作依旧只在APP内推送通道处理,既FCM只负责传递需要在通知面板展示的通知类消息推送。

APP启动设置默认关闭FCM通道,在Application初始化过程中检查Google Play Services的可用性,一旦确定打开FCM就关闭APP内推送通道的Notification功能。

4. 集成流程介绍

  • 第一步,要到Firebase控制台申请创建APP,按照下面的的步骤,可以得到一个名为google-services.json的配置文件,把这个文件下载并放在工程内app/目录下。




  • 第二步,配置Google Services插件,向根级build.gradle文件中添加规则,以引入google-services插件和Google的maven仓库:

buildscript {
    // ...
    dependencies {
        // ...
        classpath 'com.google.gms:google-services:4.0.1' // google-services plugin
    }
}
allprojects {
    // ...
    repositories {
        // ...
        maven {
            url "https://maven.google.com" // Google's Maven repository
        }
    }
}
  • 第三步,在app/build.gradle的底部添加apply plugin代码,启用google-services插件,并添加FCM所需要的库

需要及时更新firebase-core和firebase-messaging,否则可能会收不到FCM服务器发来的消息。

apply plugin: 'com.android.application'
android {
  // ...
}
dependencies {
  // ...
  compile 'com.google.firebase:firebase-core:16.0.1'
  compile 'com.google.firebase:firebase-messaging:17.0.0'
}
// 虽然通常把apply放在文件顶部,但在文档中明确要求放在文件的底部,就放在底部
apply plugin: 'com.google.gms.google-services'
  • 第四步,在应用清单中设置通知面板图标和颜色并设置FCM默认不启动,ic_notification图片不宜过大,过大的图片在一些手机上显示不出来,建议100*100左右合适,不要留边框或者边缘透明区域;
<meta-data
    android:name="com.google.firebase.messaging.default_notification_icon"
    android:resource="@drawable/ic_notification" />
<meta-data
    android:name="com.google.firebase.messaging.default_notification_color"
    android:resource="@color/color_src" />
<meta-data android:name="firebase_messaging_auto_init_enabled"
    android:value="false" />
  • 第五步,APP初始化过程中检查Google Play Services的可用性,并决定是否开启FCM,如果开启则根据“协调”中的建议或者自选的逻辑关闭APP内推送通知的Notification功能。
// 示例程序,这个方法是写在Application类中的,所以这里的this指的是Application实例对象
private void checkGoogleService() {
    final int available = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this);
    switch (available) {
        case ConnectionResult.SUCCESS:
            FirebaseMessaging.getInstance().setAutoInitEnabled(true);
            // TODO:对APP推送通知的处理
            break;
        case ConnectionResult.SERVICE_MISSING:
            Log.d(TAG,"check google service service missing");
            break;
        case ConnectionResult.SERVICE_UPDATING:
            Log.d(TAG,"check google service service updating");
            break;
        case ConnectionResult.SERVICE_DISABLED:
            Log.d(TAG,"check google service service disabled");
            break;
        case ConnectionResult.SERVICE_INVALID:
            LogEx.d(TAG,"check google service service invalid");
            break;
        case ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED:
            Log.d(TAG,"check google service version too old");
            ToastUtil.showLENGTH_LONG("Google play service need to update");
            break;
        default:
            Log.d(TAG,"check google service default: available = " + available);
            break;
    }
}
  • 第六步,注册令牌。FCM消息服务器是通过一个叫注册令牌的字符串来定位设备的,APP初次启动时,FCM SDK会为客户端应用生成一个注册令牌,客户端应用需要获取到这个注册令牌并发送到APP服务器并和当前APP用户关联保存,当APP服务器需要向指定用户发送通知消息,需要检索到与用户关联保存的注册令牌,通过这个注册令牌向FCM消息服务器发送通知消息,FCM消息服务器通过这个注册令牌,定位移动设备,并发送通知消息。

注册令牌在以下几种情况会有变更:

    1. 通过FirebaseInstanceId.getInstance().deleteToken()主动删除注册令牌
    1. 用户卸载重装APP
    1. 用户清除应用数据
    1. 应用在新设备上恢复

以上可知,注册令牌跟客户端APP具体的登录用户是没有关联的。所以,在APP启动完成后要确认获取注册令牌并与用户关联保存到APP服务器,在用户退出客户端APP时,要通知APP服务器把关联保存的注册令牌删除,避免服务器错误地把已经退出的用户的通知消息,发送到无效的设备上。

监听注册令牌的生成,需要继承FirebaseInstanceIdService并在onTokenRefresh回调中使用FirebaseInstanceId.getInstance().getToken()方法获取并在本地持久化保存。

<service android:name="com.fcm.MyFireBaseInstanceIDService">
    <intent-filter>
        <action android:name="com.google.firebase.INSTANCE_ID_EVENT" />
    </intent-filter>
</service>
package com.fcm;
import com.google.firebase.iid.FirebaseInstanceId;
import com.google.firebase.iid.FirebaseInstanceIdService;
public class MyFireBaseInstanceIDService extends FirebaseInstanceIdService {
    @Override
    public void onTokenRefresh() {
        String token = FirebaseInstanceId.getInstance().getToken();
        // TODO: 本地持久化保存获取到的注册令牌
    }
}
  • 第七步,如果按照”协调”中的建议,FCM只处理通知类型的消息,这一步不需要。如果希望对FCM消息做更多的处理,需要继承PalFishFireBaseMessagingService并在onMessageReceived回调方法中拆解消息内容,根据APP内部协议进行相应的处理。
<service android:name="com.fcm.MyFireBaseMessagingService">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>
package com.fcm;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
public class MyFireBaseMessagingService extends FirebaseMessagingService {
    @Override
    public void onMessageReceived(RemoteMessage remoteMessage) {
        // TODO: 处理接收到的FCM消息
    }
    // 在某些情况下,FCM 可能不会传递消息。如果在特定设备连接 FCM 时,您的应用在该设备上的待处理消息过多(超过 100 条),
    // 或者如果设备超过一个月未连接到 FCM,就会发生这种情况。在这些情况下,您可能会收到对 FirebaseMessagingService.onDeletedMessages() 的回调。
    // 当应用实例收到此回调时,应会执行一次与您的应用服务器的完全同步。如果您在过去 4 周内未向该设备上的应用发送消息,FCM 将不会调用 onDeletedMessages()。
    @Override
    public void onDeletedMessages() {
    }
}

5. 集成过程中的问题

    1. 在app/build.gradle中引入firebase-core和firebase-messaging库时,遇到firebase-messaging版本不匹配,导致开启FCM功能FirebaseMessaging.getInstance().setAutoInitEnabled(true)时,出现问题:No virtual method zzcz(Z)V in class com.google.firebase.iid.FirebaseInstanceId,解决办法就是调整firebase-core和firebase-messaging的版本,两个库的版本可以在maven仓库中搜索。
    1. 检查Google Play Services可用性的方法GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)并不总是可靠的,例如中国大陆运行的手机,如果安装了Google Play Services服务,返回值也会是true,对于从中国大陆以外进入大陆地区的用户,会因为网络不通无法接收FCM消息,而启动时又依据这个判定打开FCM并关闭APP内推动通知,导致用户在一段时间内收不到任何通知。建议在APP设置中给一个FCM的开关,如果遇到这种情况,关闭FCM开关之后不做可用性检查。