利用Gitlab CI将代码部署至FTP服务器

作为Github的拥趸,最初这篇文章的标题是《利用Github Travis CI将代码部署至FTP服务器》。本来盘算的很好,利用这篇文章,一来是记录一下自己解决的问题,二来是宣传一下Github是多好用。
众所周知,Github等一众在线同性交友代码托管平台随着开发方法论的不断进化,已经演变成了CI/CD平台。哪家平台要是说自己不支持CI/CD,都不好意思出来打招呼。而Gitbub原来并没有自己的CI/CD功能,Travis CI充当了这一角色。最近,Github也推出了自己的Gitbub Actions来弥补这一块空缺,目前仍然在beta阶段。我也申请了测试资格,但目前仍然没有轮到我。

作为个人立场,很喜欢使用Github来托管代码。以前免费账号只能托管公开仓库,而收费账户又舍不得银子,无奈只能把私有仓库分散在Bitbucket和Gitlab上。
后来大家都知道了,Github被微软收购之后,免费账号也可以托管私有仓库了。我就琢磨着把所有repo都集中到Github上吧,结果猜怎么着?Travis CI免费版仍然只能用在公开仓库上。只好作罢。

当然,这次放弃Github+Travis CI组合的原因之中,Repo必须公开是其中一方面。另一方面是在使用Travis CI时遇到了无法解决的问题。

俗话说,先有问题才有的工具,而不是先有的工具而后产生的问题。首先说这次要解决的问题是什么。
其实这个问题很简单。平时在写文章或者写插件的过程中会遇到一些不懂的web技术,然后就需要做一些小demo或者简单的概念验证放到我自己的服务器上。由于有多设备切换开发的需求,任何代码都要放在远程git上做版本管理与同步。这样的话,就会出现一个稍许繁琐的操作————改完代码之后,要先commit到本地仓库,然后再push到远程仓库,然后将本地文件通过FTP工具上传到服务器,之后在浏览器打开网站进行测试,如果有问题则循环本套操作。

如果在我将代码提交到远程仓库之后,能够自动部署到FTP上该多好啊~!
很久很久以前,在SFDX还没发布的那个年代,我曾经利用GitHub+Travis CI做过一个Salesforce StandardSetController的小Demo的自动部署。每次提交完改动,就能自动部署到指定环境。我觉得部署到FTP怎么也得比部署到Salesforce Org还要简单吧!

说干就干,由于对Travis CI还算熟悉,立马查了一下文档,自信满满的写下了如下的yml文件。

# .travis.yml
language: node_js
script:
- echo "skipping tests"
after_success:
- curl --ftp-create-dirs
       -T index.html
       ftp://${FTPUSER}:${FTPPASSWORD}@${FTPENDPOINT}

语言无所谓,测试跳过,直接将文件通过ftp传送到服务器上。
结果执行之后,发现不对劲了。正常传送一个文件不说是瞬间完成,也不至于10分钟都传不完,导致被Travis强行把任务中止吧?然后去FTP上瞧了一眼,文件是创建出来了,但是大小是0KB。这肯定不对劲啊,查查吧。结果查到一篇Travis的官方博客,解释他们的NAT因为什么什么原因,导致FTP的被动模式失败之类的。总之就是ftp命令不能用了。
那行,我改用sftp命令。结果这次直接报错了,说不支持sftp命令。Execuse me? 然后搜了大半天,也没找到什么靠谱的解决方案。这就让人觉得不可思议了,对于CI工具来讲,FTP上传不比连接AWS、Heroku之类的简单多了?

正在感到弱小无助之际,我想起了一直在默默当备胎的Gitlab。
Gitlab也有自己的CI/CD工具,并且是免费,且可以用在私有Repo上的。
但是Gitlab的yml文件语法与Travis不同,只好硬着头皮速读了一下Gitlab CI的yml写法。
经过不断的研究与尝试,终于写下了如下的yml文件。(其实是从sample yml文件改造出来的)

# .gitlab-ci.yml
# This file is a template, and might need editing before it works on your project.
# Full project: https://gitlab.com/pages/plain-html
pages:
  stage: deploy
  script:
    - mkdir .public
    - cp -r * .public
    - mv .public public
    - apt-get update -qq && apt-get install -y -qq lftp
  artifacts:
    paths:
      - public
  only:
    - master
  after_script:
    - lftp -c "set ftp:ssl-allow no; open -u $USERNAME,$PASSWORD $HOST; mirror -Rev public/ ./ --ignore-time --parallel=10 --exclude-glob .git* --exclude .git/"

