virtualbox 网络桥接

virtualbox的默认方式是NAT,用宿主机对虚拟机做端口转发。在组建本地的集群环境时,使用这种方式是不行的。可以使用桥接网卡的方式,使得虚拟机分配到一个宿主机局域网内的ip地址。

首先,修改/etc/network/interfaces文件。

这个文件原来的内容如下:

# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).

source /etc/network/interfaces.d/*

# The loopback network interface
auto lo
iface lo inet loopback

# The primary network interface
auto enp0s3
iface enp0s3 inet dhcp

修改成如下:

# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).

source /etc/network/interfaces.d/*

# The loopback network interface
auto lo
iface lo inet loopback

# The primary network interface
auto enp0s3
#iface enp0s3 inet dhcp
iface enp0s3 inet static
address 192.168.0.100
gateway 192.168.0.1
netmask 255.255.255.0

注意这里的addressgateway的网段要和宿主机网段一致。比如我的宿主机ip为192.168.0.8

注意这里还要修改一下dns,不然会出现无法解析域名的问题,编辑/etc/resolvconf/resolv.conf.d/base

添加一下的nameserver:

nameserver 8.8.8.8
nameserver 1.1.1.1

执行sudo resolvconf -u使dns的配置生效。

然后修改虚拟机的设置,在VirtualBox的菜单栏中:设备->网络->网络->连接方式:桥接网卡。

然后重启网络

sudo /etc/init.d/networking restart

如果网络还是有问题,可以尝试重启虚拟机。

之后在终端之中输入ifconfig,网络信息如下:

enp0s3    Link encap:Ethernet  HWaddr 08:00:27:f1:6d:fd  
          inet addr:192.168.0.100  Bcast:192.168.0.255  Mask:255.255.255.0
          inet6 addr: fe80::a00:27ff:fef1:6dfd/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:304 errors:0 dropped:0 overruns:0 frame:0
          TX packets:173 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000 
          RX bytes:28254 (28.2 KB)  TX bytes:28063 (28.0 KB)

虚拟机的ip已经变成192.168.0.100。使用ping 192.168.0.8检查和宿主机的通信是否正常。

系统权限设计中RBAC模型的使用

什么是RBAC

RBAC,全称是Role-Based-Access-Control,可以译为基于角色的权限控制,是一种应用非常广泛的权限控制模型。

什么是角色(Role)

角色不是一个用户实体,而是代表了一组行为能力或责任的命名实体,以我们常用的QQ群为例,有以下角色:超级管理员、群主、管理员、普通成员、非群成员。

每个角色都有不同的权限:

  • 超级管理员可以管理任何的QQ群,但是一般只能禁言、删除群,不能管理具体的某个群成员,也不能在群里聊天;
  • 群主可以管理自己的QQ群,可以在群里聊天;
  • 管理员可以管理自己的QQ群,可以聊天,但是不能解散群,也不能任命或撤销其他管理员;
  • 普通成员则没有管理权限,只有在群里聊天的权限
  • 非群成员无管理权限,也无法在群里聊天。

所以可以知道,每个角色都对应着一组行为能力或责任。

隐式的基于角色的权限控制

对于QQ群的例子来说。如果我们要做一个删除某个群成员的动作,伪代码可能如下:

if( user.hasRole('Group Owner') || user.hasRole('Group Manager') ){
    // delete the Group Member
} else {
    // Permission Denied
}

那么现在,如果腾讯的权限策略发生了改变,超级管理员也可以删除某个群的群成员来防止某些不当言论的传播,那么代码就要改为

if( user.hasRole('Group Owner') || user.hasRole('Group Manager') || user.hasRole('Super Manager') ){
    // delete the Group Member
} else {
    // Permission Denied
}

上面这种权限的管理方式,就可以称为隐式的基于角色的权限控制。因为Group OwnerGroup ManagerSuper Manager并不能显式的表达出:它们的角色具有删除群成员的权限。没有任何的代码显式的定义了这些角色的权限。我们只能从代码中隐式的推测出:这三个角色拥有删除群成员的权限。所以程序员们使用if/else语句来反映这些假设。

显式的基于角色的权限控制

了解了隐式的基于角色的权限控制,那么我们就可以知道,显式的基于角色的权限控制要有能力直接表达出:当前用户有权限去删除群成员。这样我们的代码可以调整如下:

if( user.isPermitted('GroupMember:delete:478') ){
    // delete the Group Member
} else {
    // Permission Denied
}

这样从代码中可以看到,如果当前用户被允许删除ID为478的群成员,那么就去删除,否则报权限不足的错误。至于isPermitted()中如何去判断权限的,可能依然是回到了哪些角色有哪些权限的问题。但是不同之处在于,应对上面的需求变更问题时,我们只需要更改user的isPermitted的判断规则,而不用去更改散布在代码中各个地方的if语句。可以做到以最小的变更来应对复杂的需求变化。

隐式 vs 显式

隐式和显式在我看来,其内在的权限控制仍然是一样的,都是基于角色在做权限判断。但对外的抽象则是截然不同的:隐式侧重于某个用户是否有某些角色,显式则直接将问题聚焦于某个用户是否有对某个资源的某个操作的权限。这在写代码中给程序员带来的影响是不一样的。截取一段项目中的代码来佐证:

//检查操作的权限
if(
    !$familyDB->isUserForFamily($familyId,$userId)&&
    !$familyDB->isAdminForFamily($familyId,$userId)&&
    !$familyDB->isOriginatorForFamily($familyId,$userId)
){
    Util::printResult( $GLOBALS['ERROR_PERMISSION'], "操作权限错误");
    exit;
}

这段代码判断了用户是否对家族有读取权限。因为我们是隐式的基于角色的权限控制,很直观的想法就是:家族成员、家族管理员、家族创始人这三个角色都有对家族的读取权限。所以这里判断了三个权限。事实上,家族创始人的判断是多余的,因为家族创始人肯定属于家族成员。但是真正在写代码的时候,很有可能考虑不到这个问题。

但如果是显式的基于角色的权限控制,这个if语句就是:

//检查操作的权限
if(
    !$familyDB->isUserHasReadPermission($familyId,$userId))
){
    Util::printResult( $GLOBALS['ERROR_PERMISSION'], "操作权限错误");
    exit;
}

程序员写代码时就不会做出多余的判断。可以得出,显式的抽象表达能力是明显更强的。

新的RBAC:Resource-Based-Access-Control

通过对比隐式显式的区别,我们知道显式是直接检查某个用户对某个资源(Resource)是否有某个权限。所以不如直接抛弃角色(Role)的概念,将资源这个概念引入。这样就有了基于资源的权限控制(Resource-Based-Access-Control)

朋友圈式的Timeline设计方案

一、前言

几乎每个人都会发朋友圈、微博,一个看似简单的发布功能,实际在背后是经过精细设计的组织架构。这里有一篇关于新浪微博的架构设计的演讲,主要是讲了通过redis+缓存的使用,来实现发微博的实时性和高效性。

二、Timeline

目前来说,大多数发朋友圈、发微博这种架构的设计都是Timeline的方式。以微博为例,每个用户都有一个自己的Timeline,用户查看微博时,只从自己的Timeline上获取数据。而如果我们是使用普通的sql查询,那么查询可能就是select weibo from table where table.userId in (select userId from guanzhu where guanzhu.userId = 'me')。也就是先查询我所关注的人,再查询所有我关注的人发的微博,然后以时间排序返回。而且因为数据的变化会非常的快,根本没有办法去做缓存,并且刷微博又是一个非常高频的动作,因此普通的sql查询必然无法满足。

而Timeline方案,其实就是以空间换时间的一种方案,假设现在姚晨(1000万粉丝)发布了一条微博,在姚晨的视角里,她已经发布完成了,但是实际上此时并不是她的所有粉丝都能立刻看到这条微博。这个发布动作做了异步处理,此时正在向关注她的微博的粉丝的Timeline上推送(当然微博也不可能真的向她所有粉丝推送,可能会去除一些长期不上线的用户以及僵尸粉)。

因此Timeline方案最复杂的地方就在于发微博这个环节而不是刷新微博这个环节了。发微博相对于刷新微博是非常低频次的,并且只要自己能实时看到就行,并不要求其他人都能实时看到,大大降低了复杂度。

三、使用redis配合完成Timeline的设计。

redis是一款内存数据库,但是它的数据也可以持久化的磁盘上。因为是内存数据库,它最擅长的就是高频次的读取和写入。并且redis支持多种数据结构,其中list数据结构非常符合Timeline的需求。

在redis中,可以创建命名的list(系统中现在使用了zset去保存timeline,比list更优),并且可以对list做push、pop、range操作。比如id为28的用户,他的timeline可以是名为u28的list,然后使用push来向用户的Timeline增加新的推文、使用range来获取指定范围内的推文。

下面的php代码演示了如何从Timeline上获取推文信息

/**
 * 获取一页时间轴上的推文id
 * @param $userId 用户id
 * @param $pageIndex 页码
 * @param $pageSize  页大小
 * @return array 一页postId
 */
