应用系统无事务时如何保证交易一致性

在进行应用系统开发时一笔交易往往需要多次操作数据库,为了保证交易的一致性、正确性,简单的方式是通过数据库事务来控制,多次数据库操作要么均成功要么均失败。但有时我们不能将多次数据库操作封装在一个事务中,主要有两方面原因:1.多次数据库操作中间需要调用外部服务,而这个外部服务比较耗时,此时如果统一封装在一个事务中会长时间占据数据库连接,可能导致长时间锁定某些资源,从而严重影响系统并发度;2.随着异构系统越来越流行,一个系统可能同时有多个数据库,或是同时有关系数据库、Nosql数据库、分布式缓存、消息队列等,如何保证这些系统之间的一致性,可以使用“两阶段提交”或PAXOS等分布式协议,这样的话会显著增加系统的延时,降低系统的吞吐量,另外当有异常时处理也很复杂,会有超时、死锁等问题。

其实对于应用系统来说,如果不是要求“实时一致性”,只是要求“最终一致性”,是可以通过系统设计做到不使用两阶段协议也可以保证一致性的(参考Idempotency Patterns)。下面先表述几个概念:

  • 一致性:其实就是同一系统或多个系统对于某个数据状态的一致意见,这也包括从外部调用者来看状态是一致的。在数据库系统中只要记录了提交日志,事务就相当于成功了。这里日志就相当于合同契约,所以日志必须持久化,否则就没有意义了。在分布式系统协议PAXOS中其实多个系统最终也是得到了一份契约,所以PAXOS也可以作为类似两阶段协议这样使用。
  • 最终一致性:系统在运行中或发生故障时处于某个“临时状态”,经过一段时间后会重新进入到一个“一致状态”。注意这里的“临时状态”并不是错误状态,只是说它是一个中间状态,这个中间状态也可以是正确的,只是不是最终状态而已。如果能做到这个中间状态对于客户是透明的就更完美了(数据库系统就做到了)。这里不提什么“弱一致性”,弱一致性其实就是不保证一致性,这只是业务对于系统设计问题的容忍,如果你是做账务系统,就不要考虑弱一致性了。

其实很多系统仅要求“最终一致性”即可,如果系统可以做到正常情况下完全一致,异常的情况下一段时间(如5分钟内)做到交易结果一致,客户应该都没什么问题。时间也是我们的朋友,虽然这个朋友在多个机器上不完全一致,但是误差都很小,在系统设计时可以加以利用。

下面主要介绍在不使用事务的情况下如何保证系统正确性,对于如何使用“两阶段提交”(如JAVA XA规范的分布式事务)可以参考其他文章(推荐这篇)。我们这里主要依靠下面几个工具:

  1. 状态:当前交易所处的状态,状态要尽量少、明确,要以事务边界为粒度。状态应避免循环,尽量采用顺序前进的方式。对于需要重复或是循环时可以设置子状态,以保证业务逻辑清晰。
  2. 自动任务:对于中间状态的记录进行处理,使其恢复正常状态。此任务应该考虑时间,防止过早调起,将正常交易作为中间交易进行处理。
  3. 时间:要记录数据的最后更新时间,通过时间确定记录是真的异常还是正常处理中。不同的系统的时间间隔应该不同,具体根据业务需要和技术实现来定。
  4. 可测试性/幂等性(重发):可以参考我的另一个文章《系统重发设计》。

系统内部设计

下面先介绍单个系统内的处理,假设一个系统涉及如下处理逻辑:

  1. 系统收到交易请求,进行交易输入合法性检查,如检查失败直接报错。
  2. 业务合法性检查,如合法性检查失败则记录交易失败信息。
  3. 调用外系统交易进行信息登记。(可能会有异常)
  4. 系统内部进行限额扣减。
  5. 调用外部系统进行转账处理。
  6. 记录转账结果
  7. 如转账失败则回冲限额(这里为简单起见,假设前面的信息登记动作由其他自动任务保证一致性,否则此处需增加外呼冲正)
  8. 返回交易结果。

