STF [STF] 二次开发之办公机接入 STF 服务

Lucas · 2018年01月29日 · 最后由 menglinxi 回复于 2018年12月04日 · 492 次阅读
本帖已被设为精华帖!

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

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里面。

后记

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

附言 1  ·  2018年01月29日
  1. 实测有 bug,Provider 如果 reaper 的心跳检测 adb server 超时自动退出。
  2. 所以数据库落库落客户端 provider 信息的这个逻辑去掉了。
  3. 因为之前担心多台机器用同一个端口会有问题,目前看上去 FRP 会报错。
  4. 同时,当 provider 断线以后,用户可以自己在创建。
  5. 最终的 Swagger API 改成只接受请求创建 provider,其他啥也不做了。
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 24 条回复 时间 点赞

mark 一下。

恒温 将本帖设为了精华贴 01月29日 12:55

嗯,今年打算也做这个,到时候参考一下,mark 一下。

STF 本身是支持挂载远端机的,

比较好奇稳定性如何。

之前因为公司原因,主机和 provider 分开在两个大厦,provider 和主机之间掉线频率很高。

陈恒捷 回复

你这个场景非常好。目前我也在想。初步想可能是 reaper 的心跳超时后重启 provider,目前不敢定论,还没具体实施到这一步。或者修改 provider 的 on exit 事件。也不是很确定,也还是没有具体实践到这部分。恒洁老板如果有好的建议,欢迎指点。

bauul 回复

对啊,在 server 那边输入一条命令就可以了,没必要搞这么麻烦

Lucas 回复

建议可以先用原生的 provider 试试。provider 和 stf 主服务之间的网络稳定性还是有一定要求的,稳定性太差会导致掉线频繁,用起来很不爽。

我们其实也没有特意去解决,公司搬了之后大家都在一个办公楼,就没问题了。

我之前公司也是跨楼的,但网络也还好,主要还是看运维怎搭建的网吧。

Lucas #10 · 2018年01月30日 Author
0x88 回复

恩,你说的对。
做这么个包装,是为了让用户决定自己什么时候可连。
服务端也可以自动创建,不需要频繁登录服务器。
几百号人(可能拥有不止一台电脑)的管理成本也相对较大。
相当于自动化运维了。🤓

之前试过上海接入杭州,必须上行带宽给力,不然延迟的厉害,然并卵

Lucas #12 · 2018年01月30日 Author
mling 回复

恩,多谢前车之鉴。我还是走在了您这位巨人的肩膀上。

嗯,不同地区如果网络弄不好的话,会延迟很厉害

以前我也弄过类似的方案。当时自己手写了一个穿墙代理,穿墙代理的服务端收到请求后,自动启动一个 provider。provider 启动多了,有点耗资源。

Lucas #15 · 2018年01月31日 Author
codeskyblue 回复

好的,多谢,确实耗资源。目前您有什么好的方案么?不吝赐教。:-)

Lucas 回复

后来我就把这个服务关掉了

Lucas #17 · 2018年01月31日 Author
codeskyblue 回复

多谢。

匿名 #18 · 2018年02月03日

mark,有机会尝试一下方案二

郭丽丽 [该话题已被删除] 中提及了此贴 02月03日 21:06

楼主,jclinet 开源吗?

bauul 回复

这个怎么挂内网手机?

Lucas 回复

楼主,你这个公网挂载内网设备,运行效果如何?

Lucas #24 · 2018年02月11日 Author
menglinxi 回复

目前还可以。取决于网络带宽。目前还处于小规模运行阶段,还没有性能瓶颈问题。
provider 在 adb 断了之后进程未退出,年后回来看看,现在休假了。
其他凑合。

Lucas 回复

您好,我想问一下,如果 provider 在内网,连接部署在外网的 app 组件,那么手机对应的 device 进程启动的 websocket 服务端可以在外网上访问的到吗?

Lucas 回复

马上,5G 了,是不是,服务端部署,内网挂载手机,提供给远程,访问,会更方便呢?类似共享手机的概念。

simple 专栏文章:[精华帖] 社区历年精华帖分类归总 中提及了此贴 12月13日 14:44
simple [精彩盘点] TesterHome 社区 2018 年 度精华帖 中提及了此贴 01月07日 12:08
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册