被多个姑娘拒绝是一种什么体验?很荣幸,我体会到了。这种心路历程,对于我这么一个大龄青年,一开始心酸,现在是坦然的接受,同时煮几碗心灵鸡汤,告诉自己不管这特么看上去是多么糟糕,你也应该积极拥抱这个世界。

1. 前言

STF 部署之后,如何让北京、上海的设备进行共享成为了一个必然的需求。
然而这其中有很多不确定性:

  1. 怎样才能将设备接入呢?
  2. 接入后是否能够正常使用呢?
  3. 会不会因为接入的后某些不确定的因素导致二次开发失败呢? 带着这些疑问,接入需求进入了探索及研究阶段。

2. 原理

其实,我在之前的一篇文章[STF] 二次开发之批量安装做过 STF 拓扑结构的介绍,其中介绍过 Provider 这个概念。
现在回顾一下:

provider 用来连接 adb,然后为每个设备启动一个 worker process。之后接受并发送从这些 processors 传过了的命令。

provider 也表示你的宿主机,这个服务你部署在哪台机器上就代表是哪台机器接入,也即设备连在哪台主机上。另外,注意几个关键词连接adb,这说明 provider 只要给定 ADB 的 IP 地址和端口信息,他就能进行设备管理。然而,provider 这个服务有个问题,底层调用的是 adbkit 的 createClient 方法,这个只能连接一台主机。聪明的同学肯定立刻意识到,启动多个 provider 不就可以了么?没错,当前阶段正式采用这种方案。
那么,provider 服务应该在哪里呢?

  1. 服务端创建?
  2. 办公室的主机创建?

关于答案,我认为两种都可以。理由是 STF 的模块(服务)架构相对比较独立,就想 deployment.md 文档中描述的一样,可分布式部署。然而,目前我采用的是方案一,在于相对简便。方案二的问题在于 windows 安装 STF 会有各种问题,不是很顺利。不符合快速迭代的原则,想让用户能用起来。

3. Provider 原理解释

首先要知道 provider 是一个可以独立工作的模块,当然有依赖,具体依赖什么自行翻看deployment.md文档。
接下来看看 provider 被启动时的代码,在lib/cli/local/index.js的 266 行。

  // provider
, procutil.fork(path.resolve(__dirname, '..'), [
      'provider'
    , '--name', argv.provider
    , '--min-port', argv.providerMinPort
    , '--max-port', argv.providerMaxPort
    , '--connect-sub', argv.bindDevPub
    , '--connect-push', argv.bindDevPull
    , '--group-timeout', argv.groupTimeout
    , '--public-ip', argv.publicIp
    , '--storage-url'
    , util.format('http://localhost:%d/', argv.port)
    , '--adb-host', argv.adbHost
    , '--adb-port', argv.adbPort
    , '--vnc-initial-size', argv.vncInitialSize.join('x')
    , '--mute-master', argv.muteMaster
    ]
    .concat(argv.allowRemote ? ['--allow-remote'] : [])
    .concat(argv.lockRotation ? ['--lock-rotation'] : [])
    .concat(!argv.cleanup ? ['--no-cleanup'] : [])
    .concat(argv.serial))

provider 启动时,会传递各个命令行参数,这些参数是支持分布式部署的外部接口。
挑几个我用到的说一下。

  1. --name:这个参数就是设备所在机器的名称,可以自己定义。如果部署在用户的机器上,默认获取的是用户的计算机名称。目前的方案是创建一个客户端,通过InetAddress获取计算机名。
  2. --connect-sub:这个是丙丁设备的 Pub 端口,用来接收数据。(可以通过 ps 命令获取 local provider 的参数信息,拷贝帖进去就行了。)
  3. --connect-push:这个是设备的 Pull 端口发送数据。
  4. --public-ip:这个就是主机的公共 IP 了,既然在服务上,那就是服务器的 ip 地址。
  5. --storage-url:这个要注意,不能填http://localhost,这个是在浏览器端进行跳转到服务端的。所以要填服务器的 ip 地址,http://192.168.1.8/
  6. --adb-host:这个是 adb 的主机,这里没有填主机,填的是127.0.0.1,后面会说到,因为服务器不能访问办公及本机,需要网络穿透。不过,如果采用方案二,以我对 provider 的理解,我认为可以是真正主机的 ip 地址。
  7. --adb-port:这个就是 adb 所在机器的 adb 端口了,当然这里面也是通过网络穿透,并非是实际的5037端口
  8. --allow-remote:很容易理解,是否允许远程接入。

