分类: 技术

  • 树莓派使用 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 可以查看此次构建的过程,构建出错时,也可以通过这里查找原因。

  • 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),并不理想。

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

  • “两数之和(Two Sum)”问题的 Python3 解答

    “两数之和(Two Sum)”问题的 Python3 解答

    题目描述

    给定一个整数数组和一个目标值,找出数组中和为目标值的两个数。你可以假设每个输入只对应一种答案,且同样的元素不能被重复利用。

    示例

    给定 nums = [2, 7, 11, 15], target = 9
    因为 nums[0] + nums[1] = 2 + 7 = 9
    所以返回 [0, 1]

    解题思路

    要求的结果是两个索引组成的 List:[aIndex, bIndex],这两个索引对应的值记为 a, b。

    遍历列表,假设列表中第1项的值 a 是结果 List 中的一个索引的值,那么用 target 减去 a 后得到的结果 b 就是就是要找的另一个索引的值,列表除掉 a 后的子列表里面,如果 b 存在,问题已解——答案是 a 的索引和 b 的索引组成的列表 [aIndex, bIndex]。

    如果不存在,将列表的第二项赋值给 a,继续寻找 b,直到找到为止。

    解答

    class Solution:
        def twoSum(self, nums, target):
            """
            :type nums: List[int]
            :type target: int
            :rtype: List[int]
            """
    
            i = 0
    
            for num in nums:
                nextNum = target - num
                j = i + 1
                if nextNum in nums[j:]:
                    return [i, nums[j:].index(nextNum) + i + 1]
                i += 1
    
    

    中文官网的题目地址:两数之和

  • “两数相加(Add Two Numbers)”问题的 Python3 解答

    “两数相加(Add Two Numbers)”问题的 Python3 解答

    题目描述

    给定两个非空链表来表示两个非负整数。位数按照逆序方式存储,它们的每个节点只存储单个数字。将两数相加返回一个新的链表。

    你可以假设除了数字 0 之外,这两个数字都不会以零开头

    示例

    输入:(2 -> 4 -> 3) + (5 -> 6 -> 4)
    输出:7 -> 0 -> 8
    原因:342 + 465 = 807

    测试用例

    [2,4,3]
    [5,6,4]
    
    [1]
    [9,9]
    
    [5]
    [5]
    
    [0]
    [7,3]
    
    [9,9]
    [9]
    

    解题思路

    遍历链表,按节点两两相加,有进位将进位加到下一个节点。

    解题思路很简单,此题被标记为中等难度,是因为:

    1. 数据结构是链表;
    2. 两个链表长度不一定相等,尤其是有进位情况下的处理需考虑。

    解答

    # Definition for singly-linked list.
    # class ListNode:
    #     def __init__(self, x):
    #         self.val = x
    #         self.next = None
    
    class Solution:
        def addTwoNumbers(self, l1, l2):
            """
            :type l1: ListNode
            :type l2: ListNode
            :rtype: ListNode
            """
            l3 = l1
    
            while l1 != None or l2 != None:
                sum = l1.val + l2.val
                l1.val = sum % 10
    
                if sum >= 10:
                    if l1.next != None:
                        l1.next.val = l1.next.val + 1
    
                        if l1.next.val >= 10 and l2.next == None:
                            l2.next = ListNode(0)
                    else:
                        l1.next = ListNode(1)
    
                else:
                    if l1.next == None and l2.next != None:
                        l1.next = ListNode(0)
    
                if l2.next == None:
                    break
    
                l1 = l1.next
                l2 = l2.next
    
            return l3
    

    算法分析

    上述算法通过一次遍历完成,没有用单独的变量存储 carry 值。两个链表同节点相加的结果最大为 9 + 9 + 1 = 19,所以结果链表中对应节点的值永远可以用(l1[index] + l2[index] + carry) % 10来得到。

    上述算法将进位 carry 计入到了下一位中。具体解题过程为

    1. 用新变量 l3 保留对 l1 的引用
    2. l1 和 l2 对应节点做加法
    3. 有进位且 l1 有下一节点的情况下,将进位加入到 l1 的下一个节点;l1 没有下一个节点就给 l1 添加一个值为 1 的节点;没有进位执行步骤4
    4. l1 没有下一个节点了,但是 l2 还有下一个节点,就给 l1 添加一个值为 0 的节点
    5. 3 或 4 步骤执行完毕,如果 l2 没值了,就跳出循环,返回 l3,原题得解;否则将 l1 和 l2 移动一个节点,执行步骤2

    中文官网的题目地址:两数相加

  • 从浏览器页面呈现原理来讨论站点优化

    从浏览器页面呈现原理来讨论站点优化

    当前的浏览器现状

    当前主流浏览器使用的内核包括 Webkit(Safari、Chrome、Opera等),Trident(IE),Gecko(Firefox)等,其中最值得了解的当然是 Webkit。

    Webkit 是由苹果公司开发的,供自家浏览器 Safari 使用的内核。Safari 于2003年1月7日首次发行测试版。2010年4月,苹果公司宣布了其浏览器引擎的最新项目 Webkit2。Webkit 内核实际上包含 WebCore 渲染引擎和 JavaScriptCore javascript 引擎。

    Chrome 浏览器自2008年发行起,一直使用 Webkit 作为其内核原型。在13年发布的 Chrome 28.0.1469.0 版本中,又改为使用 Blink 内核。

    前面说过,Webkit 内核包含 WebCore 渲染引擎和 JavaScriptCore javascript 引擎。Chrome 中使用的 javascript 引擎实际上是自家开发的大名鼎鼎的 V8 引擎,V8 主要用在 Chromium 和 Chrome 浏览器中。因此,Chrome 中的 Webkit 和 Safari 中的 Webkit 并不等价,而从 Blink 内核开始,其差别越来越大。

    Chromium 和 Chrome 使用同样的内核,Chromium 是开源的,Chrome 是闭源的,许多功能会在 Chromium 浏览器上应用,稳定后才会在 Chrome 上推出。大多国产浏览器实际上是基于 Chromium 而不是 Webkit 开发的,因此会称 Chromium 为内核。

    移动设备上,Android 4.4 之前的浏览器默认使用 Webkit 内核,Android 4.4 以及之后使用 Chromium 内核。

    鉴于上述,本文技术方面的介绍,是在 Chromium 层面的。

    从资源加载过程来优化站点

    网页是如何展现在我们面前的

    从输入 URL 开始,到网页展现在我们面前,浏览器主要做了以下几件事件:

    1. 从网页的 URL 加载资源
    2. 构建 DOM 树
    3. 从 DOM 树构建 Webkit 的绘图上下文
    4. 从绘图上下文生成最终的图像

    当然,每一步都进行了复杂的处理。而且,浏览器一般会同时做这些事情。下面从网页开发者角度说明每一步中值得关注的点。

    从网页 URL 到加载资源的过程

    通过 URL 打开一个网站,无论这个网站是静态的还是动态的,浏览器通过这个 URL 识别到的都应该是一个 html 文档资源。然后浏览器通过解析这个 html 文档,通过标签识别文档中的其他资源,进一步加载其他资源。

    资源加载和缓存

    Chromium 使用多进程的资源加载和缓存机制。其中 Renderer 进程并没有请求资源的权限,当浏览器需要请求资源的时候,会交给 Browser 进程处理,Browser 进程处理完毕后再交给 Renderer 进程渲染页面。Browser 进程和 Renderer 进程通过进程间通信的方式传递数据。

    如果每次请求资源都从远程服务器请求,将会非常耗时和浪费资源,Chromium 采用了高效的缓存机制。需要请求资源的时候,会先去缓存查找资源是否已经存在,不存在时才会去请求网络资源。

    DNS 预取和 TCP 预连接

    在请求网络资源的时候,会经过域名解析和 TCP 连接过程,Chromium 针对这两个过程,分别做了优化。当我们在浏览网页的时候,Chromium 会提取网页中的超链接,利用系统的 DNS 机制预解析,并将解析结果保存下来,当我们点击链接的时候,就不需要进行 DNS 解析了。开发者可以显式声明哪些域名需要提前解析,做法如下:

    当我们在浏览器地址栏输入网址的时候,如果输入的网址和候选项匹配,那么 Chromium 也会预解析该域名。DNS 预取机制大概可以节省 60ms – 120ms 或者更长的时间。

    更进一步,Chromium 甚至会在 DNS 解析之后,用户点击链接之前提前建立 TCP 连接。这里,Chromium 会使用追踪技术预测我们可能点击的链接,在有很大把握的时候才会做这些事情。

    对于网页开发者的启示:

    • 减少网页的重定向,以更好地应用浏览器的 DNS 预取技术
    • 适当地合并资源,减少浏览器建立连接的次数
    • 压缩资源,节省数据量和数据传输时间

    在 Chromium 或者 Chrome 浏览器中,我们可以通过chrome://net-internals/#dns来查看 DNS 缓存情况,并可以通过 Clear cache 和 Flush sockets 工具来及时清除 DNS 缓存数据。在开发过程中,在切换域名 host 之后,用此工具可以使网页立即解析到新的地址,而不用重启浏览器。

    net-internals -w600

    net-internals 工具中的 DNS 面板截图

    从 html 文档构建 DOM 树的过程优化站点

    构建 DOM 树与资源加载

    上面介绍了浏览器加载资源的过程,在这个过程中,浏览器还会同时进行 DOM 树的构建工作,甚至下文要介绍的构建 Webkit 绘图上下文的工作也是同步进行的,要不然页面呈现在我们面前就太慢了。

    对于图片、CSS、视频等文件,浏览器从 HTML 代码解析到他们的 URL 的时候,会通过相关的资源管理器异步加载,这不会阻碍浏览器构建 DOM 树,但是 JavaScript 代码除外。当浏览器解析到 JavaScript 代码节点或者 JavaScript 资源的 URL,会停下 DOM 树的构建,去加载 JavaScript 文件,执行 JavaScript 代码,然后再继续构建 DOM 树。所以,我们要特别注意,当用 JavaScript 去操作 DOM 的时候,DOM 树很可能还没有构建完成,此时 JavaScript 是获取不到 DOM 节点的。总结 JavaScript 代码出现在文档中时会出现的具体问题:

    1\. 中止 DOM 树的构建;
    2\. 不会并发下载接下来文档中可能存在的 src 资源,比如图片;
    3\. JavaScript 代码如果涉及到对 DOM 的读写,可能会失败;
    4\. JavaScript 代码的执行耗费时间,延长文档的加载完成时间。
    
    

    针对上述问题,关注两个 JavaScript 事件:onload 和 DOMContentLoaded。顺便说明,支持 onload 事件的 JavaScript 对象有 image, layer, window,经常谈到的是window.onloadwindow.onload事件是在包括图像等资源在内的,整个页面全部加载完成之后才会触发,DOMContentLoaded事件在网页文档加载并解析完毕(DOM 树构建完成)之后就会触发。所以,对 DOM 的操作放在DOMContentLoaded事件触发之后最好。将 JavaScript 代码放在文档的最后(一般是置于 body 元素里面紧贴 body 闭合标签)也是一种方案。

    当然,确实有一些 JavaScript 代码是与 DOM 无关的。这种情况下,可以为 script 元素添加async属性,告诉浏览器这是可以异步执行的 JavaScript 代码。

    HTML 网页和它的 DOM 表示

    Webkit 实际上具有预扫描和预加载机制,在文档中解析到 JavaScript 代码的时候,会继续解析后面的文档查看有没有资源需要下载,有的话就下载,但在实际测试中,浏览器确实去下载资源了,但是由于 JavaScript 代码执行时的阻塞,资源并没有加载。而且其他的浏览器中不一定有此机制。因此,还是推荐使用前面的处理策略。