It’s gangzz!

A blogging about coding and life..

Storm和ES的简单推荐功能实现

说明

本文只实现一个最基本推荐功能,其可以实时分析用户的行为并据此提取推荐线索,从而根据最新的用户线索利用搜索引擎获取推荐结果。

本策略缺少以下基本能力:

  • 被推荐物品之间缺乏关联关系,某种意义上不能推荐与当前物品相似的物品。
  • 只对用户行为进行定性分析,非定量。比如用户看了“口腔溃疡”十次、看了“感冒一次”,这两者却获得了相同的加权。
  • 缺乏推荐后用户行为的反馈。

实现思路

该推荐功用于“轻问诊”场景中向用户推荐感兴趣的医生。基本思路是把用户的行为日志逐条读入Storm中,根据用户当前关心的疾病名、医生科室线索推荐符合条件的医生给用户。

用户行为日志来源: 来自为App提供数据接口的服务器,拦截用户的全部请求并抽取requestUrl和requestParameters就满足了获取用户(userId)感兴趣的疾病或科室(请求参数)的目标。

日志传输路径:log -(flume)-> Kafka –> Storm –> Redis –> ElasticSearch。

具体实现

各组件物理拓扑

各个组件的物理拓扑图

图中分为绿色线条和红色线条两部分。

绿色线条部分是用户的行为日志处理:

  1. 首先使用拦截器拦截所有用户请求,整理出必需参数交给logback记录日志——此处包含两个Appender:RollFileAppender和FlumeAppender,前者是传统日志文件(存本地),后者是日志收集组件(Flume)能够在保证可靠性的前提下高效地将多个host的日志汇聚到Flume Agent处,再由Flume Agent提交给kafka。
  2. Storm通过KafkaSpout逐条抽取提交到消息服务的日志并实时处理,处理结果根据UserId生成唯一Key保存在Redis中供推荐服务查询。

红色部分是推荐行为:

  1. 用户触发推荐行为后服务器根据UserId从Redis获取所有该用户的推荐线索——疾病名称、科室Id等。
  2. LogicalServer根据当前用户的线索,生成合适的查询条件调用搜索引擎获取推荐结果。比如:根据疾病名在医生擅长项目中查找、根据科室线索筛选口碑好的医生等。

除此之外用户的行为日志具有时效性,在Storm处理过程中会保留用户日志的原始数据,根据日志发生时间判断是否要将某一条日志保留或删除。

Storm中的Topology

医生推荐Topology

进入Kafka的数据格式是未经处理的日志数据(包括logback的layout处理),本部分首先开发了一个根据layout信息逆转日志为结构化数据的工具(暂时命名为log-delayout,未来日志介绍)用来把日志转换成结构化的数据。

ApiLogToMapBolt负责文本日志到结构化数据的转换,除此之外ApiLogToMapBolt还起到过滤的作用把不感兴趣的日志直接排除掉,减轻系统负担。

从ApiLogToMapBolt出来的Tuple会根据Url的特征值分发到DiseaseHintBolt(疾病)或StdDeptHintBolt(科室)中,在这两个Bolt中完成关键信息的提取过程——提取疾病或科室Id、更新数据到Redis中、同时删除已经超过时效性的记录。

最后通知HintsRefreshBolt根据最新的用户特征刷新推荐信息,以便下次服务器查询时能够获得最新的推荐条件。

Flume FileChannel 源码

Flume结构

FileChannel是Flume Channel的一种基于文件系统的实现,能够直接持久化消息(FlumeEvent)到文件系统中。

参数&功能

FileChannel的参数包含但不限于以下几个主要参数

  • checkpointDir:checkpoint数据的存储目录。
  • useDualCheckpoints:在更新checkpoint前是否备份。
  • backupCheckpointDir:检查点数据备份目录,只有上一个值为true时才有意义。
  • dataDirs:数据存放目录,允许一个或多个逗号分隔的目录。
  • checkpointInterval:checkpoint的执行周期,默认3秒,单位毫秒。
  • maxFileSize:产生新文件的临界值,文件的最大长度。

在FS中的组织结构

以下将从效率、可靠性、事务、更新策略上介绍FileChannel设计及其文件系统的利用。

文件数据组织结构

data Dirs

dataDirs虽然可以接受多个逗号分隔的目录,但是只有在不同的存储介质上时才有其实际意义。FileChannel会以轮询的方式写入各个目录,只有独立介质的情况下才能达到减轻寻址等机械动作导致的性能开销。

效率

FileChannel的核心数据包括Record(消息或操作)、元数据和checkpoints三部分(FlumeEventQueue的持久化数据),先介绍Record数据。

Record数据存储在log-n的文件中,其中n为变量随文件长度达到最大限制而递增,最佳的效率只能发生在顺序写入的情况下。FileChannel支持增加、删除和事务回滚操作(put、take、rollback),但是所有操作都以操作日志的形式追加到log-n文件中。

对log-n文件的写动作发生在LogFile.Writer中,每段数据的格式相同[flag][len][data]一位标志位、int长度的位数表示数据长度、数据。flag选值:OP_RECORD、OP_NOOP、OP_EOF(记录、操作、结尾)。

  // OP_RECORD + size + buffer
  int recordLength = 1 + (int)Serialization.SIZE_OF_INT + buffer.limit();

可靠性

Flume要保证即使进程崩溃了也能保证数据的可靠性。

首先,从log-n文件数据直接写入文件系统中正常情况下是可靠的,但是崩溃可以发生在任何时候,比如记录写到一半。

因此,FileChannel使用checkpoint的方式,检查周期由checkpointInterval指定,默认3秒。FileChannel在内存中(FlumeEventQueue,下面介绍)维护了队列中、事务中(未提交)的记录,该数据周期性地序列化到checkpointsDir目录中,同时每个log-n文件维护了对应的log-n.meta元数据文件,记录checkpoint发生是当前文件最新的record_id。在恢复过程中只要找到checkpoint和各文件对应的record-id就能尽力挽回损失了(record-id之后的数据无法保证完整性)。

还有一个小概率事件——写checkpoint时崩溃了,导致旧的checkpoint文件删除而新的不完整。该问题可以设置useDualCheckpoints(默认false),即在更新checkpoint文件前先对其备份。

FlumeEventQueue

FileChannel通过FlumeEventQueue管理了记录位置和支持事务。

记录Record位置使用logFileId和offset确认,前者说明记录保存在哪一个log-n文件中,后者则是在文件中起始位置的偏移量。

事务问题,FileChannel为每个线程开启一个事务,只有事务被提交其操作才会对外可见。FlumeEventQueue通过Map结构隔离了各个未提交事务的操作(transactionId作为key),根据事务提交与否觉得该key对应的值加入还是抛弃——虽然record保存在了log-n文件中,但如果因回滚抛弃了该record的位置信息,其数据对外仍然不可见。

Record位置和文件滚动(roll)

当log文件到达maxFileSize限制时需要产生新的文件继续记录日志,出于效率考虑不会把旧文件中未出队的数据复制到新的log文件中的,此时旧的日志文件中仍然存在record数据,因此转为只读文件,并且FileChannel允许每个log-n文件有若干个只读的RandomAccessFile的handler以支持多线程。