通过这些参数,能获得什么信息?
没错,只要知道个参数的地址,provider 就会自动将设备接入整个系统,它根据你传进的 sub, pub, adb-host, port 寻找相应的 zmq 及 adb-server。
所以,一台主机接入就启动一个 provider 服务,一个 provider 可以支持多个设备连接,每个 provider 为每个设备启一个 worker process。(不理解的,反复阅读文档吧,读书百遍,其义自现)

4. 关于网络穿透

4.1 为什么需要网络穿透?

前文已述,因为服务器不能访问办公室机器,需要中间层做一个代理,即所谓的网络穿透,可以将访问通过代理连入办公机。

4.2 网络穿透的原理

  1. 网络穿透用的是 frp,这个可以自己在github上找到
  2. frp 分为服务端和客户端。
  3. 服务端 bind 端口即可,这是暴露给客户端链接用的。服务端的程序是 frps,服务端的配置文件是 frps.ini,修改 frps.ini。
# frps.ini
[common]
bind_port = 7000
  1. 客户端连入服务端,指定服务端的 ip 地址,端口,以及 tcp 的设置。客户端程序是 frpc,frpc.ini 是配置文件。
[ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 5037
remote_port = 6668
  1. type:连接类型,标识 tcp,我们通过 tcp 与服务端通信。
  2. local_ip:这是本机的 ip 地址。
  3. local_port:实际需要监听的端口,因为 adb 的端口是 5037,所以我们监听这个端口。
  4. remote_port:这个是 STF 服务端要调用的端口,通过这个端口连接,区分是机器的连入。 通过以上分析,思路基本就有了。

5. 接入方案

通过 FRP 做为网络中转,客户端通过 FRP 连入服务器,服务器接受到请求后自动创建 provider。

6. 改造

6.1 客户端

  1. 保存修改网路穿透,API 等配置信息。
  2. 启动本地 frp 服务。
  3. 连接远程服务器。

6.2 STF 服务端

  1. 提供一个 api 借口。
  2. 接受客户端请求
  3. 为客户端创建 provider。

7. 改造细节

7.1 客户端

7.1.1 客户端界面展示

7.1.2 客户端架构,用 javaFX 开发,包含了三个平台的 FRP。

7.1.3 界面布局代码。

<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Separator?>
<?import javafx.scene.control.TextArea?>
<GridPane fx:controller="sample.Controller"
          xmlns:fx="http://javafx.com/fxml" alignment="center" hgap="10" vgap="10">
    <Text text="Configuration" GridPane.columnIndex="0" GridPane.rowIndex="0" GridPane.columnSpan="2" />

    <Label text="Access Token:"  GridPane.rowIndex="1" GridPane.columnIndex="0" />
    <TextField fx:id="accessTokenTextField" GridPane.columnIndex="1" GridPane.rowIndex="1" />

    <Label text="API:" GridPane.columnIndex="0" GridPane.rowIndex="2" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="2" fx:id="apiTextField"/>

    <Label text="Server Address:" GridPane.columnIndex="0" GridPane.rowIndex="3" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="3" fx:id="serverAddrTextField"/>

    <Label text="Server Port:" GridPane.columnIndex="0" GridPane.rowIndex="4" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="4" fx:id="serverPortTextField"/>

    <Label text="Type:" GridPane.columnIndex="0" GridPane.rowIndex="5" />
    <TextField fx:id="typeTextField" GridPane.columnIndex="1" GridPane.rowIndex="5" text="tcp" editable="false" disable="true"/>

    <Label text="Local IP:" GridPane.columnIndex="0" GridPane.rowIndex="6" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="6" fx:id="localIpTextField"/>

    <Label text="Local ADB Port:" GridPane.columnIndex="0" GridPane.rowIndex="7" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="7" fx:id="localAdbPortTextField"/>

    <Label text="Remote Port:" GridPane.columnIndex="0" GridPane.rowIndex="8" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="8" fx:id="remotePortTextField"/>

    <HBox spacing="10" alignment="BOTTOM_RIGHT" GridPane.columnIndex="1" GridPane.rowIndex="9">
        <Button text="#1 Save Configuration" onAction="#handleSaveConfigAction" />
        <Button text="#2 Start Frp Client" onAction="#handleStartFrpClientAction" />
        <Button text="#3 Connect to Server" onAction="#handleConnectAction" />
    </HBox>

    <Label text="Config Status: " GridPane.columnIndex="0" GridPane.rowIndex="10" />
    <Text fx:id="configStatusText" GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.halignment="RIGHT" GridPane.rowIndex="10" />

    <Label text="Frp Status: " GridPane.columnIndex="0" GridPane.rowIndex="11" />
    <Text fx:id="frpStatusText" GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.halignment="RIGHT" GridPane.rowIndex="11" />

    <Label text="Server Status: " GridPane.columnIndex="0" GridPane.rowIndex="12" />
    <TextField fx:id="serverStatusTextField" GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.halignment="RIGHT" GridPane.rowIndex="12" />
</GridPane>

7.1.4 控制器代码(其他代码略)

package sample;

import frp.Common;
import frp.FRP;
import frp.Ssh;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.TextField;
import javafx.scene.text.Text;
import stf.STF;
import util.PlatformUtil;
import util.PropertyUtil;

import java.net.URL;
import java.util.ResourceBundle;

public class Controller implements Initializable {
    // UI Fields
    @FXML
    private TextField accessTokenTextField;
    @FXML
    private TextField apiTextField;
    @FXML
    private TextField serverAddrTextField;
    @FXML
    private TextField serverPortTextField;
    @FXML
    private TextField typeTextField;
    @FXML
    private TextField localIpTextField;
    @FXML
    private TextField localAdbPortTextField;
    @FXML
    private TextField remotePortTextField;
    @FXML
    private Text configStatusText;
    @FXML
    private Text frpStatusText;
    @FXML
    private TextField serverStatusTextField;
    // Settings
    private PropertyUtil propUtil;
    private FRP frp;
    private Common commonSettings;
    private Ssh sshSettings;
    // STF
    private STF stf;

    public Controller() {
    }

    public void handleConnectAction(ActionEvent actionEvent) {
        try {
            saveAccessToken();
            saveApi();

            String api = propUtil.getApi();
            String json = "{\n"
                    + "\"computerName\": \"${computerName}\",\n"
                    + "\"userName\": \"${userName}\",\n"
                    + "\"remotePort\": ${remotePort}\n"
                    + "}";

            json = json.replace("${computerName}", PlatformUtil.getComputerName() == null ? "unknown" : PlatformUtil.getComputerName())
                    .replace("${userName}", PlatformUtil.getUserName() == null ? "unknwon" : PlatformUtil.getUserName())
                    .replace("${remotePort}", sshSettings.getRemotePort());

            String tmp = stf.connect(propUtil.getAccessToken(), api, json);
            serverStatusTextField.setText(tmp);
        } catch (Exception e) {
            serverStatusTextField.setText(e.getMessage());
        }
    }

    public void handleSaveConfigAction(ActionEvent actionEvent) {
        String serverAddr = serverAddrTextField.getText();
        String serverPort = serverPortTextField.getText();
        String type = typeTextField.getText();
        String localIp = localIpTextField.getText();
        String localAdbPort = localAdbPortTextField.getText();
        String remotePort = remotePortTextField.getText();

        commonSettings.setServerAddr(serverAddr);
        commonSettings.setServerPort(serverPort);

        sshSettings.setType(type);
        sshSettings.setLocalIp(localIp);
        sshSettings.setLocalPort(localAdbPort);
        sshSettings.setRemotePort(remotePort);

        try {
            saveAccessToken();
            saveApi();
            frp.save();
            configStatusText.setText("Save config succeed!");
        } catch (Exception e) {
            configStatusText.setText("Save config failed!");
        }
    }

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        stf = new STF();

        propUtil = new PropertyUtil();
        accessTokenTextField.setText(propUtil.getAccessToken());
        apiTextField.setText(propUtil.getApi());

        frp = new FRP();
        commonSettings = frp.getCommonSettings();
        sshSettings = frp.getSshSettings();

        serverAddrTextField.setText(commonSettings.getServerAddr());
        serverPortTextField.setText(commonSettings.getServerPort());

        localIpTextField.setText(sshSettings.getLocalIp());
        localAdbPortTextField.setText(sshSettings.getLocalPort());
        remotePortTextField.setText(sshSettings.getRemotePort());
    }

    public void handleStartFrpClientAction(ActionEvent actionEvent) {
        try {
            frp.connect();
            frpStatusText.setText("FRP Client is running!");
        } catch (Exception e) {
            frpStatusText.setText("failed! " + e.getMessage());
        }
    }

    private void saveApi() {
        String api = apiTextField.getText().trim();
        propUtil.setApi(api);
    }

    private void saveAccessToken() {
        String accessToken = accessTokenTextField.getText().trim();
        propUtil.setAccessToken(accessToken);
    }
}