任务名称随意,artifact就是本次部署的全部文件归档,这里文件夹名字叫public。为了使用lftp命令,在脚本中提前安装lftp命令。
在CI/CD的setting页面提前配置好USERNAME, PASSWORD, HOST三个变量可以避免将敏感信息放到仓库文件中。
接下来在Gitlab仓库中启用CI/CD,就可以享受了。

其实同理,既然能用Gitlab CI部署代码到FTP,也能部署代码到Salesforce。之后还会写一篇Salesforce的兄弟篇。而这篇文章呢,也不想写CI/CD是什么,也不想谈论DevOps的优势,毕竟这种东西用关键字一搜索,满大街都是,感兴趣的人在看到文章头两个自然段之后早就自己去搜了。毕竟我说它千好万好,不如举一个实际的,好上手的例子让大家看到实实在在的东西来的实惠。

// 后记

也很长时间没有更新了。有人问我哪去了。其实。。。这期间发生了很多事情,也经历了很多事情。素材也是越攒越多。奇怪的是素材攒的越多越觉得没什么值得写的东西。可能,大家都在进步,对于Salesforce平台的基本认知已经都跨越了那道门槛,现在更需要的是Salesforce这款产品以外的,更关乎于计算机世界的、网络世界的基础知识,软件工程的基本概念。还有对于各个行业是如何运转的行业基本了解。

随着年龄增加,进入到了人生的不同阶段,会发现你完全属于你自己的时候越来越少。你逐渐被必须要承担的各种角色完全瓜分。以前争不到的东西现在更加的争不到,原来拥有的东西反而越丢越多。Whatever,要不然又能怎样呢,要不然选择随波逐流,要不然选择自己受的苦自己咽下去,你敢选第三条路吗?(摊手)

1+

关于在Component中向Lightning Button传值的方法

某日,
打水途中看到某位同学眉头紧锁双手合十做沉思状,于是乎忍不住凑了过去。
哦。。。原来在写Lightning Component。隐隐约约看到密密麻麻的controller.js代码感觉不妙。
“咳。”为了避免吓到他,我轻咳了一下表示进场。
只见他先是略微抬起头,之后彻底滑落到了椅子上。
“唉,事情是这样的,” 他用沙哑的嗓音说道,“最开始他们要我做一个button,点了之后去后台更新一条数据,将其字段A__c更新为a。”
他把自己从椅子上重新支了起来,指着屏幕说,“这个很简单,我放了一个attribute来存需要更新的数据的id,然后在lightning button的onClick属性指定controller的方法,在controller的方法中cmp.get那个attribute的值,之后传给apex去更新这条数据。”
我微微点头表示赞同。“没毛病。然后呢?”

“然后,他们又说,要再加一个button,点这个button之后,要给这条数据的字段A__c更新为b。OK,我在cmp中加了又一个button,然后在controller里又为了新button新建了一个方法,与之前的方法不同的是,更新的值为空。”
“也不是不可以呀。“
“对,”接下来他双眼空洞的目视前方。“然后他们又让我加三个button,点了之后分别赋值b,c,d。这时候我意识到情况不对,打算在button的conlick事件把值传给controller中的方法,这样就不用每加一个button就新加一个方法了。结果……我发现……lightning button的onclick根本不能设参数,controller的方法参数也是固定的。我没办法了……”

“我明白你的意思了”。我按住了他的肩膀。

在常规前端开发中,在button的click事件中绑定的js函数传递不同的值是常见做法。如果点击button之后,处理相同,仅是需要处理的参数不同的情况应该对代码进行合理的复用。如下例。

let foo1 = function(param1) {
console.log(param1);
}
<div>
    <input type="button" name="Button" onClick="foo1('a')"/>
</div>

但是lightning button(官方文档)的onclick事件,并没有提供直接传递参数的方式。所以我们只能走曲线救国的路线。
其实很简单。
首先,在官方文档里提到,

You can retrieve the button that’s clicked by using event.getSource(). For example, to retrieve the label on the button, use event.getSource().get("v.label").