在每次文件滚动时会尝试删除无用的log-n文件,Id从N-2开始的文件会在本轮被标记、在下一轮roll的过程中删除,N为滚动产生的最新log id。

类图结构

FileChannel类图

  • Channel 接口封装。
  • BasicChannelSemantics 提供了基于事务的Channel的底层框架,子类需要实现createTransaction方法。
  • BasticTransactionSemantics 提供了事务的底层框架实现,子类需要实现doPut、doGet、doCommit、doRollback等方法。
  • FileBackedTransaction FileChannel中事务的实现。
  • Log FileChannel中数据的存储,包括log-n、meta和通过FlumeEventQueue持久化checkpoint数据。
  • FlumeEventQueue checkpoint和事务的管理。
  • LogFile.Writer log-n文件的读写封装。
  • LogFile.MetaDataWriter log-n对应的元数据文件读写。
  • LogFile.CachedFsUsableSpace 当前文件系统可用空间的监控,除了根据写入情况递减可用空间还会定时与FS实际情况同步。
  • LogFile.RandomReader 和Writer对应,只读log-n文件的读取封装

SpillableFileChannel方案

FileChannel在保证安全的情况下牺牲了较大的性能,SpillableFileChannel提供了折中方案。SpillableFileChannel允许设置内存Channel的大小,只有内存Channel溢出时才将数据写入到FileChannel中,在安全和性能两者之间提供了平衡性可调的方案。

Content Scripts

Content Scripts可以读取用户Page的所有Dom细节,或是修改它们。

Content Scripts面临的限制:

  • 不可以访问extension中定义的函数。
  • 不可调用Chrome.* API。
  • 不可以调用用户页面定义的函数。

然而Content Scripts可以调用一下Chrome.* API:

  • extension ( getURL , inIncognitoContext , lastError , onRequest , sendRequest )
  • i18n
  • runtime ( connect , getManifest , getURL , id , onConnect , onMessage , sendMessage )
  • storage

除此以外content Script可以使用message和extension通信,可以调用cross-origin XMLHttpRequest。

Manifest

如果你的content scripts在任何时候都需要注入,可以选择content script:

{
  "name": "My extension",
  ...
  "content_scripts": [
    {
      "matches": ["http://www.google.com/*"],
      "css": ["mystyles.css"],
      "js": ["jquery.js", "myscript.js"]
    }
  ],
  ...
}

如果你的content scripts只注入特定的URL,可以选择permission:

{
  "name": "My extension",
  ...
  "permissions": [
    "tabs", "http://www.google.com/*"
  ],
  ...
}

Event Page

app和扩展一个常见的需求是长时间保存任务和状态。Event Page提供了解决方法。Event Page在需要是被加载,在不必要时卸载和释放空间。

从长期稳定版本Chrome 22开始支持,并且带来的显著的性能提升,特别是低配机器上。

Manifest

{
  "name": "My extension",
  ...
  "background": {
    "scripts": ["eventPage.js"],
    "persistent": false
  },
  ...
}

通过persistent声明background page是否是Event Page类型。如果不包含persistent参数,则为常规类型。

LifeTime

Event Page会在它们需要时加载,当他们变为空闲时释放。发生以下事件时将导致Event Page加载:

  • 首次安装或者更新到新版本。
  • Event Page监听一个事件,并且该事件已经发出。
  • content script或其他扩展发送了消息。
  • 扩展插件的某个视图调用了getBackgroundPage()方法。

一旦Event Page被加载它会持续整个活跃期(比如调用一个扩展函数或者处理网络请求)。除此以为只有当其扩展的所有视图关闭并且所有消息接口也关闭的情况下才会卸载Event Page。注意启动一个View不会触发Event Page的加载,只会延迟Event Page的卸载。

当所有视图都关闭后Event Page变为空闲,在转为卸载前会发送runtime.onSuspend事件。Event Page会存活几秒保证在卸载前处理onSuspend事件,如果该事件处理过程中扩展开启了新的视图或是请求导致Event Page不能被卸载,则发出runtime.onSuspendCancled事件。

Event registration

Chrome会跟踪所有已注册的事件,当它分发事件时响应的Event Page就会加载。同样当通过removeListener方法移除了所有的事件监听时,Event Page也不再会被加载。

Event Listener只存在于Event Page的上下文中,因此每次Event Page加载时都要调用注册,仅仅在onInstalled方法中调用是不够的。

Convert background page to event page

  1. 添加persistent:false参数到background中。
  2. 如果存在调用JS的window.setTimeout()或window.setInterval()注册函数时,需要改为Chrome.* API的alarms API。
  3. 同理其它HTML5中异步API, notifications和geolocation可能在Page卸载时还没有完成。需要替换成对等的notifications。
  4. 如果使用了extension.getBackgroundPage()方法需要切换成runtime.getBackgroundPage()方法。新的方法是异步的,所以在返回之前它可以启动Event Page。

Best practices when using event pages

  1. 应该在每次页面加载时注册你感兴趣的事件。注册的逻辑应该放在Event Page的顶级作用域。
  2. 如果你希望在扩展插件安装或升级时做一些初始化,应该放在onInstalled中。这个位置适合做一次性事件。
  3. 如果你希望保存运行时状态,应该使用storage API或者是IndexDB,不能依赖Event Page。
  4. 使用事件过滤器来明确过滤你需要的事件。
  5. 监听onSuspend事件来做必要的退出清理,更建议定期保存的方式来保存数据以避免不能预知的崩溃。
  6. 如果使用message,必须在不需要后关闭message port以便Event Page可以卸载。
  7. If you’re using the context menus API, pass a string id parameter to contextMenus.create, and use the contextMenus.onClicked callback instead of an onclick parameter to contextMenus.create.
  8. Remember to test that your event page works properly when it is unloaded and then reloaded, which only happens after several seconds of inactivity. Common mistakes include doing unnecessary work at page load time (when it should only be done when the extension is installed); setting an alarm at page load time (which resets any previous alarm); or not adding event listeners at page load time

Chrome Extension Overview

The basics

插件的本质是一组有html、css、js和图片组成的bundle。

插件可以通过content script和cross-orgin XMLHttpRequest和当前页面互动,并且可以调用浏览器赋予页面的特性:书签、页签等。

插件UI

插件UI分为browser action和page action两种,browser action会时时出现而page action只在匹配到的页面时才出现。如果你的插件是对所有网站通用的就选择browser action,否则应该选择page action。

除此以外插件还有其它途径展现自己的界面比如增加菜单项、提供一个配置页面、利用content script修改当前页面。

Files

插件包含的文件:

  • manifest.json json格式的元数据文件。
  • html文件。
  • js文件(可选)。
  • 其它你可能用到的文件,比如图片。

文件引用

文件的访问:一般情况下一个扩展插件中的文件可以通过相对路径访问。你也可以使用chrome-extensions/extensionID/file的方式访问。

在你的扩展插件打包并且上传到应用市场后,它将获得永久ID。其它情况下(开发)每当其更改目录或者重新打包都会导致变更,这时可以使用预置变量@@extension_id来解决。

manifest.json