针对上面的场景应该如何设计系统呢,最简单的设计是把所有这些全部放在一个事务内,由事务保证一致性,这样的话事务很长,如果遇到外呼超时会严重影响系统性能。如果采用状态来控制应该采用下面步骤:

  1. 定义状态,根据前面的状态规范,尽量少、明确,以外部系统为边界。可以看出可以分为如下状态:1-待登记;2-处理中(登记完成,限额扣减完成,待转账);3-交易成功;4-交易失败。
  2. 确定合适的记录数据库时机,延迟保存原则,只在状态需要变更时保存流水,且预计流水(参考数据库的先记日志原则)。系统内尽量通过事务保证一致性。
  3. 定义自动任务,对各种中间状态(此处为1-待登记;2-处理中)进行处理,通过重发或是查询外系统的结果来确定后续做法。需要和对方系统约定处理方式。

下面展示下设计后的系统处理模式:

  1. 系统收到交易请求,进行交易输入合法性检查,如检查失败直接报错。(无DB操作)
  2. 业务合法性检查,如合法性检查失败则记录交易失败信息。(若失败记录4-交易失败)
  3. 调用外系统交易进行信息登记。(可能会有异常)(外呼前记录1-待登记) (外呼后若失败,则记录4-交易失败)
  4. 系统内部进行限额扣减。(扣减限额与更新流水放在一个事务中,状态改为2-处理中)
  5. 调用外部系统进行转账处理。 (外呼后若失败,则记录4-交易失败)
  6. 记录转账结果(若5成功则更新状态为3-交易成功)
  7. 如转账失败则回冲限额 (若5失败,则记录4-交易失败,同时回冲限额,放在一个事务中)
  8. 返回交易结果。

对于上面的状态如果想保存中间结果,例如登记成功的结果、外呼失败的信息,可以通过子状态来做,或是增加状态。但这样会增加自动任务处理的复杂度,需要做更多的判断,好处是可以一定程度减少外呼查询或其他动作。如果系统内部的多数据库操作不想使用事务封装或是无法封装(可能涉及多个模块),也可以通过状态区分开各个阶段。

系统间设计规范

多个系统内一致性控制需要采用分段控制。即每个系统都要保证自身的一致性、正确性。调用方系统保证消息被正确传递,并保证最终更新交易结果;被调用方保证自身的一致性,实现自身状态可测试性或是幂等性。系统之间的消息,采用唯一标识区分,被调用系统提供查询消息结果接口,或支持幂等性(可重发)。此唯一标识由调用方生成,调用方系统在调用外呼系统前记录此唯一标识,被调用方通过此唯一标识实现交易幂等性。如果被调用方支持多个调用方,可以通过调用方编号+唯一标识来作为唯一标识,或是指定统一的标识生成规则,由各方遵守。

如果存在多个系统串联的情况,可以通过这种方式来实现正确性传递。注意这种传递只能是从前->后,而不能由后面的步骤保证正确性,因为后面方法可能会没收到消息。

本文所述方法可以从系统设计层面保证交易的正确性,可简化记为:状态 + 自动任务 + 时间 + 可测性/幂等性 = 最终一致性。当然了这种方法也有弊端,1.应用设计需要考虑各种状态转移,系统在记录状态同时,需要记录时间、唯一标识等额外信息,增加系统复杂度。2.自动任务需要详细设计,通过时间保证和联机交易不同时执行,另外需通过乐观锁保证状态仅被自动任务更新(建议update时进行原状态对比)。3.后一步结果严格依赖前面的执行结果,只能采用串行模式。4.需保证中间状态对于客户是无害的,包括查询结果和实际业务效果(这一点有时很难做到)。

本文为我个人的一点经验总结,希望能对大家设计系统有帮助,大家有更好方式也欢迎指出。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《ITechLib》

系统重发设计

重发又称为幂等性(Idempotency,即f(x) = f(f(x))),是指重复发送后会得到和和首次发送相同的结果,且不会导致不良的结果。重发是一个很重要的系统特性,数据库系统的事务设计、系统异常的处理都依赖对重发的支持。重发说起来简单,但做起来可不简单,有些交易本身天然支持重发,例如将所有记录状态改为正常,重复做一次并没有什么问题。但有些交易支持重发则很困难,例如向A转账10元,如果重做一次就可能会重复入账。