说明我们可以在controller的方法中拿到event的触发者——被点击的button,然后获得此button上的属性——比如Label。

那么就好办了,首先我们在lightning button上光明正大的设置value属性

<lightning:button label="button1" title="create" onclick="{!c.updateRecord}" value="a"/>

然后在controller的方法中,去get这个button的value属性,接下来正常执行业务逻辑就可以了。

({   
    updateRecord : function (component, event, helper) {
        let targetValue = event.getSource().get("v.value");
        // YOUR STATEMENT HERE ......
    }
})

好了,水凉了,还得重新接一杯。

5+

关于Javascript的Proxy

我只是想开橘子罐头,你却要卖我一把瑞士军刀Plus。为了用这把瑞士军刀Plus开罐头,我需要先折叠锤子,收起剪子,掰开螺丝刀……

一个只是想吃橘子罐头的人

某天,我要写一个显示某服务实时状态的页面。
动手之前感觉这个需求还是挺简单的。不就是新建一组HTML元素,然后用javascript对其中的元素赋值。So easy。

一边哼着小曲,一边就把HTML元素整整齐齐的码好了。接来下,让我看看有几个信息来源会更新HTML的值。1,2,3,4,5,6……什么?三十个模块?OK,OK,让我一个一个写,只是体力活而已。

继续阅读“关于Javascript的Proxy”
0

关于window.postMessage

很久没写东西了,偶尔会对着本文编辑器发呆,不知道自己想写什么,不知道应该写什么。又觉得什么都不应该写,什么也都不值得写。能静下心坐在电脑前写点什么是越来越奢侈的事情。也许,too many mind。

我自己说的

我相信每一个接触过前端开发的程序员都被跨域问题折磨过。

那么什么是跨域问题?
经历过的人脱口而出,“跨域问题就是处于安全考虑,当前域名不允许访问另一个域名的资源”。
这样说其实并不正确。仔细回忆一下,在曾经访问过的网页中,是否包含非本域名的CSS,JS和图片?
实际上获取这些CSS,JS和图片的请求已经跨域了,可是并没有出错。

CORS定义 点这里。严谨点说,是只有通过脚本发出的跨域请求才会被禁止,而通过标签发出的资源请求则可以顺利完成。并且进行拦截动作的,是浏览器本人。
原因显而易见————脚本运行在浏览器,而不是服务器。所以通过服务器,或者非浏览器的客户端(或者魔改版浏览器),可以随意的向任意域名发出请求。
为了绕开这个机制,各路神仙也是各显神通,开了各种各样的脑洞。

继续阅读“关于window.postMessage”
1+

关于Stub API

Spring’17,Salesforce为开发者提供了一个强大的工具,Apex STUB API

看完官方文档提供的例子之后,感觉确实很美好,但又觉得没什么机会用。
不同于Java项目对框架和设计模式的极致追求。
我接手的Apex代码均为意识流散文诗式写法。根本没有抽象化,解耦等操作。

Stub Api是基于Java的Mocking Framework
mockito开发的。
使用Mocking Framework的前提是代码必须进行解耦,就是所谓的依赖注入(Dependency Injection)。
要求代码不能按业务直接写成流水账,而是将模块之间的强依赖解开。

比如,A类的构造强依赖与B类,如果没有B则没有A。

A a = new A();
//-----------------------------------
Class A {
    private B b;
    public A() {
        b = new B();
    }
    public String getName() {
        return b.getName();
    }
}

Class B {
   public String getName() {
       return "I'm the B";
   }
}

那么,问题来了,如果我有一个C类,返回“I’m the C”,我就要为C类新建一个A1类,在A1类中将A1与C进行强依赖。
如果需求越来越多,代码也随时越来越膨胀。
那么怎么改变这个局面呢?

B b = new B();
A a = new A(b);
-----------------------------------------------
Class A {
    private CustomObject co;
    public A(CustomObject cObj) {
        this.co = cObj;
    }
    public String getName() {
        reutrn co.Name;
    }
}

Class B extends CustomObject {
    public String getName() {
        return "I'm the B";
    }
}
abstract Class CustomObject {
    abstract public String getName();
}

这样一来,A和B的强依赖就解耦了,不再是每次new一个A,就必然得到B的内容,而是根据我传进去的B来决定A返回的内容。
如果想返回“I’m the C”,就不用再去新建一个A1类,想返回“I’m the D”,不用再去新建一个A2类。

