记为Electron应用添加上自动更新

electron

在最近的一个项目中用Electron包装了一个网站,生成了一个桌面应用。虽说渲染进程内容是被包装的网站,而网站的更新很灵活,但也不排除Electron主进程有时也会有需要更新的时候,因此,为Electron加上自动更新吧。

首先,我们要引入 autoUpdater  。electron里有一个 autoUpdater  但是这里我们使用electron-updater库里的autoUpdater。(PS:和原生autoUpdater的差别

先是把 electron-updater 作用应用依赖去安装。
yarn add electron-updater

配置package.json中的publish设置

...
"build": {
    "productName": "我的Electron应用",
    "copyright": "Copyright © 2019 Lunastudio.cn",
    "appId": "cn.lunastudio.myapp",
    "publish": {
      "provider": "generic",//这里我们使用普通更新提供器,我这里用的是腾讯COS
      "url": "https://upload-123456.cos.ap-chengdu.myqcloud.com/myApp/update/" 
      //这里的地址就是cos的某个存放安装包的文件夹的目录地址,我们后边生成的安装包要扔到这上边来
    },
    "win": {
      "target": "nsis"
    },
    "nsis": {
      "perMachine": true
    }
  },
...

然后再去主程序中来写autoUpdater的相关逻辑

const {app, BrowserWindow, globalShortcut, session, dialog} = require('electron')
const {autoUpdater} = require("electron-updater")
let win;
//保持单窗口模式。只有第一个窗口的应用才会有返回单例锁
const shouldQuit = app.requestSingleInstanceLock();
if (!shouldQuit) {
//没有正常返回的非第一个窗口的应用程序,就直接退出,以免多开
    app.quit()
}

function createWindow() {
    // 创建浏览器窗口。
    win = new BrowserWindow({
        webPreferences: {
            nodeIntegration: true
        }
    })
//使用electron-log,方便查看更新日志。
//日志被存放在
//on Linux: ~/.config/<app name>/log.log
//on macOS: ~/Library/Logs/<app name>/log.log
//on Windows: %USERPROFILE%\AppData\Roaming\<app name>\log.log
 const log = require("electron-log")
    log.transports.file.level = "debug"
    autoUpdater.logger = log
    autoUpdater.allowDowngrade = true;
    autoUpdater.autoInstallOnAppQuit = true;
//窗口一建立,就开始检查是否需要更新
    autoUpdater.checkForUpdatesAndNotify()

win.loadURL('https://mysite.com/index.html')
}
app.on('ready', createWindow)

//可以通过autoUpdater.on()这种事件监听的方式来处理,也可以直接用signals回调的方式,这里我用的是后者
autoUpdater.signals.progress(({total,
    delta,
    transferred,
    percent,
    bytesPerSecond}) => {
//显示加载进度条
    showProgressBar(percent);
})

autoUpdater.signals.updateDownloaded(({version, releaseNotes, releaseName}) => {

//在下载完后,显示一个对话框,提示安装
    const dialogOpts = {
        type: 'info',
        buttons: ['开始安装'],
        title: '程序更新',
        message: `v${version}程序已经下载好了,点击“重启安装”`
    }
    dialog.showMessageBox(dialogOpts, (response) => {
       autoUpdater.quitAndInstall(true)
    })
})

//显示进度条
let progressBar, progressBarReady
function showProgressBar(progress) {
    if (!progressBar) {
        progressBar = new ProgressBar({
            //对于可以指明加载进度值的,要把这里的值设为false
            indeterminate: false,
            title: '正在更新新版本',
            text: '新版本下载中...',
            closeOnComplete: true,
            abortOnError: true,
            style: {
                text: {
                    "overflow-y": 'hidden'
                }
            },
            browserWindow: {
                modal: true,
                parent: null,
                resizable: false,
                closable: false,
                minimizable: false,
                maximizable: false,
                //注意这里的配置,如果不写出来,会导致进度条显示不出来
                //@see https://github.com/AndersonMamede/electron-progressbar/issues/10
                webPreferences: {
                    nodeIntegration: true
                }
            }
        });

        progressBar
            .on('ready', function () {
                progressBarReady = true;
            })
            .on('completed', function () {
                console.info(`completed...`);
                progressBar.text = '下载完成,即将安装!';
                progressBarReady = false;
            }).on('progress', function (value) {
                value = Math.round(value);
                progressBar.text = `新版本下载中...${value}%`;
            });
    }
    //更新进度值
    progressBarReady && (progressBar.value = progress);
}
 

主程序差不多就是这个样子。package.json中写一个生成安装包的运行脚本

...
  "scripts": {
    "start": "electron index.js",
    "installer_build_win32": "electron-builder --win --ia32",
  }
...

当成运行npm run installer_build_win32的时候,就会在dist目录中生成对应版本的安装程序和blockmap文件,以及一个latest.yml文件。这三个文件我们都需要上传到上边提到的腾讯云COS文件夹中。当主程序在启动时执行app.requestSingleInstanceLock方法时,会向package.json中的publish里设定的目录中去找latest.yml文件。
如:https://upload-123456.cos.ap-chengdu.myqcloud.com/myApp/update/latest.yml
对比发现版本号有更新,就会自动开始下载新的安装文件。所以我们这里为了方便一点,需要在生成安装包后,立即把那三个新版本的相关文件上传上去。因为我使用的COS,所以就结合COS的SDK写了一个简单的脚本。

//uploader.js
const COS = require('cos-nodejs-sdk-v5');
const md5File = require('md5-file/promise')
const fs = require('fs');



const config = {
    SecretId: <COS_SECRET_ID>,
    SecretKey: <COS_SECRET_KEY>,
    Bucket: <COS_BUCKET>,
    Region: <COS_REGION>,
    ProgressInterval: 1000
};

async function uploadFile(file) {
    let cos = new COS(config);
    var fileStat = fs.statSync(file);
    var md5str = await md5File(file);
    if (uploadLog[md5str]) {
        return Promise.resolve(file);
    }

    uploadLog[md5str] = 1;
//上传到myApp/update目录下
    let key ='myApp/update/' + path.basename(file)

    return new Promise(resolve => {
        cos.putObject({
            Bucket: config.Bucket, 
            Region: config.Region,
            Method: 'PUT',
            Key: key,
            Body: fs.createReadStream(file),
            ContentLength: fileStat.size,
            onProgress: (progressData) => {
                console.log('uploading...', path.basename(file), Math.round(progressData.percent * 100) + '%')
            }
        }, function (err, data) {
            resolve(file);
        });
    })

}

function uploadFiles(files) {
    (files.map(uploadFile)).forEach(p => p.then(function (file) {
        console.log('uploaded file:', file);
        require("fs").writeFileSync('uploadLog.json', JSON.stringify(uploadLog), {encoding: 'utf-8'})
    }))
}


const {version} = require('./package.json');
const uploadLog = require('./uploadLog.json')

const path = require('path')
let files = [];
//新版本的三个关键文件
files.push(path.resolve(__dirname, './dist/latest.yml'))
files.push(path.resolve(__dirname, `./dist/myApp Setup ${version}.exe`))
files.push(path.resolve(__dirname, `./dist/myApp Setup ${version}.exe.blockmap`))
//开始上传
uploadFiles(files);

最后就把“installer_build_win32”: “electron-builder –win –ia32”改成“installer_build_win32”: “electron-builder –win –ia32″ && node ./uploader.js”
这样就可以很方便地在打包完成就自动上传到COS服务器了。

最后,当程序启动时,发现有新版本时,就会提示

记一次接口服务器网络请求的优化过程

最近做了一个提供给中学生使用的背单词的小型WEB项目
部署在一个1G CPU 2G内存 2M宽带的 腾讯云容器服务上
接口所在的服务,分了两个实例,各分配了0.1个CPU,196M的内存
人数不多的情况下,一切都正常,CPU内存并没有吃紧的情况,相反还很宽裕
但是同时上百个人在线的时候,发现部分学生出现加载卡顿的情况,初步分析瓶颈卡在网络带宽上

目前项目中用到的一些静态图片,比如单词配图,单词发音等文件,分离放到了对象存储服务器COS中.
主域名通过腾讯的CDN做了加速.因为前端工程中还有相当多的静态文件(css,js,html等)
预期的策略是,全部不缓存,但静态文件做30天缓存.

错误的缓存过期配置

通过CDN的日志分析,发现大量的静态文件也没有HIT,回过头查看发现,CDN的缓存优先级弄反了
根据CDN文档说明
匹配是从上一条到最后一条,下层覆盖上层,而不是匹配即中止的模式
所以我们应该把全部不缓存这一大条件先设到最上,然后是针对静态文件做30天的缓存
值得注意的是,这里的静态文件,即前端工程的JS,CSS文件等,在前端工程的编译设置里,务必处理成hash结尾的,如果不带hash区别内容的变化就加CDN缓存,很容易就陷入缓存陷阱而无法正常刷新变更了的内容。

修改后的缓存过期配置

通过这一优化,回源的数量大量减少

缓存顺序调整前后的回源请求变化

通过上边的优化,把静态文件从源服务器上松绑了。很大程度上解决了2MB小水管带来的宽带瓶颈问题。通过CDN日志的进一步分析发现,很多接口在重复请求的时候,依然从源服务器上处理并返回了,即使那些接口的结果内容并没有什么改变,这里就要引入ETag缓存机制了,来专门处理这种内容没发生变化的请求的优化。

项目的接口服务框架采用的是基于 Expressnest.js(也可以基于 Fastify ),而Express默认有开启弱类型的etag,这样根据每次的返回内容,会用md5方式计算一个字段,通过响应头返回来

// express\lib\application.js#75行

app.defaultConfiguration = function defaultConfiguration() {
  var env = process.env.NODE_ENV || 'development';

  // default settings
  this.enable('x-powered-by');
  this.set('etag', 'weak');
  ...

// express\lib\application.js#366行

app.set = function set(setting, val) {
  if (arguments.length === 1) {
    // app.get(setting)
    return this.settings[setting];
  }

  debug('set "%s" to %o', setting, val);

  // set value
  this.settings[setting] = val;

  // trigger matched settings
  switch (setting) {
    case 'etag':
      this.set('etag fn', compileETag(val));
      ...


// express\lib\request.js#463行
var fresh = require('fresh');
...463行
defineGetter(req, 'fresh', function(){
var method = this.method;
var res = this.res
var status = res.statusCode
// GET or HEAD for weak freshness validation only
if ('GET' !== method && 'HEAD' !== method) return false;
// 2xx or 304 as per rfc2616 14.26
if ((status >= 200 && status < 300) || 304 === status) {
return fresh(this.headers, {
'etag': res.get('ETag'),
'last-modified': res.get('Last-Modified')
})
}
return false;
});

虽然说服务端因为nestjs基于express,express又通过fresh实现了ETag的if-not-match请求头的比较功能
但是使用axios的前端,并没有天然的实现etag的数据缓存,以及if-not-match的请求头的自动添加
因此有必要扩展一下axios,使其能够读取服务端返回的ETag头,并缓存返回的内容,然后请求的时候,根据请求的地址,查找ETag值,并添加到请求头if-not-match中。

//AxiosEtagCache.ts
import * as localForage from "localforage";
import axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from 'axios';

function isCacheableMethod(config: AxiosRequestConfig) {
  return ~['GET', 'HEAD'].indexOf(config.method.toUpperCase());
}
const stripSlash = (str) => str[str.length - 1] === '/' ? str.slice(0, str.length - 1) : str;
function getUUIDByAxiosConfig(config: AxiosRequestConfig) {
  var paramStr = "";
  var prefix = "";
  if (config.url.indexOf(config.baseURL) == -1) {
    prefix = stripSlash(config.baseURL)
  }
  if (config.params) {
    Object.keys(config.params).forEach(k => {
      paramStr += k + '=' + config.params[k] + '&';
    })
  }
  return prefix + config.url + "?" + paramStr;
}

async function getCacheByAxiosConfig(config: AxiosRequestConfig) {
  return await localForage.getItem(getUUIDByAxiosConfig(config));
}

async function requestInterceptor(config: AxiosRequestConfig) {
  if (isCacheableMethod(config)) {
    const uuid = getUUIDByAxiosConfig(config);
    const lastCachedResult: any = await localForage.getItem(uuid);
    if (lastCachedResult) {
      config.headers = {...config.headers, 'If-None-Match': lastCachedResult.etag};
    }
  }
  return config;
}

async function responseInterceptor(response: AxiosResponse) {
  if (isCacheableMethod(response.config)) {
    const responseETAG = response.headers.etag;
    if (responseETAG) {
      await localForage.setItem(getUUIDByAxiosConfig(response.config), {etag: responseETAG, value: response.data});
    }
  }
  return response;
}

async function responseErrorInterceptor(error: AxiosError) {
  if (error.response.status === 304) {
    const getCachedResult: any = await getCacheByAxiosConfig(error.response.config);
    if (!getCachedResult) {
      return Promise.reject(error);
    }
    const newResponse = error.response;
    newResponse.status = 200;
    newResponse.data = getCachedResult.value;
    return Promise.resolve(newResponse);
  }
  return Promise.reject(error);
}

export function resetCache() {
  localForage.clear();
}

export default function axiosETAGCache(config?: AxiosRequestConfig) {
  const instance = axios.create(config);
  instance.interceptors.request.use(requestInterceptor);
  instance.interceptors.response.use(responseInterceptor, responseErrorInterceptor);

  return instance;
}
//request.js
import axiosETAGCache from './AxiosEtagCache.ts';
// 通过axiosEtagCache创建axios实例
const service = axiosETAGCache({
  baseURL: process.env.VUE_APP_API_PREFIX',//请求头变量,方便在不同的环境切换
  timeout: 10000, // 请求超时时间  
})
//然后就可以通过service.get()这样的方式来正常请求接口,这时接口就会自动带上if-not-match请求头了

其中保存数据用到了localforage这个库,这是一个很强大的客户端缓存解决方案

这里的原理就是为axio做一个请求和响应的拦截器,并根据页面地址(含参数)为索引,将ETag以及返回的数据,存在本地缓存中
后续请求的时候,就先查找一下本地有没有缓存,如果有,就带上if-not-match请求头字段,服务端用来比较字段和内容生成的etag是否一致,如果一致就直接304返回。
虽然依然需要请求服务端,但是通过ETag比对的机制,可以有效的减少没有必要的数据传输,节省带宽进而提升用户体验。

通过ETag实现了单个client与server之间的缓存问题,但如果出现大量用户的访问,服务器还是会一个一个地返回数据
针对这种情况,对于一些公用的变动频度不高的接口数据(实时性要求不强的排名数据,可能会更新的单词教材),可以考虑把缓存前移到CDN级,让CDN去缓存接口请求的结果,就可以避免每个用户都去回源问服务器要数据的问题
但是想借由CDN的路径匹配来建立缓存策略,却发现腾讯云CDN的全路径匹配必需要有一个明确的文件后缀,以匹配静态文件

腾讯云CDN全路径文件不支持通配符结尾

因此我计划将现有的各个接口统一都加上一个/data.json这样的后缀,用来模拟静态文件,以符合腾讯云CDN的规则。
比如 /user/login » /user/login/data.json
要通篇逐个去在数百个接口后添加/data.json是很不方便的一件事情,因此,考虑用点hack的办法来解决这个问题
在nestjs中,通过@Get这样的装饰器,顺藤摸瓜,找到了关键词PATH_METADATA,再通过PATH_METADATA进一步找到了最后应用路由的关键方法
@nestjs\core\router\router-explorer.js中的 applyCallbackToRouter(router, pathProperties, instanceWrapper, moduleKey, basePath)
再进一步在构造函数上打断点,查找route-explorer实例是通过何种方式建立的,最后发现一个改写applyCallbackToRouter方法的路径
在main.ts中 先从router-explorer.js中把原本的方法COPY过来,并做一点点小修改,为每个路由加上一个“小尾巴”

//main.ts中把原本@nestjs\core\router\router-explorer.js中的 applyCallbackToRouter方法拷贝过来,做一点小修改
import {createContextId} from '@nestjs/core/helpers/context-id-factory'
function applyCallbackToRouter(router, pathProperties, instanceWrapper, moduleKey, basePath) {
  const {path: paths, requestMethod, targetCallback, methodName, } = pathProperties;
  const {instance} = instanceWrapper;
  const routerMethod = this.routerMethodFactory
    .get(router, requestMethod)
    .bind(router);
  const stripSlash = (str) => str[str.length - 1] === '/' ? str.slice(0, str.length - 1) : str;
  const isRequestScoped = !instanceWrapper.isDependencyTreeStatic();
  const module = this.container.getModuleByKey(moduleKey);
  const collection = module.controllers;
  if (isRequestScoped) {
    const handler = async (req, res, next) => {
      const contextId = createContextId();
      this.registerRequestProvider(req, contextId);
      const contextInstance = await this.injector.loadPerContext(instance, module, collection, contextId);
      this.createCallbackProxy(contextInstance, contextInstance[methodName], methodName, moduleKey, requestMethod, contextId, instanceWrapper.id)(req, res, next);
    };
    paths.forEach(path => {
      const fullPath = stripSlash(basePath) + path;
      routerMethod(stripSlash(fullPath) || '/', handler);
      routerMethod(stripSlash(fullPath) + '/data.json' || '/', handler);
    });
    return;
  }
  const proxy = this.createCallbackProxy(instance, targetCallback, methodName, moduleKey, requestMethod);
  paths.forEach(path => {
    const fullPath = stripSlash(basePath) + path;
    routerMethod(stripSlash(fullPath) || '/', proxy);
    routerMethod(stripSlash(fullPath) + '/data.json' || '/', proxy);
  });
}

//然后在建立app的地方,将这个修改后方法替换原本正常的方法
async function bootstrap() {
    const server = express();
    const app = await NestFactory.create(AppModule, new ExpressAdapter(server));
    //把原本的实例中的方法,指向修改后的方法,移花接木
    app['routesResolver'].routerBuilder.applyCallbackToRouter = applyCallbackToRouter;
    ...
    app.use((req, res, next) => {
        res.header('Access-Control-Allow-Origin', '*');
        res.header('Access-Control-Allow-Credentials', 'true');
        res.header('Access-Control-Expose-Headers', 'Authorization,Content-Disposition,ETag'); //注意要把ETag在这里Expose出来,这样客户端才能在response.headers里读到Etag的值
        res.header('Access-Control-Allow-Methods', 'OPTIONS,GET,PUT,POST,DELETE');
        res.header(
          'Access-Control-Allow-Headers',
          'Content-Type, Accept,Authorization,Content-Encoding',
        );
        next();
  });

测试接口,发现一切OK。即可以用原本的接口地址访问,也可以用加个小尾巴的地址来访问。

返回CDN的缓存设置处,加上新的缓存规则。

添加了带小尾巴的接口缓存策略

因为很多接口是伴随着请求参数来的,比如说某些分页参数的接口。
list?page=1&size=10和list?page=2&size=10
应该被设为不同的缓存对象,因此,在访问过滤处,将过滤参数关闭,好让这种参数不同的路径,能被分别缓存

关闭过滤参数

做了上边一些优化操作后,2MB的小水管也一点无压力了。

再之后的排查中,发现存放在COS上的一些静态文件cache-control设成了no-cache。
然而并没有一个方便的办法直接批量去把某个文件夹下的内容都加个统一的头,搜索了一圈,发现了一个同步工具
它的操作是拿本地的文件夹里的内容和服务端文件夹做比较,有文件才会去设置服务端的,所以我只好先从COS把文件拉到本地来。
如此操作了一番后,cache-control被设成了no-cache这个问题得以解决。

记一次在腾讯云容器服务中安装wordpress,并配合nginx反向代理的完整过程

整个过程完全基于腾讯云的各项服务来完成
网络架构:
域名解析 » 负载均衡 » 内网NGINX » 内网WORDPRESS

先安装wordpress
注意足够的内存分配,以及环境变量的填写
关于环境变量的内容,参考一下wordpress镜像文件中的说明

值得注意的是,因为我这里已经在负载均衡那启用了https访问了,因此需要在wp-config.php中激活https相关配置。否则会出现部分资源请求的是http://,进而导致内容加载异常

但是因为前边说了,wp-config.php是根据环境变量自动生成的,同时考虑容器重启就又会重新生成,所以直接修改wp-config.php不是最好选择。好在有一个叫做WORDPRESS_CONFIG_EXTRA的特殊环境变量,通过使用这个变量,可以把需要附加的字段,添加在wp-config.php中。添加变量值为:

$_SERVER['HTTPS']='on';
define('FORCE_SSL_ADMIN', true);

至此,wordpress的安装先暂告一段落。
接下来是安装nginx服务。

nginx的访问设置设为主机端口访问,也就是等于从外网指向30917的访问,映射到nginx的80端口

my-nginx配置里的内容如下,定义了一个叫my-nginx.conf的文件,注意配置文件的这种写法,而且还不能使用制表符,只能用空格来隔开

接下来进到腾讯云的负载均衡管理页面
这里我们将利用了负载均衡的监听器功能,将不同域名的请求进行转发,同时还可以非常方便地将腾讯云免费的SSL证书绑定起来,而无需在nginx中再进行SSL证书的绑定操作

进到腾讯云的域名解析页面,添加一个A记录,指向前边的负载均衡的公网IP

经过上边的一系列操作,不出意外的话,应该是可以通过自己的域名正常访问内网的wordpress了。

现在回到wordpress。
通过刚刚的挂载操作,插件、主题都已经分离存储到宿主机上做持久化保存了,上传目录因为也是在wp-content中,所以也可以通过这样的方式分离存储,但是优雅一点的作法,自然是直接使用对象存储服务,这里我采用的是腾讯云的COS
使用COS的相关内容这里不做赘述,这里主要强调一下子帐号的启用,以备后边通过插件的方式操作COS。

进到腾讯云的云密钥管理界面,用户列表 » 新建用户 » 子用户

最后得到该用户的secertId和key,先复制下来,一会儿要在wordpress的上传插件中填写。
回到用户列表,复制新建立的子用户的用户ID备用。

回到COS,在我们建立的用来存wordpress上传内容的存储桶中,在权限管理处添加用户

回到wordpress。
这里我们将要安装一个刚刚提到的上传文件到COS的wordpress插件
将压缩包文件下载后,登录到wordpress的管理后台,在插件中,采用手动安装的方式安装好该插件。

在设置界面里,填写好刚刚的子用户的相关信息。APP ID就是存储桶名字后的那一串数字。
这里这个url前缀要再补充说明一下,COS的桶有一个域名管理,一般来说,需要绑定一个自己的域名。记得做域名解析。

至此,在腾讯云容器中安装wordpress算是完成了。