public function getTimeline($userId,$pageIndex,$pageSize){
    $end = (1 - $pageIndex) * $pageSize - 1;
    $start = $end - $pageSize + 1;

    $result = $this->redis->lRange($this->getUserTimelineKey($userId),$start,$end);

    return $result;
}

/**
 * 获取用户时间轴上推文的总数
 * @param $userId 用户id
 * @return int 总数
 */
public function getTimelinePostCount($userId){
    return $this->redis->lSize($this->getUserTimelineKey($userId));
}

四、php如何异步推送到其他用户的Timeline

php的机制决定了它无法完成异步操作(一个php文件执行完成之后线程会直接被销毁),因此这里同样是采用了Redis当做任务队列来解耦合,实现异步操作。一个用户发布推文之后,使用下面的setPostTask($pushUserIds,$postId,$userId),将发布推文放到任务队列中然后直接返回。在这里,对用户自己的Timeline做了特殊处理,发推文之后立马给自己的timeline同步推送,其他用户的timeline是异步推送。

        /**
         * 设置推送任务
         * @param $pushUserIds 要推送的userId
         * @param $postId      推文内容
         * @return mix 任务推送状态
         */
        public function setPostTask($pushUserIds,$postId,$userId){

            //为了用户第一时间看到自己发的,这里特殊处理用户自己发的推文
            $this->redis->rPush($this->getUserTimelineKey($userId),$postId);

            $userIds = array();
            foreach($pushUserIds as $value){
                array_push($userIds,$value['userId']);
            }
            $data['u'] = $userIds;
            $data['p'] = $postId;
            $str = \json_encode($data);
            return $this->redis->rPush($GLOBALS['redis_post'],$str);
        }