只有依赖注入的写法才能进行方便的Mocking。

Stub Api可以做到的是,在解耦之后,为A类mock一个B类,C类。做到完美的单元测试(模块隔离)。
Whatever,用不上。至今还没见到过有控制反转思想的的Apex。
说句题外话,apex-enterprise-patterns(官方介绍文章)到现在还没推广开呢。

// 未完待续

1+

Salesforce的StandardSetController使用Demo

先放Github地址
https://github.com/Kealthals/Salesforce-StandardSetControllerDemo.git

事情起因,是有人问我为什么StandardSetController无法保存Selected状态。
我觉得既然标准List View可以,那么使用StandardSetController应该也可以。
虽然这个Class我用的也不多,但还是动手写个一个小Demo验证了一下我的想法,虽然花了些小心思,但证明确实可行。
验证完毕,转念一想,既然已经动手了,就干脆把所有的methods都演示一遍吧。权当给自己留一个财富。

StandardController大家都比较熟悉,用来处理单条数据。模拟的是Create/Edit页面和Detail页面。
StandardSetController用的不多,因为模拟的是List View页面。标准List View页面已经很强大了,很少会遇到这种需求。
这个Demo也就是尽量做一个和标准List View类似的页面,时间有限,肯定做不到标准页面一般的好看和完美。
最低目标是将这个Class提供的method都使用一遍,除了一个不明所以的method之外,最低目标我觉得是完成了。
功能上,也提供了基本的
1. 罗列数据
2. 翻页
3. 选择数据(Selected)
4. 选择List View
5. 改变Page Size
6. 跳转指定页
7. Inline Edit(还不完美)
作为一个独立功能来讲,勉强达到了凑合着用的程度。
在功能实现的时候,又不断遇到了一些或新或旧的小问题,在大家的帮助下都找了比较好的小方案解决掉了这些小问题。
比如SFDC的autofocus,inputtext的输入类型限制等等。

继续阅读“Salesforce的StandardSetController使用Demo”

2+

如何优雅的干掉VF的autofocus

How to remove VF input field’s autofocus?
最近在写StandardSetController Demo的时候遇到了一个逼死完美主义者的问题。
在VF加载之后,SFDC会非常Nice的,热心的,把第一个文本输入框设为焦点。
我推测sfdc的产品设计逻辑是下面这样的。
嗯。。。你设置一个输入框,代表着用户一定得输入点什么才能继续。
既然用户必须输入点什么的话,那么就必须用鼠标点一下这个输入框。
那么让我们来做点什么吧,帮用户先把输入框点上怎么样,节省用户的操作。
看呐,如果用户双手都在键盘上,他们连右手离开键盘的工夫都省下来了,用户一定会非常满意的。

对于大部分业务场景,这个贴心的小设计确实很有用。但有的时候会起反作用。
比如我的StandardSetController的Demo。
在List View画面,一般只有在数据列表最下方才有输入框,为了输入Page Size或者Page No。
但是不碰这几个输入框并不影响我使用List View。
不过,当默认Page Size非常大,画面超出一屏的时候,由于sfdc自动把第一个输入框设成焦点,导致画面滚动条直接被拖到了滚动条最下面。
还有当VF的第一个input项目是Date型的时候,sfdc的自动设置焦点机制,会触发datepicker,就像大晴天自动弹开的雨伞一样,造成另一场灾难。

研究了半天,虽然标准List View并没有这个问题,但还是没有找到比较官方的解决方法。
只好Hack一下了。

在VF中插入

   <script>
        function setFocusOnLoad() {}
    </script>

问题完美解决。

1+

Salesforce的Custom Permission

Salesforce的权限体系我相信大家已经很熟了。
数据级别权限,表级别权限,字段级别权限,功能权限,分别坐落在Profile/Permission Set和role/Sharing rule当中,还有一些是作为Feature License放在了User上。

