bt365体育投注.主頁欢迎您!!

    <acronym id="zvmrr"></acronym>
    <td id="zvmrr"></td>
  • <tr id="zvmrr"><label id="zvmrr"></label></tr>
  • <acronym id="zvmrr"></acronym>
  • 公子

    公子 查看完整档案

    北京编辑中国地质大学(北京)  |  构造地质学 编辑奇舞团  |  前端工程师 编辑 zh.eming.li 编辑
    编辑
    • 额米那个陀佛,无量那个天尊!
    • SF啥时候出注销功能啊

    个人动态

    公子 发布了文章 · 2020-12-27

    静态博客如何高性能插入评论

    ?? 前言

    我们知道,静态博客由于不带有动态功能,所以针对评论这种动态需求比较大众的做法就是使用第三方评论系统。第三方评论的本质其实就是使用 JS 去调取第三方服务接口获取评论后动态渲染到页面中。虽然它很好的解决了这个问题,但是由于需要请求接口,在体验上远比动态博客的直出效果要差很多。所以当我把博客从动态博客 Typecho 迁移到静态博客 Hugo 上来时,就一直在思考这个问题。直到我看到了 Hugo 的 getJSON 方法,发现原来静态博客也是能够像动态博客一样直出评论的。

    大部分的静态博客的原理是解析存储内容的文件夹,使用一些模板语言遍历数据生成一堆 HTML 文件。而 Hugo 除了解析 Markdown 内容之外,还支持额外的数据获取方法 getJSON。由于有了 getJSON 方法的出现,我们可以实现在博客编译构建过程中动态的去获取评论接口数据,将其渲染到页面中,实现评论数据的直出效果。关于 getJSON 的更多介绍,可以查看 Hugo 文档数据模板一节。

    ?? 方案

    高性能方案基本思路是在需要评论数据的地方通过 getJSON 方法调用接口获取评论数据并进行模板渲染。当评论更新的时候,我们需要触发重新构建。实现这个方案依赖三个关键要素:

    1. 构建过程支持调取接口获取数据
    2. 评论服务提供 HTTP 接口返回数据
    3. 博客部署服务支持钩子触发重新构建

    我的博客使用的是 Hugo 静态博客系统,如上文所说通过 getJSON 即可解决第一个问题。而我的评论服务使用的是自研的 Waline 评论系统,它提供了评论数、评论列表、最近评论等基础接口满足我们的数据获取需求。并且 Waline 提供了丰富的钩子功能,支持在评论发布的时候触发自第一方法。我的博客部署在 Vercel 上,它提供了 Deploy Hooks 功能,通过 URL 即可触发重新构建。也就是说我只要在 Waline 评论发布的钩子中调用 Vercel 的钩子 URL 触发重新构建即可解决第三个问题。

    ?? 实现

    我的博客上有三处地方和评论有关,分别是首页侧边栏的最近评论,文章标题下方的评论数,以及文章详情页底部的评论列表展示。

    ?? 最近评论

    Waline 最近评论接口:文档

    {{ $walineURL := .Site.Params.comment.waline.serverURL }}
    <h2 class="widget-title ">最近回复</h2>
    <ul class="widget-list recentcomments">
      {{ $resp := getJSON $walineURL "/comment?type=recent" }}
      {{ range $resp }}
      <li class="recentcomments">
        <a href="{{.Site.BaseURL}}{{ .url }}">{{ .nick }}</a>:{{ .comment | safeHTML | truncate 22 }}
      </li>
      {{ end }}
    </ul>

    ?? 文章评论数

    Waline 获取文章对应的评论数接口:文档

    {{ $walineURL := .Site.Params.comment.waline.serverURL }}
    {{ $count := getJSON $walineURL "/comment?type=count&url=/" .Slug ".html" }}
    <a href="{{ .Permalink }}#comments" title="{{ .Title }}">
      <i class="fas fa-comment mr-1"></i>
      <span>{{- if gt $resp 0}}{{$resp}} 条评论{{else}}暂无评论{{end -}}</span>
    </a>

    ?? 评论列表

    评论列表由于有分页的存在,不像最近评论和评论数一样简单的调用接口即可。先获取评论数,发现有评论时先获取第一页的评论,主要是用来获取总共有多少页评论。之后再从第二页开始循环获取评论数据。最终将获取到的数据全部存到 {{$scratch.Get "comments"}} 数组中,使用模板语法渲染该数组数据即可。

    {{$baseUrl := .Site.Params.comment.waline.serverURL}}
    {{$slug := .Slug}}
    {{$count := getJSON $baseUrl "/comment?type=count&url=/" $slug ".html" }}
    {{$scratch := newScratch}}
    {{$scratch.Add "comments" slice}}
    
    {{if gt $count 0}}
      {{$comments := getJSON $baseUrl "/comment?path=/" $slug ".html&page=1&pageSize=100"}}
      {{range $cmt := $comments.data}}
        {{$scratch.Add "comments" $cmt}}
      {{end}}
    
      {{$totalPages := $comments.totalPages}}
      {{if gt $totalPages 1}}
        {{range $page := seq 2 $totalPages}}
          {{$comments := getJSON $baseUrl "/comment?path=/" $slug ".html&pageSize=100&page=" $page}}
          {{range $cmt := $comments.data}}
            {{$scratch.Add "comments" $cmt}}
          {{end}}
        {{end}}
      {{end}}
    {{end}}
    
    <div class="vcards">
      {{range $cmt := $scratch.Get "comments"}}
      <div class="vcard" id={{$cmt.objectId}}>
        <img class="vimg" data-original="https://gravatar.loli.net/avatar/{{$cmt.mail}}?d=mp">
        <div class="vh">
          <div class="vhead">
            <a class="vnick" rel="nofollow" href="{{$cmt.link}}" target="_blank">{{$cmt.nick}}</a>
            <span class="vsys">{{$cmt.browser}}</span>
            <span class="vsys">{{$cmt.os}}</span>
          </div>
          <div class="vmeta">
            <span class="vtime">{{dateFormat $cmt.insertedAt "2006-01-02 03:04:05"}}</span>
            <span class="vat">回复</span>
          </div>
          <div class="vcontent" data-expand="查看更多...">
            {{$cmt.comment | safeHTML}}
          </div>
          <div class="vreply-wrapper"></div>
          <div class="vquote">
            {{range $cmt := $cmt.children}}
            <div class="vh" id="{{$cmt.objectId}}">
              <div class="vhead">
                <a class="vnick" rel="nofollow" href="{{$cmt.link}}" target="_blank">{{$cmt.nick}}</a>
                <span class="vsys">{{$cmt.browser}}</span>
                <span class="vsys">{{$cmt.os}}</span>
              </div>
              <div class="vmeta">
                <span class="vtime">{{dateFormat $cmt.insertedAt "2006-01-02 03:04:05"}}</span>
                <span class="vat">回复</span>
              </div>
              <div class="vcontent" data-expand="查看更多...">
                {{$cmt.comment | safeHTML}}
              </div>
              <div class="vreply-wrapper"></div>
            </div>
            {{end}}
          </div>
        </div>
      </div>
      {{end}}
    </div>

    ?? 构建触发

    Waline 在评论发布、更新和删除阶段都支持自定义钩子,在钩子中触发 Vercel 的构建钩子即可完成发布评论重新构建的流程。

    按照如下内容修改服务端部署的 index.js 文件,查看文档了解全部的 Waline 钩子。

    const Waline = require('@waline/vercel');
    const https = require('https');
    const buildTrigger = _ => https.get('https://api.vercel.com/v1/integrations/deploy/xxxxx');
    
    module.exports = Waline({
      async postSave(comment) {
        if(comment.status !== 'approved') {
          return;
        }
        buildTrigger();
      },
      async postUpdate() {
        buildTrigger();
      },
      async postDelete() {
        buildTrigger();
      }
    });

    ?? 后记

    通过以上操作,就能在不损失用户体验的情况下实现评论数据的动态支持了。有些人可能会担心是否会在构建阶段造成超多的接口请求。这里大可不用担心,Hugo 自己会在构建的时候做接口的缓存,同 URL 的接口调用会走缓存数据而不会重新调用。

    除了用户体验之外,由于只会在构建的时候触发数据的获取,针对有调用次数配额的第三方评论服务也能节省额度。当然,理论上构建次数是远小于访问次数的,所以额度节省的结论是能成立的。如果说你的构建次数要比访问次数还要大的话,那这种方法就无法节省额度了。

    当然这种方式也会有带来些问题,主要是评论的更新没那么快。好在 Hugo 的构建速度非常快,一两分钟的时间也能接受。而针对用户评论的发布,则可以通过评论发布后先假插入缓解该问题。

    查看原文

    赞 1 收藏 0 评论 0

    公子 赞了文章 · 2020-12-17

    基于 ThinkJS 的云开发体验

    背景

    ThinkJS 是一款企业级的 Node.js Web 开发框架,致力于集成项目最佳实践,规范项目让企业级团队开发变得更加简单,更加高效。 它基于 Koa 2.0 开发,兼容 Koa 的所有 Middleware。 内核小巧,支持 Adapter, Extend 等多种插件扩展方式,框架内的大部分功能也是通过这些方式进行扩展的。 性能优异,支持 TypeScript。

    云开发 CloudBase 是云原生一体化应用研发平台为开发者提供高可用、自动弹性扩缩的后端云服务,包含计算、存储、托管等能力,可用于云端一体化开发多种端应用(小程序、公众号、Web 应用、Flutter 客户端等),帮助开发者统一构建和管理后端服务和云资源,避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。

    在云开发中使用ThinkJS

    其实在云开发中使用 ThinkJS 和我们日常使用大同小异,除了启动文件需要按照云开发的要求修改一下以外,内部的业务逻辑基本不需要改动。 我们可以使用云开发的 CLI 工具快速的初始化一个适配云开发的 ThinkJS 项目。 其中 thinkjs-app 是你的项目文件夹名称。

    tcb new thinkjs-app thinkjs-starter

    初始化完毕进入项目目录后执行 npm install 安装好依赖,就可以通过 npm start 启动开发环境了。

    如果一切正常,你可以通过访问 http://127.0.0.1:8360 看到经典的 ThinkJS 的初始化界面了。

    之后我们就可以愉快的进行项目的开发了。 当项目开发完毕之后,直接执行如下命令,CLI 工具就会将我们的代码部署到云函数上啦!

    由此可见,通过云开发,我们的开发部署流程变的更加简单了。打通流程之后具体就是业务逻辑的开发了,关于更多 ThinkJS 和云开发的开发指南可参见官方文档 。

    除了上述的云函数,云开发还提供了云数据库和云存储服务方便我们的项目快速接入相关服务。 数据库这块是比较经典的 NoSQL 的数据库操作,使用云开发的 SDK 可以非常方便的进行操作。

    const cloudbase = require(‘@cloudbase/node-sdk’);
    
    const app = cloudbase.init({…});
    
    const db = app.database();
    
    const _ = db.command;
    
    let userInstance = db.collection(‘User’);
    
    userInstance = userInstance.where({create_time: _.gt(new Date(2020, 0, 1))});
    
    const users = await userInstance.get();

    大家可以直接通过在 ThinkJS 中调用 SDK 的方法实现数据库的查询操作。

    需要注意是:ThinkJS 中模型的操作都是单例的,模型实例创建后之后的所有实例操作都会对当前实例有影响。 而云开发的 SDK 是纯函数形式的,因此每次操作完都会返回被操作后的对象,不会对源对象进行修改,所以我们需要通过变量覆盖的形式获取最新的操作结果。

    总结

    云开发作为基于 Serverless 的应用开发平台,不仅能方便的将我们的项目无服务器化,同时也提供了其它业务常用服务的快速接入,让我们的项目开发更加便捷。

    产品介绍

    云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为开发者提供高可用、自动弹性扩缩的后端云服务,包含计算、存储、托管等serverless化能力,可用于云端一体化开发多种端应用(小程序,公众号,Web 应用,Flutter 客户端等),帮助开发者统一构建和管理后端服务和云资源,避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。
    开通云开发:https://console.cloud.tencent.com/tcb?tdl_anchor=techsite
    产品文档:https://cloud.tencent.com/product/tcb?from=12763
    技术文档:https://cloudbase.net?from=10004
    技术交流加Q群:601134960
    最新资讯关注微信公众号【腾讯云云开发】

    查看原文

    赞 1 收藏 0 评论 0

    公子 发布了文章 · 2020-11-16

    基于 Serverless 的 Valine 可能并没有那么香

    Valine 是一款样式精美,部署简单的评论系统, 第一次接触便被它精美的样式,无服务端的特性给吸引了。它最大的特色是基于 LeanCloud 直接在前端进行数据库操作而无需服务端,极大的缩减了部署流程,仅需要在静态页引入 Valine SDK 即可。

    ?????? 初识 Valine

    以下是 Valine 官网提供的快速部署脚本,其中 appIdappKey 是你在 LeanCloud 上创建应用后对应的应用密钥。也正是基于这对密钥,Valine 在内部调用了 LeanCloud SDK 进行数据的获取,最终将数据渲染在 #vcomments 这个 DOM 上。这便是 Valine 的大概原理。

    <head>
      ..
      <script data-original='//unpkg.com/valine/dist/Valine.min.js'></script>
      ...
    </head>
    <body>
      ...
      <div id="vcomments"></div>
      <script>
        new Valine({
          el: '#vcomments',
          appId: 'Your appId',
          appKey: 'Your appKey'
        })
      </script>
    </body>

    有同学可能会有疑问了,appIdappKey 都直接写在前端了,那岂不是谁都可以修改数据了?这就需要牵扯到 LeanCloud 的数据安全问题了,官方专门写了篇文档《数据和安全》 来说明这个问题。简单的理解就是针对数据设置用户的读写权限,确保正确的人对数据有且仅有正确的权限来保证数据的安全。

    乍听一下,保证用户数据只读的话,感觉还是挺安全的。可事实真的如此么,让我们继续来看看。

    ???♂? Valine 的问题

    ?? 阅读统计篡改

    Valien 1.2.0 增加了文章阅读统计的功能,用户访问页面就会在后台 Counter 表中根据 url 记录访问次数。由于每次访问页面都需要更新数据,所以在权限上必须设置成可写,才能进行后续的字段更新。这样就造成了一个问题,实际上该条数据是可以被更新成任意值的。感兴趣的同学可以打开 https://valine.js.org/visitor... 官网页面后进入控制台输入以下代码试试。试完了记得把数改回去哈~

    const counter = new AV.Query('Counter');
    const resp = await counter.equalTo('url', '/visitor.html').find();
    resp[0].set('time', -100001).save();
    location.reload();

    可以看到该页面的访问统计被设置成了 -100000 了。这个问题唯一值得庆幸的是 time 字段的值是 Number 类型的,其它的值都无法插入。如果是字符串类型的话就是一个 XSS 漏洞了。

    该问题有一个解决办法,就是不使用次数累加的存储方式。更改为每次访问都存储一条只读的访问记录,读取的时候使用 count() 方法进行统计。这样所有数据都是只读的,就不存在篡改的问题了。这种解决方案唯一的问题就是数据量会比较大,对查询会造成一定压力。当然如果是在基于原数据不变的情况下,只能是增加一层服务端来做修改权限的隔离了。

    ?? XSS 安全

    从很早的版本开始就有用户报告了 Valine 的 XSS 问题,社区也在使用各种方法在修复这些问题。包括增加验证码,前端XSS过滤等方式。不过后来作者才明白,前端的一切验证都只能防君子,所以把验证码之类的限制去除了。

    现有的逻辑里,前端发布评论的时候会将 Markdown 转换成 HTML 然后走一下前端的一个 XSS 过滤方法最后提交到 LeanCloud 中。从 LeanCloud 中拿到数据之后因为是 HTML 直接插入进行显示即可。很明显,这个流程是存在问题的。只要直接提交的是 HTML 而且拿到 HTML 之后直接进行展示的话,XSS 从根本上是无法根除的。

    那有没有根本的解决办法?其实是有的。针对存储型的 XSS 攻击,我们可以使用转义编码进行解决。只要效仿早前 BBCode 的做法,提交到数据库的是 Markdown 内容。前端读取到内容对所有 HTML 进行编码后再进行 Markdown 转换后展示。

    function encodeForHTML(str){
      return ('' + str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')    
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#x27;')
        .replace(/\//g, '&#x2F;');
    };

    由于 Serverless 攻击者是可以直达存储阶段,所以数据存储之前的一切防范是无效的,只能在读取展示过程处理。由于所有的 HTML 转义后无法解析,Markdown 相当于我们根据自定义的语法解析成 HTML,保证转换后的 HTML 没有被插入的机会。

    不过这个方法存在一个问题,那就是对老数据存在不兼容。因为这相当于修改了存储和展示的规则,而之前一直存储的都是 HTML 内容,修复后之前的数据将无法展示 HTML 样式。而为了能在存储的还是 HTML 情况下规避 XSS 安全问题,唯一的办法就是增加服务端中间层。存储阶段增加一道阀门,将转义阶段提前至存储阶段,保证新老数据的通用。

    ?? 隐私泄露

    说完了存储的问题,我们再来看看读取的问题。攻击者除了可以直达存储,也可以直达读取,当一个数据库的字段开放了读取权限后,相当于该字段的内容对攻击者是透明的。

    在评论数据中,有两个字段是用户比较敏感的数据,分别是 IP 和邮箱。灯大甚至专门写了一篇文章来批判该问题 《请马上停止使用Valine.js评论系统,除非它修复了用户隐私泄露问题》。甚至掘金社区在早期使用 LeanCloud 的时候也暴出过泄露用户手机号的安全问题。

    为了规避这个问题,Valine 作者增加了 recordIP 配置用来设置是否允许记录用户 IP。由于是 Serverless,目前能想到的也只是不存储的方式解决了。不过该配置项会存在一个问题,就是该配置项的配置权在网站,隐私的问题是评论者遇到的,也就是说评论者是无权管理自己的隐私的。

    除了这个矛盾点之外,还有就是邮箱的问题。邮箱本质上只需要返回 md5 用来获取 Gravatar 头像即可。但是由于无服务端的限制,只能返回原始内容由前端计算。而邮箱我们又需要获取到原始值,方便做评论回复邮件通知功能。所以我们也不能不存储,或者存储 md5 后的值。

    该问题的解决方案只能是增加一层服务端,通过服务端过滤敏感信息解决这个问题。

    ?? Waline!

    基于以上原因,我们发现只有增加一层服务端中间层才能很好的解决 Valine 的安全问题,所以 Waline 横空出世了!Waline 与 Valine 最大的不同就是增加了服务端中间层,解决 Valine 暴露出来的安全问题。同时基于服务端的特性,提供了邮件通知微信通知评论后台管理、LeanCloud, MySQL, MongoDB, SQLite, PostgreSQL 多存储服务支持等诸多特性。不仅如此,Waline 默认使用 Vercel 部署,实现完全免费部署!

    Waline 最初的目标仅仅是为 Valine 增加上服务端中间层。但是由于作者不知为何从 1.4.0 版本开始只推送编译后的文件到 Github 仓库中,源文件停止更新。导致我只能连带前端也实现一遍。当然前端的很多代码和逻辑为了和 Valine 的配置保持一致都有参考 Valine,甚至在名字上,我也是从 Valine 上衍生的,让大家能明白这个项目是 Valine 的衍生版。

    ?? 后记

    Serverless 的概念火了非常多年,但技术没有银弹,我们在看到它的优点的同时,也要正视它所带来的问题。而 Serverless 自己可能也意识到了这个问题,从早期的无服务端慢慢转向了无服务器,更偏向 BaaS 了。不过由于 Valine 没有开放源代码,所以上面说的一些问题和解决方法只能等待作者自己发现这件事了。

    查看原文

    赞 9 收藏 2 评论 0

    公子 发布了文章 · 2020-11-09

    Hugo 之旅

    之前写了篇文章《博客迁移至 Hugo》,提了下使用 Typecho 多年后越发感受到运维的成本之高后,将博客迁移到了静态博客程序 Hugo 下。使用 Vercel + Github 可以免费搭建高性能博客,绑定域名还能自动帮忙创建 SSL 证书。当然偷懒的话也可以直接使用默认分配的二级域名。

    搭建

    创建 Hugo 博客

    点击上面的按钮快速抵达创建页面,未登录的会需要登录,这块直接使用 Github 登录即可。登录后第一步会让你选择 Vercel 账号,这里直接选择 Personal Account 即可。之后会让你输入仓库名称,Vercel 会自动帮你创建并初始化该仓库。如果你的仓库不想让其它人看的话,这里可以勾选 Private Git Repository 创建私有仓库。

    下一步这块会让你输入 Vercel 中项目的名称和一些配置。这里需要注意一下,官方提供的默认 Hugo 编译命令会把草稿文章也生成出来。需要在 BUILD COMMAND 那打开 OVERRIDE 按钮后输入 hugo --gc 进行覆盖。

    稍等片刻之后,你就可以看到飘着满屏的彩带庆祝你创建博客成功叻!点击 Visit 按钮你就可以看到你的博客的样子了。

    由于是静态博客,你所有的文章都会存储在你刚才新建的仓库中。你可以选择将仓库下来修改后提交,也可以利用 Github 的在线编辑功能在线修改提交。提交之后 Vercel 会自动触发更新,重新构建并更新你的博客。

    配置

    默认每次提交 Vercel 构建完成之后都会把构建后的地址评论在你的 Github 提交下。你可以通过设置关闭该功能。另外默认 Vercel 指定的 Hugo 版本比较老了,在 Markdown 编译过程中会发生一些异常的行为。我们可以通过配置指定最新的 Hugo 版本进行编译。在项目根目录下新建 vercel.json 文件,并加入以下内容。

    {
      "github": {
        "silent": true
      },
      "build": {
        "env": {
          "HUGO_VERSION": "0.78.1"
        }
      }
    }

    Hugo 博客本身的配置都在 config.toml 下。默认情况下 Hugo 生成的 URL 都是 /posts/hello-world/ 这种格式,不过之前做动态博客为了做伪静态,一般都将路由设置成了 /hello-world.html 这种格式。这种时候就需要在 config.toml 中增加 uglyurls 配置。

    uglyurls = true
    [permalinks]
      posts = ":slug"

    后面的 permalinks 配置主要是用来去除文章的 /posts 前缀的。除此之外,默认的配置文章中的 HTML 是会被转义的,对于我这种偶尔会在 Markdown 中写 HTML 的人来说操作有点多余。这时候可以在配置中定义它不转义。

    [markup.goldmark.renderer]
      unsafe= true

    域名

    使用 Vercel 搭建的网站,它会默认提供一个 *.vercel.app 的二级域名,你可以直接使用这个域名访问网站。如果你想要绑定自己的域名,也可以在后台设置。进入网站后选择自己的项目,选择 Settings - Domains 进入域名配置界面,在输入框中添加自己的域名。它会提示你需要给域名增加 A 记录或者是 CNAME 解析。按照提示添加后后台会自动检测是否生效。

    生效的时间视 DNS 服务器的生效时间而定,我这边使用 DNSPod 还挺快的,大概 30 秒之内就生效了。生效后 Vercel 会自动帮我们申请配置 SSL 证书,我们全然不用操心证书的问题。等待片刻之后我们就能直接使用新域名进行访问了。

    图片

    默认所有的静态资源都放在 static/ 目录下。你可以将图片放在该目录下,例如 static/hello-world.jpg。在文章中则直接使用 /hello-world.jpg 地址引用即可。

    由于 Github 仅有单文件小于 100M 的限制,Vercel 会将所有的资源部署到自己服务器上。所以使用仓库存储的方式会非常方便和安全,而且还不损失速度。唯一美中不足的是,由于是 HTTP 路径,在本地编写文章的时候会不方便。如果是使用 Typora 的话可以点击标签栏 格式 - 图像 - 设置图片根目录 将目录指定到 static/ 目录解决。VSCode 的话暂时没有倒腾出来。

    其实比较好的方案是建议大家创建一个文件夹,将该篇文章和它所用到的图片都归置到一块。使用类似组件化的思路管理内容,会更加方便后续的修改。

    除了使用本地存储之外,你也可以选择使用第三方的存储服务。免费服务的话可以试试 又拍云 提供的联盟计划,按照要求申请下即可。其他家的存储服务要么免费时间有限,要么有部分收费功能。第三方存储的好处在于带有 CDN 加速,在速度上会很方便。但目前 Vercel 的速度我觉得还挺不错的,所以最终还是选择了直接存仓库。

    评论

    使用静态博客之后,评论则只能选择第三方评论系统了。鉴于国情,国内的第三方评论服务都已名存实亡。目前比较知名的 Disqus , HyperComments 以及 来必力 都是国外的服务。HyperComments 是付费的服务就不多做讨论,Disqus 是老牌服务,提供了强大的 API 对开发者较为友好。来必力则是对国内用户友好,集成了很多国内的社交账号登录。

    除了专门提供评论的第三方服务之外,也有一部分是选择自建评论服务。其中比较知名的是 ValineGitalk 以及 Staticmanisso。Staticman 提供了强大的 API 但是缺少界面,isso 则是需要服务器部署,偏离了我使用静态博客的初衷就是不想维护服务器的目的,所以这两者都不多做讨论。Gitalk 是使用 Github issue 进行评论数据存储的评论脚本,适合纯技术博客和极客使用。Valine 则是基于LeanCloud Serverless 云存储进行评论数据存储的评论脚本,同时它还带有漂亮的外观,适合各类人群使用。

    博客站点上与评论相关的地方一般有三个地方:

    1. 首页显示最近评论
    2. 文章下显示当前文章评论数
    3. 文章底部显示该文章的评论列表以及输入框

    最开始我选择了 Disqus 作为本站的评论系统。但是它有个问题国内正常无法访问。后来我发现 Hugo 的模板中是可以使用 getJSON 方法调取接口的。而 Vercel 的机器本身就在海外,那我实际上可以在博客编译阶段就获取到输入写到页面中。最近评论和评论数的显示还是比较简单的,而评论列表的显示则需要折腾一下了。不过 Disqus API 的使用频率是 1000 次/小时,当短时间部署频繁的话可能会有超过的风险。而且发布评论的接口没办法走该逻辑,还是需要一个代理服务。

    最终让我决定不使用 Disqus 的原因是它的评论没办法让评论者输入网站地址,评论列表中的评论者昵称点击也无法跳转到用户输入的网站中。这对于有着大量的老式评论数据的我来说是不太能接受的。所以基于这些种种原因,我最后又将评论迁移到了 Valine 下。

    我对 Valine 的主要问题在于两点:

    1. 早前使用 Valine 的时候发现了大量的 XSS 漏洞
    2. 荒野无灯大大反馈的“泄露用户隐私问题

    以上两个是非常重要的安全问题,不过我看 XSS 的问题作者已经修复,实际上对用户输入的所有 HTML 内容进行转义可以规避该问题。而灯大反馈的 IP 泄露的问题,作者也通过不记录 IP 来规避该问题。这两个问题造成的原因其实还是因为 Serverless 。因为我想直接在编译的时候将相关数据静态化,所以我势必是需要增加一个服务端来帮我进行接口的封装,最后也能规避这些问题。

    不过 Valine 不知因为什么原因不开放源码,只在 Github 提供编译后代码,这点除了让我比较无语之外,改造工作也比较慢。本来我只需要将它 UI 中调用 LeanCloud API 的逻辑替换成我的接口逻辑即可。但是因为没有源码,只能自己重新制作 UI 了。

    更新:基于 Valine 衍生的带后端评论系统已经完成 https://github.com/lizheming/... 已切换至该评论系统。2020/11/08

    搜索

    缺少服务端之后,静态博客的搜索功能也无法自己完成。目前比较知名的是第三方搜索服务 Agolia,提供了免费的文章索引和搜索的功能。除了使用第三方服务之外,还发现有一种比较简单的做法就是将所有数据生成到一个文件中,前端下载该文件进行搜索结果展示。这里头比较知名的是 fuse,它是一个 JS 模块,将输入传入之后它能帮我们快速的匹配到命中结果。

    目前本站使用的是后一种方法,该方法的优点是不依赖第三方服务,自己就能完成搜索功能。缺点是第一次搜索的时候需要下载完整的数据,对文章比较多的网站用户鸭梨会比较大。使用第二种方法第一步是需要先创建全量的数据索引,通过以下配置告知 Hugo 编译时创建 index.json 索引文件。

    [outputs]
      home = ["HTML", "RSS", "JSON"]

    当然我们还需要为新的数据编写生成的模板文件,在 layouts/ 下增加 index.json 文件,并加入以下内容:

    {{- $.Scratch.Add "index" slice -}}
    {{- range .Site.RegularPages -}}
        {{- $.Scratch.Add "index" (dict "title" .Title "contents" .Plain  "summary" .Summary "permalink" .Permalink "date" (.Date.Format "2006年01月02日")) -}}
    {{- end -}}
    {{- $.Scratch.Get "index" | jsonify -}}

    其中 dict后的字典可以添加多个字段,视你 JS 渲染脚本中需要的字段而定。完成这些之后 Hugo 编译就会生成 index.json 文件了。

    最后我们需要增加一个搜索页面,该页面会先加载 index.json 然后使用 fuse 进行数据查找,最后渲染成 HTML 输出搜索结果。具体的代码可以直接查看源码 https://imnerd.org/search.htm... 参考本站的搜索页。

    //以下为示例代码
    let fuse;
    async function search(text) {
      if(!fuse) {
        const indexData = await fetch('/index.json', {method: 'GET'}).then(resp => resp.json());
        fuse = new Fuse(indexData, {...});
      }
      const result = fuse.search(text);
      renderHTML(result, '#app');
    }

    其它

    Hugo 静态博客能提供给我们发挥的空间非常多,比如说我参考 屈屈的博客 增加了一个博客文章发布 >180 天的话,就在文章详情页黄条提醒:

    提醒:本文最后更新于 313 天前,文中所描述的信息可能已发生改变,请谨慎使用。

    正常来说由于我没有服务端,所以只能使用前端来计算当前时间与发布时间的时间差。但其实转念一想,编译时也是可以拿到时间差的,只是这个时间差无法随着时间的变化而变化。不过我只要每天编译一次,就能解决变化的问题了。在 Vercel 项目 Settings - Git - Deploy Hooks 中你可以输入 Hook 名称和触发分支创建一个 Hook URL。只要访问该 URL 就可以触发 Vercel 更新博客。

    {{ if eq .Type "posts" -}}
        {{ $ageDays := div (sub now.Unix .Date.Unix) 86400 }}
        {{ if gt $ageDays 180 }}
            <p class="expired-tips"> 提醒:本文最后更新于&nbsp;{{$ageDays}}&nbsp;天前,文中所描述的信息可能已发生改变,请谨慎使用。</p>
        {{ end }}
    {{ end }}

    而在 Vercel 的 Marketplace 中,有一个 EasyCron 的服务提供了定时任务的功能。我在上面设置了每天零点访问 Hook URL 触发博客更新,就这样解决每天需要更新时间的问题。

    另外屈屈博客中的“查看本文Markdown版本”也是个不错的功能,在 Hugo 中也可以实现,本质是在编译的时候顺便生成一份 .md 的 Markdown 文件即可。

    首先我们需要在 layouts/ 下增加 single.md 文件,表示的是当文章页(Single)需要导出 .md 的数据的时候使用该模板。模板内容不用填写,为空即可。然后在 config.toml 中新增 .md 文件类型,并告知 Hugo 文章需要增加编译 Markdown 文件类型。

    [mediaTypes]
      [mediaTypes."text/plain"]
        suffixes = ["md"]
    
    [outputFormats.MarkDown]
      mediaType = "text/plain"
      isPlainText = true
      isHTML = false
    
    [outputs]
      page = ["HTML", "MarkDown"]
    查看原文

    赞 3 收藏 1 评论 0

    公子 发布了文章 · 2020-11-02

    常见登录鉴权方案

    编者注:今天我们分享的是卢士杰同学整理的网站常用鉴权方案的实现原理与实现以及他们的适用场景,帮助大家在业务中做合适的选择。

    背景

    说起鉴权大家应该都很熟悉,不过作为前端开发来讲,鉴权的流程大头都在后端小哥那边,本文的目的就是为了让大家了解一下常见的鉴权的方式和原理。

    认知:HTTP 是一个无状态协议,所以客户端每次发出请求时,下一次请求无法得知上一次请求所包含的状态数据。

    一、HTTP Auth Authentication

    简介

    HTTP 提供一个用于权限控制和认证的通用框架。最常用的HTTP认证方案是HTTP Basic Authentication

    鉴权流程

    加解密过程

    // Authorization 加密过程
    let email = "postmail@test.com"
    let password = "12345678"
    let auth = `${email}:${password}`
    const buf = Buffer.from(auth, 'ascii');
    console.info(buf.toString('base64')); // cG9zdG1haWxAdGVzdC5jb206MTIzNDU2Nzg=
    
    // Authorization 解密过程
    const buf = Buffer.from(authorization.split(' ')[1] || ''),  'base64');
    const user = buf.toString('ascii').split(':');

    其他 HTTP 认证

    通用 HTTP 身份验证框架有多个验证方案使用。不同的验证方案会在安全强度上有所不同。

    IANA 维护了一系列的验证方案,除此之外还有其他类型的验证方案由虚拟主机服务提供,例如 Amazon AWS ,常见的验证方案包括:

    • Basic (查看 RFC 7617, Base64 编码凭证. 详情请参阅下文.),
    • Bearer (查看 RFC 6750, bearer 令牌通过OAuth 2.0保护资源),
    • Digest (查看 RFC 7616, 只有 md5 散列 在Firefox中支持, 查看 bug 472823 用于SHA加密支持),
    • HOBA (查看 RFC 7486 (草案), HTTP Origin-Bound 认证, 基于数字签名),
    • Mutual (查看 draft-ietf-httpauth-mutual),
    • AWS4-HMAC-SHA256 (查看 AWS docs)

    二、Cookie + Session

    注册流程

    思考:为什么要在密码里加点“盐”?

    鉴权流程

    Session 存储

    最常用的 Session 存储方式是 KV 存储,如Redis,在分布式、API 支持、性能方面都是比较好的,除此之外还有 mysql、file 存储。

    如果服务是分布式的,使用 file 存储,多个服务间存在同步 session 的问题;高并发情况下错误读写锁的控制。

    Session Refresh

    我们上面提到的流程中,缺少 Session 的刷新的环节,我们不能在用户登录之后经过一个 expires 时间就把用户踢出去,如果在 Session 有效期间用户一直在操作,这时候 expires 时间就应该刷新。

    以 Koa 为例,刷新 Session 的机制也比较简单:
    开发一个 middleware(默认情况下所有请求都会经过该 middleware),如果校验 Session 有效,就更新 Session 的 expires: 当前时间+过期时间。

    优化:

    1. 频繁更新 session 会影响性能,可以在 session 快过期的时候再更新过期时间。
    2. 如果某个用户一直在操作,同一个 sessionID 可能会长期有效,如果相关 cookie 泄露,可能导致比较大的风险,可以在生成 sessionID 的同时生成一个 refreshID,在 sessionID 过期之后使用 refreshID 请求服务端生成新的 sessionID(这个方案需要前端判断 sessionID 失效,并携带 refreshID 发请求)。

    单设备登录

    有些情况下,只允许一个帐号在一个端下登录,如果换了一个端,需要把之前登录的端踢下线(默认情况下,同一个帐号可以在不同的端下同时登录的)。

    这时候可以借助一个服务保存用户唯一标识和 sessionId 值的对应关系,如果同一个用户,但 sessionId 不一样,则不允许登录或者把之前的踢下线(删除旧 session )。

    三、JWT

    简介

    JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。

    JWT 组成

    JWT 由三部分组成,分别是 header(头部),payload(载荷),signature(签证) 这三部分以小数点连接起来。

    例如使用名为 jwt-token 的cookie来存储 JWT 例如:

    jwt-token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoibHVzaGlqaWUiLCJpYXQiOjE1MzI1OTUyNTUsImV4cCI6MTUzMjU5NTI3MH0.WZ9_poToN9llFFUfkswcpTljRDjF4JfZcmqYS0JcKO8;

    使用.分割值可以得到三部分组成元素,按照顺序分别为:

    • header

      • 值:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
      • Base64 解码: {"alg": "HS256", "type": "JWT"}
    • payload

      • 值:eyJuYW1lIjoibHVzaGlqaWUiLCJpYXQiOjE1MzI1OTUyNTUsImV4cCI6MTUzMjU5NTI3MH0
      • Base64 解码:

        {
          "name": "lushijie", 
          "iat": 1532595255, // 发布时间
          "exp": 1532595270 // 过期时间
        }
    • signature

      • 值:WZ9_poToN9llFFUfkswcpTljRDjF4JfZcmqYS0JcKO8
      • 解码:

        const headerEncode = base64Encode(header);
        const payloadEncode = base64Encode(payload);
        let signature = HMACSHA256(headerEncode + '.' + payloadEncode, '密钥');

    鉴权流程

    Token 校验

    对于验证一个 JWT 是否有效也是比较简单的,服务端根据前面介绍的计算方法计算出 signature,和要校验的JWT中的 signature 部分进行对比就可以了,如果 signature 部分相等则是一个有效的 JWT。

    Token Refresh

    为了减少 JWT Token 泄露风险,一般有效期会设置的比较短。 这样就会存在 JWT Token 过期的情况,我们不可能让用户频繁去登录获取新的 JWT Token。

    解决方案:

    可以同时生成 JWT Token 与 Refresh Token,其中 Refresh Roken 的有效时间长于 JWT Token,这样当 JWT Token 过期之后,使用 Refresh Token 获取新的 JWT Token 与 Refresh Token,其中 Refresh Token 只能使用一次。

    四、OAuth

    简介

    有时候,我们登录某个网站,但我们又不想注册该网站的账号,这时我们可以使用第三方账号登录,比如 github、微博、微信、QQ等。

    开放授权(OAuth)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。

    OAuth允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站(例如,视频编辑网站)在特定的时段(例如,接下来的2小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。

    OAuth是OpenID的一个补充,但是完全不同的服务。

    —— 摘自 维基百科

    授权流程

    名词解释:

    • Third-party application:第三方应用程序又称"客户端"(client),比如打开知乎,使用第三方登录,选择 Github 登录,这时候知乎就是客户端。
    • Resource Owner:资源所有者,本文中又称"用户"(user),即登录用户。
    • Authorization server:认证服务器,即 Github 专门用来处理认证的服务器。
    • Resource server:资源服务器,即 Github 存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

    • A. A网站让用户跳转到 GitHub,请求授权码;GitHub 要求用户登录,然后询问“知乎网站要求获得 xx 权限,你是否同意?”;
    • B. 用户同意,GitHub 就会重定向回 A 网站,同时发回一个授权码;
    • C. A 网站使用授权码,向 GitHub 请求令牌;
    • D. GitHub 返回令牌;
    • E. A 网站使用令牌,向 GitHub 请求用户数据;

    其他授权模式

    授权码模式(authorization code)是功能最完整、流程最严密的授权模式。除了我们上面所说的授权码模式,其实还有其他授权模式:

    1. 简化模式(Implicit grant type)
      有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌。这种方式没有授权码这个中间步骤
    2. 密码模式(Resource Owner Password Credentials Grant)
      如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌
    3. 客户端模式(Client Credentials Grant)
      适用于没有前端的命令行应用,即在命令行下请求令牌

    关于这些模式详细请见:OAuth2.0 的四种方式

    单点登录

    单点登录(Single Sign On, SSO),即:单一标记(单点)登录。例如:QQ,我在QQ空间登录一次,我可以去访问QQ产品的其他服务:QQ邮箱、腾讯新闻等,都能保证你的账户保持登录状态。

    延伸阅读:

    五、总结对比

    没有最好,只有最合适!!!

    • HTTP Auth Authentication:

      • 梳理总结:
        通用 HTTP 身份验证框架有多个验证方案使用。不同的验证方案会在安全强度上有所不同。HTTP Auth Authentication 是最常用的 HTTP认证方案,为了减少泄露风险一般要求 HTTPS 协议。
      • 适用场景:
        一般多被用在内部安全性要求不高的的系统上,如路由器网页管理接口
      • 问题:

        1. 请求上携带验证信息,容易被嗅探到
        2. 无法注销
    • Cookie + Session:

      • 梳理总结:

        • 服务端存储 session ,客户端存储 cookie,其中 cookie 保存的为 sessionID
        • 可以灵活 revoke 权限,更新信息后可以方便的同步 session 中相应内容
        • 分布式 session 一般使用 redis(或其他KV) 存储
      • 使用场景:
        适合传统系统独立鉴权
    • JWT:

      • 梳理总结:

        • 服务器不再需要存储 session,服务器认证鉴权业务可以方便扩展
        • JWT 并不依赖 cookie,也可以使用 header 传递
        • 为减少盗用,要使用 HTTPS 协议传输
      • 适用场景:

        • 适合做简单的 RESTful API 认证
        • 适合一次性验证,例如注册激活链接
      • 问题:

        1. 使用过程中无法废弃某个 token,有效期内 token 一直有效
        2. payload 信息更新时,已下发的 token 无法同步
    • OAuth:

      • 梳理总结:

        • OAuth是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容。
        • GitHub OAuth 文档 Identifying and authorizing users for GitHub Apps
      • 适用场景:
        OAuth 分为下面四种模式

        1. 简化模式,不安全,适用于纯静态页面应用
        2. 授权码模式,功能最完整、流程最严密的授权模式,通常使用在公网的开放平台中
        3. 密码模式,一般在内部系统中使用,调用者是以用户为单位。
        4. 客户端模式,一般在内部系统之间的 API 调用。两个平台之间调用,以平台为单位。
    查看原文

    赞 2 收藏 1 评论 0

    公子 发布了文章 · 2020-10-31

    博客迁移至HUGO

    我的博客从09年开始到现在已经度过了十一个年头,虽然更新的频率变慢了,但也还是一直在坚持输出力求能够帮助到大家。本站最早使用过 ASP 的 PJBlog 到之后大名鼎鼎的 WordPress,之后在 2011年 的时候迁到了 Typecho 一直到现在。

    因为多年的数据和主题修改沉淀,我一直懒得更换程序,甚至是自己开发的 Node.js 博客系统 Firekylin 也没有激发我把主站迁移过去。不过随着网络环境的不稳定性,我也在慢慢寻找一些比较靠谱的备份和快速迁移的方式来降低博客的运维成本。

    数据库和Docker

    现在最方便的部署方式就是容器了,所以我第一个想到的就是将博客容器化。因为 Typecho 可能会有图片上传等用户数据产生,所以比较合理的镜像方式是为 Typecho 程序制作镜像,同时将用户数据挂载到本地。

    数据库则选择购买了阿里云专门的数据库服务,降低数据库这块的人工维护成本。这样维护的成本就变成了只要定时备份用户上传文件即可。但是随之又带来了新的问题。

    数据库服务贵不好用

    首当其冲的就是数据库服务的成本问题,最低配版套餐都要好几百,基本等同于一台主机的价钱。人工运维成本是降低了,但是金钱运维成本直接加倍!而且跨主机商只能使用外网连接,虽然都是同地区服务,但是连接意外的慢。导致主站打开需要好几秒,大大降低了用户体验。

    机器性能要求高

    然后容器对机器的性能也有一定要求。我一直在使用 1核2G 的低配机器维护着我的网站。由于我维护的站点比较多,使用上容器之后发现机器扛不住。换成了 2核4G 之后好一点了,但是金钱运维成本再次加倍!

    镜像构建困难

    最后其实是容器构建这块的成本。因为手上很多的老站都是使用 PHP 开发的,之前只是单纯的用这些程序。现在为了制作镜像,需要去看程序的基础依赖。

    另外还有一些老程序是破解版的,不太适合放 Docker Hub 上,寻找一个可以让我托管私有镜像的地方也废了我不少时间。之前一直没关注过这块,很早之前知道的几个做容器服务的要么不做了,要么就是改成收费了。后来发现其实 Github 就能直接托管镜像,这才方便了点。

    总的来说容器的虽然降低了迁移的成本,但是目前看来性价比不高,想要更好的服务势必要投入更多的资金才行,对我来说有点舍本逐末。

    静态部署

    同时为了降低我的其它站的运维成本,考虑之后决定将一些不使用的站进行静态博客处理。保留了存档的功能,同时降低了维护成本。主要是现在有很多的免费服务支持静态网站部署。比如我之前在用的 Vercel,很好的提供了对静态博客的支持。在挑选程序的时候发现 Hugo 上我现在在用的主题已经有人实现了 TwentyFourteen,这个主题是我很早之前从 WordPress 的默认主题上迁移过来的。性能非常好。遂就使用它将其他的一些站进行了迁移。

    只需要在 Vercel 上使用 Import Template 的形式创建项目即可。轻轻一点它便会自动帮你创建 Github 仓库,分配域名,添加证书,最后自动部署上线。然后我再把仓库克隆下来,把导出的旧数据挪进仓库提高一下即可。Vercel 会自动触发更新的流程。

    不管是人力成本,金钱成本,还有数据备份成本上来看,这种方案都达到了最低方案。特别在 Github 给个人开放了无限的私有仓库之后,你连数据隐秘性都不需要考虑了。所以在迁移了四五个老站之后,我看着生下来的主站,决定一不做二不休一块迁移得了。

    后记

    关于如何进行迁移我之后再单独写一篇文章来讲。迁移之后运维成本直接消失,不仅构建快而且页面访问速度也快,一个字香!之前 Typecho 的 Markdown 格式一直不支持表格语法,换了之后没有这些问题了。不过也不是说没有缺点,之前其实用 Typecho 的后台写文章非常方便,现在换成 VSCode 之后在快捷方式上没有网页的好用,之后再考虑做个单独的发布页吧。

    查看原文

    赞 0 收藏 0 评论 5

    公子 发布了文章 · 2020-10-26

    Drone 自定义 UI

    Drone 是一款开源的 CI/CD 工具,基于容器提供了强大的插件系统。多年前我有写过《基于Docker的CI工具——Drone》中有详细的介绍它的优点。Drone 采用的是 Server/Agent 架构,Server 端用来处理请求派发任务给 Agent,最终在 Agent 上执行任务。

    Drone 整体是使用 Golang 写的,drone/drone-ui 是它的前端页面仓库,采用 Vue.js 进行开发(很早之前是使用 React 进行开发的)。前后端分离的项目,比较正常的中间会使用 NGINX 之类的 Web Server 进行桥接,用户通过 Web Server 访问前端页面,然后页面在访问 Web Server 反代后的接口。不过 Drone Server 端直接是使用的 Golang 自己起的服务,而 Golang 又是一种需要编译的语言。为了能让 Server 编译后还是单文件,作者特地写了一款工具 bradrydzewski/togo 用来将静态资源编译成 Golang 代码。编译出来的结果本质就是文件路由和内容的哈希表,可以在官方仓库中一窥究竟。

    将编译后生成的 Golang 文件提交到仓库之后,就可以在 Server 中使用模块的形式将其加载进来,剩下的就是在 Server 中根据路由获取内容返回了。这种做法在开发上会比较麻烦,不过对使用的人来说倒是方便很多了。不过由于静态资源被编译进了执行文件中,所以我们如果要自定义前端界面的话,就需要按照这个流程重新构建编译 Server 执行文件了。

    构建前端模块

    首先我们需要针对 drone/drone-ui 原始仓库进行 Fork,在新的仓库中根据你们的需求进行前端代码的修改。在 RADME 中介绍了如何在开发环境中进行开发。如果改动不大的话,可以在每次 Drone 官方发布版本的时候根据上游仓库提交 Pull Request 进行需求合并。执行 npm run build 会在 dist/files 目录生成最终需要的前端静态资源。

    前端资源备好之后需要安装 bradrydzewski/togo 将静态资源嵌到 Golang 代码中。如果没有安装 Golang 的话需要先安装 Golang。另外 Golang 的全局 bin 目录需要配置到 PATH 环境变量中,否则编译时会提示找不到该命令。

    go get github.com/bradrydzewski/togo
    cd dist
    go generate dist.go
    注:go generate 是利用注释快速执行脚本的一种方式。本质上是执行了 dist.go 文件中的 togo http -package dist -output dist_gen.go 这条命令。

    最后将编译生成的 dist_gen.go 文件添加到仓库中提交,完成前端模块的构建。接下来我们需要重新构建 Server 执行文件。

    构建执行文件

    Server 执行文件的仓库是在 drone/drone,我们需要找到依赖了 github.com/drone/drone-ui 模块的文件,并将其替换成我们 Fork 的新仓库地址 xxx.com/xxx/drone-ui。主要有 ./handler/web/{logout,pages,web}.go 三个文件需要被替换。

    go get -v -insecure xxx.com/xxx/drone-ui
    sed -i '' 's/github.com\/drone\/drone-ui/xxx.com\/xxx\/drone-ui/' ./handler/web/{logout,pages,web}.go

    注: 针对这种场景,Golang 官方的模块管理中其实是支持 replace 方式用来将 A 模块替换成 B 模块的,不过我当时没有实验成功,就还是使用了 sed 的方式。

    go mod edit -replace=github.com/drone/drone-ui=xxx.com/xxx/drone-ui

    之后我们就可以执行 go build 对其进行构建了。我们并没有对该项目进行修改,只是针对它依赖的前端模块进行处理。所以我的想法是当 drone-ui 仓库发生变更的之后,执行 CI 流水线将 Server 仓库克隆下来修改后执行镜像构建并上传到镜像仓库中。

    CI 执行当然是选择 Drone 啦,用 Drone 去构建 Drone 听起来就很酷!默认 Drone 会把当前仓库克隆下来,但实际上我们不需要克隆当前仓库,当前仓库是被主仓库依赖的模块。我们真正需要下载的是 drone/drone 主仓库。

    clone: 
      disable: true
    
    steps:
    - name: clone
      image: alpine/git
      commands:
      - git clone https://github.com/drone/drone.git .
      - git checkout ${DRONE_TAG}
    
    trigger:
      event:
      -tag

    在 Drone 的配置中,设置 disable: false 即可实现不克隆当前仓库。然后自己在单独增加 git clone 的步骤。我们将仓库克隆到当前目录中,并根据当前 git tag 的版本号切换 Server 仓库的版本。这样保证最后编译出来的镜像同版本号和上游不会有其它差异。

    - name: build
      image: golang:1.14.4
      commands:
      - go get -v -insecure xxx.com/xxx/drone-ui
      - sed -i '' 's/github.com\\/drone\\/drone-ui/xxx.com\\/xxx\\/drone-ui/' ./handler/web/{logout,pages,web}.go
      - sh scripts/build.sh
      environment:
        GOARCH: amd64
        GOOS: linux

    接下来这段构建命令除了增加前端模块依赖替换之外,其它的都是从上游 Server 仓库 中搬运过来的。上游构建中还有 ARM, ARM64 架构版本的构建,由于我这里并不需要,就不增加构建时间了。

    之后我们再像官方一样,增加 Docker 镜像构建上传的步骤即可完成最终镜像的创建。使用的时候使用该镜像即可。

    后记

    同样是使用 Drone 搭建,官方针对 Github 搭建的 https://cloud.drone.io 在未登录的情况下还会自带一个登录页。原理是 Server 服务在 pages.go 中判断接入域名为 "cloud.drone.io" 的话会展示位于 handler/web/landingpage/index.html 的静态页。如果有门户页的需求的话可以针对这些文件进行对应的修改。

    查看原文

    赞 1 收藏 0 评论 0

    公子 发布了文章 · 2020-10-19

    Sketch 插件导出切片

    Sketch 作为流行的 UI 设计软件,除了设计之外,还承担了设计与开发之间沟通的桥梁作用。通过 Sketch 导出的在线标注能够节省很多沟通的成本。除了标注之外还有个比较重要的功能就是切图的导出。Sketch 中如果要导出一张切图,需要将其标记为切片(Slice)。在 Sketch 中切片的标记是多种多样的,针对不同的切片标记插件需要处理的逻辑也有细微的差别。下面我们就来看看不同的切片操作在插件中应该如何导出吧。

    注:Ctrl + Shift + K 可以在 Sketch 中调出插件脚本运行的 Playground,可以方便的调试代码。

    图层及编组切片

    这种是最普通的方式了,当我们想要将某个图层导出成图片的时候,就会为该图层设置导出选项。导出选项中我们可以设置多种导出尺寸和格式,在左侧图层面板中设置了导出选项的图层会增加类似刻刀的图标标记。

    Sketch API 提供了 sketch.export() 方法帮助我们在切片中导出切片图层。设置了导出选项的图层,图层属性会带有 exportFormats 属性,我们可以根据它判断是否是需要导出切图的图层。

    const sketch = require("sketch/dom");
    const artboard = sketch.getDocuments()[0].pages[0].layers[0];
    
    const exportLayers = artboard.layers.filter(layer => layer.exportFormats.length);
    exportLayers.forEach(layer => sketch.export(layer, {
      scales: 1, 
      formats: 'svg', 
      output: `~/Desktop/Sketch-Export-Demo` 
    }));

    编组带切片图层

    除了为图层设置导出项之外,我们还可以专门添加切片图层来导出图片。切片图层会将所有与该切片图层同级的图层叠加后产生的图片进行导出。同时它不依赖素材图层,在尺寸设置上更加自由。

    理论上这种情况使用 sketch.export() 方法也是没有问题的。不过这种情况下会像示例图一样,切片导出会把父级的白色背景色也导出出来,然而大部分情况下我们需要的其实只是透明图层。

    这时候要导出不带背景色的图片的话,需要将切片图层和同级元素都放在一个编组里,这时候切片图层导出会多出一个 Export group contents only 的选项,中文译为仅导出编组内内容。当它被选中后,由于父级背景色不属于编组就会被排除了。

    所以我们需要使用代码实现编组勾选配置导出三件事情。我们使用 sketch.Group 实例化了一个与画板等大的编组,并将同级的图层复制了一份放到了新创建的编组里。这里需要注意的是图层的坐标是相对的,在编组内的坐标会基于编组图层本身进行偏移。所以我们需要基于新的编组图层位置重新计算复制图层的位置,嗯,小学减法操练起来~

    至于勾选配置这件事,我似乎没有找到 JavaScript API 能干这个事情,只能通过 sketchObject 属性获取到 OC 对象调用 Native 的方法设置了。至于为什么 setLayerOptions() 的参数是 2,我要说是因为要设置的是第 2 个选项你信么(掩面…

    const sketch = require('sketch/dom');
    const artboard = sketch.getDocuments()[0].pages[0].layers[0];
    
    const duplicateLayers = artboard.layers.map(layer => {
      const copy = layer.duplicate();
      copy.frame = new sketch.Rectangle(
        layer.frame.x - artboard.frame.x, 
        layer.frame.y - artboard.frame.y, 
        layer.frame.width, 
        layer.frame.height
      );
      return copy;
    });
    const group = new sketch.Group({
      name: '切片编组',
      parent: artboard,
      frame: artboard.frame,
      layers: duplicateLayers
    });
    
    const slice = group.layers.find(layer => layer.type === sketch.Types.Slice);
    slice.sketchObject.exportOptions().setLayerOptions(2);
    
    sketch.export(slice, {scales: 2, formats: 'png', output: '~/Desktop/Sketch-Export-Demo'});
    group.remove();

    控件内切片图层

    上面说的都是画板本身的图层设置成切片的配置。除了上文说到的元素之外,在 Sketch 中还存在着控件(Symbol)元素。它可以类比为代码中的基类,每一个控件可以实例化出一个控件实例,实现控件一处修改,处处生效的特性,让 UI 设计更加的工程化。控件还有类似代码中变量的覆盖层概念,支持将控件中的某个元素配置化,每个实例配置不同的覆盖层满足不同控件实例求同存异的需求。

    正常情况下插件只能拿到画板下的图层,也就是只能拿到最终的控件实例。如果在控件中包含切片的话(如上图),普通方法是无法获取的。这时候就需要使用上图的“解绑”这个功能,对应到代码的话就是 layer.detach() 方法。点击解绑之后,控件实例就会转换成普通的编组图层,里面会包含一份控件所有元素的复制。这样我们就能按照之前的流程进行处理了。

    const sketch = require('sketch/dom');
    const artboard = sketch.getDocuments()[0].pages[0].layers[0];
    
    artboard.layers.forEach(layer => {
      if(layer.type !== sketch.Types.SymbolInstance) {
        return;
      }
    
      //为了不影响原图层,使用 duplicate() 方法复制一份图层出来再使用 detach() 进行解绑
      const symbolGroup = layer.duplicate().detach({recursively: false});
      
      const exportLayers = symbolGroup.layers.filter(layer => layer.exportFormats.length);
      exportLayers.forEach(layer => sketch.export(layer, {
        scales: 1, 
        formats: 'svg', 
        output: '~/Desktop/Sketch-Export-Demo'
      }));
      
      //最后操作完成后将复制图层删除
      symbolGroup.remove();
    });

    控件画板为切片

    在之后的使用中,我们发现也会存在直接给控件画板设置成切片导出的操作。这种情况下我们直接对控件实例进行解绑会发现解绑后的编组并没有标记成切片,甚至还会出现其他的一些情况。从下图可以看到不仅解绑后的编组丢失了切片标记,控件尺寸也发生了变化。原始是 32×32 的控件,经过解绑之后尺寸变成了 26×26,周边填充的留白消失了。这是因为看到的留白本质是画板尺寸撑起来的,解绑相当于对控件内的所有图层的拷贝,然而并不包括画板。所以画板的切片属性,以及因为画板尺寸带来的留白等特性都丢失了。

    解决的办法也很简单,我们可以通过 sketch.getSymbolMasterWithID() 方法获取到控件,判断控件本身有切片标记的话特殊处理一下。刚才我们说了,控件解绑后肯定是没办法获取到尺寸了,所以我们需要换个思路。由于整个控件是切片,所以我们其实是不需要去观察控件内部是否存在切片导出图层,也就不需要像上面那么复杂去做解绑的操作。通过额外增加一个切片图层,补充上控件丢失的切片标记信息。剩下来的事情其实就和前文“编组带切片图层”一节是一样的了。

    const sketch = require('sketch/dom');
    const document = sketch.getDocuments()[0];
    const artboard = document.pages[0].layers[0];
    
    artboard.layers.forEach(layer => {
      if(layer.type !== sketch.Types.SymbolInstance) {
        return;
      }
    
      const master = document.getSymbolMasterWithID(layer.symbolId);
      if(!master?.exportFormats.length) {
        return;
      }
    
      const instance = layer.duplicate();
      instance.frame = new sketch.Rectangle(0, 0, layer.frame.width, layer.frame.height);
      const slice = new sketch.Slice({
        name: layer.name + '_Slice', 
        frame: new sketch.Rectangle(0, 0, layer.frame.width, layer.frame.height),
        exportFormats: [
          {size: '1x', fileFormat: 'svg'}
        ]
      });
      slice.sketchObject.exportOptions().setLayerOptions(2);
      const group = new Group({
        name: layer.name + '_Group',
        parent: layer.parent,
        frame: layer.frame,
        layers: [
          slice,
          instance
        ]
      });
    
      sketch.export(slice, {scales: '1', formats: 'svg'});
      group.remove();
    });

    这里我们分别创建了当前图层的复制层和与控件等大的切片图层,最后在当前图层的位置实例化了一个编组巧妙的将前两者包裹住再导出切片。可能会有同学疑问,既然已经不需要解绑来获取控件内部的切片,那为什么不在判断该控件实例需要切片导出的时候直接设置该控件实例的导出项呢?

    感兴趣的同学可以试试,你会发现在这种情况下控件实例的导出项也会和我们最开始说的一样丢失画板的留白的。可以想象到 Sketch 内部本身的导出逻辑可能和我们解绑操作差不多。通过等大的切片图层,我们能很好的将控件画板带来的留白保存下来。而冗余的再套了一层编组,则是为了解决前文说的切片导出会附带同层级的背景问题。

    后记

    上述列出来的切片情况基本包含了大部分设计师的切片导出习惯,控件切片在进行解绑后可以回归到图层、编组、切片的逻辑中。按照上述逻辑递归所有图层可以完成所有切片图层的导出。为了更好的帮助大家理解,以上代码都是真实代码,可以直接在 Sketch 的代码编辑器中运行。

    通过以上的例子可以看到,Sketch API 操作真的就是 JavaScript 语法,一点 OC 的东西都没有,对前端工程师非常友好。不过 sketch.export() 方法目前封装的不是非常的完美,在导出组件库中的控件时会存在导出图片空白的情况。这时候只能使用 OC 的方法进行导出了,希望能在之后的版本中修复该问题。

    function nativeExport(layer, {format, scale, filename}) {
      const output = MSExportRequest.exportRequestsFromExportableLayer(layer.sketchObject).firstObject();
      output.format = format;
      output.scale = scale;
      return context.document.saveExportRequest_toFile(output, options.filePath);
    }

    参考资料:

    1. 《Set "Export group contents only"》
    2. 《手把手教你写一个批量切图sketch插件》
    查看原文

    赞 1 收藏 0 评论 0

    公子 发布了文章 · 2020-10-12

    如何制作 Sketch 插件

    Sketch 是近些年比较流行的 UI 设计软件,它比起之前常用的 Illustrator 或者 Photoshop 比较好的地方在于小巧功能简单但足够,同时对 Mac 的触摸板支持更加友好。另外它的插件系统也要比 Adobe 更加友好,大量的插件帮助我们解决协同和效率上的问题。

    Sketch 插件最大的好处在于可以直接使用 JavaScript 进行开发,并提供了许多配套的开发工具。下面我就以帮助设计师同学快速插入占位图的插件 Placeholder 为例,带大家一步一步的了解如何进行 Sketch 插件开发。

    在进行插件开发之前,我们需要了解一些基础的知识。Sketch 是一套原生 Objective-C 开发的软件,它之所以能支持使用 JS 开发,是因为它使用 CocoaScript 作为插件的开发语言。它就像是一座桥(Bridge),能让我们在插件中写 OC 和 JS,然后 Sketch 将基础方法进行了封装,实现了一套 JavaScript API,这样我们就能使用 JS 开发 Sketch 插件了。

    注: 关于如何开发插件,官方提供了一份入门教程《Create a plugin》,在阅读下文之前,也可以花 2~3min 先看看这篇官方教程,内容比较简短。

    需求整理

    在进行插件开发之前,我们捋一捋我们需要实现的功能。http://placeimg.com/ 是一个专门用来生成占位图的网站,我们将利用该网站提供的服务制作一个生成指定大小的占位图并插入到 Sketch 画板中的功能。插件会提供一个面板,可以让使用者输入尺寸、分类等可选项,同时提供插入按钮,点击后会在画板插入一张图片图层。

    使用 skpm 初始化项目

    skpm 是 Sketch 官方提供的插件管理工具,类比于 Node.js 中的 npm。它集插件的创建、开发、构建、发布等多项功能于一体,我们在很多场景都需要使用它。安装的话比较简单,直接使用 npm 全局安装即可。

    npm install -g skpm

    按照官方教程,安装完毕之后我们就可以使用 skpm create 命令来初始化项目目录了。当然 skpm 是支持基于模板初始化的,官方仓库也列举了一些模板,我们可以使用 --temlate 来指定模板进行初始化。不过处于教学的目的,我这里就还是使用官方默认的模板创建了。

    ?  ~ skpm create sketch-placeimg
    ? Done!
    
    
    To get started, cd into the new directory:
      cd sketch-placeimg
    
    To start a development live-reload build:
      npm run start
    
    To build the plugin:
      npm run build
    
    To publish the plugin:
      skpm publish

    skpm 内部会使用 webpack 进行打包编译,运行 npm run build 会生成 sketch-placeimg.sketchplugin 目录,该目录就是最终的插件目录。双击该目录,或者将该目录拖拽到 Sketch 界面上就成功安装插件了。和 webpack --watch 类似,运行 npm run watch 的话对监听文件变化实时编译,在开发中非常有帮助。

    注: 不要使用 npm start 进行开发,它携带的 --run 命令会使得构建速度特别慢。虽然它带 Live Reload 功能会很方便,但在官方未修复该问题前还是不建议大家使用。

    项目结构入门

    创建好的模板目录结构如下,为了帮助大家理解,我们来简单的介绍下这些目录和文件。

    .
    ├── README.md
    ├── assets
    │   └── icon.png
    ├── sketch-assets
    │   └── icon.sketch
    ├── sketch-placeimg.sketchplugin
    │   └── Contents
    │       ├── Resources
    │       │   └── icon.png
    │       └── Sketch
    │           ├── manifest.json
    │           ├── my-command.js
    │           └── my-command.js.map
    ├── node_modules
    ├── package.json
    └── src
        ├── manifest.json
        └── my-command.js

    package.json

    和大多数 JS 项目一样,skpm 创建的项目中也会有 package.json 文件。该文件除了像之前一样记录了项目的依赖和快捷命令之外,还增加了 skpm 字段用来对 skpm 进行配置,默认的值如下。

    {
      ...
      "skpm": {
        "name": "sketch-placeimg",
        "manifest": "src/manifest.json",
        "main": "sketch-placeimg.sketchplugin",
        "assets": [
          "assets/**/*"
        ],
        "sketch-assets-file": "sketch-assets/icons.sketch"
      },
      ...
    }

    这里指定了该插件的名称为 sketch-placeimg,插件的 manifest 文件为 src/manifest.jsonmain 表示的是最终生成的插件目录名称。assets 则表示的插件依赖的图片等相关素材,在编译的时候会将命中该配置的文件拷贝到 <main>/Contents/Resources 目录下。

    manifest.json

    manifest.json 这个文件大家可以理解为是 Sketch 插件的 package.json 文件。我们来看看默认生成的 manifest.json

    {
      "$schema": "https://raw.githubusercontent.com/sketch-hq/SketchAPI/develop/docs/sketch-plugin-manifest-schema.json",
      "icon": "icon.png",
      "commands": [
        {
          "name": "my-command",
          "identifier": "sketch-placeimg.my-command-identifier",
          "script": "./my-command.js"
        }
      ],
      "menu": {
        "title": "sketch-placeimg",
        "items": [
          "sketch-placeimg.my-command-identifier"
        ]
      }
    }

    看到 $schema 就有 JSON Schema 那味了,它对应的 JSON 文件地址告诉我们可以在里面配置那些字段。其实最重要的其实就是上面列出来的 commandsmenu 两个字段。

    commands 标记了插件有哪些命令,这里只有一个命令,命令的名称(name)是 my-command,该命令的 ID(identifier)为 sketch-placeimg.my-command-identifier,对应的执行脚本为 ./my-command.js

    menu 则标记了该插件的导航菜单配置,比如示例这里它指定了该插件在插件菜单中的名称(title)为 sketch-placeimg,并拥有一个子菜单,对应的是 ID 为sketch-placeimg.my-command-identifier的命令。通过这个 ID,菜单的行为就和执行脚本关联起来了。

    appcast.xml

    manifest.json 默认的示例中有两个比较重要的字段没有配置,那就是 versionappcastversion 很明显就是用来表示当前插件的版本的。而 appcast 它的值是一个 XML 的 URL 地址,该 XML 里面包含了该插件所有的版本以及该版本对应的下载地址。Sketch 会将 version 对应的版本和 appcast 对应的 XML 进行对比,如果发现有新的版本了,会使用该版本对应的下载地址下载插件,执行在线更新插件。一个 appcast.xml 文件大概是这样的格式。

    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
      <channel>
        <item>
          <enclosure url="https://github.com/lizheming/sketch-placeimg/releases/download/v0.1.1/sketch-placeimg.sketchplugin.zip" sparkle:version="0.1.1"/>
        </item>
        <item>
          <enclosure url="https://github.com/lizheming/sketch-placeimg/releases/download/v0.1.0/sketch-placeimg.sketchplugin.zip" sparkle:version="0.1.0"/>
        </item>
      </channel>
    </rss>

    如果是通过 skpm publish 命令去发布插件的话,会自动在根目录生成一个 .appcast.xml 文件。当然按照官方文档 《Update a plugin》 所说,你也可以手动生成。

    resource

    从上面的内容我们可以知道,skpm 会通过 package.json 中指定的 manifest 文件读取所有 commands 对应的 script 文件作为编译入口文件,将这些文档编译打包输出到 <main>/Contents/Sketch 目录。所有的 assets 配置对应的文件会拷贝到 <main>/Contents/Resources 目录中。最终完成插件的生成。

    换句话来说只想要走 webpack 打包编译的话就必须是插件的命令才行。如果有一些依赖的非插件类资源,比如插件嵌入的 HTML 页面依赖的 JS 文件想要走编译的话,就需要使用 resource 这个配置了。resource 配置中配置的文件会走 webpack 的编译打包,并输出到 <main>/Contents/Resources 目录中。

    插件开发

    一些基本原理了解清楚之后我们就可以进行插件的开发了。首先我们需要用户点击插件菜单之后打开一个面板,该面板可以配置尺寸、分类等基础信息。

    Sketch 插件中我们可以使用原生写法进行面板的开发,但是这样写起 UI 来说比较麻烦,而且对前端同学来说入门比较高。所以一般大家都会采用 WebView 加载网页的形式进行开发。原理基本上等同于移动端采用 WebView 加载网页一样,客户端调用 WebView 方法加载网页,通过实例的 webContents.executeJavaScript()方法进行插件到网页的通信,而网页中则使用被重定义的 window.postMessage 与插件进行通信。

    sketch-module-web-view

    想要在插件中加载网页,需要安装 Sketch 封装好的 sketch-module-web-view 插件。

    npm install sketch-module-web-view --save-dev
    // src/my-command.js
    import BrowserWindow from 'sketch-module-web-view';
    export default function() {
      const browserWindow = new BrowserWindow({
        width: 510,
        height: 270,
        resizable: false,
        movable: false,
        alwaysOnTop: true,
        maximizable: false,
        minimizable: false
      });
      browserWindow.loadURL(require('../resources/webview.html'))
    }

    当你做完这些你会发现点击插件菜单后什么都没有发生,这是因为还需要更改一下配置。大家可以看到我们最后是使用了 require() 引入了一个 HTML 文件,而官方默认的模板是没有提供 HTML 引入的支持的,所以我们需要为 HTML 文件增加对应的 webpack loader。

    我们这里需要的是 html-loader@skpm/extract-loader 两款 Loader。前者是用来解析处理 HTML 中存在的包括 <link /> 或者 <img /> 之类的 HTML 代码中可能存在的资源关联情况。而后者则是用来将 HTML 文件拷贝到 <main>/Contents/Resources 目录并返回对应的 file:/// 格式的文件路径 URL,用来在插件中进行关联。

    npm install html-loader @skpm/extract-loader --save-dev

    Sketch 插件官方为我们自定义 webpack 配置也预留好了入口,在项目根目录中创建 webpack.skpm.config.js 文件,它导出的方法接收的参数中第一个则是插件最终的 webpack 配置,我们直接在这基础上进行修改即可。

    // webpack.skpm.config.js
    module.exports = function (config, entry) {
      config.module.rules.push({
        test: /\.html$/,
        use: [
          { loader: "@skpm/extract-loader" },
          {
            loader: "html-loader",
            options: {
              attributes: {
                list: [
                  { tag: 'img', attribute: 'src', type: 'src' },
                  { tag: 'link', attribute: 'href', type: 'src' }
                ]
              }
            }
          }
        ]
      });
    }

    html-loader 插件在新版里对配置格式做了一些修改,所以之前很多老的教程中的配置都会报错。当然如果你有更多的插件需求也可以按照这个流程往配置对象中添加。之后我们再执行 npm run watch,点击菜单就可以看到我们预期的页面了。

    注: 官方是提供了一套带有 sketch-module-web-view 模块的模板的,这里只是为了能更清楚的给大家解释清楚插件的原理和流程所以和他家一步一步的进行说明。真实的开发场景中建议大家直接使用以下命令进行快速初始化。

    skpm create <plugin-name> --template=skpm/with-webview

    React 的集成

    面板这块我准备使用 React 进行开发,主要是有 React Desktoop 这个 React 组件,能够很好的在 Web 中模拟 Mac OSX 的 UI 风格(虽然也就几个表单没什么好模拟的就是了)。

    令人开心的是 skpm 默认的 webpack 配置已经增加了 React 的支持,所以我们不需要额外的增加 webpack 的配置,只需要把 React 相关的依赖安装好就可以进行开发了。

    npm install react react-dom react-desktop --save-dev

    增加 webview.js 入口文件。由于该文件需要走 webpack 编译,但是又不是插件命令的执行文件,所以我们需要像上文说的,将入口文件加入到 package.jsonskpm.resources 配置中。

    // package.json
    {
      "skpm": {
        "resources": [
          "resources/webview.js"
        ]
      }
    }
    
    // resources/webview.js
    import React from 'react';
    import ReactDOM from 'react-dom';
    
    function App() {
      return (<>
        <p>Hello World!</p>
        <hr />
        via: <em>@lizheming</em>
      </>)
    }
    
    ReactDOM.render(<App />, document.getElementById('app'));

    webview.html 也需要改造一下,引入 JS 入口文件。这里需要注意一下 ../resource_webview.js 这个引用文件地址,这是 JS 入口文件编译后最终的文件地址。主要是因为 HTML 文件最终会生成到 <name>.sketchplugin/Resources/_webpack_resources 目录下,而 JS 入口文件会将 / 分隔符替换成 _ 分隔符,生成在 <name>.sketchplugin/Resources 目录下。

    <!DOCTYPE html>
    <html lang="zh-CN">
      <head>
        <meta charset="utf-8" />
        <title>PlaceIMG</title>
      </head>
      <body>
        <div id="app"></div>
        <script data-original="../resources_webview.js"></script>
      </body>
    </html>

    注:

    1. HTML 文件生成到 _webpack_resources 配置
    2. JS 入口文件生成到 Resource 目录配置

    面板开发

    流程打通了之后接下来我们可以专心进行面板的开发了。面板开发这块就不多描述了,无非就是前段页面的编写而已,最后插件面板大概是长这样子的。

    -_-||嗯,其实我就是想和大家讲下流程硬上 React 的…

    选择完毕点击插入后,调用 postMessage() 方法将最终的配置传递给插件。

    //resources/webview.js
    import React, {useReducer} from 'react';
    
    function App() {
      const [{width, height, category, filter}, dispatch] = useReducer(
        (state, {type, ...payload}) => ({...state, ...payload}),
        {width: undefind, height: undefined, category: 'any', filter: 'none'}
      );
      const onInsert = _ => postMessage('insert', width, height, category, filter);
      return (
        <button onClick={onInsert}>插入</button>
      );
    }
    注:Web 原生的 postMessage() 方法的语法为 postMessage(message, targetOrigin, [transfer])。事件名称和事件参数都应该序列化之后通过 message 参数传入。

    Sketch 插件中的 postMessage() 方法是注入方法,它对原生的方法进行了复写,所以参数格式上会与原生的不一样。注入方法的实现可参见 sketch-module-web-view 代码

    在插件中,我们监听 insert 事件,获取到用户选择的配置之后给生成图片图层插入到画板中。

    //src/my-command.js
    import sketch, { Image, Rectangle } from 'sketch/dom';
    import BrowserWindow from 'sketch-module-web-view';
    
    export default function() {
      const browserWindow = new BrowserWindow({...});
      browserWindow.webContents.on('insert', function(width, height, category, filter) {
        const url = 'https://placeimg.com/' + [width, height, category, filter].join('/');
        new Image({
          image:  NSURL.URLWithString(url),
          parent: getSelectedArtboard(),
          frame: new Rectangle(0, 0, width, height),
        });
        return browserWindow.close();
      });
    }

    插件发布

    最终我们的插件的主体功能就开发完毕了。下面我们就可以进行插件的发布了。我们可以直接使用 skpm publish 进行发布,它需要你通过 skpm publish --repo-url 或者是 package.json 中的 repository 字段为插件指定 Github 仓库地址。

    Personal Access Token 页面为 skpm 申请新的 Token,记得勾选上 repo 操作的权限。使用 skpm login <token> 进行登录之后,skpm 就获得了操作项目的权限。

    最后通过 skpm publish <version> 就可以成功发布了。如前文所说,发布后会在项目目录创建 .appcast.xml 文件,同时会发布一条对应版本的 Release 记录,提供插件的 zip 包下载地址。执行完 publish 操作后,如果发现你的插件还没有在插件中心仓库中列出来,还会询问你是否提交个 PR 把自己的插件增加上。

    当然如果你的插件不方便发布到 Github 上,也可以使用前文所说的手工发布,执行 skpm build 后对生成的 <name>.sketchplugin 目录进行打包即可。

    插件调试

    上文的示例插件比较简单,所以没有使用特别多的调试手段。在官方教程《Debug a plugin》中描述了多种可以进行调试的方式。用的比较多的还是日志调试方式,可以使用系统的 Console.app 查看日志,也可以使用 skpm log -f 插件日志。

    文档里说的大部分是插件的调试,WebView 内的前端代码调试会更简单一点。WebView 窗体右键审查元素即可使用 Safari 的开发者工具进行调试了。

    注:插件本身的代码本质是客户端代码,WebView 本质是前端代码,所以两者的调试和日志输出位置都是有区别的,这里要注意区分。

    后记

    以上就是开发 Sketch 的一些基础知识和简单流程,其它的就是多去看一下 Sketch API 文档了。不过在实际的使用中 Sketch 的这套 JavaScript API 并不是非常完美,部分功能可能还暂时需要使用原生 API 区别。这时候可以多 Google 一下,能找到很多前人的实现,节省自己的工作量。

    本文主要是介绍了一套 JavaScript API + WebView 的偏前端的开发方式,代码我都已经放到 Github 上 https://github.com/lizheming/...,大家可以自行查阅和下载。除了这种方式之外,我们也可以使用 OC + WebView 甚至是纯 OC 客户端的方式去开发插件。使用纯客户端开发的话性能会比 JavaScript API 的形式好一点,但是对于不了解 OC 开发的前端同学来说上手难度还是比较高的。

    除了 Sketch 之外,Figma 也是一款非常棒的 UI 设计软件。它基于 Web 开发,天生跨平台,更提供了更加易用的协作模式,解决 UI 开发中的多人协作问题。感兴趣的同学也可以去了解一下。

    参考资料:

    1. 《Sketch插件开发总结》
    查看原文

    赞 5 收藏 3 评论 0

    公子 发布了文章 · 2020-09-28

    如何使用 ThinkJS 优雅的编写 RESTful API

    RESTful 是目前比较主流的一种用来设计和编排服务端 API 的一种规范。在 RESTful API 中,所有的接口操作都被认为是对资源的 CRUD,使用 URI 来表示操作的资源,请求方法表示具体的操作,响应状态码表示操作结果。之前使用 RESTful 的规范写过不少 API 接口,我个人认为它最大的好处就是帮助我们更好的去规划整理接口,如果还是按照以前根据需求来写接口的话接口的复用率不高不说,整个项目也会变得非常的杂乱。

    文件即路由是 ThinkJS 的一大特色,比如 /user 这个路由等价于 /user/index,会对应到 src/controller/user.js 中的 indexAction 方法。那么就以 /user 这个 API 为例,在 ThinkJS 中要创建 RESTful 风格的 API 需要以下两个步骤:

    1. 运行命令 thinkjs controller user -r 会创建路由文件 src/controller/user.js
    2. src/config/router.js 中使用自定义路由标记该路由为 RESTful 路由

      //src/config/router.js
      module.exports = [
        ['/user/:id?', 'rest']
      ];

    这样我们就完成了一个 RESTful 路由的初始化,这个资源的所有操作都会被映射成路由文件中对应请求方法的 Action 函数中,例如:

    • GET /user 获取用户列表,对应 getAction 方法
    • GET /user/:id 获取某个用户的详细信息,也对应 getAction` 方法
    • POST /user 添加一位用户,对应 postAction 方法
    • PUT /user/:id 更新一位用户资料,对应 putAction 方法
    • DELETE /user/:id 删除一位用户,对应 deleteAction 方法

    然而每个 RESTful 路由都需要去 router.js 中写一遍自定义路由未免过于麻烦。所以我写了一个中间件 think-router-rest,只需要在 Controller 文件中使用 _REST 静态属性标记一下就可以将其转换成 RESTful 路由了。

    //src/controller/user.js
    module.exports = class extends think.Controller {
      static get _REST() {
        return true;
      }
    
      getAction() {}
      postAction() {}
      putAction() {}
      deleteAction() {}
    }

    简单的了解了一些入门知识之后,下面我就讲一些我平常开发 RESTful 接口时对我有帮助的一些知识点,希望对大家开发项目会有所帮助。

    表结构梳理

    拿到需求之后千万不要急着先敲键盘,一定要把表结构整理好。其实说是表结构,实际上就是对资源的整理。以 MySQL 为例,一般一类资源就会是一张表,比如 user 用户表,post 文章表等。当你把表罗列出来之后那么其实你的 RESTful 接口就已经七七八八了。比如你有一张 post 文章表,那么之后你的接口肯定会有:

    • GET /post 获取文章列表
    • GET /post/1 获取 id=1 的文章信息
    • POST /post 添加文章
    • PUT /post/1 修改 id=1 的文章信息
    • DELETE /post/1 删除 id=1 的文章

    当然不是所有的事情都这么完美,有时候接口的操作可能五花八门,这种时候我们就要尽量的去思考接口行为的本质是什么。比如说我们要迁移文章给其它用户,这时候你就要思考它其实本质上就是修改 post 文章资源的 user_id 属性,最终还是会映射到 PUT /post/1 接口中来。

    想清楚有哪些资源能帮助你更好的创建表,接下来就要想清楚资源之间的关系了,它能帮助你更好的创建表结构。一般资源之间会存在以下几类关系:

    • 一对一:如果一位 user 只能创建一篇 post 文章,则是一对一的关系。在 post 中可以使用 user_id 字段来关联对应的 user 数据,在 user 中也可以使用 post_id 来关联对应的文章数据。
    • 一对多:如果一位 user 能创建多篇 post 文章,则是一对多的关系。在 post 中可以使用 user_id 字段来关联对应的 user 数据。
    • 多对多:如果一位 user 可以创建多篇 post 文章,一篇 post 文章也可以有多位 user,则是多对多的关系。多对多关系没办法通过一个字段来表示,这时候为了描述清楚多对多的关系,就需要一张中间表 user_post,用来做 userpost 表的关系映射。表内部的 user_id 表示 user 表 ID,post_id 则表示 post 表对应数据 ID。
    mysql> DESCRIBE user;
    +-------+--------------+------+-----+---------+----------------+
    | Field | Type         | Null | Key | Default | Extra          |
    +-------+--------------+------+-----+---------+----------------+
    | id    | int(11)      | NO   | PRI | NULL    | auto_increment |
    | name  | varchar(100) | YES  |     | NULL    |                |
    +-------+--------------+------+-----+---------+----------------+
    2 rows in set (0.01 sec)
    
    mysql> DESCRIBE post;
    +-------+---------+------+-----+---------+----------------+
    | Field | Type    | Null | Key | Default | Extra          |
    +-------+---------+------+-----+---------+----------------+
    | id    | int(11) | NO   | PRI | NULL    | auto_increment |
    | title | text    | YES  |     | NULL    |                |
    +-------+---------+------+-----+---------+----------------+
    2 rows in set (0.00 sec)
    
    mysql> DESCRIBE user_post;
    +---------+---------+------+-----+---------+----------------+
    | Field   | Type    | Null | Key | Default | Extra          |
    +---------+---------+------+-----+---------+----------------+
    | id      | int(11) | NO   | PRI | NULL    | auto_increment |
    | user_id | int(11) | NO   |     | NULL    |                |
    | post_id | int(11) | NO   |     | NULL    |                |
    +---------+---------+------+-----+---------+----------------+
    3 rows in set (0.00 sec)

    作为一款约定大于配置的 Web 框架,ThinkJS 默认规定了请求 RESTful 资源的时候,会根据当前资源 URI 找到对应的资源表,比如 GET /post 会找到 post 表。然后再进行查询的之后会进行自动的关联查询。例如当你在模型里标记了 postuser 是一对多的关系,且 post 表中存在 user_id 字段(也就是关联表表名 + _id),会自动关联获取到 project 对应的 user 数据。这在进行数据操作的时候会节省非常多的工作量。

    登录登出

    当我第一次写 RESTful API 的时候,我就碰到了这个难题,平常大家都是使用 /login, /logout 来表示登录和登出操作的,如何使用资源的形式来表达就成了问题。后来想了下登录操作中涉及到的资源其实就是登录后的 Token 凭证,本质上登录就是凭证的创建与获取,登出就是凭证的删除。

    • GET /token:获取凭证,用来判断是否登录
    • POST /token:创建凭证,用来进行登录操作
    • DELETE /token:删除凭证,用来进行登出操作

    权限校验

    我们平常写接口逻辑,其实会有很大一部分的工作量是用来做用户请求的处理。包括用户权限的校验和用户参数的校验处理等,这些逻辑其实和主业务场景没有太大的关系。为了将这些逻辑与主业务场景进行解耦,基于 Controller 层之上,ThinkJS 会存在一层 Logic 逻辑校验层。Logic 与 Controller 一一映射,并提供了一些常用的校验方法,我们可以将权限校验,参数校验,参数处理等逻辑放在这里,让 Controller 只做真正的业务逻辑。

    在 Logic 和 Controller 中,都存在 __before()魔术方法,当前 Controller 内所有的 Action 执行之前都会先执行 __before() 操作。利用这个特性,我们可以将一些通用的权限校验逻辑放在这里,比如最平常的登录判断逻辑,这样就不需要在每个地方都做判断了。

    //src/logic/base.js
    module.exports = class extends think.Logic {
      async __before() {
        //接口 CSRF 校验
        if (!this.isCli && !this.isGet) {
          const referrer = this.referrer(true);
          if (!/^xxx\.com$/.test(referrer)) {
            return this.fail('请不要在非其它网站中使用该接口!');
          }
        }
    
        // 非登录接口需要做登录校验
        const userInfo = await this.session('userInfo') || {};
        if(think.isEmpty(userInfo) && !/\/(?:token)\.js/.test(this.__filename)) {
          return this.ctx.throw(401, 'UnAuthorized');
        }
      }
    }
    
    //src/logic/user.js
    const Base = require('./base.js');
    module.exports = class extends Base {}

    创建一个 Base 基类,所有的 Logic 通过继承该基类就都能享受到 CSRF 和登录校验了。

    问:所有的请求都会实例化类,所以 contructor 本质上也会在所有的 Action 之前执行,那为什么还需要 __before() 魔术方法的存在呢?

    答:constructor 构造函数虽然有前置执行的特性,但是无法在保证顺序的情况下执行异步操作。构造函数前是不能使用 async 标记的,而 __before() 是可以的,这也是它存在的原因。

    善用继承

    在 RESTful API 中,我们其实会发现很多资源是具有从属关系的。比如一个项目下的用户对应的文章,这句话中的三种资源 项目用户文章 就是从属关系。在从属关系中包括权限、数据操作等也都是具有从属关系的。比如说文章属于用户,非该用户的话自然是无法看到对应的文章的。而用户又从属于项目,其它项目的人是无法操作该项目下的用户的。这就是所谓的从属关系。

    确立了从属关系之后我们会发现越到下级的资源在对其操作的时候要判断的权限就越多。以刚才的例子为例,如果说我们对项目资源进行操作的话,我们需要判断该用户是否在项目中。而如果要对项目下的用户文章进行操作的话,除了需要判断用户是否在项目中,还需要判断该文章是否是当前用户的。

    在这个例子中我们可以发现:资源关系从属的话权限校验也会是从属关系,从属关系中级别越深的资源需要判断的权限越多。面向对象语言中,继承是一个比较重要的功能,它最大的好处就是能帮助我们进行逻辑的复用。通过继承,我们能直接在子资源中复用父资源的校验逻辑,避免重复劳动。

    //src/logic/base.js
    module.exports = class extends think.Logic {
      async __before() {
        const userInfo = this.session('userInfo') || {};
        this.userInfo = this.ctx.state.userInfo = userInfo;
        if(think.isEmpty(userInfo)) {
          return this.ctx.throw(401);
        }
      }
    }
    
    //src/logic/project/base.js
    const Base = require('../base.js');
    module.exports = class extends Base {
    async __before() {
        await super.__before();
    
        const {team_id} = this.get();
        const {id: user_id} = this.userInfo;
        const permission = await this.model('team_user').where({team_id, user_id}).find();
        
        const {controller} = this.ctx;
        // 团队接口中只有普通用户只有权限调用获取邀请链接详细信息和接受邀请链接两个接口
        if(controller !== 'team/invitation' && (this.isGet && !this.id)) {
          if(think.isEmpty(permission)) {
            return this.fail('你没有权限操作该团队');
          }
        }
        
        this.userInfo.role_id = permission.role_id;
      }
    }
    
    //src/logic/project/user/base.js
    const Base = require('../base');
    module.eports = class extends Base {
      async __before() {
        await super.__before();
        
        const {role_id} = this.userInfo;
        if(!global.EDITOR.is(role_id)) {
          return this.fail('你没有权限操作该文章');
        }
      }
    }

    通过创建三个 Base 基类,我们将权限校验进行了合理的拆分同时又能保证校验的完整性。同级别的路由只要继承当前层级的 Base 基类就能享受到通用的校验逻辑。

    • /project 路由对应的 Logic 因为继承了 src/logic/base.js 所以实现了登录校验。
    • /project/1/user 路由对应的 Logic 因为继承了 src/logic/project/base.js 所以实现了登录校验以及是否在是项目成员的校验。
    • /project/1/user/1/post 路由对应的 Logic 因为继承了 src/logic/project/user/base.js 所以实现了登录校验、项目成员校验以及项目成员权限的校验。

    瞧,套娃就这么简单!

    数据库操作

    从属的资源在表结构上也有一定的反应。还是以之前的项目、用户和文章为例,一般来说你的文章表里会存在 project_iduser_id 两个关联字段来表示文章与用户和项目资源的关系(简单假设都是一对多的关系)。那么这时候实际上你对项目下的文章操作实际上都需要传入 project_iduser_id 这两个 WHERE 条件。

    ThinkJS 内部使用 think-model 来进行 SQL 数据库操作。它有一个特性是支持链式调用,我们可以这样写一个查询操作。

    //src/controller/project/user/post.js
    module.exports = class extends think.Controller {
      async indexAction() {
        const ret = await this.model('post').where({project_id: 1}).where({user_id: 2}).select();
        return this.success(ret);
      }
    }

    利用这个特性,我们可以对操作进行优化,在 constructor 的时候将当前 Controller 下的通用 WHERE 条件 project_iduser_id 传入。这样我们在其它的 Action 操作的时候就不用每个都传一变了,同时也一定规避了可能会漏传限制条件的风险。

    //src/controller/project/user/post.js
    module.exports = class extends think.Controller {
      constructor(ctx) {
        super(ctx);
        const {project_id, user_id} = this.get();
        this.modelInstance = this.model('post').where({project_id, user_id});
      }
    
      async getAction() {
        const ret = await this.modelInstance.select();
        return this.success(ret);
      }
    }

    后记

    RESTful API 除了以上说的一些特性之外,它对响应状态码、接口的版本也有一定的规范定义。像 Github 这种 RESTful 实现比较好的网站还会实现 Hypermedia API 规范,在每个接口中会返回操作其它资源时需要的 RESTful 路由地址,方便调用者进行链式调用。

    当然 RESTful 只是实现 API 的一种规范,还有其它的一些实现规范,比如 GraphQL。关于 GraphQL 可以看看之前的文章《GraphQL 基础实践》,这里就不多做补充了。

    查看原文

    赞 15 收藏 7 评论 1

    认证与成就

    • 获得 2673 次点赞
    • 获得 104 枚徽章 获得 8 枚金徽章, 获得 44 枚银徽章, 获得 52 枚铜徽章

    擅长技能
    编辑

    开源项目 & 著作
    编辑

    • Firekylin

      A Simple & Fast Node.js Blogging Platform Base On ThinkJS & React & ES2015+

    • Animaris

      使用 ThinkJS + MongoDB + React + Antd 开发的移动端 WebView 接口文档系统

    • Pharos

      A Simple Front End monitor system Base On ThinkJS 3 & ReactJS & ES2015+.

    • Ionic实战

      基于AngularJS的移动混合应用开发

    • Waline

      基于 Valine 衍生的简洁、安全的评论系统

    注册于 2011-09-21
    个人主页被 35.7k 人浏览

    bt365体育投注