于此同时,一个以cli模式运行的php脚本从任务队列中顺序获取所有的推送任务,向其他用户的Timeline上推送。

/**
 * 获取推送推文的任务
 * @return mix 推送结果
 */
public function getPostTask(){
    return $this->redis->lPop($GLOBALS['redis_post']);
}

/**
 * 将推文id推送到所有用户的timeline中
 * @TODO 现在是同步的方式
 * @param $pushUserIds 要推送的用户id
 * @param $postId 推文id
 * @return boolean  true操作成功
 */
public function pushToTimeline($pushUserIds,$postId){
    foreach($pushUserIds as $value){
        $result = $this->redis->rPush($this->getUserTimelineKey($value),$postId);
    }
    return true;
}

cli模式运行的php脚本代码:

<?php
/**
 * 从Redis队列中轮询要执行的任务,并执行推送
 *
 */
require_once('vendor/autoload.php');
use DB\PostDB;

$postDB = new PostDB();
$postContent = "";
while(true){

    while( ($postContent = $postDB->getPostTask()) != FALSE ){
        $post = json_decode($postContent,true);
        var_dump($post['u']);
        $postDB->pushToTimeline($post['u'],$post['p']);
    }

    sleep(1);

}

即使这个推送会花费一些时间,但是没有任何用户的体验受到了影响,发布推文的用户timeline因为是特殊处理,所以他能立马看到自己发的推文,其他用户虽然无法立刻看到,但是其他用户对“立刻看到”的需求几乎没有(“立刻看到”指的是刚发布的一瞬间,绝大多数人对于延迟几秒看到根本无所谓)。并且这个架构可以很轻松的横向扩展。