对于不是天然支持重发的交易,如果要支持可重发,需要做一些特殊处理。简单的方法是通过唯一标识来判重,具体步骤如下:

  1. 发送方生成消息的唯一标识,在发送消息中附上此标识;
  2. 接收系统收到消息在处理时,在完成消息处理的同时进行唯一标识的判重。例如记录流水,在流水中包括此标识,并对此标识通过唯一索引来判重;
  3. 对于消息重复的情况,给客户端反馈特定的错误码、错误信息,接收方以此得知消息已重复。交易结果建议反馈“处理中”,以免客户认为交易失败。

如果流水信息中不便于添加此标识,可以建立一个辅助表,用于存储此标识,通过唯一索引判重。记录辅助表和更新其他信息尽量放在一个事务中以免双方记录不一致。由于判重一般只要保证一段时间即可,故此辅助表的数据不必保留太久。

此处记录辅助表和记录流水放在一个事务中是为了保证一致性,如果不采用事务保证,建议先记录判重辅助表,这时可能会出现记录了辅助表,但未记录流水的情况(可能记录流水时出错,或是记录流水前出错),这时如果再有相同消息进来仍然会报错。这种程度的误报你需要分析业务上是否有问题,这种情况下你的系统响应是否正确。不建议在记录流水后再记录判重表,这时数据库中的流水记录无法回滚,这时客户来查消息结果会得到两条流水,很难区分哪个是正确的结果。

对于系统交易判重不建议采用查询方式,即在记录流水前查询一下流水是否存在,因为会有并发问题,之前生产上也遇到过,当两笔交易几乎同时发送时就会出现判重规则失效,记录重复流水的问题。需要指出,这并不是小概率事件,而是设计缺陷。

系统正确性的保证依赖与系统两方面的能力支持:1.可测试性,即系统支持调用方查询交易结果。2.可重发,且不会因为重复执行造成不良影响,即幂等性。系统只要支持其中一种就行,如果一种都不支持,永远无法保证操作正确性。其实很多实操做就是如此,例如你需要向水中扔1个石头,如果你正巧扔的时候精神溜号了,等你回过神发现水面很平,你不知道是否扔进去了,你现在再扔1个可能多扔了,如果不扔可能前面1个没扔进去。对于这种实操作,必须在执行前深重考虑,确保精神不要溜号,哈哈。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《ITechLib》

账务交易一致性设计概述

对于账务交易最重要的就是交易一致性,即给客户反馈正确的一致的结果。交易一致性并不是一个简单的话题,说来话长了。下面我结合项目的一些经验,谈一下我认为重要的地方。

交易本身大体分为三类,联机交易、批量交易、批量联机交易(即联机实时返回多笔交易的结果)。下面分别对于不同的场景进行说明:

1 联机交易

1.1 接口设计

从接口设计角度,对于联机交易一般包括两个接口,账务发起接口、交易结果查询接口。其中联机交易接口用于发起实时交易,并实时返回结果。交易查询交易则用于对账使用,用于确认账务成功与否。

账务发起接口:

此接口输入至少应包括如下字段:客户标识、客户交易流水号(用于客户对账使用)、其他账务相关的要素包括付方账号、收方账号、金额等(具体根据业务需要来定)。

输出至少应包括如下字段:交易结果代码、错误码、错误信息、系统交易流水号(可选)、交易日期(可选)。交易结果代码包括成功、失败、不确定三种状态,其中当交易超时等异常时反馈不确定状态,由客户调用“交易结果查询”接口进行对账。另外两个可选字段系统交易流水号用于表示本系统内部的交易流水号,方便排查问题;交易日期表示交易发生的记账日期,以便客户知道这笔账属于哪日的交易,如失败“交易日期”可以为空。接口务必保证交易结果代码的正确性。

交易结果查询接口:

此接口仅用于账务交易反馈不确定或超时时对账使用,对于需要列表查询的需求建议另外使用单独接口来做(例如支持分页,支持模糊查询等),这主要是出于性能、可维护性如流控等方便的考虑。对于交易结果查询接口输入字段应包括:客户交易流水号、系统交易流水号(两者二选一输入)、客户标识,输出字段除了包括账务发起接口的交易结果输出字段外,还应该包括查询结果代码、查询错误码、查询错误信息,用于反馈这笔查询交易是否查询成功,是否应该后续继续查询。对于同时包括报文头、报文体的交易,查询结果代码等可以放在报文头中,而交易本身结果可以放在报文体中。