manifest.json提供了扩展插件的重要信息以及它需要用到的能力。

 {
  "name": "My Extension",
  "version": "2.1",
  "description": "Gets information from Google.",
  "icons": { "128": "icon_128.png" },
  "background": {
    "persistent": false,
    "scripts": ["bg.js"]
  },
  "permissions": ["http://*.google.com/", "https://*.google.com/"],
  "browser_action": {
    "default_title": "",
    "default_icon": "icon_19.png",
    "default_popup": "popup.html"
  }
}

架构

一个扩展插件有一个不可见的background页面,用于保存其逻辑。除此还可能存在其它html页面作为UI。如果一个页面要和用户加载的页面交互就必须使用content script。

The background page

无论browser action还是page action都有一个background page。background page分为 持久background page和事件 background page。除非你希望自己的插件要时刻允许,否则推荐使用事件background page。

UI Pages

每个扩展插件都可以有自己的常规HTML界面。比如popup.html用于点击插件是弹出,options.html作为配置页面。另一个类型的页面是override页面。

一个扩展插件内部的页面是可以互相操作dom和调用函数的。因此同样的函数没有必要在插件中重复出现,它们都可以通过调用background page的函数完成。

Content Script

如果要和用户页面交互就必须使用content script。content script是在用户加载页面的作用域内执行的脚本。

content script可以轻易的读取和修改用户页面的DOM,但是它不能修改其所在插件background page中的dom。

content script与扩展插件并非完全隔离,它们之间可以通过消息通信。

Useing Chrome.* APIS

扩展插件还可以调用浏览器专有方法,实现和浏览器的紧密集成。比如如果想打开一个窗口你可以调用window.open()方法,但是如果想控制url应该展现在哪个窗口时,就要用到chrome的tabs.create方法。

ansynchronous vs synchronous

大部分Chrome.* API都是异步的,这意味着它们的调用会立即返回。如果希望关心方法调用的输出可以使用callback。异步方法会在执行后调用callback方法。

chrome.tabs.create(object createProperties, function callback)

其它同步的Chrome.* API则不具备callback参数,它们的调用会持续到结果返回。

string chrome.runtime.getURL()

Communication between pages

同一个扩展插件中的页面是可以互相调用的,这是因为它们都在同一个进程的同一个线程范围内执行的。

可使用chrome.extension查找同一个扩展插件中的页面,使用方法getViews()和getBackgroundPage()。一旦某个页面获得了另一个页面的引用,它就可以调用其函数,修改它的DOM。

Saving Data And Incognito Mode

扩展插件可以使用storage API、 HTML5 web storage API或者通过云端通信存储数据。当保持数据时应该先考虑是否是在隐身模式下,默认情况下插件不应该在隐身环境中执行。

隐身模式保证浏览器不保留任何用户的踪迹,因此需要考虑隐身模式下用户会希望一款插件做什么、不做什么。比如记录浏览历史的动作不应该执行,但是插件配置如果在隐身模式下被修改也应该在之后起作用。

可以通过chrome.tabs或是window的API判断当前是否是隐身模式。

function saveTabData(tab, data) {
  if (tab.incognito) {
    chrome.runtime.getBackgroundPage(function(bgPage) {
      bgPage[tab.url] = data;      // Persist data ONLY in memory
    });
  } else {
    localStorage[tab.url] = data;  // OK to store data
  }
}

Svn Server搭建

参考:https://www.rosehosting.com/blog/install-and-configure-svn-webdav-server-on-a-centos-6-vps/

介绍

本文目标为实现http方式远程svn repository访问,实现借助于apache、webDAV、subversion,操作环境为centos。

安装subversion

使用管理员账号执行。

yum install subversion

完成后创建svn repository,由于使用了apache,选择执行以下命令:

svnadmin create /var/www/svn/repo

安装webdav

webdav(Web Distributed Authoring and Versioning )是超文本协议的一个扩展,允许远程客户端执行web内容鉴权。DAV提供了一系列文件系统操作命令,各平台存在多种webDav的实现,其中包括svn。

执行以下命令安装

yum install mod_dav_svn

配置Apache