8. 服务端改造

  1. 服务端主要通过改造 swagger 生成控制器。
  2. 同时修改 stf 的 db api,创建 providers 表用来存储为用户创建 provider 的信息。

8.1 swagger 的改造

8.1.1 api_v1.yaml 的修改

/providers:
  x-swagger-router-controller: provider
  post:
    summary: Create Provider
    description: Create one provider for each connection from client
    operationId: createProvider
    tags:
      - providers
    parameters:
      - name: clientInfo
        in: body
        description: Provider for client to create
        required: true
        schema:
          $ref: '#/definitions/CreateProviderPayload'
    responses:
      "200":
        description: Provider Created
        schema:
          $ref: "#/definitions/ProviderResponse"
    security:
      - accessTokenAuth: []

8.1.2 控制器的改造

新建一个 provider.js 控制器,创建如下代码。

var _ = require('lodash')
var Promise = require('bluebird')
var proc = require('child_process')

var procutil = require('../../../util/procutil')
var path = require('path')
var util = require('util')
var dbapi = require('../../../db/api')
var logger = require('../../../util/logger')

var log = logger.createLogger('api:controllers:provider')

module.exports = {
  createProvider: createProvider
}

function createProvider(req, res) {
  var computerName = req.body.computerName
  var userName = req.body.userName
  var remotePort = req.body.remotePort

  dbapi.loadProvider(remotePort)
    .then(function(provider) {
      if(provider) {
        return res.status(200).json({
          code: 1
        , message: 'Remote port already in use!'
        , provider: provider
        })
      }

      dbapi.saveProvider({
        remotePort: remotePort
        , computerName: computerName
        , userName: userName
      })
        .then(function(provider) {
          procutil.fork(path.resolve(__dirname, '..'), [
            'provider'
            , '--name', computerName
            , '--min-port', '7400'
            , '--max-port', '7700'
            , '--connect-sub', 'tcp://127.0.0.1:7114'
            , '--connect-push', 'tcp://127.0.0.1:7116'
            , '--group-timeout', '900'
            , '--public-ip', '192.168.177.15'
            , '--storage-url'
            , util.format('http://192.168.177.15:%d/', '80')
            , '--adb-host', '127.0.0.1'
            , '--adb-port', remotePort
            , '--vnc-initial-size', '600x800'
            , '--mute-master', 'never'
            , '--allow-remote'
          ])

          return res.status(200).json({
            code: 0
            , message: 'Established connection!'
            , provider: provider
          })
        })
    })
}

8.2 数据库的改造

数据库的改造比较简单

8.2.1 修改该 tables.js 代码,用来创建 providers 表。

, providers: {
    primaryKey: 'remotePort'
  }

8.2.2 修改该 db/api.js 代码,用来插入及查询 provider 信息。

dbapi.loadProvider = function(remotePort) {
  return db.run(r.table('providers').get(remotePort))
}

dbapi.saveProvider = function(provider) {
  return db.run(r.table('providers').insert({
    remotePort: provider.remotePort
  , computerName: provider.computerName
  , userName: provider.userName
  }))
}
  1. 这样,api 改造就完毕了。客户端只要调用http://xxx.xxx.xxx/api/v1/providers就能创建 provider,支持办公机接入。
  2. 由于接的是 STF 的 security,所以访问前需要用户获取 accessToken。
  3. 具体位置Settings->Keys里面。

后记

本文一开始说了两个方案,个人希望尝试一下方案二,这样就可以减轻服务端的压力,办公机器也撑得起这点进程开销。后续再改吧。
另,如果需要客户端完整代码的话,我会贴在评论区里面。


↙↙↙阅读原文可查看相关链接,并与作者交流