1.2 其他说明

上面提到的客户交易流水号由客户生成并上传,生成规则应保证同一个客户内部全局唯一,以便用于对账。注意不要采用一天内唯一的概念,由于时间并不是一个双方都认可的值,特别是涉及到日切时。可以采用一周内、一个月内唯一的概念,这需要在接口中提前约定。此客户交易流水号涉及到判重的问题,系统应保证交易唯一性,建议通过数据库唯一索引来做(具体实施方式将在后续文章介绍)。

系统交易流水号由我方系统生成,应保持全局唯一性,此流水号建议包含日期、时间信息,以便排查问题。

交易结果代码:由于此代码十分重要,程序务必保证其正确性。应用应将此字段放在应用能够完全控制的地方,例如对于报文包括报文头、报文体两部分,而报文头由框架负责填充,此时建议将此字段连同错误码、错误信息一起放在报文体中,对于报文头中的错误和客户约定为”处理中”状态,这是为了避免由于框架原因而导致原本正确的交易返回失败。此规则适用于账务发起接口及交易结果查询接口。

如需提供交易结果列表查询功能,应发布单独接口。此接口应支持分页查询、或是查询下一页功能(可通过在输入中增加上次查询最大系统流水号字段实现),支持按账号、按金额、按日期等进行筛选。注意此处日期包括:客户提交日期、系统营业日期、记账日期,这些日期概念是不同的,由于此接口仅用于查询不用于对账,如果业务允许可以仅支持一种,建议使用客户提交日期。

1.3 交易超时设置

交易在每个外呼中均应该设置超时时间,超时时间应该满足“漏斗原则”,防止在后端未处理完成时前端已经失败返回。漏斗原则是指从客户前端->中间转发服务器->应用服务器->账务核心服务器,各个节点的超时时间应该以此减少。对于特殊情况不能满足漏斗原则的情况,应该由应用服务器在程序逻辑上保证交易一致性,例如返回处理中并提供异步对账功能,或是提供冲正功能。若采用冲正方式,违反漏斗原则会造成很多交易结果不确定,交易应该在设计阶段考虑各种情况下超时如何保证交易一致性(冲正比较复杂,在此不做详细介绍)。

交易超时还包括一点:避免异常情况下查询交易早于账务发起交易到达。这一方面可以通过控制查询交易和账务发起交易的间隔来保证,例如约定超时时的查询间隔30s,但即使这样在特殊场景也会有问题。为了防止此问题可以在交易报文中设置交易发起时间、交易剩余处理时间,这两个字段在进入我系统后由前端系统生成并赋值(不能由客户填充),如客户通过外围系统接入此字段由外围系统填充。交易剩余时间一般设置为30s,在消息传递的各个环节应该对剩余时间进行重写(包括中间系统、应用系统等),如剩余时间为0,则不应该进一步传递消息,应直接返回失败。对于应用系统(即需要保证交易正确性的系统)在记录流水前也应该判断此剩余时间,如为0则应直接返回失败而不记录流水。

应用设计者应了解每只账务交易的一致性保证机制,从设计上保证一致性。对于账务交易尽量不要支持重发,重发很容易导致交易重复提交,且不容易识别查询返回结果,除非服务方和调用方系统进行周密的设计不建议采用。

2 批量交易

2.1 接口设计

批量交易是指客户通过上传批量文件的方式发起的账务交易,这种交易一般为异步处理。一般包括批量文件上传交易、批量结果查询交易。

批量文件上传交易

批量文件上传交易主要完成客户文件上传功能,系统如接收文件成功应返回客户文件接收成功。接口输入报文应包括客户标识、客户批次流水号(用于查询批量整批结果)、批量文件路径及名称、批量总笔数、批量总金额。输出字段和联机单笔交易类似,至少应包括如下字段:批量交易结果代码、错误码、错误信息、系统批次号(可选)、交易日期(可选)。