五、一些其他问题

5.1 内存很贵,尽量压缩

redis是内存数据库,内存相对于磁盘的价格要贵上很多倍,因此存在redis中的内容要尽量的小。比如存的是Json,那么Json的字段要尽量的小(直接使用1,2,3,4,5这种也完全可以,即使损失了可读性),同时Json也可以考虑改成谷歌的Protocol Buffers等等。

5.2 timeline里应该存什么

使用Redis存储了每个用户的Timeline,在我的实现中,timeline中存储的是每篇推文的id,然后获取到id之后再去mysql数据库中查询,但是这样的设计虽然大大降低了查询的复杂度,但是并没有降低mysql的IO负荷,如果把推文内容直接存储到Timeline中呢?这样的话如果用户要删除一篇推文,如何对应的删除其他用户的timeline中的推文?这个地方一直是我无法解决的疑惑。

这里有一篇关于twitter使用redis解决timeline的方案,这篇方案中使用的不是list来保存用户的post,而是zset,zset中每条数据由推文的id和时间戳组成,通过时间来排序。仔细考虑一下,list因为是根据推送时间来排序的,可能出现后发的推文出现在靠前的位置(当然我认为是可以通过隐藏描述显示来解决这个问题,因为错位几秒并不影响)。

所以理论上来说,上面那篇文章介绍的方案和我的方案是一致的,只是我把推文的源放在mysql上,twitter直接是存储在redis中来获取更快的速度。

网站登录系统设计

一、前言

一个网站的登录系统可以算作是一个网站的大门,如何让这个大门足够的安全,并且又不用太复杂是一个很考验经验的工作。作为一个没有经验的新手,简单记下一点自己在做网站登录系统设计时做法。

二、前提

首先假设我们设计的网站是一个单服务器的应用,并且他有多种登录方式(邮箱,手机,用户名),以及多种登录途径(web,app)。

三、正文

1.会话状态

因为是单服务器的应用,所以不用担心多台服务器之间的会话状态转移的问题,所以为了简单,我们采用的是cookie方式,这样既不用在用户关闭浏览器后再次访问就要重新登录,也可以通过设定cookie的失效时间,来让浏览器自动在一定时间后让用户重新登录,保护用户设备丢失带来的风险。

2.登录方式

考虑到需要支持邮箱,手机或者是用户名的登录,也就是用户随意输入其中的一种以及密码都要可以登录,所以要对用户名有一定的限制,首先用户名不能重复,并且用户不能是手机或者邮箱,因此要对用户名做一些简单的限制——用户名不能为纯数字,也不能包含@符号。

3.密码存储

密码的存储是一定要足够安全的。因此对密码的存储一定要够安全。我采用的是密码+salt的方式,使用sha-256加密。

4.身份验证

身份验证上,我在cookie中填入用户id和token连接而成的字符串(称为id_token),例如24|dashdiuasdhgiuagsduigaiusdg这种形式,|符号前面是Id,后面是token,token是登录时随机生成的,在登录时会讲登录记录插入到数据库中,这条记录包括用户id,登录名(用户名,邮箱或手机中的任一种)、登录途径(web还是app)、登录时生成的token、登录时间等等,然后之后用户提交请求时都会验证cookie,将cookie中的id和token截取出来,与最新的一条该用户id的登录成功的记录的token对比,如果一致,则表示身份是对的。这种做法可以很好的防止伪造cookie。对于app来说,将这个cookie保存下来就可以一直作为登录凭证。如果遇到手机丢失,想使手机上的登录失效,重新用新手机登录一次即可。

5.多端登录的冲突解决

在4.身份验证中说到,登录的身份验证是根据最新的该用户的登录记录来判断的,所以如果用户在app上登录了,再在网站上登录则会使app的登录失效,为了解决这个问题,可以在登录时附带上登录方式的字段,那么就可以解决web端和app端的冲突问题。