完成后在/etc/httpd/conf.d中新建针对svn的配置subversion.conf,这么做是因为httpd默认的配置/etc/httpd/conf/httpd.conf中包含inclue conf.d/*.conf的引用。

subversion的核心内容

LoadModule dav_svn_module modules/mod_dav_svn.so

LoadModule authz_svn_module modules/mod_authz_svn.so

\

DAV svn

SVNParentPath /var/www/svn

AuthType Basic

AuthName “some Realm”

AuthUserFile /etc/svn_htpasswd

AuthzSVNAccessFile /etc/svn_acl

Require valid-user

\</Location>

LoadModule 加载了webdav需要的模块。

\<Location>的使用定义了apache路径的闭包,在apache中包括\<Directory>、\<File>、\<Location>三种命令用于定义url path的闭包,前两者用于映射文件系统,而Location用于非文件系统位置的映射。

其中AuthUserFile、AuthzSVNAccessFile分别定义了全局的用户账号、SVN ACL配置文件的位置。

配置PASSWD

使用以下命令修改svn_htpasswd文件内容。

#创建文件并加入user
htpasswd -cm /etc/svn_htpasswd user1
#新增user
htpasswd -m /etc/svn_htpasswd user2

配置ACL

acl是Access Control List的缩写,提供了用户和分组范围的访问控制配置,配置可以复制/var/www/svn/repo/conf/auth文件,如果没有指定全局的ACL,svn就会使用每个仓库下conf/auth的配置。

ACL文件配置如下:

[groups]
dev_group = user1,user2

[/]
@dev_group = rw

[repository:/repo]
@dev_group = rw

至此完成,启动或重启apache httpd即可:

service httpd start/restart

IK源码学习

名词说明

  • Lexeme(词元):成功识别的完整的词(在下面论述中为了方便表达,扩展了其不完整的情况,实际中不存在)。
  • AnalyzeContext:分词过程中的上下文环境,非线程安全。
  • LexemePath:词元路径(或者矢量),用于歧义消除。
  • Segmenter:分词器。
  • 完整词:一个词库中的词(个人胡乱发明的)。
  • 前缀:一个完整词的开头部分。
  • 歧义:由于断句的不同,句子可能产生歧义。如“他是中国大学博士”分词存在“他/是/中国/大学博士”和“他/是/中国大学/博士”的歧义。
  • Arbitrator:决策者,用于判断存在歧义的LexemePath的最优解。
  • 归一化:存入词库和用于分词的字符必须经过归一化(全角转半角,大写转小写)。

IKAnalyzer的工作流程

总流程

IKAnalyzer总处理流程

Ik分词流程分为两部分:

  1. 分词(词识别):逐字符遍历输入的字符流,解析出所有可能的词元。
  2. 歧义消除:解决交叉词元的问题,选取最优(或接近)不相交词元的组合作为最终解。

分词流程

分词流程图

注意:图中省略了对AnalyzeContext字符数组的再填充,实际流程于此稍有差别

分词流程是一个迭代的过程,这里的图表述可能不够清楚,下面一步一步介绍:

  1. 创建一个空的单字符词元,待用。
  2. 取字符:分词过程是个不断循环的过程,在此之前要判断是否已到达输入字符流的末尾,没有的话则选取下一个字符存入上一步创建的词元中。
  3. 判断无效字符:中断,所有前缀被当前字符中断。
  4. 识别:通过查询预置的词库,判断当前字符是一个完整的单词或是一个完整单词的前半部分(前缀)。
  5. 前缀:如果是前缀重复【步骤2】,读取下一个字符继续判断。如果不是重复【步骤1】,进行下一字符判断。
  6. 完整词:输出当前词到AnalyzeContext。注意是完整词的词元同时可以是前缀(如中华、中华人民)所以要判断(逻辑同【步骤5】)。
  7. 分词结束:如果触及到输入字符流的末尾则表明此次分词结束。
  8. 歧义消除:分词结束后,对输出结果做歧义消除处理后才是最终结果。

IKAnalyzer 源码分析(分词阶段)

这里按照在【分词流程】中描述的步骤一一描述。

AnalyzeContext

这个类要提前介绍下,一个Analyzer只有一个AnalyzeContext,对应一个输入流。换句话说Analyzer不是线程安全的,在处理下一个输入流之前必须显式地调用rest()方法。

AnalyzeContext职责:

  • 缓存字符流:默认的AnalyzeContext内置了4096长度的一个char数组用于缓存。
  • 保存分词结果:分词过程中输出的词元,全部作为原始词元存储在AnalyzeContext中。
  • 支持歧义消除:AnalyzeContext还肩负了部分歧义消除的任务——维护歧义消除的中间结果,保存最终输出结果。
  • 协调各分词器(Segmenter):IKAnalyzer默认适配三个分词器:英文单词、中文数量词、中文(中日韩),在补充缓存时AnalyzeContext要找到三者都认可的分割点,否则有可能出现某个分词器正在分的词被人截断了。

AnalyzeContext内的变量:

class AnalyzeContext {
    //默认缓冲区大小
    private static final int BUFF_SIZE = 4096;
    //缓冲区耗尽的临界值
    private static final int BUFF_EXHAUST_CRITICAL = 100;   


    //字符窜读取缓冲
    private char[] segmentBuff;
    //字符类型数组
    private int[] charTypes;


    //记录Reader内已分析的字串总长度
    //在分多段分析词元时,该变量累计当前的segmentBuff相对于reader起始位置的位移
    private int buffOffset; 
    //当前缓冲区位置指针
    private int cursor;
    //最近一次读入的,可处理的字串长度
    private int available;


    //子分词器锁
    //该集合非空,说明有子分词器在占用segmentBuff
    private Set<String> buffLocker;

    //原始分词结果集合,未经歧义处理
    private QuickSortSet orgLexemes;    
    //LexemePath位置索引表
    private Map<Integer , LexemePath> pathMap;    
    //最终分词结果集
    private LinkedList<Lexeme> results;

    //分词器配置项
    private Configuration cfg;

...
}
  • segmentBuff、charTypes字符流缓存,前者缓存字符,后者缓存字符类型。
  • orgLexemes 原始词元的保存
  • pathMap 辅助歧义消除
  • results 最终分词结果
  • bufferLocker 分词器协调,用于锁定该Context。

取字符

取字符是通过移动AnalyzeContext的指针实现的,会涉及到两个问题:字符的归一化和何时补充AnalyzeContext中的charBuffer。

归一化

AnalyzeContext提供moveCursor()方法向下移动一位,移动的同时会归一化当前字符、判断当前字符类型(无效、量词、字母、阿拉伯数字、汉子),具体实现可以参看CharacterUtil的代码。

补充charBuffer

首先必须明确补充charBuffer带来的副作用,补充charBuffer就是把当前buffer中未处理的字符移到数组前面并从字符流中读取新的数据补充后面的空闲位置。该动作将引发AnalyzeContext中offset、cursor位置变化,已经识别的词元(只保存了begin、offset和length)将丢失内容;正在识别的字符(当前为前缀)将被中断。

处理方式如下:

针对已经识别词元

调用addLexeme()方法,输出到AnalyzeContext的orgSegments中。

针对正在识别词元

阻止补充charBuffer的动作,也就是AnalyzeContext的协同功能:

  1. AnalyzeContext设立了临界值(100),在charBuffer小于临界值时开始尝试补充buffer。
  2. 分词器Segmenter发现自己识别到前缀时,调用lockBuffer()锁定当前AnalyzeContext,锁定动作导致AnalyzeContext不能补充buffer。
  3. 只有所有分词器都释放锁以后,才能执行补充动作。

识别

识别是指查询当前字符串是前缀、完整词,或者都不是,在分词过程中AnalyzeContext每向前移动一个字符,都要识别当前字符。

识别的功能交由分词器实现,IKAnalyzer中包括三个分词器:

  1. org.wltea.analyzer.core.LetterSegmenter:英文字母和单词识别。
  2. org.wltea.analyzer.core.CN_QuantifierSegmenter:中文数量词子分词器。
  3. org.wltea.analyzer.core.CJKSegmenter:中文-日韩文子分词器。

以下是ISegmenter.next()的代码(context.fillBuffer() 补充buffer;segmenter.analyze() 子分词器分词;context.moveCursor() 指针移动;context.needRefillBuffer() 判断可否补充<到达临界值且没有锁定>):

/**
 * 分词,获取下一个词元
 * @return Lexeme 词元对象
 * @throws IOException
 */
public synchronized Lexeme next()throws IOException{
    Lexeme l = null;
    while((l = context.getNextLexeme()) == null ){
        /*
         * 从reader中读取数据,填充buffer
         * 如果reader是分次读入buffer的,那么buffer要  进行移位处理
         * 移位处理上次读入的但未处理的数据
         */
        int available = context.fillBuffer(this.input);
        if(available <= 0){
            //reader已经读完
            context.reset();
            return null;

        }else{
            //初始化指针
            context.initCursor();
            do{
                //遍历子分词器
                for(ISegmenter segmenter : segmenters){
                    segmenter.analyze(context);
                }
                //字符缓冲区接近读完,需要读入新的字符
                if(context.needRefillBuffer()){
                    break;
                }
            //向前移动指针
            }while(context.moveCursor());
            //重置子分词器,为下轮循环进行初始化
            for(ISegmenter segmenter : segmenters){
                segmenter.reset();
            }
        }
        //对分词进行歧义处理
        this.arbitrator.process(context, this.cfg.useSmart());          
        //将分词结果输出到结果集,并处理未切分的单个CJK字符
        context.outputToResult();
        //记录本次分词的缓冲区位移
        context.markBufferOffset();         
    }
    return l;
}

前两个分词器相对简单,这里只重点介绍第三个。

CJKSegmenter

先来看一个复杂的分词用例“中华人民共和国”,这个词在分词过程中会产生多个完整词和前缀:

  • 中: 前缀[中]
  • 华: 前缀[中华,华];完整词[中华]
  • 人: 前缀[中华人,人];完整词[中华,华人]
  • 民: 前缀[中华人民, 人民];完整词[中华,华人,中华人民,人民]
  • 共: 前缀[中华人民共,人民共,共];完整词[中华,华人,中华人民,人民]
  • 和: 前缀[中华人民共和,人民共和,共和];完整词[中华,华人,中华人民,人民,共和]
  • 国: 前缀[];完整词[中华,华人,中华人民,人民,中华人民共和国,人民共和国,共和国]