接口的其他注意事项包括交易结果代码、唯一性约束和联机接口一致。除此之外还有以下几点需要注意:

  1. 客户应保证批量文件早于联机报文到达,以便根据报文解析文件时文件已经发送完毕;
  2. 对于批量文件名称应保证唯一性,可以对于每个客户使用单独的目录。这一点需要和客户提前约定;
  3. 文件应使用特定分隔符分隔,需要保证分隔符和正常业务字段不重复,例如 @ # 等;
  4. 文件内每笔交易应包含客户交易流水号或批内子序号,用来查询结果时标识每笔明细;
  5. 系统对于批量的联机处理,至少应包括记录批量流水记录;对于后续处理如文件解析、校验,记录批量明细信息、业务逻辑校验,业务处理可以根据业务、性能需要考虑是否包括在联机处理中。
  6. 系统联机处理时应对每批文件的笔数进行控制,具体可以根据业务需要来,可以为200笔或2000笔。

批量结果查询交易

客户通过此交易可以查询批量处理的结果,包括两部分:一是批量整批的结果,一是每笔批量明细的结果。为了处理简单,可以当批量整批处理完成后再返回批量明细的结果,如果确实需要实时返回批量明细的结果,需要在设计时保证返回结果的正确性,包括明细的完整性、正确性(如批量太大不建议采用这种方式)。

输入接口应包括: 客户标识、客户批次流水号、系统批次号(可选);

输出接口应包括:1.整批信息:批量交易结果代码、错误码、错误信息、系统批量流水号(可选)、交易日期(可选);2.明细信息:客户交易流水号/批内子序号、明细交易结果代码、错误码、错误信息、系统批量子序号(可选)、交易日期(可选)。明细信息可以根据接口定义是否返回,如只有整批处理完再返回结果,可以定义为:当整批信息不为整批失败、处理中时会返回交易明细结果,当整批结果为交易成功时应返回明细结果。

这里需注意以下几点:

  1. 上述输出接口字段建议放在报文体中,通过报文头的系统交易结果、错误码、错误信息来表示查询交易本身的结果,如交易超时等;
  2. 如系统支持的批量文件较大时,需要通过文件来返回交易结果,返回报文中没有明细信息,但应该有明细结果文件路径及名称。文件名可以和客户输入文件名加后缀方式来对应。结果文件建议作为批处理的最后一步给客户提前生成好(建议在修改整批状态之前,以免客户查不到文件),并和客户约定保存多长时间。
  3. 为防止批量状态卡死,系统应该有专门的检测自动任务来检查异常的批量状态,并针对错误进行相应的处理(包括转人工处理)。
  4. 批量结果查询交易应和批量历史流水列表查询分开,以保证系统性能和可维护性。

2.2 其他说明

批量交易的其他注意事项,如判重、交易结果、超时,可参考联机单笔交易中的说明。在设计批量交易时应将整批作为一个整体考虑,结合系统非功能要求确定应支持的功能点,切记为了所谓业务需求,盲目实现不必要的功能。

3 批量联机交易

这类交易相当于系统提供的单笔交易的批量接口,但整批的结果要求同步返回(如不同步返回可以参考上面的批量交易)。这类交易比较特殊,为了保证交易一致性,需要将这多笔交易当做一笔来看待,作为整体记录流水,而不是简单的循环调多笔。批量联机交易一般不要直接发布给直连客户,可以作为界面或是内部调用的性能优化手段,如果没有性能问题应尽量采用循环单笔的方式。

输入接口应包含:客户标识、客户交易流水号[支持列表]、其他单笔交易的业务字段[支持列表]。输出接口应包含每笔交易的结果信息列表。对账接口应支持按照客户交易流水号列表来查询结果。

