单一责任原则的适用性


40

我最近遇到了一个看似微不足道的建筑问题。我的代码中有一个简单的存储库,其名称如下所示(代码在C#中):

var user = /* create user somehow */;
_userRepository.Add(user);
/* do some other stuff*/
_userRepository.SaveChanges();

SaveChanges 是一个简单的包装程序,用于将更改提交到数据库:

void SaveChanges()
{
    _dataContext.SaveChanges();
    _logger.Log("User DB updated: " + someImportantInfo);
}

然后,过了一段时间,我需要实现新的逻辑,该逻辑每次在系统中创建用户时都会发送电子邮件通知。由于有许多来电_userRepository.Add()SaveChanges系统的时候,我决定更新SaveChanges如下:

void SaveChanges()
{
    _dataContext.SaveChanges();
    _logger.Log("User DB updated: " + someImportantInfo);
    foreach (var newUser in dataContext.GetAddedUsers())
    {
       _eventService.RaiseEvent(new UserCreatedEvent(newUser ))
    }
}

这样,外部代码可以订阅UserCreatedEvent并处理将发送通知的所需业务逻辑。

但有人向我指出,我对的修改SaveChanges违反了“单一责任”原则,SaveChanges应该保存而不触发任何事件。

这是有效的观点吗?在我看来,在此处引发事件与日志记录本质上是相同的:只是向该函数添加一些辅助功能。SRP并没有禁止您在函数中使用日志或激发事件,它只是说这种逻辑应该封装在其他类中,并且存储库可以调用这些其他类。


22
您的反驳是:“好吧,将如何编写它使其不违反SRP但仍允许单点修改?”
罗伯特·哈维

43
我的观察是,引发事件并不会增加额外的责任。实际上,情况恰恰相反:它将职责转移到其他地方。
罗伯特·哈维

我认为您的同事是对的,但是您的问题是有效和有用的,因此被否决了!
Andres F.

16
根本没有单一责任的明确定义。指出其违反SRP的人使用他们的个人定义是正确的,而使用您的定义是正确的。我认为您的设计非常完美,但需要注意的是,该事件不是一次性的,而其他类似功能是以不同的方式完成的。要注意的一致性远比诸如SRP等模糊的指导原则要重视的多,最终导致大量的非常容易理解的类,没人知道如何在系统中进行工作。
扣篮

Answers:


14

是的,拥有一个可以在某些操作(例如AddSaveChanges-)上触发某些事件的存储库可能是一个有效的要求,而我不会质疑这一点(就像其他答案一样),只是因为您添加用户和发送电子邮件的特定示例看起来像是人为的。在下文中,让我们假设此要求在您的系统环境中是完全合理的。

因此,是的,对事件机制以及日志记录以及保存为一种方法进行编码都违反了SRP。在许多情况下,这可能是可以接受的违规行为,尤其是在没人希望将“保存更改”和“举报事件”的维护职责分配给不同的团队/维护人员的情况下。但是,假设有一天某人想做到这一点,是否可以通过简单的方式解决该问题,也许将这些关注的代码放入不同的类库中?

解决方案是让您的原始存储库负责将更改提交到数据库,而没有别的事情,并制作一个具有完全相同的公共接口的代理存储库,重用原始存储库,并将其他事件机制添加到方法中。

// In EventFiringUserRepo:
public void SaveChanges()
{
  _basicRepo.SaveChanges();
   FireEventsForNewlyAddedUsers();
}

private void FireEventsForNewlyAddedUsers()
{
  foreach (var newUser in _basicRepo.DataContext.GetAddedUsers())
  {
     _eventService.RaiseEvent(new UserCreatedEvent(newUser))
  }
}

你可以调用代理类NotifyingRepository或者ObservableRepository,如果你喜欢,沿@彼得行高度投票的答案(这实际上并没有告诉如何解决SRP违规,只说违规就可以了)。

新存储库类和旧存储库类都应从一个公共接口派生,如经典Proxy模式描述中所示

然后,在您的原始代码中,_userRepository通过新EventFiringUserRepo类的对象进行初始化。这样,您可以将原始存储库与事件机制分开。如果需要,您可以并排放置事件触发存储库和原始存储库,并让调用者决定使用前者还是后者。

为了解决评论中提到的一个问题:这是否导致代理服务器上的代理服务器位于代理服务器之上,依此类推?实际上,添加事件机制为通过简单地订阅事件来添加“发送电子邮件”类型的进一步要求奠定了基础,因此也遵循那些要求的SRP,而无需任何其他代理。但是,这里必须添加的一件事是事件机制本身。

如果这种分离确实值得,那么在您的系统中,您和您的审阅者必须自行决定。我可能不会将日志记录与原始代码分开,也不会使用其他代理,也不会通过将记录器添加到侦听器事件中,尽管这是可能的。


3
除了这个答案。代理还有其他替代方法,例如AOP
莱夫

1
我认为您错了一点,并不是说引发事件会破坏SRP,而是仅针对“新”用户提出事件需要回购协议负责知道什么构成“新”用户而不是“新添加”给我”用户
Ewan

@Ewan:请再次阅读问题。它与代码中的某个地方有关,该地方执行某些操作,而该操作需要与该对象的责任以外的其他操作结合在一起。同行评审员质疑将行动和事件提出放在一个地方违反了SRP。“保存新用户”的示例仅用于演示目的,如果您愿意,可以将其称为人为设计的示例,但这不是恕我直言的问题所在。
布朗

2
这是IMO的最佳/正确答案。它不仅维护SRP,而且还维护“打开/关闭”原则。并考虑所有可能在类中进行更改的自动化测试。添加功能时修改现有测试是一个很大的尝试。
基思·佩恩

1
如果正在进行交易,此解决方案如何工作?这样做时,SaveChanges()实际上不会创建数据库记录,并且最终可能会回滚。似乎您必须重写AcceptAllChanges或订阅TransactionCompleted事件。
John Wu

29

在保存时,发送持久性数据存储已更改的通知似乎是明智的选择。

当然,您不应将“添加”视为特殊情况-您还必须触发“修改”和“删除”事件。这是对“添加”案例的特殊对待,它会嗅到气味,迫使读者解释为什么会闻到气味,并最终导致一些代码阅读者得出结论,认为它必须违反SRP。

可以查询,更改并在更改时引发事件的“通知”存储库是完全正常的对象。您几乎可以在几乎任何规模合适的项目中找到其多种变体。


但是,“通知”存储库实际上是您需要的吗?您提到了C#:许多人会同意,使用a System.Collections.ObjectModel.ObservableCollection<>而不是仅System.Collections.Generic.List<>在需要后者时会遇到各种各样的坏事,但是很少有人会立即指出SRP。

您现在正在做的是将您的UserList _userRepository替换为ObservableUserCollection _userRepository。这是否是最佳选择,取决于应用程序。但是,尽管毫无疑问,它使_userRepository轻便性大大降低了,但在我看来,它并没有违反SRP。


ObservableCollection这种情况下使用的问题是,它不会在调用时触发等效事件SaveChanges,而是在调用时触发了等效事件Add,这将导致行为与示例中所示的行为截然不同。请参阅我的答案,如何使原始存储库保持轻量级,并通过保持语义完整而仍然坚持SRP。
布朗

@DocBrown我调用了已知的类ObservableCollection<>List<>以进行比较和上下文。我并不是要建议对内部实现或外部接口使用实际的类。
Peter

好的,即使OP将事件添加到“ Modify”和“ Delete”(为了简单起见,我认为OP为了简化问题也省略了这些内容),但我认为审阅者很容易得出结论违反SRP。这可能是可以接受的,但如果需要,则无法解决。
布朗

16

是的,这违反了单一责任原则和有效点。

更好的设计是让一个单独的过程从存储库中检索“新用户”并发送电子邮件。跟踪向哪些用户发送了电子邮件,失败,重新发送等信息。

这样,您可以处理错误,崩溃等问题,并避免存储库抓住所有要求,即事件“在将某些内容提交给数据库时发生”。

存储库不知道您添加的用户是新用户。它的职责只是存储用户。

在下面的评论中可能值得扩展。

存储库不知道您添加的用户是新用户-是的,它知道,它有一个称为Add的方法。它的语义意味着所有添加的用户都是新用户。在调用“保存”之前,将传递给“添加”的所有参数合并在一起,您将获得所有新用户

不正确 您正在将“添加到存储库”和“新建”进行混合。

“添加到存储库”指的是它所说的内容。我可以添加和删除用户,然后将其重新添加到各个存储库。

“新”是由业务规则定义的用户状态。

当前,业务规则可能是“新==刚刚添加到存储库中”,但这并不意味着了解和应用该规则不是单独的责任。

您必须小心避免这种以数据库为中心的思维。您将拥有一些边际案例流程,这些流程会将非新用户添加到资源库中,当您向他们发送电子邮件时,所有业务部门都会说“当然,这些用户不是'新'用户!实际规则是X”

恕我直言,这个答案完全没有说明这一点:回购正好是代码中知道何时添加新用户的中心位置

不正确 出于上述原因,除非您实际上将电子邮件发送代码包括在类中,而不仅仅是引发事件,否则它不是中心位置。

您将拥有使用存储库类的应用程序,但没有发送电子邮件的代码。在这些应用程序中添加用户时,将不会发送电子邮件。


11
存储库不知道您添加的用户是新用户 -是的,它有一个称为的方法Add。它的语义意味着所有添加的用户都是新用户。Add在调用之前合并所有传递给的参数Save-您会获得所有新用户。
安德烈·博尔赫斯

我喜欢这个建议。但是,实用主义胜于纯洁。视情况而定,如果您需要做的只是在添加用户时发送一封电子邮件,那么很难为现有应用程序添加全新的体系结构层。
亚历山大

但事件并不是说用户已添加。它说用户创建。如果我们考虑正确地命名事物,并且同意add和create之间的语义差异,那么代码片段中的事件将被错误命名或放错位置。我认为审阅者没有任何反对Notyfing存储库的内容。可能它担心事件的种类及其副作用。
Laiv

7
@Andre是回购的新手,但从业务意义上讲不一定是“新手”。这两个想法的融合掩盖了第一眼的额外责任。我可能会将一吨旧用户导入到我的新存储库中,或者删除并重新添加用户等。对于“新用户”超出“已添加到dB”之外的内容,会有业务规则
Ewan

1
主持人注:您的回答不是新闻采访。如果您进行编辑,则可以自然地将其合并到您的答案中,而不会产生整个“突发新闻”效果。我们不是讨论论坛。
罗伯特·哈维

7

这是有效的观点吗?

是的,尽管它在很大程度上取决于代码的结构。我没有完整的背景信息,所以我将尝试大体谈一下。

在我看来,在此处引发事件与日志记录本质上是相同的:只是向该函数添加一些辅助功能。

绝对不是。日志记录不是业务流程的一部分,可以禁用日志记录,它不应该导致(业务)副作用,也不应以任何方式影响应用程序的状态和健康状况,即使您出于某种原因无法登录任何东西了。现在,将其与您添加的逻辑进行比较。

SRP并没有禁止您在函数中使用日志或激发事件,它只是说这种逻辑应该封装在其他类中,并且存储库可以调用这些其他类。

SRP与ISP(在SOLID中为S和I)协同工作。您最终会得到许多类和方法,这些类和方法可以完成非常具体的事情,而仅此而已。它们非常专注,非常容易更新或替换,并且通常更易于测试。当然,在实践中,您还将拥有一些更大的类来处理业务流程:它们将具有许多依赖性,并且它们将不专注于原子化的动作,而是专注于业务动作,这可能需要多个步骤。只要业务环境清晰,它们也可以称为单一职责,但是正如您正确地说的那样,随着代码的增长,您可能希望将其中的一些抽象为新的类/接口。

现在回到您的特定示例。如果您在创建用户时绝对必须发送通知,甚至可能执行其他更专门的操作,那么您可以创建一个单独的服务来封装此要求,例如UserCreationService,它公开一个方法Add(user),同时处理两个存储(调用到您的存储库)和作为单个业务操作的通知。或者在您的原始代码段中执行_userRepository.SaveChanges();


2
日志记录不是业务流程的一部分 -在SRP中它与日志记录有何关系?如果我的活动目的是向Google Analytics(分析)发送新的用户数据-则禁用它与禁用日志记录具有相同的商业效果:不是很关键,但是很令人不快。在函数中添加/不添加新逻辑的经验法则是什么?“禁用它会导致主要的业务副作用吗?”
安德烈·博尔赫斯

2
If the purpose of my event would be to send new user data to Google Analytics - then disabling it would have the same business effect as disabling logging: not critical, but pretty upsetting 。如果您引发引起假“新闻”的过早事件怎么办。如果分析考虑由于数据库事务错误而最终没有创建的“用户”怎么办?如果公司在错误的前提下以不准确的数据为依据做出决定怎么办?您过于关注问题的技术方面。“有时您看不到树木的木头”
莱夫

@Laiv,您的意思是正确的,但这不是我的问题或答案的重点。问题是这在SRP上下文中是否是有效的解决方案,因此我们假设没有数据库事务错误。
安德烈·博尔赫斯

您基本上是在要求我告诉您您想听的内容。我只给你范围。由于SRP没有适当的上下文就没有用,因此您可以决定是否使用SRP更大的范围。IMO处理问题的方式不正确,因为您只关注技术解决方案。您应该充分考虑整个上下文。是的,数据库可能会失败。它有可能发生,您不应该忽略它,因为如您所知,事情会发生,而这些事情可能会改变您对SRP或其他良好实践的怀疑。
莱夫

1
也就是说,请记住,原则并非一成不变的规则。它们是可渗透的(自适应)。如您所见,它们是开放的。您的审阅者有一种解释,而您有另一种解释。尝试看看您所看到的,解决他/她的疑问和疑虑,或让他/她解决您的问题。您在这里找不到“正确”的答案。正确的答案取决于您和您的审阅者,首先要询问项目的要求(功能和非功能)。
拉夫

4

从理论上讲,SRP是关于人的,正如Bob叔叔在他的《单一责任原则》中所述感谢Robert Harvey在您的评论中提供它。

正确的问题是:

哪个“利益相关者”添加了“发送电子邮件”要求?

如果该利益相关者还负责数据持久性(不太可能但可能),那么这不会违反SRP。否则,它会。


2
有趣-我从未听说过对SRP的这种解释。您是否有任何指向这种解释的更多信息/文献的指针?
sleske

2
@sleske:来自Bob叔叔本人“这就是单一责任原则的症结所在。该原则与人有关。 在编写软件模块时,您要确保在请求更改时,这些更改只能源于来自一个人,或者说是一个紧密耦合的人,代表一个狭义的业务功能。”
罗伯特·哈维

谢谢罗伯特。国际海事组织,“单一责任原则”这个名字听起来很简单,但却很糟糕,但是很少有人遵循“责任”的意图。有点像OOP如何从许多原始概念转变而来,现在已经是一个毫无意义的术语。
user949300

1
是的 这就是“ REST”一词的含义。甚至罗伊·菲尔丁(Roy Fielding)也说人们在错误地使用它。
罗伯特·哈维

尽管引用是相关的,但我认为此答案错过了“发送电子邮件”要求不是SRP违规问题所针对的直接要求。但是,通过说“哪些“利益相关者”增加了“筹款活动”要求”,该答案将与实际问题更加相关。我对自己的答案做了一些修改,以使其更加清楚。
布朗

2

从技术上讲,存储库通知事件没有什么问题,但我建议从功能的角度考虑它的便利性,这会引起一些关注。

创建用户,确定新用户的身份以及其持久性是3件不同的事情

我的前提

在确定存储库是否是通知业务事件的适当位置之前,请考虑先前的前提(与SRP无关)。请注意,我说商务活动是因为对我而言UserCreated,其含义不同于UserStoredUserAdded 1。我还将考虑将这些事件分别针对不同的受众。

一方面,创建用户是特定于业务的规则,可能涉及持久性,也可能不涉及持久性。它可能涉及更多的业务操作,涉及更多的数据库/网络操作。持久层不知道的操作。持久层没有足够的上下文来决定用例是否成功结束。

另一方面,_dataContext.SaveChanges();成功保留用户并不一定是正确的。这将取决于数据库的事务范围。例如,对于像MongoDB这样的事务是原子性的数据库而言,这可能是正确的,但对于实现ACID事务的传统RDBMS而言,事实并非如此,因为它可能涉及更多事务但尚未提交。

这是有效的观点吗?

它可能是。但是,我敢说这不仅是SRP的问题(从技术上来说),而且还是方便性的问题(从功能上来说)。

  • 从不知道正在进行的业务操作的组件中触发业务事件是否方便?
  • 他们代表正确的地方和正确的时机吗?
  • 我应该允许这些组件通过这样的通知来协调我的业务逻辑吗?
  • 我可以使过早事件引起的副作用无效吗? 2

在我看来,在这里引发事件与记录日志基本上是一样的

绝对不。日志记录没有任何副作用,但是,正如您所建议的那样,该事件UserCreated可能会导致其他业务操作发生。像通知。3

它只是说这种逻辑应该封装在其他类中,并且存储库可以调用这些其他类

不一定正确。SRP不仅是特定于类别的问题。它在不同的抽象级别上运行,例如层,库和系统!这是凝聚力,是在相同的利益相关者的手下,将由于相同原因发生的变化保持在一起。如果用户创建(用例)发生变化,则此刻可能发生,事件发生的原因也可能发生变化。


1:适当命名事物也很重要。

2:假设我们UserCreated在之后发送_dataContext.SaveChanges();,但是由于连接问题或违反约束,整个数据库事务稍后都失败了。请谨慎处理事件的过早广播,因为它的副作用很难消除(如果可能的话)。

3:未充分处理的通知过程可能会导致您触发无法撤消/阻止的通知>


1
+1关于交易跨度的非常好的要点。断言用户已经创建还为时过早,因为可能发生回滚。而且与日志不同,应用程序的其他部分可能会对事件有所帮助。
Andres F.

2
究竟。事件表示确定性。发生了什么,但结束了。
莱夫

1
@莱夫:除非他们没有。Microsoft提供了各种带有前缀的事件,Before或者Preview根本无法保证确定性的事件。
罗伯特·哈维

1
@ jpmc26:如果没有其他选择,您的建议将无济于事。
罗伯特·哈维

1
@ jpmc26:因此,您的答案是“转向具有完全不同的工具和性能特征的完全不同的开发生态系统”。叫我相反,但是我想这对于绝大多数开发工作是不可行的。
罗伯特·哈维

1

不,这不违反SRP。

许多人似乎认为“单一责任原则”意味着功能只能执行“一件事”,然后陷入有关“一件事”的讨论。

但这不是该原则的含义。这是关于业务级别的问题。一个班级不应实施可能在业务级别上独立变化的多个关注点或要求。假设一个类既存储用户,又通过电子邮件发送硬编码的欢迎消息。多个独立的关注点可能会导致此类需求的变化。设计人员可能需要更改邮件的html /样式表。通讯专家可能需要更改邮件的措词。UX专家可以决定实际上应该在入职流程的其他位置发送邮件。因此,该类可能会受到来自独立来源的多种需求更改的影响。这违反了SRP。

但是,触发事件并不会违反SRP,因为事件仅取决于保存用户,而不取决于其他任何问题。事件实际上是维护SRP的一种非常不错的方法,因为您可以通过保存触发电子邮件,而不会使存储库受到(甚至不知道)邮件的影响。


1

不用担心单一责任原则。这不会帮助您在这里做出正确的决定,因为您可以主观地选择特定的概念作为“责任”。您可以说该类的责任是管理数据库的数据持久性,或者您可以说它的责任是执行与创建用户有关的所有工作。这些只是应用程序行为的不同级别,它们都是“单一责任”的有效概念表达。因此,此原理无助于解决您的问题。

在这种情况下最有用的原则是最小惊讶原则。因此,让我们问一个问题:具有将数据持久保存到数据库中的主要作用的存储库也发送电子邮件是否令人惊讶

是的,这非常令人惊讶。这是两个完全独立的外部系统,名称SaveChanges并不意味着也发送通知。您将其委托给事件的事实使行为更加令人惊讶,因为阅读代码的人不再容易看到调用了哪些其他行为。间接危害可读性。有时,这些好处值得增加可读性,但是当您自动调用对最终用户可见的其他外部系统时,却不值得。(这里可以排除日志记录,因为日志记录的作用实际上是出于调试目的而保留记录。最终用户不使用日志,因此始终记录日志没有任何危害。)更糟糕的是,这降低了计时的灵活性 发送电子邮件的方式,使得无法在保存和通知之间进行其他操作。

如果您的代码通常在成功创建用户后通常需要发送通知,则可以创建一个这样做的方法:

public void AddUserAndNotify(IUserRepository repo, IEmailNotification notifier, MyUser user)
{
    repo.Add(user);
    repo.SaveChanges();
    notifier.SendUserCreatedNotification(user);
}

但是,这是否增加价值取决于您的应用程序的具体情况。


我实际上根本不鼓励使用该SaveChanges方法。该方法可能会提交数据库事务,但是其他存储库可能已在同一事务中修改了数据库。它确实提交了所有它们的事实再次令人惊讶,因为SaveChanges它专门与该用户存储库实例相关联。

管理数据库事务的最直接的模式是外部using模块:

using (DataContext context = new DataContext())
{
    _userRepository.Add(context, user);
    context.SaveChanges();
    notifier.SendUserCreatedNotification(user);
}

这使程序员可以明确控制何时保存所有存储库的更改,强制代码显式记录提交之前必须发生的事件序列,确保在发生错误时DataContext.Dispose进行回滚(假设发出回滚),并避免隐藏有状态类之间的连接。

我也不想在请求中直接发送电子邮件。在队列中记录对通知的需求会更可靠。这样可以更好地处理故障。特别是,如果在发送电子邮件时发生错误,可以在不中断保存用户的情况下稍后再次尝试,并且避免了创建用户但站点返回错误的情况。

using (DataContext context = new DataContext())
{
    _userRepository.Add(context, user);
    _emailNotificationQueue.AddUserCreateNotification(user);
    _emailNotificationQueue.Commit();
    context.SaveChanges();
}

最好先提交通知队列,因为在context.SaveChanges()调用失败的情况下,队列的使用者可以在发送电子邮件之前验证用户是否存在。(否则,您将需要一个成熟的两阶段提交策略来避免heisenbug。)


底线是实用的。实际上,以一种特殊的方式来思考编写代码的后果(在风险和收益方面)。我发现“单一责任原则”通常不会帮助我做到这一点,而“最不惊讶的原则”通常可以帮助我进入另一个开发人员的头脑(可以这么说)并思考可能发生的事情。


4
具有将数据持久保存到数据库中的主要作用的存储库还会发送电子邮件,这令人惊讶吗?我想您错过了我的问题的重点。我的存储库未发送电子邮件。它只是引发一个事件,以及如何处理此事件-是外部代码的责任。
安德烈·博尔赫斯

4
您基本上是在说“不要使用事件”。
罗伯特·哈维

3
[耸耸肩]事件是大多数UI框架的核心。消除事件,这些框架根本不起作用。
罗伯特·哈维

2
@ jpmc26:它称为ASP.NET Webforms。糟透了
罗伯特·哈维

2
My repository is not sending emails. It just raises an event因果。存储库正在触发通知过程。
莱夫

0

当前SaveChanges执行件事:保存更改记录更改。现在,您要添加其他内容:发送电子邮件通知。

您有个聪明的主意要添加一个事件,但是它被批评为违反了“单一职责原则”(SRP),却没有注意到它已被违反。

要获得纯SRP解决方案,请首先触发该事件,然后调用该事件的所有挂钩,现在,该挂钩共有三个:保存,记录日志,最后发送电子邮件。

您要么首先触发事件,要么必须添加到SaveChanges。您的解决方案是两者之间的混合体。它没有解决现有的违规行为,但确实鼓励阻止它超出三件事。重构现有代码以符合SRP可能需要比严格必要的工作还要多。由您的项目决定,他们要采用SRP的程度。


-1

该代码已经违反了SRP-同一类负责与数据上下文进行通信并进行日志记录。

您只需将其升级为3个职责即可。

将事情归结为1种责任的一种方法是抽象事物_userRepository; 使其成为命令广播员。

它具有一组命令以及一组侦听器。它获取命令,并将其广播给其侦听器。可能是那些侦听器是有序的,也许他们甚至可以说命令失败了(该命令又广播给已经通知的侦听器)。

现在,大多数命令可能只有1个侦听器(数据上下文)。更改之前,SaveChanges具有2-数据上下文,然后是记录器。

然后,您的更改将添加另一个侦听器以保存更改,这将引发事件服务中用户创建的新事件。

有一些好处。现在,您可以删除,升级或复制日志记录代码,而无需关心其余代码。您可以在保存更改时添加更多触发器,以获取更多需要的触发器。

所有这些都将_userRepository在创建和连接时确定(或者可能是在运行中添加/删除了那些额外的功能;可以在应用程序运行时添加/增强日志记录)。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.