结论:一段字符串既可以是前缀又可以是完整词;任何一个字符都可能是新的前缀。

CJKSegmenter正是根据这个“结论”来的:

  1. 维护了LinkedList tmpHits保存每个可能的前缀,使用LinkedList也说明了这里每个元素有随时被干掉的风险。
  2. 读入新字符,对tmpHits每个元素加上当前字符判断。是完整词输出到AnalyzeContext,是前缀继续下一轮,都不是移除。
  3. 判断当前字符是否是前缀,如果是加入tmpHits。
  4. tmpHits不为空,锁定AnalyzeContext;否则,解锁。
  5. 重复步骤2。
前缀和完整词识别

IKAnalyzer的分词依赖词库,简单讲CJKSegment的识别就是拿着拼好的字符串去词库里查询是否存在这个词,或者存在以这段字符开始的词,关键在于字典的组织。

IKAnalyzer中使用多叉树储存词库,识别逻辑共涉及三个类:

  • Hit:描述命中情况,并保存最后一个DictSegment的指针,用于下次快速进入。
  • Dictionary:词库。
  • DictSegment:多叉树节点的封装。包含当前字符、下一级节点、是否是完整词的status。

词库示意图

上图展现了一个词库示意图。

  1. root根节点是虚构的。
  2. 节点“中”是一个DictSegment的实例,false指明其不是一个完整词,但是有“华”、“国”两个儿子节点,所以它是前缀。
  3. “华”既是一个完整词,又是前缀。
  4. “界”是完整词,但不是前缀。

分词结束

当字符流读到结尾时,分词结束。此时清理所有为前缀而不能构成完整词的数据,并进入歧义消除阶段。

IKAnalyzer 源码分析(歧义消除阶段)

这部分主要有三个类负责:QuickSortSet、LexemePath、Arbitrator,其次还涉及到Lexeme内的compareTo方法。

QuickSortSet

QuickSortSet用于AnalyzeContext中存储原始词元。

private QuickSortSet orgLexemes;    

其中的数据有两个特点:

  1. 去重,重复词元在此过滤掉了。
  2. 有序。

QuickSortSet中词元存储顺序由Lexeme的compareTo方法决定:

public int compareTo(Lexeme other) {
    //起始位置优先
    if(this.begin < other.getBegin()){
        return -1;
    }else if(this.begin == other.getBegin()){
        //词元长度优先
        if(this.length > other.getLength()){
            return -1;
        }else if(this.length == other.getLength()){
            return 0;
        }else {//this.length < other.getLength()
            return 1;
        }

    }else{//this.begin > other.getBegin()
        return 1;
    }
}
  1. 起始位置靠前词元排在前面。
  2. 词元长度越长词元位置越靠前。

所以在QuickSortSet中,元素的排布情况包括以下几种。

QuickSortSet的介绍暂时到此,在后面会继续涉及这部分。

Arbitrator

Arbitrator处理歧义分两步进行:划分冲突范围和选择最优解。

范围划分

可见在QuickSortSet中除了第四种情况是天然不存在歧义的,其它三种都需要处理。那么歧义处理的范围应该如何划定——范围越小,所消费的资源越少,处理难度越小。

所以在歧义处理过程中第一步要进行的就是划分歧义处理范围,IK的做法是按照QuickSortSet中的顺序遍历原始词元,每当找到一个与之前所有词元不相交的词元时此位置之前便是一个最小歧义处理范围。

歧义处理

歧义处理的方法是在最小歧义范围内对所有词元进行排列组合,选取不相交的组合;再根据一定的判断标准判定出最优的组合即为最终解。

简言之Arbitrator干的事情就是划分,排列,再划分,在排列…… 如何划分和排列需要使用LexemePath。

LexemePath

LexemePath继承自QuickSortSet,顾名思义它是一组词元(Lexeme)的路径。

先介绍LexemePath中关键的几个变量:

  • begin:路径起始位置,即所有词元中最靠前的位置。
  • end:路径结束位置,即所有词元中最靠后的位置。
  • payloadLength:路径有效负载,所有词元有效长度的累加值。

再来关注该类中三个方法:

  • boolean addCrossLexeme(),只添加交叉词元,如果该词元与路径中其它词元无交叉,返回false。
  • boolean addNotCrossLexeme(),只添加非交叉词元,如果该词元与路径中任意词元交叉,返回false。
  • int compareTo(),比较。这个要直接看源码:

      public int compareTo(LexemePath o) {
          //比较有效文本长度
          if(this.payloadLength > o.payloadLength){
              return -1;
          }else if(this.payloadLength < o.payloadLength){
              return 1;
          }else{
              //比较词元个数,越少越好
              if(this.size() < o.size()){
                  return -1;
              }else if (this.size() > o.size()){
                  return 1;
              }else{
                  //路径跨度越大越好
                  if(this.getPathLength() >  o.getPathLength()){
                      return -1;
                  }else if(this.getPathLength() <  o.getPathLength()){
                      return 1;
                  }else {
                      //根据统计学结论,逆向切分概率高于正向切分,因此位置越靠后的优先
                      if(this.pathEnd > o.pathEnd){
                          return -1;
                      }else if(pathEnd < o.pathEnd){
                          return 1;
                      }else{
                          //词长越平均越好
                          if(this.getXWeight() > o.getXWeight()){
                              return -1;
                          }else if(this.getXWeight() < o.getXWeight()){
                              return 1;
                          }else {
                              //词元位置权重比较
                              if(this.getPWeight() > o.getPWeight()){
                                  return -1;
                              }else if(this.getPWeight() < o.getPWeight()){
                                  return 1;
                              }
    
                          }
                      }
                  }
              }
          }
          return 0;
      }
    

这就是选择最优解的选取原则: 有效文档长度越长越好 > 词元个数越少越好 > 路径跨越长度越长越好 > 位置越靠后越好 > 词长越平均越好(各词元长度乘机) > 词元位置权重(词元位置pos * 词元长度的累加和)越大越好。

流程说明

最后,结合代码再次说明Arbitrator的工作方式:

歧义范围划分(代码截取自Arbitrator)

    LexemePath crossPath = new LexemePath();
    while(orgLexeme != null){
        if(!crossPath.addCrossLexeme(orgLexeme)){
            //找到与crossPath不相交的下一个crossPath  
            if(crossPath.size() == 1 || !useSmart){
                //crossPath没有歧义 或者 不做歧义处理
                //直接输出当前crossPath
                context.addLexemePath(crossPath);
            }else{
                //对当前的crossPath进行歧义处理
                QuickSortSet.Cell headCell = crossPath.getHead();
                LexemePath judgeResult = this.judge(headCell, crossPath.getPathLength());
                //输出歧义处理结果judgeResult
                context.addLexemePath(judgeResult);
            }

            //把orgLexeme加入新的crossPath中
            crossPath = new LexemePath();
            crossPath.addCrossLexeme(orgLexeme);
        }
        orgLexeme = orgLexemes.pollFirst();
    }
  1. 新建LexemePath,开始遍历orgSegments。
  2. if条件不成立时(当前词元与路径相交),继续下一条。
  3. 直到if条件成立,crossPath.size() == 1 || !useSmart(词元个数为1,或者不使用userSmart模式)直接输出,否则划分范围调用this.judge()方法做歧义消除。
  4. 构建新的LexemePath,重复步骤1。