在交易设计时需要考虑如下几点:

  1. 对交易支持的最大笔数进行限制,这个限制的建议值为超时时间内能处理的交易笔数(包括启动多个并发、不启动并发两种情况),最大值为系统可以保证交易一致性的最大笔数。
  2. 交易处理方式可以考虑以下几种:a.将多笔交易作为一个整体提前记录流水信息,然后逐笔处理[建议];b.直接调用逐笔交易当做单笔处理,如已超时未处理记录直接返回失败,不做处理[可以满足一致性要求,但不建议];c.直接当做单笔处理,超时仅返回处理中,由判重功能来保证不会有账务问题。[不建议,客户无法知道应该等多久查结果、再次发送]
  3. 查询结果交易需要保证在交易处理的任何时点的结果都是正确的,不能仅查到部分交易的结果,故应该将多笔当做一个整体来预计流水。
  4. 多笔交易一般是有关联的,建议对中间数据进行交易级别缓存,以提高性能。
  5. 是否要采用并发处理建议能支持客户选择,由于并行处理对于某些场景是会影响交易正确性的。(PS.有些客户是在乎顺序的)
  6. 此交易会长时间占据联机服务器的线程,会大大降低系统的吞吐量,采用并发并不会使情况缓解。针对此交易设置单独限流策略。

上面对于三类交易的接口设计以及一些注意事项进行了介绍,后面文章再对账务交易的一些设计细节进行详细描述。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《ITechLib》

sublime_terminal插件启动iTerm2(V3.0)问题修复

目前使用sublime来作为编辑器,经常需要切换到iTerm2终端来进行文件处理、执行命令。最近升级了iTerm2后发现sublime打不开终端了。在网上找了一圈发现iTerm2的3.0版本的脚本调用方式改了,一直未找到合适的解决方案,实在忍不了了自己动手修改了一下。现在分享一下,方便大家使用。

软件版本:

修复方法

打开sublime_terminal包的根目录,可以看到其中有一个iTerm.sh文件,这个就是启动iTerm的脚本,启动iTerm失败主要是此脚本的问题。可以直接使用下面脚本替换原文件中的内容:

#!/bin/bash

CD_CMD="cd "\\\"$(pwd)\\\"" && clear"
if echo "$SHELL" | grep -E "/fish$" &> /dev/null; then
  CD_CMD="cd "\\\"$(pwd)\\\""; and clear"
fi
VERSION=$(sw_vers -productVersion)
OPEN_IN_TAB=0

while [ "$1" != "" ]; do
  PARAM="$1"
  VALUE="$2"
  case "$PARAM" in
    --open-in-tab)
      OPEN_IN_TAB=1
      ;;
  esac
  shift
done

if (( $(expr $VERSION '<' 10.7) )); then
  RUNNING=$(osascript<<END
  tell application "System Events"
      count(processes whose name is "iTerm2")
  end tell
END
)
else
  RUNNING=1
fi

if (( ! $RUNNING )); then
  osascript<<END
  tell application "iTerm"
    activate
    tell current session of current window
      write text "$CD_CMD"
    end tell
  end tell
END
else
  if (( $OPEN_IN_TAB )); then
    osascript &>/dev/null <<EOF
    tell application "iTerm"
      activate
      tell current window
        select
        create tab with default profile
        tell current session of current tab
          write text "$CD_CMD"
        end tell
      end tell
    end tell
EOF
  else
    osascript &>/dev/null <<EOF
    tell application "iTerm"
      create window with default profile
      tell current session of current window
        write text "$CD_CMD"
      end tell
    end tell
EOF
  fi
fi

也可以新建一个iTerm2_3.sh的文件,此时需要修改sublime_terminal的配置Preferences > Package Settings > Terminal > Settings – User:

{
  "terminal": "iTerm.sh",
  "parameters": ["--open-in-tab"]
}

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《ITechLib》

Ruby元编程笔记

1.常用方法:

  • Object#class 具体的类信息。[注]#表示实例方法,.表示类方法。
  • Object#class.instance_methods(false) 显示public的实例方法。false表示只显示自己的方法,而不是继承来的。
  • Object#instance_variables 实例变量列表。和Java不同,Ruby中同一个类的不同对象可能有不同的实例变量列表,实例变量存放在对象中。
  • Object#methods 返回所有的公共实例方法。String.instance_methods == "aaa".methods 类的实例表方法表示它的对象的可以访问的方法。
  • Object#singleton_methods 可以得到对象的单件方法或类方法。通过true/false来表示是否显示include的模块的方法。
  • Module.constants 表示当前环境所有的常量,包括定义的类名称。
  • Module#constants 表示当前实例的常量,例如A.constants。由于A是一个Class的变量,Class继承自Module,所以A也就有了constants实例方法。
  • Module#ancestors 可以获取类的父类列表(包括模块),模块会按照声明的顺序正好在声明类的上一层。可以根据这个了解方法调用的路径。 self标识调用方法的对象,在类定义中self表示类自己。
  • Object#send(:method,args…) 可以直接调用对象的方法,这个方法可以调用私有方法,如果为了隐私可以使用public_send
  • Module#define_method方法可以给类定义对象。

