• 命令式编程、声明式编程、响应式编程与 RxJS

    命令式编程、声明式编程、响应式编程与 RxJS

    命令式编程、声明式编程、响应式编程

    命令式编程(Imperative):详细的命令机器怎么(How)去处理一件事情以达到你想要的结果(What);
    声明式编程(Declarative):只告诉你想要的结果(What),机器自己摸索过程(How)。

    出自:《命令式编程(Imperative) vs 声明式编程(Declarative)》

    命令式编程是我们一步一步告诉机器需要怎么做,机器按部就班地执行命令。声明式编程是我们告诉机器我想要这样的结果,而不管他是怎么实现的,这更符合人类的思维。举一个数据过滤的例子来说明这一点,比如我们要打印下数组中存不存在 3。

    示例1:

    // 命令式编程做法
    let res = false;
    for(i = 0; i < dataArr.length; i++) {
        if (i === 3) {
            res = true;
        }
    }
    console.log(res);
    
    // 声明式编程做法
    let res = dataArr.filter(i => i === 3);
    console.log(res);
    

    Angular 中的 RxJS,用了“声明式编程范式”,是一个使用可观察对象实践“响应式编程”模型的库。那什么是响应式编程呢?

    它希望有某种方式能够构建关系,而不是执行某种赋值命令。
    响应式编程是一种通过异步和数据流来构建事务关系的编程模型。

    出自:《重新理解响应式编程》

    构建关系是指我们可以定义两个变量(A 和 B)之间具有某种永恒的关系。一旦 A 变量改变,我们不需要人为地对 B 变量进行任何处理,B 变量自动更改以满足与 A 变量已经定义好的关系。

    示例2:

    A = 1;
    
    B - A := 2;     // 定义一种关系,这里是指 B 减去 A 永远等于 2
    console.log(B); // B = 3
    
    A = 3;          // A 改变
    console.log(B); // B = 5
    

    数据流是响应式编程传递变化的值的方式,通过数据流将变化的数据不断向下传递。因此,我们可以链式的对数据处理。

    示意图1:
    data-flo
    上图与命令式编程的不同之处是:命令式编程我们会一步步的去实现花括号里面的操作,不是“我要颜色深一点”,而是“我让颜色深一点”。

    可以说响应式编程是声明式编程的一种,两者到底是从属关系还是其他关系,并不十分清楚,这似乎并不重要。个人倾向于把响应式编程当做一种更具体的声明式编程,是声明式编程的更高级范式。

    RxJS

    RxJS 是 JS 版本的 ReactiveX 库,ReactiveX 还有 JAVA、.NET、Swift 等语言的版本。无意造轮子,此篇仅对 RxJS 做一个标记式的记录和学习指引,详细了解可查阅:《RxJS 官网文档》(英文)以及《RxJS 中文文档》

    RxJS 是使用 Observables 的响应式编程的库,它提供了一系列 API 来支持响应式编程。

    学习 RxJS 有几个概念和注意点需要重点知晓:

    • Observable(可观察对象):RxJS 处理的数据或者是 RxJS 抽象操作或者数据的方式,它是函数的泛化,支持返回多个值,惰性运算,需要被订阅之后才会执行。使用 RxJS,就是在构造 Observable 和处理 Observable。
    • Subscription (订阅):观察者对象,或者说是订阅 Observable 的那个对象,更进一步可以说是接收 Observable 返回值的那个对象。
    • Subject (主体):可以理解为支持多播的 Observable。Subjects 是将任意 Observable 执行共享给多个观察者的唯一方式。
    • RxJS 的操作符:提供便捷的数据(事件流)处理,比如 map、filter,以及 combineAll、forkJoin、pairwise 等。
    • Rx.Observable.create 是 Observable 构造方法的别名,它通知是下面这个样子:
      Rx.Observable.create({
          next: Function,
          error: Function,
          complete: Function
      });
    

    RxJS 还提供了 from of 等其他创建 Observable 的方法。

    • Observables 并不是异步编程,传递值可以是同步的,也可以是异步的。
    • 在 Observable 执行中, 可能会发送零个到无穷多个 “Next” 通知。如果发送的是 “Error” 或 “Complete” 通知的话,那么之后不会再发送任何通知了。
  • openresty 部署 https 并开启 http2 支持

    openresty 部署 https 并开启 http2 支持

    这里分两步介绍,第一部是配置 https , 第二步是开启 http2 支持。事实上开启 http2 支持,必须配置站点使用 https 传输协议。

    HTTPS

    为站点部署开启 HTTPS 支持,需要一个可信任的第三方 SSL 证书,然后针对不同的服务器环境进行配置。

    我选择使用Certbot来部署一个免费、可自动更新的由Let’s Encrypt提供的期限为 90 天的权威可信任证书。Certbot官网根据不同的服务器环境提供了不同的部署方法,我使用 ubuntu + openresty(nginx) ,根据官网介绍部署方式如下:

    1. 安装 certbot(或者通过源码安装certbot
    $ sudo apt-get update
    $ sudo apt-get install python-software-properties software-properties-common
    $ sudo add-apt-repository ppa:certbot/certbot
    $ sudo apt-get update
    $ sudo apt-get install certbot
    
    1. 获得证书
    $ certbot(or path/certbot-auto) certonly --webroot -w /path/your-web-root -d your-domain.com -d www.your-domain.com
    

    执行上述代码后,会在系统的/etc/letsencrypt/live目录下生成以your-domain.com的目录,里面会生成cert.pem``chain.pem``fullchain.pem``privkey.pem``README几个文件接下来在 nginx 配置文件中需要用到fullchain.pem``privkey.pem两个文件,这有关SSL证书链的概念,超出本文叙述范围。
    3. 配置 nginx,在 server 中与 https 相关的配置如下

    server {
       ...
       listen 443 ssl;
       ssl on;
       ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
       ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
    
       ssl_session_cache shared:le_nginx_SSL:1m;
       ssl_session_timeout 1440m;
    
       ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;
       ssl_ciphers RC4:HIGH:!aNULL:!MD5;
       ssl_prefer_server_ciphers on;
       ...
    }
    
    1. 执行nginx -t测试 nginx 配置是否正确,确认无误后,重启 nginx 服务。
    2. 设置证书自动更新。
      上文说过Let’s Encrypt提供的免费证书期限为 90 天,certbot 提供了证书自动更新服务,通过源码安装则需要进行一些配置(配置系统的定时任务)。
      1. (Ubuntu) 在/var/spool/cron/crontabs/root文件中添加以下配置:
      0 0 * * * root /path/certbot-auto renew --quiet --no-self-upgrade
      1. 使用certbot renew --dry-run命令测试是否配置成功。

    HTTP2

    配置好 HTTPS 后开启 HTTP2 比较简单,两步即可完成。

    1. 重新编译openresty加入--with-http_v2_module --with-http_ssl_module选项,替换系统中使用的nginx执行文件:
    ./configure --with-http_v2_module --with-http_ssl_module
    make // 不要 make install
    

    关闭 nginx,使用编译生成的/resources/openresty-1.11.2.2/build/nginx-1.11.2/objs/nginx文件替换系统正使用的/usr/local/openresty/nginx/sbin/nginx文件,重启 nginx 即可。
    2. 修改 nginx 配置。在 listen 后添加 http2 ,如下所示:

    server {
       ...
       listen 443 ssl http2;
       ...
    }
    

    如果一开始就开启了 nginx 的 h2 模块,就不需要第一步了。至此,配置 https 并开启 http2 完成。

  • 树莓派使用 frp 实现内网穿透搭建 Nextcloud 私有云的准备工作

    树莓派使用 frp 实现内网穿透搭建 Nextcloud 私有云的准备工作

    需要用到的硬件

    烧录好系统的树莓派主机,我使用的是 Raspberry Pi 3B,系统使用RASPBIAN JESSIE LITE

    树莓派适用的散热外壳(大量上传下载的情况下,系统发热量惊人,推荐配置);

    硬盘 + 硬盘底座(树莓派的供电是带不起移动硬盘的,即便给移动硬盘额外的 USB 供电也吃力,强烈推荐)。


    我买的 3B 及其外壳,现在树莓派已经出了第四代了,可以去这里购买:传送门

    双盘位的硬盘盒:传送门
    安装 Nextcloud 前的准备
    确保树莓派已经烧录好系统,可参考:《树莓派(Raspberry Pi)入门实战记录之 SSH 局域网无线连接》为树莓派烧录系统并设置 SSH 连接。

    创建目录、用户

    1. 创建 nobody 用户组,统一系统用户,避免出现权限问题
    $ sudo groupadd nobody
    
    1. 创建网站服务根目录
    $ sudo mkdir [path]/web
    

    树莓派硬盘挂载

    1. 树莓派开启支持 exfat
     $ sudo apt-get install exfat-utils
     $ sudo reboot now // 重启生效
    
    1. 连接硬盘,查看硬盘信息
     $ sudo fdisk -l
    

    我的硬盘信息显示如下:
    5170_P_1442946199645.jpg
    所以,挂载路径为:/dev/sda
    3. 格式化硬盘为 ext4 格式

     $ sudo mkfs.ext4 /dev/sda
    
    1. 创建硬盘挂载点
     $ mkdir [path]/disk
    
    1. 挂载硬盘
     $ sudo mount -t ext4 /dev/sda [path]/disk
    
    1. 设置开机自动挂载
      编辑/etc/fstab,加入以下配置:
     /dev/sda     [path]/disk      ext4      defaults      0      0
    

    安装 MariaDB

    1. 使用 apt-get 安装 MariaDB
     $ sudo apt-get install mariadb-server
    
    1. 创建数据库
     $ mysql -uroot -p
     $ mysql> CREATE DATABASE nextcloud CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
    
    1. 创建 nobody 用户并授权
     $ mysql> CREATE USER 'nobody'@'%' IDENTIFIED BY '[password]'; 
     $ mysql> GRANT ALL ON nextcloud.* TO 'nobody'@'%';
    
    1. 为 Nextcloud 配置 MariaDB,参考:Database Requirements for MySQL / MariaDB
     $ mysql> SET GLOBAL binlog_format = 'MIXED';
    
     [mysqld]
    
    1. 配置 innodb ——/etc/mysql/my.cnf,加入以下配置:
      1. 设置 binlog_format

    innodb_large_prefix=on
    innodb_file_format=barracuda
    innodb_file_per_table=1
    “`

    安装 openresty(nginx) 设置随系统启动

    1. 上传 openresty
     scp [local-path]/openresty-1.11.2.3.tar.gz pi@[ip-address]:[path]/openresty-1.11.2.3.tar.gz
    
    1. 编译安装 openresty
     $ sudo apt-get install libpcre3 libpcre3-dev libssl-dev 
     $ ./configure --with-luajit --with-pcre
     $ make
     $ sudo make install
    
    1. 部署 Nginx 站点配置
    2. 部署 openresty 开机自启动

    编译安装 php7.1

    1. 编译安装
     $ sudo apt-get install libxml2-dev curl libcurl4-gnutls-dev libjpeg-dev libxslt-dev
    
     $ ./configure --prefix=[path]/php \
      --with-openssl \
      --with-curl \
      --with-freetype-dir \
      --with-gd \
      --with-gettext \
      --with-iconv-dir \
      --with-kerberos \
      --with-libdir=lib64 \
      --with-libxml-dir \
      --with-mysqli \
      --with-pcre-regex \
      --with-pdo-mysql \
      --with-pdo-sqlite \
      --with-pear \
      --with-jpeg-dir \
      --with-png-dir \
      --with-xmlrpc \
      --with-xsl \
      --with-zlib \
      --with-regex \
      --enable-ctype \
      --enable-maintainer-zts \
      --enable-fpm \
      --enable-bcmath \
      --enable-libxml \
      --enable-inline-optimization \
      --enable-gd-native-ttf \
      --enable-mbregex \
      --enable-mbstring \
      --enable-opcache \
      --enable-pcntl \
      --enable-shmop \
      --enable-soap \
      --enable-sockets \
      --enable-sysvsem \
      --enable-xml \
      --enable-zip
    
     $ make
     $ sudo make install
    
    1. 部署 php 配置文件
     $ sudo cp [php-source-code-path]/php.ini-production [php-path]/php/lib/php.ini
     $ sudo cp [php-path]/php/etc/php-fpm.conf.default [php-path]/php/etc/php-fpm.conf
     $ sudo cp [php-path]/php/etc/php-fpm.d/www.conf.default [php-path]/php/etc/php-fpm.d/www.conf
    
    1. 编辑[php-path]/php/etc/php-fpm.d/[www.conf](http://www.conf),加入以下 PATH(通过执行$ printenv PATH获得)
     /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/games:/usr/games
    
    1. 继续编辑[php-path]/php/etc/php-fpm.d/[www.conf](http://www.conf),启动 socket 连接方式(提升系统执行效率)
     listen= /var/run/php71-fpm.sock
     listen.owner = nobody
     listen.group = nobody
    
    1. 配置 php-fpm 随系统启动

    安装 Nextcloud 的准备工作完成,下一步是使用 frp 实现内网穿透搭建 Nextcloud 私有云

  • 树莓派使用 frp 实现内网穿透搭建 Nextcloud 私有云

    树莓派使用 frp 实现内网穿透搭建 Nextcloud 私有云

    在家庭网络没有固定 IP 或者家庭网络不能被外网访问的情况下,使用树莓派搭建 Nextcloud 私有云系统服务,需要借助于有外网 IP 的服务器,使用frp等工具进行内网穿透,整套系统的访问过程示意如下:

    格安云服务示意.png



    如果你直接访问到了这个页面,可参考下《树莓派使用 frp 实现内网穿透搭建 Nextcloud 私有云的准备工作》了解采用此方案搭建 Nextcloud 私有云服务所要进行的准备工作,以便快速上手,少走弯路。

    这里主要介绍使用 frp 进行内网穿透和 Nextcloud 私有云的安装。

    搭建内网穿透服务

    1. 参考Github-frp安装配置好 frp 内网穿透服务;
      我的树莓派端的配置文件关键信息如下:
     [common]
     server_addr = [your-public-server-ip]
     server_port = 7000
     privilege_token = [your-privilege_token]
    
     

    [ssh]

    type = tcp local_ip = 127.0.0.1 local_port = 22 remote_port = 6000

    [web]

    privilege_mode = true type = http local_port = 80 custom_domains = [your-domain]

    我的公网服务器端的配置文件关键信息如下:

     [common]
     bind_port = 7000
     vhost_http_port = 8080
     dashboard_port = 7500
     # dashboard 用户名密码,默认都为 admin
     dashboard_user = [your-dashboard_user]
     dashboard_pwd = [your-dashboard_pwd]
     privilege_mode = true
     privilege_token = [your-privilege_token]
     privilege_allow_ports = 4000-50000
     log_file = [path]/frps.log
     log_level = info
     log_max_days = 3
    
    1. 公网服务器端配置 Nginx,转发外网访问请求到 frp server 服务;
    server {
     listen 80;
     server_name  [your-domain];
    
     location / {
        proxy_pass http://127.0.0.1:8080; # frp server 端的 vhost_http_port 配置的端口
        proxy_set_header    Host            $host;
        proxy_set_header    X-Real-IP       $remote_addr;
        proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_hide_header   X-Powered-By;
      }
    }
    
    1. 配置好后,通过域名 [your-domain] 访问树莓派 Nginx 的默认网站测试配置是否正确

    安装 Nextcloud

    1. 解压下载好的 Nextcloud 程序文件到安装准备工作中创建的 web 目录:[path]/web/nextcloud
    2. 设置目录权限
     $ sudo chown -R nobody:nobody [path]/web/nextcloud
    
    1. 参考Nextcloud Nginx Configuration配置 Nginx,推荐使用 socket 方式连接 php-fpm
    upstream php-handler {
     # server 127.0.0.1:9000;
     server unix:/var/run/php71-fpm.sock;
    }
    
    1. 现在可以通过域名 [your-domain] 访问到 Nextcloud 服务了,通过 Nextcloud 的安装向导设置用户名、密码、数据存储路径和数据库信息后,Nextcloud 私有云就搭建好了。如果是严格按照《树莓派结合 frp、Nextcloud 搭建私有云之安装前的准备》进行的前期准备工作,那么:
      • 数据存储路径:[path]/disk
      • 数据库:MariaDB
      • 数据库用户名: nobody
      • 数据库密码:[your-password]
    2. 系统界面。
    屏幕快照 2017-06-29 下午1.41.02副本.png
  • 使用 Laravel 事件和队列自动更新 sitemap

    使用 Laravel 事件和队列自动更新 sitemap

    sitemap 是一种 xml 格式的文本文件,是提供给搜索引擎使用的。搜索引擎可以通过定期读取 sitemap 文件来更新相关网站的索引。因此,sitemap 文件实际上以 xml 格式包含了网站的页面信息。本站的 sitemap 文件是这个样子的,片段如下:

    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
        <url>
            <loc>https://www.gryen.com</loc>
            <lastmod>2020-03-31T00:00:00+08:00</lastmod>
            <changefreq>daily</changefreq>
            <priority>0.1</priority>
        </url>
        <url>
            <loc>https://www.gryen.com/articles</loc>
            <lastmod>2020-03-31T00:00:00+08:00</lastmod>
            <changefreq>monthly</changefreq>
            <priority>0.1</priority>
        </url>
        <url>
            <loc>https://www.gryen.com/articles/show/10.html</loc>
            <lastmod>2017-05-23T10:47:31+08:00</lastmod>
            <changefreq>monthly</changefreq>
            <priority>0.2</priority>
        </url>
        ...
    </urlset>

    sitemap 应该是搜索引擎抓取时当下网站的状态的,反映整个网站当时的全貌,是整个网站的一个概览。

    一种做法是在搜索引擎读取 sitemap 时动态生成内容,与动态网站用户访问时从数据库抓取内容填充到页面一样。

    但是这样做有两个问题:一是网站内容比较多的时候,遍历所有数据生成 sitemap 比较慢,可能会影响收录;二是 sitemap 既然是提供当前网站网页概览的,那么只需要在网站发生变更,比如增加或者删除网页时更新就可以了,网站更新频率不高(低于搜索引擎读取 sitemap 的频率)的情况下,每次搜索引擎读取时都动态生成同样的 sitemap 文件,太浪费资源了。

    比较好的做法是生成一份静态的 sitemap.xml 文件,在网站发生变更时,更新这个 sitemap.xml 文件就可以了。

    利用 Laravel 事件和队列,实现在创建、删除文章等情况下更新 sitemap.xml

    事件的使用

    事件机制提供了不影响正常业务流程,做一些额外处理的能力。像是更新 sitemap.xml 文件,它不属于我正常发表一篇文章的流程,它的成功与失败,并不影响到我将一篇文章发表到网站上。所以,只需要在特定的条件下,触发这个事件就可以了,至于它什么时间执行完成,甚至成功与否,都不在发表文章的过程中关心。Laravel 也提供给了这样的一种事件处理机制。

    Laravel 项目里面有两个文件夹看起来和事件有关系:Events 和 Listeners,一个负责事件定义,一个提供事件监听器。此外,EventServiceProvider 中还定义了事件和监听器之间的映射。具体实施起来比想象中更容易:

    1. 在 EventServiceProvider 中定义事件和监听器的映射EventServiceProvider.php
    /**
      * The event listener mappings for the application.
      *
      * @var array
      */
     protected $listen = [
         Registered::class => [
             SendEmailVerificationNotification::class,
         ],
         'App\Events\PublishArticle' => [
             'App\Listeners\GenSiteMapListener',
         ],
     ];
    1. 执行 php artisan event:generate 事件和监听器就都生成好了。PublishArticle.php
    <?php
    
    namespace App\Events;
    
    use Illuminate\Broadcasting\InteractsWithSockets;
    use Illuminate\Foundation\Events\Dispatchable;
    use Illuminate\Queue\SerializesModels;
    
    class PublishArticle { 
        use Dispatchable, InteractsWithSockets, SerializesModels;
    
        /**
        *   Create a new event instance.*
        *   @return void
        */
        public function __construct()
        { 
            //
        }
    
        /**
        *   Get the channels the event should broadcast on.
        *   @return \Illuminate\Broadcasting\Channel|array
        */
        public function broadcastOn()
        { 
            return [];
        }
    }

    GenSiteMapListener.php

    <?php
    
    namespace App\Listeners;
    
    use App\Article;
    use App\Events\PublishArticle;
    use Carbon\Carbon;
    use Illuminate\Contracts\Queue\ShouldQueue;
    use Spatie\Sitemap\Sitemap;
    use Spatie\Sitemap\Tags\Url;
    
    class GenSiteMapListener implements ShouldQueue {
    
        /**
        *   Create the event listener.*
        *   @return void
        */
        public function __construct()
        { 
            //
        }
    
        /**
        *   Handle the event.
        *   @param PublishArticle $event
        *   @return void
        */
        public function handle(PublishArticle $event)
        { 
            $publicPath = public_path('sitemap.xml');
            $siteMap = Sitemap::create();
    
            $siteMap
                 ->add(Url::create(action('HomeController@index'))
                 ->setLastModificationDate(Carbon::yesterday())
                 ->setChangeFrequency(\Spatie\Sitemap\Tags\Url::CHANGE_FREQUENCY_DAILY)
                 ->setPriority(0.1))
                 ->add(Url::create(action('ArticlesController@index'))
                 ->setLastModificationDate(Carbon::yesterday())
                 ->setChangeFrequency(\Spatie\Sitemap\Tags\Url::CHANGE_FREQUENCY_MONTHLY)
                 ->setPriority(0.1));
    
            Article::all()->each(function ($article) use ($siteMap) {
                if ($article->status === 1) {
                    $siteMap->add(Url::create(action('ArticlesController@show', [$article->id]))
                        ->setLastModificationDate($article->updated_at)
                        ->setChangeFrequency(\Spatie\Sitemap\Tags\Url::CHANGE_FREQUENCY_MONTHLY)
                        ->setPriority(0.2));
                }
            });
    
            $siteMap->writeToFile($publicPath);
            $siteMap = null;
        }
    }

    上面代码中包含了生成 sitemap.xml 具体逻辑的实现,用到了 spatie/laravel-sitemap 这个库。

    触发事件只一行代码:

    event(new PublishArticle());

    把它放到创建文章或者删除文章等需要更新 sitemap.xml 的操作里面,保证能触发就可以了,参考:ArticlesController.php#L103

    队列的使用

    实现了事件之后,实际上只是将代码从正常发表文章的逻辑中抽离了,实际执行起来,它还是同步的。也就是说,如果生成 sitemap.xml 的过程需要 1 分钟,那么,在发表文章的时候,就需要多等上 1 分钟,这显然还是对我们发表文章的过程产生了影响。我们想要的是让生成 sitemap.xml 的过程异步去执行,我们只是在发表文章时触发它,它自己选择合适的时机去执行,我们不用多等 1 分钟就能将文章发表了。

    这就用到了队列,我们将这个任务放入队列中,队列来决定什么时候执行这个任务。

    Laravel 中使用队列只需要在事件监听器中实现 ShouldQueue 接口就可以了,代码见上面 GenSiteMapListener.php,然后我们还要对队列做一些配置工作,配置 Laravel 采用哪种方式实现队列和采取什么样的策略。

    队列的配置

    除了同步执行的队列 sync 以外,Laravel 支持 database、beanstalkd、sqs 以及 redis 多种队列实现策略,这里只介绍 redis 策略的配置,其他配置方式可参考 Laravel 的队列配置文件 config/queue.php 和官方相关说明文档。使用 redis 方式的队列,需要在 .env 文件中配置:

    QUEUE_CONNECTION=redis

    config/queue.php 中 redis 的配置节点保持默认就可以了,需要注意的是失败队列的配置,也就是以下部分;

    'failed' => [
            'driver' => env('QUEUE_FAILED_DRIVER', 'database'),
            'database' => env('DB_CONNECTION', 'mysql'),
            'table' => 'failed_jobs',
        ],

    这里面的配置也不需要更改,需要留意的是 table 中的 failed_jobs 这个表,laravel 默认是不会生成的,需要我们执行 php artisan queue:failed-table 生成 migration,然后执行 php artisan migrate 来生成 failed_jobs 表。

    以上,采用 redis 的 laravel 队列配置完成。要让它开始工作,还得执行:

    php artisan queue:work --tries=3

    其中,--tries=3 参数给出了默认尝试的次数,失败后会记录到上一步生成的 failed-table 表中,否则队列将不停尝试。

    另外,这是个需要在后台一直执行的任务,需要我们给它配置守护进程。laravel 提供了使用 Supervisor 实现守护进程的方法,当然也可以使用其他方式实现,比如 ubuntu 系统的 service

  • NestJS 实现定时自动发送 Exchange 邮件

    NestJS 实现定时自动发送 Exchange 邮件

    定时任务

    NestJS 定时任务可通过miaowing/nest-schedule实现。

    定时任务的实现

    1. 按照 miaowing/nest-schedule 的说明文档,将 nest-schedule 模块导入到项目中:
     import { Module } from '@nestjs/common';
     import { ScheduleModule } from 'nest-schedule';
    
     @Module({
       imports: [
         ScheduleModule.register(),
       ]
     })
     export class AppModule {
     }

    2. 需要支持定时任务的服务继承 NestSchedule 类:

    import { Injectable } from '@nestjs/common';
     import { Cron, Interval, Timeout, NestSchedule } from 'nest-schedule';
    
     @Injectable() // Only support SINGLETON scope
     export class ScheduleService extends NestSchedule {    
       @Cron('15 16 * * *')
       async cronJob() {
         console.log('executing cron job');
       }

    3. 执行定时任务的方法添加@Cron('15 16 * * *')注解。这条注解的意思是“每天16:15” 执行一次定时任务。这里似乎与常见的 Cron 表达式不一样:第一个数据段代表的不是秒而是分,由此可见,miaowing/nest-schedule 不支持秒级的定时任务

    以上三步就能实现定时任务的功能了。

    需要留意的问题

    • 不知何故,开启服务后,定时任务并没有执行,必须手动访问过任意一个 controller 之后,定时任务才会开启。开始猜测是在子模块导入的原因,尝试在根模块注入之后,问题依旧,难道与Only support SINGLETON scope有关系?
    • @Cron('15 16 * * *')中的表达式并非常见的 Cron 表达式,第一个数据段代表的是而不是秒,且不支持如:“0/15 * * * *每15分钟执行一次”这样的配置,逗号分隔符是支持的,因此每15分钟执行一次可以配置成“0,15,30,45”。

    发送 Exchange 邮件

    Exchange 邮件的支持借助gautamsi/ews-javascript-api来实现。

    Exchange 邮件发送实现

    1. 导入相关方法
    import {
       ExchangeService, ExchangeVersion,
       Uri as ExchangeUri,
       WebCredentials, EmailMessage, MessageBody, ConfigurationApi
     } from 'ews-javascript-api';
    

    2. 新建ExchangeService 服务

    const exch = new ExchangeService(ExchangeVersion.Exchange2007_SP1);

    3. 配置登录凭据以及服务地址

    exch.Credentials = new WebCredentials(ewsConfig.username, ewsConfig.password);
     exch.Url = new ExchangeUri(ewsConfig.host);
    

    4. 构建要发送的邮件实体,Subject 为邮件的标题,Body 为邮件的内容,收件人地址可以通过msgattach.ToRecipients.Add(address)方法实现:

    const msgattach = new EmailMessage(exch);
    
     msgattach.Subject = mailContent.Subject;
     msgattach.Body = new MessageBody(mailContent.Body);
     msgattach.ToRecipients.Add(address);
    

    5. 发送邮件

     msgattach.SendAndSaveCopy() // 此方法返回 Promise

    按照上述步骤实现,可能会出现鉴权不通过的问题,可能是 NTLM 认证的问题,NTLM 是 telnet 的一种验证身份的方式,是 Windows NT 早期版本的标准安全协议。可以安装另外一个库@ewsjs/xhr, 尝试以下配置:

    ...
    import { XhrApi } from '@ewsjs/xhr';
    ...
    const exch = new ExchangeService(ExchangeVersion.Exchange2007_SP1);
    const xhr = new XhrApi({ rejectUnauthorized: false }).useNtlmAuthentication(username, password);
    
    ConfigurationApi.ConfigureXHR(xhr);
    

    完整代码如下:

    import {
      ExchangeService, ExchangeVersion,
      Uri as ExchangeUri,
      WebCredentials, EmailMessage, MessageBody, ConfigurationApi
    } from 'ews-javascript-api';
    
    /**
    * 发邮件
    */
    mailToSomeOne(address: string, mailContent: {
        Subject: string,
        Body: string
    }): void {
        const exch = new ExchangeService(ExchangeVersion.Exchange2007_SP1);
        const xhr = new XhrApi({ rejectUnauthorized: false }).useNtlmAuthentication(ewsConfig.username, ewsConfig.password);
    
        ConfigurationApi.ConfigureXHR(xhr);
    
        exch.Credentials = new WebCredentials(ewsConfig.username, ewsConfig.password);
        exch.Url = new ExchangeUri(ewsConfig.host);
    
        const msgattach = new EmailMessage(exch);
    
        msgattach.Subject = mailContent.Subject;
        msgattach.Body = new MessageBody(mailContent.Body);
        msgattach.ToRecipients.Add(address);
    
        msgattach.SendAndSaveCopy().then(res => {
          console.log(res);
        }, (err) => {
          console.error(err);
        });
    }

    需要留意的问题

    • 权限部分可能并非使用 NTLM 方式,@ewsjs/xhr还支持 Cookies Auth,可查阅文档。
    15764854565097.jpg
  • Angular 使用 RxJS 实现过期 token 刷新并重试

    Angular 使用 RxJS 实现过期 token 刷新并重试

    刷新重试策略

    如果 API 返回 access_token 失效,那么从 API 获取新的 access_token,并重试失败的请求。
    如果 refresh_token 也失效,让用户去登录。

    实现过程

    使用 Angular 的拦截器可以拦截 API 请求,可以在请求前后做些处理。要使用拦截器,只需要实现 HttpInterceptor 接口的 intercept 方法。一个不做任何处理的拦截器是下面这个样子。

    示例:

    import { Injectable } from '@angular/core';
    import {
      HttpEvent, HttpInterceptor, HttpHandler, HttpRequest
    } from '@angular/common/http';
    
    import { Observable } from 'rxjs';
    
    @Injectable()
    export class NoopInterceptor implements HttpInterceptor {
    
      intercept(req: HttpRequest<any>, next: HttpHandler):
        Observable<HttpEvent<any>> {
        return next.handle(req);
      }
    }
    

    token 刷新就是在 intercept 方法里面做的。

    可以在请求发出前对 HttpRequest 进行处理,比如获取请求头中的数据或者更改请求等等。

    示例:

    intercept(req: HttpRequest<any>, next: HttpHandler):
        Observable<HttpEvent<any>> {
        const token = req.headers.get('Authorization');
        console.log(token);
    
        return next.handle(req);
    }
    

    intercept 方法返回的是一个 Observable。因此,对 response 做后置处理可以使用 rxjs6 的 pipe

    示例:

    intercept(req: HttpRequest<any>, next: HttpHandler):
        Observable<HttpEvent<any>> {
        return next.handle(req).pipe(some code...);
    }
    

    我在项目中,对 Angular 的 HttpClient 做了一次封装,对外提供了一个 HttpService。 请求头的前置处理,比如设置 token 等的处理放在了 HttpService 中了:
    示例:

    ...
    let headers = new HttpHeaders();
    
    headers.append('Authorization', ''[token]);
    ...
    

    所以在处理 token 刷新的拦截器中,只需判断 API 请求是否设置了 token 信息就可以筛选出需要处理的请求了,需要处理的对 response Observable 使用 pipe,不需要的就直接返回 next.handle(req)

    处理过程用到了 rxjs 的几个操作符:maptapconcatMapretryWhendelay,前期投入的时间也主要是花在了这些操作符上,rxjs 的数据流操作方式与以往开发经验还是有很大不同,需要思维的转换。

    复习下这几个操作符。

    • map:对源 Observable 发出的值应用处理,并将结果作为 Observable 发出,常用的操作符,相当于 promise 的 then
    • tap:没有副作用的进行一些透明地操作,比如打印日志、数据采集可以使用这个操作符;
    • concatMap:这个与 map 的区别:map 订阅后输出 Observable,订阅 concatMap 输出值;
      示例:
      becomeHero() {
        return of('hero');
      }
    
      const source = of(1);
    
      source.pipe(
        map(() => this.becomeHero())
      ).subscribe(res => {
        console.log('map:', res); // 输出:map:Observable {_isScalar: true, _subscribe: ƒ, value: "hero"}
      });
    
      source.pipe(
        concatMap(() => this.becomeHero())
      ).subscribe(res => {
        console.log('concatMap:', res); // 输出:concatMap:
    hero
      });
    
    • retryWhen:出错后重试修改后的源;
    • delay:延迟源 Observable 的发送。

    按照刷新策略,Angular 中使用 rxjs 实现的重试逻辑如下所示。在 Angular 中间件拦截器中,先筛选出需要刷新控制的请求。这里使用 rxjs 的 map 操作符判断此请求是否需要刷新,需要刷新的抛出错误,以被 retryWhen 操作符接收做重试处理,否则,返回正确的结果。由于是 token 失效的请求重试,所以 retryWhen 里面需要获取新的 token 并重新设置到此请求当中,否则依然用旧的 token 尝试请求是一定会失败的。这里是放在了 tap 操作符当中处理的。然后在 map 操作符当中控制下刷新的次数。最后通过 delay 操作符设置下重试的时间间隔,整个逻辑就完成了。

    示例:

    intercept(req: HttpRequest<any>, next: HttpHandler): Observable<any> {
    
    // 判断是否是需要校验的请求,不是的直接放行
    if (!req.headers.get('Authorization')) {
      return next.handle(req);
    }
    
    const response = next.handle(req)
      .pipe(
        map((res: HttpResponse<any>) => {
          // 判读是否需要重试,抛出错误的请求会进入 retryWhen 走重试逻辑,否则返回结果
          if (Lhas(res, 'body.status') && Lget(res, 'body.status') === 401) {
            throw res;
          }
    
          return res;
        }),
        retryWhen(error => {
          return error
            .pipe(
              concatMap(() => this.handleToken()), // handleToken 是获取 token 的逻辑,代码贴到下面
              tap(token => {
                // 重新设置 token
                const headersMap = new Map();
    
                headersMap.set('authorization', [`${token}`]);
    
                Lset(req, 'headers.headers', headersMap);
    
                return req;
              }),
              map((res, count) => {
                // 重试次数控制
                if (count > 0) {
                  throw res;
                }
    
                return res;
              }),
              delay(1000), // 延迟重试
            );
        }
        )
      );
    

    上面代码中获取 token 的处理逻辑贴在下面。这段代码逻辑处理了一个页面中多个请求都 token 失效的情况,这种情况下会造成发送多个 refreshToken 请求,这样不仅仅会造成资源的浪费,最重要的是可能会导致第一个获取到 token 的请求重试时,第二个 refreshToken 请求也成功了,然后第一个重试就因 token 被刷新调而失败了。这里是通过定义一个刷新时间间隔(timeDebounce)来控制的。将 token 放在本地存储中(cookie 或者 localStorage),如果是间隔时间内的请求就直接本地取 token 返回,超过间隔时间的请求才从网络获取 token。间隔时间需要根据页面接口调用数量及处理时间来确定,比如设置为10秒的意思是所有的请求都会在10秒内发出,这几个请求都设置了新的 token 并发出了请求。

    示例:

    /**
    * 刷新 token
    * @param req
    */
    handleToken(): Observable<any> {
      const currentTime = new Date().getTime();
      const accessTokenFromLocal = getAccessTokenFromLocal();
      const reGetTokenFromNet = this.refreshToken() // 请求接口,刷新 token
        .pipe(
          map(this.saveToken.bind(this))
        );
    
      // 没有 refreshTokenTime, 或
      // 没有 accessTokenFromLocal,或
      // 重试间隔大于 timeDebounce 秒,从网络获取,并记录时间
      if (!this.refreshTokenTime ||
        !accessTokenFromLocal ||
        currentTime - this.refreshTokenTime > this.timeDebounce) {
        this.refreshTokenTime = currentTime;
        return reGetTokenFromNet;
      } else {
        return accessTokenFromLocal;
      }
    }
    

    到此为止,整个 token 的刷新重试流程就完成了。

  • 微信公众号网页开发配置 JSSDK 获取调用微信原生功能及网页授权开发介绍

    微信公众号网页开发配置 JSSDK 获取调用微信原生功能及网页授权开发介绍

    两个开发工具

    首先介绍两个开发者工具,在开发过程中可以用到:

    1. 微信公众号开发是可以申请个开发用的测试公众号的,入口在开发者工具里面:
    测试号申请入口
    1. 另外还有在线接口的调试地址,可以用来验证自己写得接口错在了什么地方:
    在线接口调试地址

    开始开发需要清楚的两件事

    微信公众号开发会遇到获取用户信息,使用二维码、拍照或者获取地理位置等的场景,这就需要公众号网页开发者清除两件事情:一是微信公众号网页是通过 JSSDK 来获取调用微信原生功能的能力的,二是我们可以通过微信网页授权的方式来获取用户的信息。相关说明在官方的文档里有详细介绍,这里做个简单介绍。

    使用 JSSDK 获得调用微信原生功能的能力

    微信公众号网页需要通过 JSSDK 来获得调用微信 APP 功能比如扫码、拍照等的能力的,使用 JSSDK 前要对其配置,配置过程要在服务端完成,因为要用到 appId 和 appSecret。

    使用 JSSDK 获取调用二维码扫描等的微信原生能力,需要对 JSSDK 进行配置,官方关于微信JS-SDK说明文档中的配置示例如下:

    wx.config({
        debug: true,  // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
        appId: '',    // 必填,公众号的唯一标识
        timestamp: ,  // 必填,生成签名的时间戳
        nonceStr: '', // 必填,生成签名的随机串
        signature: '',// 必填,签名
        jsApiList: [] // 必填,需要使用的JS接口列表
    });
    

    上述配置信息 config 中的 signature 签名需要自行生成,生成 signature 需要用到 jsapi_ticket,而获取 jsapi_ticket 需要 access_token,因此生成上述配置信息 config,需要经过以下几步:

    1. 获取 access_token
    2. 获取 jsapi_ticket
    3. 生成 signature
    4. 生成配置 config

    需要注意的是,这里的 access_token 是调用微信接口用到的 access_token,不是采用 oauth 方式进行网页授权获取 openId 或者用户基本信息时用到的 access_token,这个在开发过程中容易搞混。

    这里的 access_token 获取,需要用到 appID 和 appsecret,接口调用地址如下:

    https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=appID&appsecret=appsecret
    

    返回数据示例:

    {
        "access_token":"ACCESS_TOKEN",
        "expires_in":7200
    }
    

    拿到 access_token 就可以获取 jsapi_ticket 了,接口调用地址如下:

    https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=ACCESS_TOKEN&type=jsapi
    

    接口返回数据示例:

    {
        "errcode":0,
        "errmsg":"ok",
        "ticket":"jsapi_ticket",
        "expires_in":7200
    }
    

    生成 signature 参与的字段:

    noncestr=noncestr   // 随机字符串
    jsapi_ticket=jsapi_ticket   // 上一步获取的 jsapi_ticket
    timestamp=1414587457        // 10 位时间戳
    url=http://mp.weixin.qq.com?params=value    // 当前页地址
    

    以上字段按照字典序排序,使用URL键值对的格式

    jsapi_ticket=jsapi_ticket&noncestr=noncestr&timestamp=1414587457&url=http://mp.weixin.qq.com?params=value
    

    对上述字符串进行 sha1 签名,得到 signature,然后可以组装得到 config:

    {
        debug: false,
        appId:[appID],
        timestamp: 1414587457,
        nonceStr: '[noncestr]',
        signature: '[signature]',
        jsApiList: [
          'scanQRCode',
          'chooseImage'
        ]
    }
    

    jsApiList 是用到的权限列表。

    微信网页授权

    微信网页授权的目的就是为了获取用户的信息,这里可以分为只获取用户的 openID 或者是用户的基本信息。获取用户的 openID 不需要用户手动同意授权,获取用户的基本信息则需要用户手动授权的操作。这是在引导用户进入授权页面时传入不同的 scope 参数实现的,详情可阅读官方文档:微信网页授权

    授权后跳到回调页面,会待会 code,接下来就可以通过这个 code 换取 openId 或者是用户的基本信息了。这时,调用的接口是:

    https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
    

    没问题的话,返回的数据格式如下:

    { 
        "access_token":"ACCESS_TOKEN",
        "expires_in":7200,
        "refresh_token":"REFRESH_TOKEN",
        "openid":"OPENID",
        "scope":"SCOPE"
    }
    

    这里的 access_token 与生成 JSSDK 签名时接口调用及微信公众平台其他接口调用时用到的 access_token 是不同的。

  • Ubuntu 系统安装 Jenkins 持续集成 Gryen-GTD

    Ubuntu 系统安装 Jenkins 持续集成 Gryen-GTD

    因为本站 Gryen-GTD 的静态资源部署到了七牛云,所以,本站的构建发布需要两步操作。首先,要构建静态资源并发布到七牛云,第二步是更新部署于生产服务器 PHP 项目代码。

    服务器系统是 ubuntu 16.04。

    安装 jenkins

    检查服务器有没有安装 java8 环境,Jenkins 需要 java8 环境,执行以下命令,安装 java8。

    $ sudo add-apt-repository ppa:webupd8team/java
    $ sudo apt-get update
    $ sudo apt install oracle-java8-installer
    

    添加 Jenkins 源,执行以下命令:

    $ wget -q -O - https://pkg.jenkins.io/debian-stable/jenkins.io.key | sudo apt-key add -
    

    添加以下代码到 /etc/apt/sources.list

    deb https://pkg.jenkins.io/debian-stable binary/
    

    更新源,安装 Jenkins:

    $ sudo apt-get update
    $ sudo apt-get install jenkins
    

    安装过程中,出现 Jenkins 启动失败的报错。Jenkins 默认使用 8080 端口号,java8 环境正常的情况下,考虑是 8080 端口被占用,修改 /etc/default/jenkins 中 jenkins 服务的端口号。

    HTTP_PORT=8081
    

    启动停止 Jenkins 的命令如下:

    $ sudo service jenkins start
    $ sudo service jenkins stop
    

    Jenkins 服务已经安装好,如果是本地安装,那么可以通过浏览器访问 http://localhost:8081 以继续 Jenkins 的初始化操作。我把 Jenkins 装在了远程服务器上面,因此需要配置 nginx 配置域名反向代理到 nginx,Jenkins 提供了配置范例,如下所示:

    upstream jenkins {
        keepalive 32; # keepalive connections
        server 127.0.0.1:8080; # jenkins ip and port
    }
    
    server {
        listen       80;
        server_name  jenkins.example.com;
    
        #this is the jenkins web root directory (mentioned in the /etc/default/jenkins file)
        root            /var/run/jenkins/war/;
    
        access_log      /var/log/jenkins/access.log;
        error_log       /var/log/error.log;
        ignore_invalid_headers off; #pass through headers from Jenkins which are considered invalid by Nginx server.
    
        location ~ "^/static/[0-9a-fA-F]{8}\/(.*)$" {
            #rewrite all static files into requests to the root
            #E.g /static/12345678/css/something.css will become /css/something.css
            rewrite "^/static/[0-9a-fA-F]{8}\/(.*)" /$1 last;
        }
    
        location /userContent {
            #have nginx handle all the static requests to the userContent folder files
            #note : This is the $JENKINS_HOME dir
            root /var/lib/jenkins/;
            if (!-f $request_filename){
            #this file does not exist, might be a directory or a /**view** url
            rewrite (.*) /$1 last;
            break;
            }
            sendfile on;
        }
    
        location / {
            sendfile off;
            proxy_pass         http://jenkins;
            proxy_redirect     default;
            proxy_http_version 1.1;
    
            proxy_set_header   Host              $host;
            proxy_set_header   X-Real-IP         $remote_addr;
            proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Proto $scheme;
            proxy_max_temp_file_size 0;
    
            #this is the maximum upload size
            client_max_body_size       10m;
            client_body_buffer_size    128k;
    
            proxy_connect_timeout      90;
            proxy_send_timeout         90;
            proxy_read_timeout         90;
            proxy_buffering            off;
            proxy_request_buffering    off; # Required for HTTP CLI commands in Jenkins > 2.54
            proxy_set_header Connection ""; # Clear for keepalive
        }
    }
    

    初始化 Jenkins

    Jenkins 服务安装好之后,使用浏览器访问,会打开 Jenkins 的解锁界面。使用 /var/lib/jenkins/secrets/initialAdminPassword 中的密码解锁 Jenkins,然后可以按照界面提示,根据自己的需要初始化 Jenkins。

    使用 Jenkins 配置持续集成的一些前置操作

    ssh key

    使用 Jenkins 配置持续集成任务,需要 Jenkins 访问 Gryen-GTD 项目部署的机器(目标机),需要拉取 github 上托管的项目代码,这里需要在 Jenkins 所在服务器上生成下 ssh key。

    在 Jenkins 所在服务器上,生成 ssh key,此操作在 jenkins 用户下执行(后续在服务器上的操作均在 jenkins 用户下)。执行以下命令,切换 jenkins 用户,生成 ssh key:

    $ sudo su - jenkins // 切换到 jenkins 用户
    $ ssh-keygen -t rsa
    

    将公钥 ~/.ssh/id_rsa.pub 中的内容填写到目标机的 .ssh/authorized_keys 文件中(配置免密登录,免密登录还需要其他配置,自行搜索,这里不再赘述)。

    github 项目的 Deploy keys 中也需要添加此公钥,此操作可解决后续配置 Jenkins 任务时拉取代码报错的问题。

    Jenkins 所在服务器安装前端构建环境

    Gryen-GTD 的前端构建需要使用 Yarn,因此需要安装 NodeJS。执行以下命令,安装 NodeJS:

    $ curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash -
    $ sudo apt-get install -y nodejs
    

    安装 Yarn

    $ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
    $ echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
    $ sudo apt-get update && sudo apt-get install yarn
    

    执行 yarn --help 测试 Yarn 是否安装好。测试通过后,前端构建环境搭建完毕。

    Jenkins 所在服务器安装七牛自动上传工具 qshell

    本站 Gryen-GTD 的静态资源是直接使用七牛云的,自动发布还需要配置自动上传静态资源到七牛云的服务。七牛云提供了 qshell 工具可以方便地实现此服务,qshell 官方文档地址:《命令行工具(qshell)》,GitHub 代码地址:qiniu/qshell

    下载 qshell 工具并选择合适的版本,置于服务器的 /var/lib/jenkins/bin/qshell_linux_x64 路径(可以根据自己需要,放置于任意喜欢的可访问位置)。

    执行以下命令配置 qshell 密钥(ak sk 在七牛云个人中心密钥管理界面可以获取,name 是该账号的名称):

    $ /var/lib/jenkins/bin/qshell_linux_x64 account ak sk name
    

    使用 qshell 的 qupload 指令上传文件,需要一个配置文件,来定义上传策略,这个文件也需要放置于服务器上,并保证 qshell 可访问,如何配置可阅读 qshell 官方文档。我的配置文件命名为 qshell.config, 示例如下:

    {
       "src_dir"            :   "/path/Gryen-GTD/public/dist",
       "bucket"             :   "[yourbuket]",
       "key_prefix"         :   "dist/",
       "rescan_local"       :   true,
       "skip_file_prefixes" :   "test,demo,.qrsignore",
       "skip_path_prefixes" :   "temp/,tmp/",
       "skip_fixed_strings" :   ".svn,.git",
       "skip_suffixes"      :   ".DS_Store,.exe",
       "delete_on_success"  :   true
    }
    
    

    自动上传静态资源到七牛云的服务器端配置完成。

    至此,在 Jenkins 所在服务器上的一些前置操作均已完成,下面的操作只需通过 WEB 界面操作。

    为 Gryen-GTD 配置自动构建发布任务

    这里需要安装一个 Jenkins 插件 Publish Over SSH,以访问目标机,还需要为 Jenkins 添加一个 GitHub 凭据,以访问 GitHub API。以上两部操作完成后,就可以新建 Gryen-GTD 自动构建发布任务了。

    安装并配置 Publish Over SSH 插件

    在 Jenkins 系统管理 -> 插件管理 -> Available 面板搜索 Publish Over SSH 并安装,然后转到 系统管理 -> 系统设置 里面找到 Publish over SSH 节点。

    • Path to key 填入 .ssh/id_rsa,即在上述步骤中生成的 ssh private key 的相对路径
    • SSH Servers 是目标机的 ssh 配置,Name 自己定义
    • Hostname 是目标机的 ip 地址
    • Remote Directory 是 Gryen-GTD 项目的部署路径

    配置完成后,可点击 Test Configuration 按钮测试是否可以连接,测试成功,点击 Save 保存配置。

    为 Jenkins 添加一个 GitHub 凭据

    1. 转到 https://github.com/settings/tokens 添加一个 Personal access tokens。
      jenkins 需要 repo、admin:repo_hook 两个权限节点,生成 token 后不要走开,注意复制 Secret 值,github 提示这个值只会出现一次
    2. 转到 Jenkins 的凭据添加页面,填入上面复制的 Secret 值,类型选择 Secret text,描述我填写的 github
    3. 转到 Jenkins 系统管理找到 GitHub 节点,配置 GitHub API 权限。凭据选择刚才添加的那个,这里是 github。保存配置,凭据添加完成。

    配置 Gryen-GTD 自动构建部署任务

    新建一个任务,填入项目名称 Gryen-GTD 或者其他,选择“构建自由风格的软件项目”,转到任务的配置界面。

    • General->GitHub 项目->项目 URL 填入项目的路径,这里是:https://github.com/itargaryen/gryen-gtd/
    • Source Code Management 选中 Git,Repositories 节点的 Repository URL 填入项目的 GitHub 地址,这里是:git@github.com:itargaryen/gryen-gtd.git
    • Build Triggers 选中 GitHub hook trigger for GITScm polling
    • Build 面板添加构建任务,点击 Add build step 按钮,选择 执行 shell
      1. 第一个要添加的是静态资源构建任务,其中写了一个简单的判断来清除已经生成过的文件,代码如下yarn if [ -d "public/dist/" ];then
        rm -rf public/dist/*
        fi
        yarn run prod
      2. 第二个是上传静态资源到七牛云的操作,代码如下:/var/lib/jenkins/bin/qshell_linux_x64 qupload qshell.config
      3. 第三个任务是要在目标机上更新代码,因此在点击 Add build step 按钮后要选择 Send file or execute commands over SSH。点击 Add Server 按钮,在 SSH Server 面板的 Name 中选择在 Publish Over SSH 插件配置时配置好的目标机服务器。在 Transfers 面板中 Exec command 节点填入更新代码的操作命令,我的配置如下:cd /path/Gryen-GTD && php artisan down && git pull && composer install && php artisan up 点击页面底部的 save 按钮保存配置,一个任务就新建好了。

    立即构建和自动构建

    在每次提交代码到 Github 上之后,可以触发 Jenkins 开始一次 Gryen-GTD 的发布任务,或者,在 Jenkins 任务管理界面也可以通过 立即构建 按钮马上开始一次新的构建。点击任务 ID 可以查看此次构建的过程,构建出错时,也可以通过这里查找原因。

  • 聊一聊上班族的时间管理

    聊一聊上班族的时间管理

    工作、生活的现状

    刚参加工作的时候,经常加班熬夜,有时会工作到第二天早晨。半年之后,状态就不行了,疲惫、乏力,睡不够,起床困难,工作也没了激情,甚至出现了失眠、莫名精神紧张、有抑郁倾向等精神问题。这也许是大多数职场新人会碰到的情况。

    这几年,互联网行业发展迅速,许多同行倒在了工作岗位上。2018年年末,大疆公司的一个25岁的哈工大硕士员工在家中猝死。2020年疫情期间,前端届知名的程序员司徒正美倒在了家中,年末又有拼多多的员工猝死在工作岗位上。考虑庞大的从业人员数量,累死的肯定是极少数,但每个人都是全部。

    加班是提高工作效率唯一且高效的方法吗

    有人自嘲说投胎到中国,相当于开启了人生的 hard 模式。我们国人确实是比较勤劳的,近现代我们落后了,等我们回过神来后奋起直追,迅速缩小了与世界强国的差距。目前,我们与西方先进国家,在科技等领域的核心技术层面,还是存在差距。这些领域的差距通过延长工作时间来追赶,效果不会好。对于上班族来说,也会遇到很多问题不是通过加班就能解决的。

    工作价值、产出与成就感的获得

    16年的一篇报道《中国劳动生产率增速远超世界平均水平 仍有提升空间》提到:2015年我国劳动生产率水平仅为世界平均水平的40%,相当于美国劳动生产率的7.4%!这也意味着单位劳动时间里,我们创造出的价值太低了。美国民众是比较重视家庭生活的,5点下班后就回家了。这样的生活工作节奏,美国每年 GDP 依旧是世界第一。原因是美国人超高的劳动生产率。

    创造性的工作是需要付出智力的劳动,特别依赖于清醒的头脑。智力也是一种力,长期处于疲惫状态,不利于发挥出较好的智力水平,工作产出质量较低。持续低效率低质量的工作输出,也不利于社会和个人发展。长期高负荷的工作,还易产生倦怠心理,让人消极工作。在疲惫状态下,人会倾向于选择最容易的解决问题的办法,做机械的重复性劳动。这样的工作获得的成就感也比较低。而成就感是非常重要的奖赏机制,可以促使人们更好地工作。一直从事低成就感的工作,会让人迅速厌倦,丧失进步的动力。

    我们学习欧美还不够

    我国与日本的发展进程有相似的地方,比如我们都从某一天开始学习向欧美国家看齐。日本在学习欧美国家的过程中诞生了自己独有的文化。尤其是在科技领域,增强了创造力。我国近年来在科技领域也有许多了不起的成就,但总觉得还是缺少一种极致的创造性。

    许知远在小凤直播室和冯克利对谈时提到说:中国人做什么事情都比较着急,不太认真。还没有完全想好,就开始行动。在一个领域能极致研究的人太少了。别人能做的事情,我们现在也能做了,但是做得不够好。科技领域的理论层面,我们与西方国家还有许多差距,但理论层的研究需要花费大量时间,而且不容易看到结果。我们大多时候太执着于早点看到结果了,而对结果质量的宽容度倒是很高。

    过上理想的生活

    确实很多人认为生活的意义完全就是工作,更多人还是希望家庭、个人生活、娱乐与工作较为均衡吧。

    《为什么精英都是时间控》里面说,我们一开始可能的想法——认为现在努力,将来就能过上理想的生活——是错误的。生活是一个连续的过程,若不是遇上了如彩票中奖这样的事情,不太可能在突然的某一天,理想的生活就来临了。我们可以现在就按照理想的生活方式开始规划自己的生活,现在就开始阅读,开始运动,开始乐器的练习。

    然而,人的精力是有限的,下班之后回到家已经是精疲力尽了,哪还有精力去做这些事情呢?疲惫的时候也只是想吃了饭早早上床睡觉了。如果再加个班,甚至洗刷都要省略了。没有掌握时间管理的技巧,以致于总觉得时间不够用。时间管理也就是精力管理,管理不好时间,也会让自己总是感到疲惫。

    科学利用时间,有节奏的工作

    人的精力是有限的,《为什么精英都是时间控》的作者桦泽紫苑在日本是有名的精神科医生,专门研究人类的大脑。他在书中提到人的专注力有个时间限度,他把这叫做“15,45,90法则”。一般而言,人类的专注力会随着时间降低。需要高度专注的工作,人们大概只能坚持15分钟,比如同声传译;需要中等专注力的工作,人们大概只能坚持45分钟,比如上课;90分钟是看足球比赛的专注力维持时间,大概也是一场电影的时长。当然,这里的时间15分钟、45分钟、90分钟只是大概的量度,每个人都会不太一样。对上班族来说,这里的借鉴意义在于我们需要有节奏的工作。并不是一整天我们都要精神高度集中地持续工作,那样没有人可以做的到。强迫自己去做,也不过是在精神疲惫下的拼死硬撑,这种情况下的工作效率是极低的。《为什么精英都是时间控》提到人在精力充沛状态下的工作效率是疲惫状态下的4倍!也就是说在疲惫状态下需要花四个小时来做的工作,精力充沛状态下,只需要1个小时就能完成了。也就是说与其强迫自己在疲惫状态下连续工作4个小时,不如休息1个小时后再来工作效率更高。采用后面的策略,我们甚至在完成工作的时候,还能节约出两个小时的时间!所以,在白天的工作中,如果感到累了,就果断去休息,偷懒5到15分钟的时间就能让自己的精力恢复到比较高的水平,从而在下一个专注力周期里可以更高效地工作。

    工作的时候不要分心

    从我的工作经历来看,有些事情是特别耗费精力的,像是做选择与一心多用。早上来到办公室想自己做点儿什么,有时还会在想着的同时,再聊聊天,看看资讯,不知不觉一个小时过去了,可还什么都没做。有时做着一项工作的时候,突然想起还有另外一件事情需要处理,就马上停下手上的工作去做那件工作,回来后再花时间接上被切断的思绪……还有同事随时来沟通工作,APP 推送资讯……

    被打断之后重新回到原来的工作,大概需要20分钟左右的时间,这20分钟的时间几乎是毫无产出的。如果一天中被打断个十次,那么三个小时就没了,实际上一个普通职员一天被打断的次数远远大于10次!

    以上这些情况都会导致分心,人类的大脑在同一时间只能处理一件事情,同时处理多件事情本质上只不过是大脑在不停的切换状态,而这种状态的切换需要耗费巨大的精力。既然已经找到了原因,那就可以对症下药了。

    自己不要为难自己,在一段时间内只做一件事情。如果是需要高度专注的工作,同事来找你,现在开始不再那么及时地与他们沟通,礼貌地回复他们,现在在忙,让他们等一等;如果是通过即时通讯工具沟通,就更容易了。这样先把手上的工作了结后再去做别的事情,会节省很多时间。时间久了之后,同事们会知道你是个在专注工作的时候不喜欢被打扰的人,这样除非特别紧急,必须你参与而且要马上解决的事情,一般都不会找你了。(但确实有些同事不太注意这些,全然不顾别人是否在专注工作,高声讲电话,粗暴打断别人的思路,对于这类同事,有时也是无力……)

    另外,去考虑先做哪些事情,突然想起来要做哪些事情……对于这样的一些杂念,也是会耗费精力去思考的。《为什么精英都是时间控》里面也给出了解决办法,就是把事情写下来,最好做成待办列表。写下来的过程本身就会让自己在这些事情上停止思考,知道自己有了记录不会遗漏之后,也可以安心工作。工作告一段落,还可以检查一下工作进度,完成的打勾,像打怪升级一样把事情做完,也是提高工作成就感的有效方式。

    有时实在是太累了

    不管是有节奏的工作,还是专注地工作,前提是我们每天都可以精力充沛。但有时,我们太累了。我就有过一段时间,几乎天天失眠,别说通过短暂休息来恢复精力了,原本应该精力较充沛的早晨都很乏力。我知道是因为工作和生活给自己的心理负担太重了,又加上其他一些突发事件的冲击,整个人都很消极。我读了一些心理辅导的书籍,后来觉得换个工作环境,换一换生活的节奏才有可能让我走出困境,我就这么做了。当我的身心较为正常之后,就开始关注生活、工作、人生等这类的事情。找到生活、工作与自己身体、内心的平衡,才能真正地解决这些烦恼。

    感觉太累了,如果是来自于确切的压力,那么去解决压力。有时是因为不健康的生活习惯导致的。人的精力要靠睡眠来补充,长期睡眠不足,人的智力水平都会下降。而造成睡眠不足的原因,如果是加班,那可以通过提高工作效率来减少加班;如果是自己造成的,比如沉迷手机到深夜,那是需要改变的恶习。我尝试过靠药物辅助睡眠,虽然可以入睡了,但是睡眠质量并不好,一般药物还会带来一定副作用,比如睡好了第二天还是精神不济。比较好的解决办法还是保持心情舒畅,养成良好的作息习惯,每天固定时间睡觉。在那段时期接触过一套“美国海军陆战队快速入睡方法”,《为什么精英都是时间控》也提到过这个方法,我试过是有作用的。那段时期过后,我的睡眠就比较正常了。

    保证了足够的睡眠,才能在第二天的清晨精力充沛,应对较困难的工作。在疲惫之后也才能有储备能量,让我们可以通过短暂休息等的方式释放出来了。

    我有时间读书、运动、练习乐器了

    在了解了造成自己身心疲惫的原因,以及有了有效的应对策略并付诸行动之后,有一天会发现自己不再那么疲惫了,感觉生活不再那么让人沮丧了,并且有了真正属于自己的时间。工作之余,对自己原有的爱好重新产生了兴趣。这种感觉和强迫自己拖着疲惫的身体勉强去做一些事情有很大不同。……如果还能坚持运动,会发现自己的能量也会越来越多。

    现代社会在带来美好的同时,也带给人们一些烦恼。至少在我看来,现代人的生活方式虽是更便利了,但对于人的身心健康弊端多多。我们无力让全社会马上地转换成另一种生活方式,相信美好的事情总是会发生的。我们现在就可以做的,从改变自身来尽可能地接近自己理想的生活状态。

    本篇是受桦泽紫苑的《为什么精英都是时间控》启发的,希望有跟我一样不快经历的朋友能从此书中找到解决问题的办法。

  • Gryen-GTD 升级日志,composer 详解

    Gryen-GTD 升级日志,composer 详解

    许久没有更新过本博客系统了,前些天有朋友想使用本系统搭建自己的博客,发现看着我给的文档根本无从下手,无奈之下我发邮件向我求助。如果对 Linux、PHP、MySQL 等有基本的了解,以目前系统的状态,我也是无能为力的。怪自己懒,更新不及时。Gryen-GTD 更新频率视个人情况,有时更新较为频繁,有时几个月也不会有代码提交。开始此项目时,仅仅作为自己的一个实验,主要帮助自己熟悉 PHP 及周边知识,以免长时间不接触 PHP 的工作后遗忘。做的有点儿样子后,将其开源,希望能借助开源提高一下自己的编程水平,如果有幸能帮到一些朋友或有朋友对此感兴趣,可以共同开发,也算是额外的激励了。

    最近有时间维护下此项目,发现果然遗忘了很多,连 composer 都有点儿搞不懂了。

    Composer

    Composer 是 PHP 的一个依赖管理工具,类比与 Node 的 npm,通常,它会将 packages 安装到项目的 vendor 文件夹中,相当于 npm 的 node_modules。关于它的详细介绍可阅读Composer 中文文档。Composer 常用的命令如下:

    • composer install:如有 composer.lock 文件,会直接安装,否则,它会根据 composer.json 配置文件安装依赖;
    • composer require xxx [--dev]:它会安装xxxpackage,并将其记录到 composer.json 文件中,添加--dev参数可安装只在开发过程中使用的 package;
    • composer update:按照 composer.json 中给定的规则,升级依赖到最新版本,并且升级 composer.lock 文件
    • composer dump-autoload:只更新自动加载而不去更新依赖

    composer.json 文件详解

    以 laravel 的 composer.json 文件举例:

    {
        "name": "laravel/laravel",
        "type": "project",
        "description": "The Laravel Framework.",
        "keywords": [
            "framework",
            "laravel"
        ],
        "license": "MIT",
        "require": {
            "php": "^7.1.3",
            "fideloper/proxy": "^4.0",
            "laravel/framework": "5.7.*",
            "laravel/tinker": "^1.0"
        },
        "require-dev": {
            "beyondcode/laravel-dump-server": "^1.0",
            "filp/whoops": "^2.0",
            "fzaninotto/faker": "^1.4",
            "mockery/mockery": "^1.0",
            "nunomaduro/collision": "^2.0",
            "phpunit/phpunit": "^7.0"
        },
        "config": {
            "optimize-autoloader": true,
            "preferred-install": "dist",
            "sort-packages": true
        },
        "extra": {
            "laravel": {
                "dont-discover": []
            }
        },
        "autoload": {
            "psr-4": {
                "App\\": "app/"
            },
            "classmap": [
                "database/seeds",
                "database/factories"
            ]
        },
        "autoload-dev": {
            "psr-4": {
                "Tests\\": "tests/"
            }
        },
        "minimum-stability": "dev",
        "prefer-stable": true,
        "scripts": {
            "post-autoload-dump": [
                "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
                "@php artisan package:discover --ansi"
            ],
            "post-root-package-install": [
                "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
            ],
            "post-create-project-cmd": [
                "@php artisan key:generate --ansi"
            ]
        }
    }
    
    
    • 其中,license 及前面的节点是对项目的说明。
    • require 是项目发布需要的依赖
    • require-dev 是项目开发以及测试过程中需要的依赖
    • config 是一些配置,以上面为例:
      • optimize-autoloader:在 dump 的时候是否自动优化
      • preferred-install:设置 Composer 的默认安装方法,其他可能的值:source、dist、auto(默认值),source 是全部,dist 不包括版本信息,相当于去掉 package 的 .git 目录
      • sort-packages:用来保证当引入新的 package 的时候,composer.json 中的 packages 按照 package name 排序
    • extra 是供 scripts 使用的额外数据
    • autoload 和 autoload-dev:PHP autoloader 的自动加载映射
      • classmap:生成支持支持自定义加载的不遵循 PSR-0/4 规范的类库
      • files:这个配置上面没有,但是很可能会用到,这个可以明确指定在每次请求中都会载入的文件,比如定义的一些公共方法
    • minimum-stability:指定安装 package 时的最低稳定性要求,可能的值:dev、alpha、beta、RC、stable
    • prefer-stable:设置为 true,优先使用 package 的稳定版本
    • scripts 指定在安装的不同阶段挂载的脚本
      • post-autoload-dump:在自动加载器被转储后触发,无论是 install/update 还是 dump-autoload 命令都会触发。
      • post-root-package-install:在 create-project 命令期间,根包安装完成后触发。
      • post-create-project-cmd:在 create-project 命令执行后触发。

    其他配置的解释可参考:composer.json

    PHP autoload 的一点儿记录

    classmap 引用的所有组合,都会在 install/update 过程中生成,并存储到 vendor/composer/autoload_classmap.php 文件中。这个 map 是经过扫描指定目录(同样支持直接精确到文件)中所有的 .php 和 .inc 文件里内置的类而得到的。

  • “无重复字符的最长子串(Longest Substring Without Repeating Characters)”问题的 Python3 解答

    “无重复字符的最长子串(Longest Substring Without Repeating Characters)”问题的 Python3 解答

    题目描述

    给定一个字符串,找出不含有重复字符的最长子串的长度。

    示例

    示例 1:

    输入: "abcabcbb"
    输出: 3 
    解释: 无重复字符的最长子串是 "abc",其长度为 3。
    

    示例2:

    输入: "bbbbb"
    输出: 1
    解释: 无重复字符的最长子串是 "b",其长度为 1。
    

    示例3:

    输入: "pwwkew"
    输出: 3
    解释: 无重复字符的最长子串是 "wke",其长度为 3。
         请注意,答案必须是一个子串,"pwke" 是一个子序列 而不是子串。
    

    测试用例

    abcabcbb    3
    bbbbb   1
    pwwkew  3
    ""      0
    au      2
    dvdf    3
    cdd     2
    ddc     2
    

    解题思路

    逐字符遍历字符串,记录第一个无重复子串s[0]。查看下一个字符是否被rStr所包含,不包含就将此字符加到rStr的末尾,直到找到一个已经包含在rStr中的字符,此时,这个无重复子串的长度就有了。依次方法,继续查找子串,并与当前已经找到的子串做比较,记录最长的子串长度即可。字符串遍历完毕,问题得解。

    解答

    class Solution:
        def lengthOfLongestSubstring(self, s):
            """
            :type s: str
            :rtype: int
            """
            sLen = len(s)
            if sLen < 1:
                return 0
            rLen = 1
            startIndex = 1
            endIndex = 0
            rStr = s[0]
    
            while startIndex != sLen != endIndex:
                for i in range(startIndex, sLen):
                    if s[i] not in rStr:
                        endIndex = i + 1
                        rStr = s[startIndex - 1:endIndex]
                    else:
                        break
                rLen = max(rLen, len(rStr))
                if startIndex != sLen:
                    rStr = s[startIndex]
                startIndex = startIndex + 1
            return rLen
    
    

    算法分析

    上述算法用Python3实现的时候有一个边界问题,字符串为空的情况下长度为 0,返回此值即可,否则下面初始化第一个无重复子串就溢出了。

    上述算法时间复杂度是O(n^2),并不理想。

    中文官网的题目地址:无重复字符的最长子串