歧义消除(代码截取自Arbitrator)

private LexemePath judge(QuickSortSet.Cell lexemeCell , int fullTextLength){
    //候选路径集合
    TreeSet<LexemePath> pathOptions = new TreeSet<LexemePath>();
    //候选结果路径
    LexemePath option = new LexemePath();

    //对crossPath进行一次遍历,同时返回本次遍历中有冲突的Lexeme栈
    Stack<QuickSortSet.Cell> lexemeStack = this.forwardPath(lexemeCell , option);

    //当前词元链并非最理想的,加入候选路径集合
    pathOptions.add(option.copy());

    //存在歧义词,处理
    QuickSortSet.Cell c = null;
    while(!lexemeStack.isEmpty()){
        c = lexemeStack.pop();
        //回滚词元链
        this.backPath(c.getLexeme() , option);
        //从歧义词位置开始,递归,生成可选方案
        this.forwardPath(c , option);
        pathOptions.add(option.copy());
    }

    //返回集合中的最优方案
    return pathOptions.first();

}

judge()的代码分两部分看,第一部分while()循环之前。

/**
 * 向前遍历,添加词元,构造一个无歧义词元组合
 * @param LexemePath path
 * @return
 */
private Stack<QuickSortSet.Cell> forwardPath(QuickSortSet.Cell lexemeCell , LexemePath option){
    //发生冲突的Lexeme栈
    Stack<QuickSortSet.Cell> conflictStack = new Stack<QuickSortSet.Cell>();
    QuickSortSet.Cell c = lexemeCell;
    //迭代遍历Lexeme链表
    while(c != null && c.getLexeme() != null){
        if(!option.addNotCrossLexeme(c.getLexeme())){
            //词元交叉,添加失败则加入lexemeStack栈
            conflictStack.push(c);
        }
        c = c.getNext();
    }
    return conflictStack;
}

调用forward(),构造了首个可能解并把它加入到有序TreeSet pathOptions中。与此同时,产生了与该解冲突的所有词元的堆栈(顺序是QuickSortSet的逆向)。

第二部分遍历冲突词元,试图从中间找出更多可能解。

private void backPath(Lexeme l  , LexemePath option){
    while(option.checkCross(l)){
        option.removeTail();
    }   
}
  1. 在当前解中移除最后一个词元。
  2. 以当前冲突词元为起点向前遍历,试图找到下一个解。
  3. 添加可能解到TreeSet中。
  4. 重复步骤1直到遍历结束。
  5. 由于TreeSet是有序的,在结束遍历之后,TreeSet中首个元素便是找到的最优解。

参考如下图片,IKAnalyzer并没有进行全量的排列组合,这种处理方式可以解决【蓝色】表示的冲突问题,但是在【红色】表示的情境下,IK会错过最优解。其作者说明的IK正确率是95%,这种查找最优解的方式也许是性能和正确率之间权衡的结果?

类图

ThreadPoolExecutor源码分析

设置

  • keepAliveTime:空闲线程的存活时间
  • allowCoreThreadTimeout:允许核心线程超时
  • maximumPoolSize:设置池的最大线程数
  • corePoolSize:设置池的核心线程数
  • rejectHandler:设置添加任务拒绝的处理策略

行为

keepAliveTime & allowCoreThreadTimeout:

设线程数为N,当(核心线程数 < N < 最大线程数)时,线程空闲超过设置的时间将会被关闭。如果设置allowCoreThreadTimeout,那么当(N > 0)时即存在上述行为。

maximumPoolSize & corePoolSize

  1. 当(N < corePoolSize)时新添加任务会创建一个线程。
  2. 当(N >= corePoolSize)时新添加的任务会入队:
    1. 如果入队成功,还会检查线程池至少有一个线程是活动的(没有会尝试创建)。
    2. 如果入队失败,尝试创建一个线程,如果线程已达上限(或其他原因)不可创建,则拒绝当前任务。
  3. 如果当前Executor已经Shutdown或停止,拒绝当前任务。

prestartCoreThread、ensurePrestart、prestartAllThread

  • prestartCoreThread:不管有没有添加任务,确保线程池中有coreSize数量个线程。
  • ensurePrestart:和prestartCoreThread行为一致,唯一不同是即使coreSize设置为0也会启动一个空闲线程。
  • prestartAllThread:启动能启动的最大数量个线程。

    rejectHandler

ThreadPoolExecutor默认提供了四种处理任务被拒绝的策略:

  • AbortPolicy:拒绝,抛出异常,也是默认的处理策略。
  • DiscardPolicy:放弃当前。不抛出异常,放弃当前任务。
  • DiscardOldestPolicy:放弃最旧任务。不抛出异常,从任务队列中移除位于队首的任务。
  • CallerRunsPolicy:执行。在调用execute(submit)方法的当前线程执行该任务,该策略意在拉长提交任务的占用时间,从而降低提交频率。

exception

被执行的Runnable或Callable在执行期间抛出异常,将导致当前线程中断,并将创建新的Thread弥补空缺(不管是不是超过了coreSize)。

purge

线程池将遍历队列中所有的Task,清理已取消的任务。如果此时Executor已经Shutdown,此操作会尝试结束Executor。

statistic

线程池会统计完成的任务总数和最大线程数。

源码分析

工作流程

ThreadPoolExecutor工作流程

状态&状态变迁

状态说明

ThreadPoolExecutor运行状态包括:

  • Running:Executor启动后的状态,接受并处理任务。
  • Shutdown:调用shutdown方法后,不接受新的任务,但是继续处理队列中的任务。
  • Stop:不接受不处理队列中的任务,并且中断正在处理的任务。
  • Tidying:所有任务已结束、WorkCount(线程数)归零时处于Tidying状态,开始调用terminated()的钩子。
  • Terminated:terminated()调用结束。

状态变迁:

  • Running -> Shutdown:调用了shutdown()。
  • (Running or Shtudown) -> Stop:调用了shutdownNow()。
  • Shutdown -> Tidying:队列和线程池已经空。
  • Stop -> Tidying:线程池已经空。
  • Tidying -> Terminated:terminated()方法已经调用。

ThreadPoolExecutor对状态和WorkCount的封装:

ThreadPoolExecutor使用一个AtomicInteger封装线程池状态和线程数

    //ctl为状态和WorkCount的封装
    private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
    private static final int COUNT_BITS = Integer.SIZE - 3;
    private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

    //编码ctl——状态和WorkCount按位与
    private static int ctlOf(int rs, int wc) { return rs | wc; }

按Integer.SIZE = 32计算,一个线程池低位(29位)是线程值可以容纳的Worker的最大数量,高位(30~32)用于表示状态——因为有5个状态必须要三位表示。

状态读取:

private static int runStateOf(int c)     { return c & ~CAPACITY; }
private static int workerCountOf(int c)  { return c & CAPACITY; }

WorkCount的操作(增删):

private boolean compareAndIncrementWorkerCount(int expect) {
    return ctl.compareAndSet(expect, expect + 1);
}