2.基本概念:

方法存放在类里,这里的可能是正常的类也可能是eigenclass,每个对象都有一个eigenclass,包括类自身。实例方法存放在类中,单件方法存放在eigenclass中,类方法是存放在类的eigenclass中。

obj是变量,Object、Class、String等是常量,它们也都是Class.new出来的变量。在Ruby中所有对象均是类,也都是对象。只要搞清楚当前执行环境的self、当前类就好理解很多了,self是方法的调用主体,当前类是方法定义的主体。

Object中包含methods等方法的定义,Module中包含instance_methods等方法的定义。由于类如String,MyClass都是Class的实例,所以都可以访问instance_methods,也由于这些类是Class的实例,所以他们可以使用Class的实例方法如new等来定义自己实例对象。上面也说明Object是为实例对象服务的,里面有很多实例方法。而Module、Class是为类服务的,里面有很多类定义相关的方法。

load('load.rb',true)中load.rb中的变量会在作用域之外,我们看不到。但是常量如果不加true会影响现有的作用域,如果仅仅是要执行的结果可以加true,如果还需要常量定义,可以不加或是使用require。

可以通过打开类冲定义方法、新增方法等:

#打开类实例
class String
  def to_alphanumberic
    gsub /[^\w\s]/, ''
  end
end

3.方法的查询路径:

  1. 查询自身(自身的单件类中是否有此方法)
  2. 查询自身所属的类的实例方法
  3. 查询自身所属类的父类的实例方法
  4. 找不到方法则执行method_missing方法

4.method_missing方法:

当某个方法沿着继承链找不到时会调用method_missing方法。核心库delegate动态代理就利用了这个特性,很多适配器库也使用了这个特性。

使用method_missing的问题:

  1. 方法可能导致死循环,使用时应注意。可以采用白名单和super来处理。
  2. 它比平常方法要慢1倍左右。
  3. 当一个幽灵方法和真实方法冲突时,比如继承自Object的方法,真实方法会胜出。这时可以使用白板类,这个类比Object类的方法还要少。ruby1.9之后可以直接继承Basic_Object来变成白板类。
class BlankSlate
  #在一个白板类中隐藏名为给定name的方法
  #但不隐藏instance_eval方法或任何一“__"打头的方法
  def self.hide(name)
    if instance_methods.include?(name.to_s) and
      name  !~ /^(__|instance_eval)/  #|instance_method 可能需要加上
      @hidden_methods ||={}
      @hidden_methods[name.to_sym] = instance_method(name)
      undef_method name
    end
    instance_methods.each { |m| hide(m)}
    #...
end

Module#const_missing()当某个常量找不到时会调用这个方法。如果是在具体类或是Object中定义,则这个类的实例或是所有对象都可以使用此方法。

5.块Block:

在代码中可以通过{}或是do...end关键字来传递块。在代码中可以使用yield(a,b)来调用块。方法中可以通过Kernel#block_given?方法判断当前是否传递了块。

def method2
  (1..10).each do |x|
    yield(x)
    end
end
method2 do |x|
  puts x if x != 4
  break if x == 4
end
#这里的break会对method2中的each生效,打印结果为1,2,3

method2 do |x|
  y ||= 1
  y= y + 1
  puts y
end
#会一直打印2,说明y每次都被初始化。

Ruby中有4中方法可以打包代码以备后用:

  1. 使用块(块不是对象);
  2. 使用Proc。(Proc是块转换的对象)
  3. 使用lambda。lambda会校验参数数目。
  4. 使用方法。

代码 + 绑定 = 块。可以通过binding方法获取当前绑定。eval方法可以指定binding。