可是对于自定义功能,SFDC并没有提供太好的控制方式。
逼的广大开发人员不得不开各种脑洞。
比如在人事系统里,HR和领导能看见员工评价,普通员工看不到员工评价,
这用Profile的FLS简简单单地就能控制了。
但是在所有人都能看到的VF上,假如有个员工评价Section不想给普通员工看的话,就不好办了。
在VF里可以判断当前User的Profile,判断一下当前用户的Profile在不在允许列表里不就行了嘛。
好家伙,一打听,500个Profile能看。这不行啊,还是看看谁不能看吧,呦呵,800个Profile。
开发人员就开脑洞了,我把能看的Profile的末尾加一个“VIP”,不能看的都没有。接着就动手把500多个Profile一个一个的改了名字。
然后在VF里判断User的Profile结尾是VIP的才显示。
妥了,实现,下班。
第二天,经理过来搂住你的肩旁,说,昨天那个功能实现的很好,今天还要再加一个Section,和昨天的差不多,不过普通员工Level里的组长可以也可以看。开发人员想了想,总不能再给Profile加一个后缀吧。就算这次可以,那以后再来一个怎么办。。。。。
后来,听说开发人员被120拉走了。

像这种自定义功能的权限控制,SFDC提供了Custom Permission,作为Profile与Permission Set向自定义功能领域的延伸。
说白了,就是Profile与Permission Set原本能控制的都是标准功能,Custom Permission就是一个插件/拓展包,赋予了Profile与Permission Set控制自定义功能的能力。

所以遇见上面的场景,我们只需要针对每一个Section建一个Custom Permission,
然后将这些Custom Permission挂到需要此权限的Profile或者Permission Set,
然后在VF中写上Custom Permission的判断。
如下方官方文档提供的例子一样

<apex:pageBlock rendered="{!$Permission.[Your Custom Permission API Name]}">
   <!-- Confidential Content Here -->
</apex:pageBlock>

在Apex中想用Custom Permission控制业务逻辑的话,
就要使用下面的方法,官方文档

if(FeatureManagement.checkPermission([Your Custom Permission API Name])) {
    // Your Codes Here
}

这样一来,想控制一个群体,就挂在Profile上,想控制个别人,就挂在Permission Set上。
为什么叫“挂在”呢,因为只有在Profile和Permission Set下面才有添加Custom Permission的按钮。

那么,想创建一个Custom Permission,在哪创建呢?
第一种方式是官方文档常写的 —– 点击Setup,然后左上方Quick Find,输入Custom Permissions。
第二种方式是按照本博客的风格 —– Setup -> Build -> Develop -> Custom Permissions。
Custom Permission本身需要定义的内容倒是不多,就一个Label一个API Name是必须的,其他都可有可无。

虽然Custom Permission只是Profile与Permission Set的功能拓展,
但由于在VF中官方提供了方便的检查方式($Permission),倒是给我们提供了另外一个用途,
正所谓,有心栽花花不开,无心插柳柳成荫。

继续阅读“Salesforce的Custom Permission”

1+

不小心Commit账户密码到本地仓库,并且push到了remote仓库怎么办

今天我就做了这个傻事。
不过还好是私有仓库,没有酿成泄密事件。

那么这种情况该怎么处理呢?
首先,把账户密码改掉,再Commit一次是肯定不行的。
看worktree,账户密码确实是没了,但是在Commit记录里仍然能看到的。

别急,Stackoverflow上我找到了一个简单粗暴的做法,直接抹掉全部历史!
https://stackoverflow.com/questions/13716658/how-to-delete-all-commit-history-in-github

首先你需要一个git命令行工具,Eclipse自带的git插件是玩具。

1.创建一个独立(孤儿)的分支,并Checkout

git checkout –orphan latest_branch

2.将目前的文件全部add到此分支

git add -A

3.全部Commit到孤儿分支

git commit -am “commit message”

4.删除带有历史信息的Master分支

git branch -D master

5.将没有任何历史的孤儿分支重命名为Master

git branch -m master

6.强制推送到Remote仓库

git push -f origin master

代价就是所有历史都没有了。
当然,我们有更精细的方式,可以剔除包含敏感信息的commit

1+

关于Force.com Web Service Connector的坑

经常做Java与Salesforce集成的同学一定对wsc这个名字很熟悉。
wsc,即Force.com Web Service Connector的简称。

Sfdc针对各种主流编程语言,都提供了类似wsc的工具套件。
方便开发者快速的搞定与sfdc连接的部分,以便将注意力放在自己的业务代码上。

目前wsc已经开源,并同步更新到了sfdc api v42.0
我于是迫不及待的去体验了一番。
结果发现了不少的坑。