private boolean compareAndDecrementWorkerCount(int expect) {
    return ctl.compareAndSet(expect, expect - 1);
}

private void decrementWorkerCount() {
    do {} while (! compareAndDecrementWorkerCount(ctl.get()));
}

对状态和WorkerCount的操作都是使用Compare-And-Set的方式,无锁。

interruptIdleThread

ThreadPoolExecutor有几个中断空闲的线程的动机:

  • 重新设置池大小的时候——CoreSize、MaximumSize。
  • 重新设置空闲线程存活时间的时候——KeepAliveTime、AllowCoreThreadTimeout。
  • 调用shutdown的时候。

中断空闲线程需要两个动作:识别空闲线程、移除空闲线程(从队列中),其中WorkerQueue的实现是HashSet,非线程安全的。这里有两个锁的实现:

不可重进的排它锁——Worker类继承AbstractQueuedSynchronizer实现

  1. 用于空闲线程识别,线程允许获得的Task前会获得自己的锁,中断线程根据能够获得锁来识别线程是否空闲。
  2. 为什么是不可重进的?为了避免线程本身修改线程池Size时导致重获得自己的锁。

     private void interruptIdleWorkers(boolean onlyOne) {
         final ReentrantLock mainLock = this.mainLock;
         mainLock.lock();
         try {
             for (Worker w : workers) {
                 Thread t = w.thread;
                 //tryLock失败视为线程在忙碌
                 if (!t.isInterrupted() && w.tryLock()) {
                     try {
                         t.interrupt();
                     } catch (SecurityException ignore) {
                     } finally {
                         w.unlock();
                     }
                 }
                 if (onlyOne)
                     break;
             }
         } finally {
             mainLock.unlock();
         }
     }
    

可重进排它锁:mainlock

  1. mainlock用于保证WorkerQueue的线程安全,需要获得该锁的操作包括:修改Workers、更新LargestPoolSize、更新CompletedTaskCount。
  2. 为什么LargestPoolSize和CompleteTaskCount不使用Atomic*?没有必要,更新这两个值的动机都是要对workers操作。
    1. largestPoolSize:添加worker时触发。
    2. CompletedTaskCount:每个worker会统计自己的CompleteTasks,在移除该worker时Executor会累加其值。

       private final HashSet<Worker> workers = new HashSet<Worker>();
      
       private final Condition termination = mainLock.newCondition();
      
       private int largestPoolSize;
      
       private long completedTaskCount;
      

性能CAS & Lock

Executor尽可能追求使用CAS,只有必要的地方才会使用lock。

使用CAS的地方:

  • poolSize计数
  • Executor的状态变迁

使用lock的地方:

  • workers的操作(包括LargestPoolSize、CompletedTaskCount)。
  • 获取LargestPoolSize值的操作。

When to terminate

大前提是Executor被调用了shutdown方法,但是从行为中得知shutdown方法不会立即终止Executor,而是要经历几个过程:Queue中task消费完、Pool为空。

tryTerminate方法的目的在于确保条件到达后,Executor可以及时退出,因此在会触发条件的位置都调用了tryTerminate方法:

  • shutdown、shutdownNow方法被调用
  • queue的清理:purge、remove
  • woker的清理:processWorkerExist、addWorkerFailed

When to interrupt idel thread

interruptIdelThread意在清理空闲的可被关闭的线程,在可能引起线程清理的位置对其调用:

  • shutdown被调用
  • 设置CorePoolSize
  • 设置MaximumPoolSize
  • 设置KeepAliveTime
  • AllowCoreThreadTimeout
  • tryTerminate调用的同时

Why is worker queue not concurrent collection

Executor中使用mainlock和HashSet来存储线程池内的worker,而不是使用Concurrent Collection。这样做可以强制对worker的操作是顺序进行的,避免造成中断风暴(interrupt storm),特别是在shutdown的时候。同时简化了largestThreadSize统计实现,因为线程的退出和加入也是顺序化进行的。

keyword “volatile” useage in Executor

Executor中所有用户控制的参数都使用了“volatile”关键字,作者的注释是说处于执行的动作能够实时读到最新的数据,同时没有使用锁的必要——内部机制不需要立即对变化做出响应。

Obtaining Authorization

Obtaining Authorization(获取授权)

客户端通过向资源拥有者申请授权的方式获得Access Token。OAuth提供四种授权方式:授权码、简化授权、资源拥有者密码凭证、客户端凭证,并提供了扩展其它凭证的机制。

4.1 Authorization Code Grant(授权码授权)

授权码授权可以用于access token、refersh token的获取,并且非常适合机密客户端。因为这是一个重定向流程,客户都必须支持与资源拥有者的浏览器交互,并可以响应来自权限服务器的重定向请求。

 +----------+
 | Resource |
 |   Owner  |
 |          |
 +----------+
      ^
      |
     (B)
 +----|-----+          Client Identifier      +---------------+
 |         -+----(A)-- & Redirection URI ---->|               |
 |  User-   |                                 | Authorization |
 |  Agent  -+----(B)-- User authenticates --->|     Server    |
 |          |                                 |               |
 |         -+----(C)-- Authorization Code ---<|               |
 +-|----|---+                                 +---------------+
   |    |                                         ^      v
  (A)  (C)                                        |      |
   |    |                                         |      |
   ^    v                                         |      |
 +---------+                                      |      |
 |         |>---(D)-- Authorization Code ---------'      |
 |  Client |          & Redirection URI                  |
 |         |                                             |
 |         |<---(E)----- Access Token -------------------'
 +---------+       (w/ Optional Refresh Token)

                注意:步骤A、B、C通过浏览器时分成两部分。

                 Figure 3: Authorization Code Flow

图示3中包含以下步骤:

  1. 客户端引导资源拥有者的浏览器到授权端口来初始化流程,客户端包括客户端Id、请求的范围、本地状态、用于重定向的URI。
  2. 权限服务器校验资源拥有者的身份,然后执行授权或者拒绝授权。
  3. 假设资源拥有者予以授权,权限服务器重定向浏览器到URI,该URI种包括之前提供了方位和本地状态。
  4. 客户单使用上一步骤中得到的授权码从服务器索取Access Token。发起请求时,客户端向权限服务器鉴权。客户端发送包含其重定向URI、授权码用于验证。
  5. 权限服务器鉴权客户端,校验授权码,确认重定向URI与注册时的匹配。如果验证通过,权限服务器发布Access Token,可选的发布刷新Token。

### 4.1.1 Authorization Request(授权请求) 客户端添加以下参数到查询部分到授权端点的URI(”application/x-www-form-urlencoded”格式)来构造授权请求:

  • response_type:必须。值必须设置为“code”。
  • client_id:必须。客户端的身份Id。
  • redirect_uri:可选。
  • scope:可选。申请访问的范围。
  • state:建议使用。一个非透明的值用于客户端维护请求和回调时的状态。权限服务器在重定向回客户端时保留该值。该参数应该使用以避免跨站欺骗。

客户端通过重定向响应或其他手段引导资源拥有者到期构造的URI。

举例,客户端引导浏览器发起TLS请求:

GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com

权限服务器校验请求确保所有参数已提供并合法。如果合法权限服务器鉴权资源拥有者,并获取授权结果。