块转换为Proc的方法:1.Proc.new; 2.lambda() 3.proc().4.&操作符。然后使用#call()方法执行。

def math(a,b)
  yield(a,b)
end
def teach_math(a,b,&operation)
  puts "Let’s do the math:"
  puts math(a,b, &operation)
end
teach_math(2,3) {|x,y| x+y}

操作符如+-的类型是Proc,可以直接使用call来执行,再加上&可以把Proc转换为块。

6.Ruby的作用域:

Ruby的作用域没有嵌套的概念,当作用域切换时只能看到新的作用域。有3个方式可以重新定义作用域:类定义、模块定义、方法定义。

扁平作用域:Kernel#instance_eval 可以把执行它的对象作为块的self对象,从而块可以访问对象中的所有实例变量、私有方法等。这种块也是扁平作用域,也称为”上下文探针”。#instance_exec功能类似,但是它可以给块传参数。

其实扁平作用域就是利用Class.newclass_evalinstance_eval等在不切换作用域的情况下完成类定义、方法定义、在对象内部执行代码等功能。

使用Module#class_eval在不知道类名的时候打开类,定义方法。它会把当前类切换为当前类,同时设置了self变量。Ruby总是会跟踪当前类,这个和self是不同的,它是self的类。

instance_eval也会修改当前类,它修改的是接收者的eigenclass.

#method1只对a有效。
a.instance_eval do
  def method1; ok;end
end

7.eigenclass:

元类是对象自身的类,可以通过下面的方法获取它。

class BasicObject
  def eigenclass
    class << self; self ; end
  end
end

对象元类的父类是它所属的类。obj.eigenclass.superclass = obj.class

类的元类的父类是类的父类的元类(为了支持类方法继承)。特殊的BasicObject的元类是Class。Object.eigenclass.superclass = BasicObject.eigenclass = Class

单件singleton方法:

str ="hello str"
def str.title?
  self.upcase == self
end

也可以通过下面这种方式定义:

class << an_object
  #这里就是eigenclass的作用域了
end

irb中定义的方法,是作为Object的私有方法存在的。因为irb的self对象是main,是Object的一个实例对象;irb中当前类是Object。

def method1
  puts "method1"
end
"aa".send :method1

8.其他法术

类宏:本质是类中的类方法。

#例子:声明取消的方法:
class Book
  def subtitle
    #...
  end

  def self.deprecate(old_method, new_method)
    define_method(old_method) do |*args, &block|
      warn "Warning: #{old_method} is deprecated.Use #{new_method}"
      send(new_method, *args, &block)
    end
  end
  deprecate :title2, :subtitle
end

include模块:在类中调用Module#include方法包含其他模块,模块中定义的方法会成为类的实例方法(通过建立ancestors关系)。如果在类的eigenclass中include模块,模块中定义的方法会成为这个类的类方法,这样其实等效于调用Object#extend方法。

类扩展混入:通过使用钩子方法(ruby中很多钩子方法,包括继承时,包含时调用的)。

Module M
  def self.included(base)
    base.extend(ClassMethods)
  end
  
  module ClassMethods
    def my_method
      'a class method'
    end
  end
end

class C
  inlcude M
end

C.my_method #=>"a class method"

空指针保护: a ||= []

参数数组: 使用*号来将多个值转换为数组。

*a = 1, 2, 3 #=> a = [1, 2, 3]

具名参数method(username: "hello", age: 13) 上述值会作为最后一个参数的值,最后一个参数会是hash数组。当默认参数、边长参数、具名参数混用时需要自己进行参数解析。

符号到Proc:把一个符号转换为调用单个方法的代码块:

#这里会自动调用Symbol#to_proc方法
#下面是这个方法的源码
class Symbol
  def to_proc
    Proc.new {|x| x.send(self)}
  end
end
# &符号可以作用于任何对象,会调用它的.to_proc方法.
[1, 2, 3, 4].map(&:even?) #=>[false, true, false, true] 

环绕别名:通过alais关键字定义方法别名,然后把原来方法重定义。

class String
  alias :real_length :length
  
  def length
    real_length > 5 ? 'long' : 'short'
  end
end
"war and peace".length  #=> 'long'
"war and peace".real_length #=> 13

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《ITechLib》