代理、VPN和内网穿透

[TOC] 代理 代理的意思就是代替你处理。在这里指网络服务相关的代理,做代理的服务器就叫代理服务器。有一些东西你自己无法直接获取,而代理服务器能做,那这些事情就可以交给代理服务器去做。这就是代理服务的价值所在。比如公司限制内部电脑不能上网,需要上网的员工可以申请连接到代理服务器上,这样员工的电脑就可以上网了。于是公司达到了网络管控的效果。 代理服务一般分为两种:正向代理和反向代理。唯一的差别在于代理服务器是作为客户端使用,还是作为服务端使用。 正向代理 正向代理中代理服务作为客户端,代替真实的客户端去访问服务。就比如前面提到的员工利用代理服务访问网络的例子。 反向代理 反向代理中,代理服务代替真实的服务器来迎接客户端的请求,然后将请求转发给真实的服务器。这样可以很好的隐藏真实的服务器地址,只需要让客户端知道代理服务器地址即可。在这里代理服务器可以做很多过滤和限制从而达到更好的保护好服务器正常的提供服务。 VPN 内网穿透

April 25, 2019

Go语言的线程

[TOC] 线程与锁 现代CPU一般含有多个核,并且一个核可能支持多线程。换句话说,现代CPU可以同时执行多条指令流水线。 为了将CPU的能力发挥到极致,我们常常需要使我们的程序支持并发(concurrent)计算。并发计算是指若干计算可能在某些时间片段内同时运行的情形。 在并行计算中,多个计算在任何时间点都在同时运行。并行计算属于特殊的并发计算。 而并发编程会存在数据竞争(data race)的情况,在不同线程同时修改统一内存控制时。并发编程的一大任务就是要调度不同计算,控制它们对资源的访问时段,以使数据竞争的情况不会发生。 此任务常称为并发同步(或者数据同步)。 锁是解决数据竞争的一种方法,在go语言中提供了sync包。 sync.Mutex: 互斥锁 sync.RWMutex: 读写分离锁 sync.WaitGroup: 等待一组goroutine 返回 sync.Once: 保证某段代码只执行一次 sync.Cond: 让一组goroutine 在满足特定条件时被唤醒 sync.Mutex Lock()加锁,Unlock()解锁 var m sync.Mutex func f1() { m.Lock() defer m.Unlock() doSomething() } func f2() { m.Lock() doSomething() m.Unlock() } sync.RWMutex 简单来说:不限制并发读,只限制并发写和并发读写 详细来说: 一个RWMutex值常称为一个读写互斥锁,它的内部包含两个锁:一个写锁和一个读锁。 对于一个可寻址的RWMutex值rwm,数据写入者可以通过方法调用rwm.Lock()对rwm加写锁,或者通过rwm.RLock()方法调用对rwm加读锁。 方法调用rwm.Unlock()和rwm.RUnlock()用来解开rwm的写锁和读锁。 rwm的读锁维护着一个计数。当rwm.RLock()调用成功时,此计数增1;当rwm.Unlock()调用成功时,此计数减1; 一个零计数表示rwm的读锁处于未加锁状态;反之,一个非零计数(肯定大于零)表示rwm的读锁处于加锁状态。 对于一个可寻址的RWMutex值rwm,下列规则存在: rwm的写锁只有在它的写锁和读锁都处于未加锁状态时才能被成功加锁。 换句话说,rwm的写锁在任何时刻最多只能被一个数据写入者成功加锁,并且rwm的写锁和读锁不能同时处于加锁状态。 当rwm的写锁正处于加锁状态的时候,任何新的对之加写锁或者加读锁的操作试图都将导致当前协程进入阻塞状态,直到此写锁被解锁,这样的操作试图才有机会成功。 当rwm的读锁正处于加锁状态的时候,新的加写锁的操作试图将导致当前协程进入阻塞状态。 但是,一个新的加读锁的操作试图将成功,只要此操作试图发生在任何被阻塞的加写锁的操作试图之前(见下一条规则)。 换句话说,一个读写互斥锁的读锁可以同时被多个数据读取者同时加锁而持有。 当rwm的读锁维护的计数清零时,读锁将返回未加锁状态。 假设rwm的读锁正处于加锁状态的时候,为了防止后续数据写入者没有机会成功加写锁,后续发生在某个被阻塞的加写锁操作试图之后的所有加读锁的试图都将被阻塞。 假设rwm的写锁正处于加锁状态的时候,(至少对于标准编译器来说,)为了防止后续数据读取者没有机会成功加读锁,发生在此写锁下一次被解锁之前的所有加读锁的试图都将在此写锁下一次被解锁之后肯定取得成功,即使所有这些加读锁的试图发生在一些仍被阻塞的加写锁的试图之后。 后两条规则是为了确保数据读取者和写入者都有机会执行它们的操作。 请注意:一个锁并不会绑定到一个协程上,即一个锁并不记录哪个协程成功地加锁了它。 example package main import ( "fmt" "runtime" "sync" ) type Counter struct { m sync.RWMutex n uint64 } func (c *Counter) Value() uint64 { c.m.RLock() defer c.m.RUnlock() return c.n } func (c *Counter) Increase(delta uint64) { c.m.Lock() c.n += delta c.m.Unlock() } func main() { var c Counter for i := 0; i < 100; i++ { go func() { for k := 0; k < 100; k++ { c.Increase(1) } }() } for c.Value() < 10000 { runtime.Gosched() } fmt.Println(c.Value()) // 10000 } sync.WatiGroup 每个sync.WaitGroup值在内部维护着一个计数,此计数的初始默认值为零。 ...

April 6, 2019

Go基础使用

[TOC] go控制语句 if 基本形式 // 大括号的左边必须跟在语句的后面 if condition1 { // do something } else if condition2 { // do something } else { // catch-all or default } 简短语句 // 处理完分号“;”前面的语句之后,再做判断 if v:=x-100; v<0 { // do something } switch fallthrough: 划过执行下一个case switch var1 { case value1: // do sth case value2: fallthrough // do next case case value3: // so sth default: ... } for // eg 1 for i:=0; i<10; i++ { sum += i } // eg 2 for ; sum < 1000; { sum += sum } // always loop for { // do something if condition { break } } for range // string for index,char := range myString { // do something } // map for k,v := range myMap { // do } // array or slice for index,value := range myArray { // do } 数据结构 variable var varName type ...

April 4, 2019

Go工具链

[TOC] Go 为了从任意目录运行Go,安装目录下的bin子目录路径必须配置在PATH环境变量中。 1、Go环境变量 GOPATH: 此环境变量的默认值为当前用户的HOME目录下的名为go文件夹对应的目录路径。 GOPATH文件夹中的pkg子文件夹用来缓存被本地项目所依赖的Go模块(一个Go模块为若干Go库包的集合)的版本。src子文件夹用来存放源码。 GOBIN: GOBIN环境变量用来指定go install子命令产生的Go应用程序二进制可执行文件应该存储在何处。 它的默认值为GOPATH文件夹中的bin子目录所对应的目录路径。 2、Go子命令 go run 编译和运行main包中的go程序。比如有一个 example.go 的文件,只需要执行 go run example.go 即可。 如果程序的main包中有多个go源码文件,我们可以指定目录。例如: $ ls a.go b.go $ go run ./ go install 编译和安装main包中的go程序,并不会执行,而是将可执行文件放入GOBIN指定的目录中。 我们可以运行go install example.com/program@latest来安装一个第三方Go程序的最新版本(至GOBIIN目录)。 在1.16版本之前,可以是用go get -u example.com/program 来安装。 go build 编译main包中的go程序,并不会安装,也不会执行。 go vet go vet子命令可以用来检查可能的代码逻辑错误(即警告)。 go fmt go fmt子命令来用同一种代码风格格式化Go代码。 go test go test子命令来运行单元和基准测试用例。 go doc go doc子命令用来(在终端中)查看Go代码库包的文档。 go mod init go mod init 命令可以用来在当前目录中生成一个go.mod文件。此go.mod文件将被用来记录当前项目需要的依赖模块和版本信息。 go mod tidy go mod tidy命令用来通过扫描当前项目中的所有代码来添加未被记录的依赖至go.mod文件或从go.mod文件中删除不再被使用的依赖。 ...

April 2, 2019

Go语言简洁

[TOC] Go语言简介 Go是一门编译型和静态型的编程语言。 1、Go语言卖点 1、做为一门静态语言,Go却和很多动态脚本语言一样得灵活 2、节省内存、程序启动快和代码执行速度快 3、内置并发编程支持 4、良好的代码可读性,Go的语法很简洁并且和其它流行语言相似。 5、良好的跨平台支持 6、一个稳定的Go核心设计和开发团队以及一个活跃的社区 7、Go拥有一个比较齐全的标准库 和C家族语言相比有以下优点: 程序编译时间短 像动态语言一样灵活 内置并发支持 2、Go语言特性 内置并发编程支持: 使用协程(goroutine)做为基本的计算单元。轻松地创建协程。 使用通道(channel)来实现协程间的同步和通信。 内置了映射(map)和切片(slice)类型。 支持多态(polymorphism)。 使用接口(interface)来实现裝盒(value boxing)和反射(reflection)。 支持指针。 支持函数闭包(closure)。 支持方法。 支持延迟函数调用(defer)。 支持类型内嵌(type embedding)。 支持类型推断(type deduction or type inference)。 内存安全。 自动垃圾回收。 良好的代码跨平台性。 3、开源Go项目 图片来源于极客时间

April 1, 2019

Ceph学习笔记-03-luminous版本部署

[TOC] 1、配置ceph.repo并安装批量管理工具ceph-deploy [root@ceph-node1 ~]# vim /etc/yum.repos.d/ceph.repo [ceph] name=Ceph packages for $basearch baseurl=http://mirrors.aliyun.com/ceph/rpm-luminous/el7/$basearch enabled=1 gpgcheck=1 priority=1 type=rpm-md gpgkey=https://mirrors.aliyun.com/ceph/keys/release.asc [ceph-noarch] name=Ceph noarch packages baseurl=http://mirrors.aliyun.com/ceph/rpm-luminous/el7/noarch enabled=1 gpgcheck=1 priority=1 type=rpm-md gpgkey=https://mirrors.aliyun.com/ceph/keys/release.asc [ceph-source] name=Ceph source packages baseurl=http://mirrors.aliyun.com/ceph/rpm-luminous/el7/SRPMS enabled=0 gpgcheck=1 type=rpm-md gpgkey=https://mirrors.aliyun.com/ceph/keys/release.asc priority=1 [root@ceph-node1 ~]# yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm [root@ceph-node1 ~]# yum makecache [root@ceph-node1 ~]# yum update -y [root@ceph-node1 ~]# yum install -y ceph-deploy ​``` 2、ceph的节点部署 (1)安装NTP 在所有 Ceph 节点上安装 NTP 服务(特别是 Ceph Monitor 节点),以免因时钟漂移导致故障 [root@ceph-node1 ~]# yum install -y ntp ntpdate ntp-doc [root@ceph-node2 ~]# yum install -y ntp ntpdate ntp-doc [root@ceph-node3 ~]# yum install -y ntp ntpdate ntp-doc [root@ceph-node1 ~]# ntpdate ntp1.aliyun.com 31 Jul 03:43:04 ntpdate[973]: adjust time server 120.25.115.20 offset 0.001528 sec [root@ceph-node1 ~]# hwclock Tue 31 Jul 2018 03:44:55 AM EDT -0.302897 seconds [root@ceph-node1 ~]# crontab -e */5 * * * * /usr/sbin/ntpdate ntp1.aliyun.com 确保在各 Ceph 节点上启动了 NTP 服务,并且要使用同一个 NTP 服务器 ...

March 13, 2019

Ceph学习笔记-02-工作原理及流程

[TOC] 一、RADOS的对象寻址 Ceph 存储集群从 Ceph 客户端接收数据——不管是来自 Ceph 块设备、 Ceph 对象存储、 Ceph 文件系统、还是基于 librados 的自定义实现——并存储为对象。每个对象是文件系统中的一个文件,它们存储在对象存储设备上。由 Ceph OSD 守护进程处理存储设备上的读/写操作。 在传统架构里,客户端与一个中心化的组件通信(如网关、中间件、 API 、前端等等),它作为一个复杂子系统的唯一入口,它引入单故障点的同时,也限制了性能和伸缩性(就是说如果中心化组件挂了,整个系统就挂了)。 Ceph 消除了集中网关,允许客户端直接和 Ceph OSD 守护进程通讯。 Ceph OSD 守护进程自动在其它 Ceph 节点上创建对象副本来确保数据安全和高可用性;为保证高可用性,监视器也实现了集群化。为消除中心节点, Ceph 使用了 CRUSH 算法。 File —— 此处的file就是用户需要存储或者访问的文件。当用户要将数据存储到Ceph集群时,存储数据都会被分割成多个object。 Ojbect —— 每个object都有一个object id,每个object的大小是可以设置的,默认是4MB,object可以看成是Ceph存储的最小存储单元。 PG(Placement Group)—— 顾名思义,PG的用途是对object的存储进行组织和位置映射。由于object的数量很多,所以Ceph引入了PG的概念用于管理object,每个object最后都会通过CRUSH计算映射到某个pg中,一个pg可以包含多个object。 OSD —— 即object storage device,PG也需要通过CRUSH计算映射到osd中去存储,如果是二副本的,则每个pg都会映射到二个osd,比如[osd.1,osd.2],那么osd.1是存放该pg的主副本,osd.2是存放该pg的从副本,保证了数据的冗余。 (1)File -> object映射 这次映射的目的是,将用户要操作的file,映射为RADOS能够处理的object。其映射十分简单,本质上就是按照object的最大size对file进行切分,相当于RAID中的条带化过程。这种切分的好处有二:一是让大小不限的file变成最大size一致、可以被RADOS高效管理的object;二是让对单一file实施的串行处理变为对多个object实施的并行化处理。 每一个切分后产生的object将获得唯一的oid,即object id。其产生方式也是线性映射,极其简单。图中,ino是待操作file的元数据,可以简单理解为该file的唯一id。ono则是由该file切分产生的某个object的序号。而oid就是将这个序号简单连缀在该file id之后得到的。举例而言,如果一个id为filename的file被切分成了三个object,则其object序号依次为0、1和2,而最终得到的oid就依次为filename0、filename1和filename2。 这里隐含的问题是,ino的唯一性必须得到保证,否则后续映射无法正确进行。 (2)Object -> PG映射 在file被映射为一个或多个object之后,就需要将每个object独立地映射到一个PG中去。这个映射过程也很简单,如图中所示,其计算公式是: hash(oid) & mask -> pgid 由此可见,其计算由两步组成。首先是使用Ceph系统指定的一个静态哈希函数计算oid的哈希值,将oid映射成为一个近似均匀分布的伪随机值。然后,将这个伪随机值和mask按位相与,得到最终的PG序号(pgid)。根据RADOS的设计,给定PG的总数为m(m应该为2的整数幂),则mask的值为m-1。因此,哈希值计算和按位与操作的整体结果事实上是从所有m个PG中近似均匀地随机选择一个。基于这一机制,当有大量object和大量PG时,RADOS能够保证object和PG之间的近似均匀映射。又因为object是由file切分而来,大部分object的size相同,因而,这一映射最终保证了,各个PG中存储的object的总数据量近似均匀。 从介绍不难看出,这里反复强调了“大量”。只有当object和PG的数量较多时,这种伪随机关系的近似均匀性才能成立,Ceph的数据存储均匀性才有保证。为保证“大量”的成立,一方面,object的最大size应该被合理配置,以使得同样数量的file能够被切分成更多的object;另一方面,Ceph也推荐PG总数应该为OSD总数的数百倍,以保证有足够数量的PG可供映射。 (3)PG -> OSD映射 第三次映射就是将作为object的逻辑组织单元的PG映射到数据的实际存储单元OSD。如图所示,RADOS采用一个名为CRUSH的算法,将pgid代入其中,然后得到一组共n个OSD。这n个OSD即共同负责存储和维护一个PG中的所有object。前已述及,n的数值可以根据实际应用中对于可靠性的需求而配置,在生产环境下通常为3。具体到每个OSD,则由其上运行的OSD deamon负责执行映射到本地的object在本地文件系统中的存储、访问、元数据维护等操作。 ...

March 11, 2019

Ceph学习笔记-01-初识Ceph

[TOC] 官方中文文档:http://docs.ceph.org.cn/ 一、元数据和元数据管理 (1)元数据 在学习Ceph之前,需要了解元数据的概念。元数据又称为中介数据、中继数据,为描述数据的数据。主要描述数据属性的信息,用来支持如指示存储位置、历史数据、资源查找、文件记录等功能。通俗地说,就 是用于描述一个文件的特征的系统数据,比如访问权限、文件拥有者以及文件数据库的分布信息(inode)等等。在集群文件系统中,分布信息包括文件在磁盘上的位置以 及磁盘在集群中的位置。用户需要操作一个文件就必须首先得到它的元数据,才能定位到文件的位置并且得到文件的内容或相关属性。 使用stat命令,可以显示文件的元数据 [root@ceph-node1 ~]# stat 1.txt File: ‘1.txt’ Size: 0 Blocks: 0 IO Block: 4096 regular empty file Device: 802h/2050d Inode: 33889728 Links: 1 Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root) Context: unconfined_u:object_r:admin_home_t:s0 Access: 2018-08-05 16:38:22.137566272 +0800 Modify: 2018-08-05 16:38:22.137566272 +0800 Change: 2018-08-05 16:38:22.137566272 +0800 Birth: - File:文件名 Size:文件大小(单位:B) Blocks:文件所占扇区个数,为8的倍数(通常的Linux扇区大小为512B,连续八个扇区组成一个block) IO Block:每个数据块的大小(单位:B) regular file:普通文件(此处显示文件的类型) Inode:文件的Inode号 Links:硬链接次数 Access:权限 Uid:属主id/属主名 Gid:属组id/属组名 Access:最近访问时间 Modify:数据改动时间 Change:元数据改动时间 以上的参数均属于文件的元数据,元数据即用来描述数据的数据。 (2)元数据管理 ...

March 10, 2019

JWT及JJWT签发与验证token

[TOC] JWT JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。 一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。 头部(Header) 头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象。 {“typ”:“JWT”,“alg”:“HS256”} 在头部指明了签名算法是HS256算法。 我们进行BASE64编码http://base64.xpcha.com/,编码后的字符串如下: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 载荷(playload) 载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分 (1)标准中注册的声明(建议但不强制使用) iss: jwt签发者 sub: jwt所面向的用户 aud: 接收jwt的一方 exp: jwt的过期时间,这个过期时间必须要大于签发时间 nbf: 定义在什么时间之前,该jwt都是不可用的. iat: jwt的签发时间 jti: jwt的唯一身份标识,主要用来作为一次性token。 (2)公共的声明 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密. (3)私有的声明 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。 这个指的就是自定义的claim。比如前面那个结构举例中的admin和name都属于自定的claim。这些claim跟JWT标准规定的claim区别在于:JWT规定的claim,JWT的接收方在拿到JWT之后,都知道怎么对这些标准的claim进行验证(还不知道是否能够验证);而private claims不会验证,除非明确告诉接收方要对这些claim进行验证以及规则才行。 定义一个payload: {"sub":"1234567890","name":"John Doe","admin":true} 然后将其进行base64加密,得到Jwt的第二部分。 eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9 签证(signature) jwt的第三部分是一个签证信息,这个签证信息由三部分组成: header (base64后的) payload (base64后的) secret 这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ 注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。 JJWT签发与验证token JJWT是一个提供端到端的JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0),JJWT很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。 官方文档: https://github.com/jwtk/jjwt 创建token (1)新建项目中的pom.xml中添加依赖: <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency> (2)创建测试类,代码如下 JwtBuilder builder= Jwts.builder() .setId("888") //设置唯一编号 .setSubject("小白")//设置主题 可以是JSON数据 .setIssuedAt(new Date())//设置签发日期 .signWith(SignatureAlgorithm.HS256,"hahaha");//设置签名 使用HS256算法,并设置SecretKey(字符串) //构建 并返回一个字符串 System.out.println( builder.compact() ); 运行打印结果: ...

March 1, 2019

Nginx通过CORS实现跨域

[TOC] 什么是CORS CORS是一个W3C标准,全称是跨域资源共享(Cross-origin resource sharing)。它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。 当前几乎所有的浏览器(Internet Explorer 8+, Firefox 3.5+, Safari 4+和 Chrome 3+)都可通过名为跨域资源共享(Cross-Origin Resource Sharing)的协议支持AJAX跨域调用。 Chrome,Firefox,Opera,Safari都使用的是XMLHttpRequest2对象,IE使用XDomainRequest。 简单来说就是跨域的目标服务器要返回一系列的Headers,通过这些Headers来控制是否同意跨域。跨域资源共享(CORS)也是未来的跨域问题的标准解决方案。 CORS提供如下Headers,Request包和Response包中都有一部分。 HTTP Response Header Access-Control-Allow-Origin Access-Control-Allow-Credentials Access-Control-Allow-Methods Access-Control-Allow-Headers Access-Control-Expose-Headers Access-Control-Max-Age HTTP Request Header Access-Control-Request-Method Access-Control-Request-Headers 其中最敏感的就是Access-Control-Allow-Origin这个Header, 它是W3C标准里用来检查该跨域请求是否可以被通过。(Access Control Check)。如果需要跨域,解决方法就是在资源的头中加入Access-Control-Allow-Origin 指定你授权的域。 启用CORS请求 假设您的应用已经在example.com上了,而您想要从www.example2.com提取数据。一般情况下,如果您尝试进行这种类型的AJAX调用,请求将会失败,而浏览器将会出现源不匹配的错误。利用CORS后只需www.example2.com 服务端添加一个HTTP Response头,就可以允许来自example.com的请求。 将Access-Control-Allow-Origin添加到某网站下或整个域中的单个资源 Access-Control-Allow-Origin: http://example.com Access-Control-Allow-Credentials: true (可选) 将允许任何域向您提交请求 Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true (可选) 提交跨域请求 如果服务器端已启用了CORS,那么提交跨域请求就和普通的XMLHttpRequest请求没什么区别。例如现在example.com可以向www.example2.com提交请求。 var xhr = new XMLHttpRequest(); // xhr.withCredentials = true; //如果需要Cookie等 xhr.open('GET', 'http://www.example2.com/hello.json'); xhr.onload = function(e) { var data = JSON.parse(this.response); ... } xhr.send(); 服务端Nginx配置 要实现CORS跨域,服务端需要下图中这样一个流程 ...

February 20, 2019