首先,wsc的github地址为https://github.com/forcedotcom/wsc
其实在forcedotcom这个路径下还有不少宝贝。有兴趣可以自己翻翻看。

如同PHP的docker,现代java项目都靠Maven进行发布,这样就免去了到处去找jar包的苦恼。
wsc自然不例外。
没有安装Maven的同学,请先去安装。什么?安装Meven需要安装JDK?如果连JDK都没有。。。还是算了吧。

首先,按README所讲,要先执行下面两行进行Build
git clone https://github.com/forcedotcom/wsc.git
mvn clean package

OK,拷贝,执行,报错。嗯?
我们知道,git clone之后会生成一个文件夹,比如这个repo叫做wsc.git,则会在执行路径生成一个文件夹叫做wsc,然后把所有的源代码放进文件夹wsc里。
如果直接执行mvn命令的话,根本找不到任何东西,要cd wsc之后再执行mvn clean package才行。
果然这个guide不是给我这种小白看的。

第一步git clone顺利完成。
第二部cd wsc也顺利完成。
第三部mvn clean package出问题了。

BUILD FAILURE

原因是有个测试类没有通过。。。。我。。。。
OK,你赢了,我选择跳过执行测试类。
命令于是变成mvn clean package -DskipTests

信心满满地再次执行
BUILD FAILURE

签名没有通过。。。。我。。。。
OK,你又赢了,按照官方指导,命令变为
mvn clean package -Dgpg.skip -DskipTests

终于成功Build

我们知道wsc只是一个外壳,我们需要用wsdl为其注入灵魂。
我分别准备了Enterprize和Partner的WSDL文件。
按照官方所给的命令,再次自信满满地敲了进去
java -jar target/force-wsc-42.0.0-uber.jar

java.lang.ClassNotFoundException: com.sun.tools.javac.Main

这个错误官方再次精确的预言了!继build时签名错误之后,他又成功的预言了我执行之后一定会出错!!
真厉害!!

OK,再次颤颤巍巍的使用官方给的Plan B命令
java -classpath “${JAVA_HOME}lib/tools.jar:target/force-wsc-42.0.0-uber.jar” com.sforce.ws.tools.wsdlc

错误: 找不到或无法加载主类 com.sforce.ws.tools.wsdlc

咦——?

一定是JAVA_HOME设置的有问题,我于是将tools.jar从jdk的lib路径拷贝到了wsc/target文件夹,这次没问题了吧。
java -classpath “target/tools.jar:target/force-wsc-42.0.0-uber.jar” com.sforce.ws.tools.wsdlc

错误: 找不到或无法加载主类 com.sforce.ws.tools.wsdlc

。。。。。。。。。。。我。。无言以对

正当绝望之时,发现,我靠,官方给的命令!居然!冒号和分号写错了!!!!!!
误:java -classpath “${JAVA_HOME}lib/tools.jar:target/force-wsc-42.0.0-uber.jar” com.sforce.ws.tools.wsdlc
正:java -classpath “${JAVA_HOME}lib/tools.jar;target/force-wsc-42.0.0-uber.jar” com.sforce.ws.tools.wsdlc
来,大家来找茬

终于,执行
java -classpath “target/tools.jar;target/force-wsc-42.0.0-uber.jar” com.sforce.ws.tools.wsdlc
之后,终于,灵魂jar包成功生成。

然后试着跑了一下官方sample,

import com.sforce.soap.partner.*;
import com.sforce.soap.partner.sobject.*;
import com.sforce.ws.*;

public class testMain {

	public static void main(String[] args) throws ConnectionException {
		// TODO Auto-generated method stub
		ConnectorConfig config = new ConnectorConfig();
		config.setUsername("[YOUR SFDC USER ID]");
		config.setPassword("[YOUR PASSWORD]");
		config.setAuthEndpoint("https://login.salesforce.com/services/Soap/u/41.0");
		
		PartnerConnection connection = Connector.newConnection(config);
		connection.setAssignmentRuleHeader(null, true);
		SObject account = new SObject();
		account.setType("Account");
		account.setField("Name", "My Account");
		connection.create(new SObject[]{account});
	}

}

正常执行结束,Account成功创建。

// update 2018-02-09

我发起了一个pull request去修正之前提到的分号问题,不知道什么时候会merge进去。
https://github.com/forcedotcom/wsc/pull/218

2+