一旦授权结论确定,权限服务器使用重定向或其它方式返回响应给client。

Oauth2 Specification Protocol Endpoints

3 Protocol Endpoints(协议端点)

在授权过程中使用到了两个端点:

  • 授权端点:客户端通过资源拥有者的重定向获得授权信息。
  • Token端点:客户端向权限服务器获取access token。

还存在一个client的端点:

  • 重定向端点:权限服务器向客户端返回授权的凭证。

3.1 Authorization Endpoint(授权端点)

授权端点是权限服务器和资源拥有者交互以便获得授权信息的端点。权限服务器必须验证资源拥有者的身份,通过何种方式(用户名、密码,会话cookie)验证超出该协议范围。

Client通过何种方式获取权限服务器授权端点的路径超出了讨论范围,不过一般都会在权限服务器的文档中说明。

端点的URI可能包含一个”application/x-www-form-urlencoded”格式的查询组件,当添加额外参数时必须保留这个组件。端点URI中不能包括不完整的查询组件。

由于发往授权端点的请求将导致明文传输的授权凭证,权限服务器必须要求客户端建立TLS的请求。

权限服务器必须支持Http Get方法,同样可以支持POST方法。

不包含值得参数必须忽略对待,服务器必须忽略不认识的参数。请求和响应中的参数不得出现超过一次。

3.1.1 Response Type (响应类型)

授权端点是为授权码类型和简化类型的授权服务的,客户端使用以下参数知会服务器其期望的授权类型:

response_type:必须。必须是章节4.1.1中描述的”code”、章节4.2.1描述的获取access token的“token”或者章节8.4描述的已注册的扩展值之一。

扩展响应类型可能保护一个空格(%x20)分隔的的类别,其中值是顺序无关的,这部分响应类型的意义定义在其对应的协议中。

如果一个请求没有或者提供了不可识别的响应类型,权限服务器必须返回4.1.2.1中描述的错误。

3.1.2 Redirection Point(重定向端点)

权限服务器完成与资源拥有者的交互后,需要将资源拥有者的访问器重定向到客户端。服务器将重定向到在客户端注册过程中或者是申请权限时指定的位置。

重定向URI必须是[RFC3986] Section 4.3中定义的绝对路径的URI。该URI中可能包含”application/x-www-form-urlencoded”格式的查询部分,当添加额外的参数时必须保留该查询部分。URI中不能包含不完整的部分。

3.1.2.1 Endpoint Request Confidentiality(端点请求的机密性)

当响应类型是“code”或者“token”,或是重定向结果将导致敏感凭证在公网上传输时,重定向端点要求是TLS。该协议没有正式要求使用TLS,因为在该协议写作时期,使用TLS对客户端开发者来说可能是一个重大障碍。如果彼时TLS不可用,权限服务器需要在重定向之前警告资源拥有者正在导向不安全的URI。

TLS的缺乏将对客户端和保护的资源造成严重影响。当客户端使用代理最终用户的授权模式(如第三方登录)时,TLS的使用显得尤为重要。

3.1.2.2 Registration Requirement(注册要求)

权限服务器要求以下两种客户端必须注册它们的重定向端点:

  • 公共客户端。
  • 采用简化模式的机密客户端。

权限服务器要求所有客户端在调用授权端点以前必须注册它们的重定向端点。

权限服务器要求客户端提供完整的重定向URI(客户端可以使用“state”参数实现每个URL的自定义)。如果无法提供完整的重定向URI,客户端至少要提供URI模式、权限和路径(只允许查询部分是可以变化的)。

权限服务器允许客户端注册多个重定向端点。

如果客户端注册缺少了重定向,攻击者可以把授权端点当做open redirector使用10.15

3.1.2.3 Dynamic Configuration(动态配置)

如果注册多个重定向URI、或者注册了部分URI、或者未注册URI,客户端在发送授权请求是要包含“redirect_uri”的参数。

如果注册了任何URI,权限服务器接收到”redirect_uri”参数时需确定其值至少匹配某一条已注册的URI。如果注册时使用了全URI,权限服务器须使用简单的文本比对方法确定两者一致。

3.1.2.4 Invalid Endpoint(非法端点)

如果授权请求缺少、非法、不匹配注册的URI,权限服务器需要通知资源拥有者并中断自动重定向。

3.1.1.5 Endpoint Content(端点内容)

指向Client的重定向请求一般会返回HTML的响应,该响应会由浏览器处理。如果该HTML直接作为请求的响应返回,其中的脚本将对重定向URI和包含的凭证有完全访问权。

客户端应该确保响应不包含第三方的脚本(统计脚本、社交插件等)。取而代之,它应该抽取重定向URI中包含的凭证,并返回浏览器另一个URI以免暴露凭证。如果要包含第三方脚本,客户端需要确保自己的脚本优先运行。

3.2 Token Point(Token端点)

Token端点用于客户端使用授权码或刷新Token获取access token。Token服务于除使用简化流程(因为直接发布了access token)以外的所有授权方式。

客户端如何获得Token端点的地址不在讨论范围,但一般可以在文档中获得。

如果端点URI包含”application/x-www-form-urlencoded”格式的查询部分则必须保存,不得包含不完整的查询。

由于涉及明文传输凭证,在发起请求时必须使用TLS。

客户端必须使用POST方法发起access token的请求。

客户端发起请求时不包含值得参数必须忽略。权限服务器需要忽略未知的参数。统一参数在请求中只能出现一次。

3.2.1 Client Authentication(客户端鉴权)

机密客户端或者其它发布凭证的客户端在发起Token端点请求前,必须要向权限服务器鉴权。客户端鉴权用于:

  • 强制绑定发布给其的授权码和刷新Token。当使用不安全通道传递授权码或者没有注册完全重定向URI时,客户端鉴权显得尤为重要。
  • 恢复被禁用或者变更用户凭证的客户端,避免攻击者滥用偷得的刷新Token。改变单一的客户端凭证比作废全部刷新Token快得多。
  • 实现鉴权管理最佳实践,需要定期凭证流转。实现全部刷新Token的流转是很具挑战的,相比实现一组用户凭着的流转更容易。

向Token端点发起请求时客户单可使用”client_id”的参数表明其身份。发起“授权码”、“授权类型”的请求时,未鉴权的客户端必须发送“client_id”以免接受了本应该发给另一个客户单的信息。

3.3 Access Token Scope(Access Token范围)

授权和Token端点允许客户端使用“scope”参数指明请求的访问范围。反之,权限服务器在响应中使用“scope”参数指明所发布的token的范围。

“scope”的值使用空格分隔、大小写敏感的字符串列表表示。如果列表包含多条空格分隔,它们是顺序无关的,并且每条记录指示一个范围。

scope       = scope-token *( SP scope-token )
scope-token = 1*( %x21 / %x23-5B / %x5D-7E )

权限服务器可以全部或者部分的忽略客户端请求的范围,取决于全新服务器的策略或者资源拥有者的指令。如果响应范围和请求范围不同,权限服务器必须包含“scope”参数指明其发布的access token范围。

如果客户端请求省略了“scope”参数,服务器必须按照预设的范围或者失败本次请求。权限服务器应该文档化其的范围和默认值。