软件开发怎么对抗抄袭_对抗软件开发中的复杂性

软件开发怎么对抗抄袭

这是怎么回事 (What's this about)

After working on different projects, I've noticed that every one of them had some common problems, regardless of domain, architecture, code convention and so on. Those problems weren't challenging, just a tedious routine: making sure you didn't miss anything stupid and obvious. Instead of doing this routine on a daily basis I became obsessed with seeking solution: some development approach or code convention or whatever that will help me to design a project in a way that will prevent those problems from happening, so I can focus on interesting stuff. That's the goal of this article: to describe those problems and show you that mix of tools and approaches that I found to solve them.

在完成了不同的项目之后,我注意到它们中的每一个都有一些共同的问题,而与域,体系结构,代码约定等无关。 这些问题并不具有挑战性,只是一个乏味的例行工作:确保您不会错过任何愚蠢而显而易见的事情。 我不再每天都执行此例程,而是沉迷于寻求解决方案:某种开发方法或代码约定或任何有助于我以防止这些问题发生的方式设计项目的事物,因此我可以专注于有趣的事物。 这就是本文的目的:描述这些问题,并向您展示我找到的解决问题的多种工具和方法。

我们面临的问题 (Problems we face)

While developing software we face a lot of difficulties along the way: unclear requirements, miscommunication, poor development process and so on.

在开发软件时,我们会遇到很多困难:要求不明确,沟通不畅,开发过程不佳等。

We also face some technical difficulties: legacy code slows us down, scaling is tricky, some bad decisions of the past kick us in the teeth today.

我们还面临一些技术难题:遗留代码使我们放慢速度,扩展很棘手,过去的一些错误决定使我们今天陷入困境。

All of them can be if not eliminated then significantly reduced, but there's one fundamental problem you can do nothing about: the complexity of your system.

所有这些问题都可以消除,甚至可以消除,但是可以解决,但是有一个根本问题您无能为力:系统的复杂性。

The idea of a system you are developing itself is always complex, whether you understand it or not. Even when you're making yet another CRUD application, there're always some edge cases, some tricky things, and from time to time someone asks "Hey, what's gonna happen if I do this and this under these circumstances?" and you say "Hm, that's a very good question.".

无论您是否了解,您自己开发的系统的想法总是很复杂。 即使当您正在制作另一个CRUD应用程序时 ,总会有一些极端情况,一些棘手的事情,并且有时会有人问“嘿,如果在这种情况下这样做,会发生什么?” 然后您说“嗯,这是一个很好的问题。”

Those tricky cases, shady logic, validation and access managing — all that adds up to your big idea. Quite often that idea is so big that it doesn't fit in one head, and that fact alone brings problems like miscommunication.

这些棘手的情况,可疑的逻辑,验证和访问管理-所有这些加在一起就构成了您的重要构想。 通常,这个想法如此之大,以至于无法一head而就,仅这个事实就带来了沟通不畅等问题。

But let's be generous and assume that this team of domain experts and business analysts communicates clearly and produces fine consistent requirements.

但是,请让我们大方一些,并假设该领域专家和业务分析人员团队进行了清晰的沟通,并提出了很好的一致要求。

Now we have to implement them, to express that complex idea in our code. Now that code is another system, way more complicated than original idea we had in mind(s).

现在我们必须实现它们,以在我们的代码中表达这个复杂的想法。 现在,该代码是另一个系统,比我们想到的原始想法复杂得多。

How so? It faces reality: technical limitations force you to deal with highload, data consistency and availability on top of implementing actual business logic.

为何如此? 它面临现实:技术限制迫使您在实施实际业务逻辑的基础上应对高负载,数据一致性和可用性。

As you can see the task is pretty challenging, and now we need proper tools to deal with it. A programming language is just another tool, and like with every other tool, it's not just about the quality of it, it's probably even more about the tool fitting the job. You might have the best screwdriver there is, but if you need to put some nails into wood, a crappy hammer would be better, right?

如您所见,任务非常艰巨,现在我们需要适当的工具来处理它。 编程语言只是另一种工具,与其他所有工具一样,它不仅与质量有关,还与适合工作的工具有关。 您可能拥有最好的螺丝刀,但是如果您需要在木头上钉一些钉子,那么a头的锤子会更好,对吧?

技术方面 (Technical aspects)

Most popular languages today a object oriented. When someone makes an introduction to OOP they usually use examples: Consider a car, which is an object from the real world. It has various properties like brand, weight, color, max speed, current speed and so on.

如今,大多数流行语言都是面向对象的。 当有人介绍OOP时,他们通常使用示例:考虑一下汽车,它是现实世界中的物体。 它具有各种属性,例如品牌,重量,颜色,最大速度,当前速度等。

To reflect this object in our program we gather those properties in one class. Properties can be permanent or mutable, which together form both current state of this object and some boundaries in which it may vary. However combining those properties isn't enough, since we have to check that current state makes sense, e.g. current speed doesn't exceed max speed. To make sure of that we attach some logic to this class, mark properties as private to prevent anyone from creating illegal state. As you can see objects are about their internal state and life cycle.

为了在我们的程序中反映该对象,我们将这些属性收集在一个类中。 属性可以是永久性的也可以是可变的,它们共同构成此对象的当前状态以及可能会变化的某些边界。 但是结合这些属性是不够的,因为我们必须检查当前状态是否有意义,例如当前速度不超过最大速度。 为了确保我们在此类上附加一些逻辑,请将属性标记为私有,以防止任何人创建非法状态。 如您所见,对象是关于其内部状态和生命周期的。

So those three pillars of OOP make perfect sense in this context: we use inheritance to reuse certain state manipulations, encapsulation for state protection and polymorphism for treating similar objects the same way. Mutability as a default also makes sense, since in this context immutable object can't have a life cycle and has always one state, which isn't the most common case.

因此,在这种情况下,OOP的这三个Struts非常有意义:我们使用继承重用某些状态操作,使用封装进行状态保护,并使用多态性以相同方式处理相似的对象。 缺省情况下,可变性也很有意义,因为在这种情况下,不可变对象不能具有生命周期,并且始终具有一种状态,这不是最常见的情况。

Thing is when you look at a typical web application of these days, it doesn't deal with objects. Almost everything in our code has either eternal lifetime or no proper lifetime at all. Two most common kinds of "objects" are some sort of services like UserService, EmployeeRepository or some models/entities/DTOs or whatever you call them. Services have no logical state inside them, they die and born again exactly the same, we just recreate the dependency graph with a new database connection.

事情是当您看到当今的典型Web应用程序时,它不处理对象。 我们代码中的几乎所有内容都具有永久寿命或根本没有适当的寿命。 两种最常见的“对象”是某种服务,例如UserServiceEmployeeRepository或某些模型/实体/ DTO或您所谓的它们。 服务内部没有逻辑状态,它们的死亡和重生完全相同,我们只是使用新的数据库连接来重新创建依赖关系图。

Entities and models don't have any behavior attached to them, they are merely bundles of data, their mutability doesn't help but quite the opposite.

实体和模型没有任何附加的行为,它们只是数据束,它们的可变性无济于事,而恰恰相反。

Therefore key features of OOP aren't really useful for developing this kind of applications.

因此,OOP的关键功能对于开发此类应用程序并不是真正有用。

What happens in a typical web app is data flowing: validation, transformation, evaluation and so on. And there's a paradigm that fits perfectly for that kind of job: functional programming. And there's a proof for that: all the modern features in popular languages today come from there: async/await, lambdas and delegates, reactive programming, discriminated unions (enums in swift or rust, not to be confused with enums in java or .net), tuples — all that is from FP.

在典型的Web应用程序中,发生的事情是数据流动:验证,转换,评估等。 这里有一个非常适合这种工作的范例:函数式编程。 有一个证明:当今流行语言的所有现代功能都来自那里: async/await ,lambda和委托,React式编程,有区别的联合(快速枚举或生锈的枚举,不要与Java或.net枚举相混淆。 ),元组-所有这些都来自FP。

However those are just crumbles, it's very nice to have them, but there's more, way more.

但是这些只是碎屑,拥有它们非常好,但是还有更多,更多的方法。

Before I go any deeper, there's a point to be made. Switching to a new language, especially a new paradigm, is an investment for developers and therefore for business. Doing foolish investments won't give you anything but troubles, but reasonable investments may be the very thing that'll keep you afloat.

在我深入之前,有一点需要指出。 切换到新的语言,尤其是新的范例,对开发人员和商业都是一项投资。 进行愚蠢的投资只会给您带来麻烦,但合理的投资可能会让您继续生计。

我们拥有的工具及其为我们提供的工具 (Tools we have and what they give us)

A lot of us prefer languages with static typing. The reason for that is simple: compiler takes care of tedious checks like passing proper parameters to functions, constructing our entities correctly and so on. These checks come for free. Now, as for the stuff that compiler can't check, we have a choice: hope for the best or make some tests. Writing tests means money, and you don't pay just once per test, you have to maintain them. Besides, people get sloppy, so every once in a while we get false positive and false negative results. The more tests you have to write the lower is the average quality of those tests. There's another problem: in order to test something, you have to know and remember that that thing should be tested, but the bigger your system is the easier it is to miss something.

我们中的许多人都喜欢静态类型的语言。 原因很简单:编译器处理繁琐的检查,例如将适当的参数传递给函数,正确地构造我们的实体等等。 这些支票是免费的。 现在,对于编译器无法检查的内容,我们可以选择:希望是最好的还是进行一些测试。 编写测试意味着金钱,您不必为每次测试支付一次费用,而是必须维护它们。 此外,人们变得草率,所以我们偶尔会得到假阳性和假阴性结果。 您必须编写的测试越多,这些测试的平均质量就越低。 还有另一个问题:为了测试某些东西,您必须知道并记住应该测试该东西,但是系统越大,遗漏某些东西就越容易。

However compiler is only as good as the type system of the language. If it doesn't allow you to express something in static ways, you have to do that in runtime. Which means tests, yes. It's not only about type system though, syntax and small sugar features are very important too, because at the end of the day we want to write as little code as possible, so if some approach requires you to write ten times more lines, well, no one is gonna use it. That's why it's important that language you choose has the fitting set of features and tricks — well, right focus overall. If it doesn't — instead of using its features to fight original challenges like complexity of your system and changing requirements, you gonna be fighting the language as well. And it all comes down to money, since you pay developers for their time. The more problem they have to solve, the more time they gonna need and the more developers you are gonna need.

但是,编译器仅与语言的类型系统一样好。 如果不允许您以静态方式表达某些内容,则必须在运行时执行。 意思是测试,是的。 不过,这不仅与类型系统有关,语法和小糖功能​​也非常重要,因为最终我们希望编写尽可能少的代码,因此,如果某种方法要求您编写多十倍的行,没有人会使用它。 这就是为什么您选择的语言具有合适的功能和技巧非常重要的原因-总体而言,正确的重点。 如果不是这样,那么您将不但没有使用它的功能来应对原始挑战,例如系统的复杂性和不断变化的需求,还需要与语言进行抗争。 这一切都归结为金钱,因为您为开发者付出了时间。 他们必须解决的问题越多,所需的时间就越多,所需的开发人员就越多。

Finally we are about to see some code to prove all that. I'm happen to be a .NET developer, so code samples are gonna be in C# and F#, but the general picture would look more or less the same in other popular OOP and FP languages.

最后,我们将看到一些代码来证明所有这些。 我刚好是.NET开发人员,所以代码示例将使用C#和F#,但是在其他流行的OOP和FP语言中,总体情况大致相同。

让编码开始 (Let the coding begin)

We are gonna build a web application for managing credit cards.

我们将构建一个用于管理信用卡的Web应用程序。

Basic requirements:

基本要求:

  • Create/Read users

    创建/读取用户
  • Create/Read credit cards

    创建/读取信用卡
  • Activate/Deactivate credit cards

    激活/停用信用卡
  • Set daily limit for cards

    设置卡的每日限额
  • Top up balance

    充值余额
  • Process payments (considering balance, card expiration date, active/deactivated state and daily limit)

    处理付款(考虑余额,卡到期日期,有效/停用状态和每日限额)

For the sake of simplicity we are gonna use one card per account and we will skip authorization. But for the rest we're gonna build capable application with validation, error handling, database and web api. So let's get down to our first task: design credit cards.

为了简单起见,我们将每个帐户使用一张卡,并且我们将跳过授权。 但是对于其余部分,我们将构建具有验证,错误处理,数据库和Web API的功能强大的应用程序。 因此,让我们开始第一个任务:设计信用卡。

First, let's see what it would look like in C#

首先,让我们看看C#的外观

public class Card
{
    public string CardNumber {get;set;}
    public string Name {get;set;}
    public int ExpirationMonth {get;set;}
    public int ExpirationYear {get;set;}
    public bool IsActive {get;set;}
    public AccountInfo AccountInfo {get;set;}
}

public class AccountInfo
{
    public decimal Balance {get;set;}
    public string CardNumber {get;set;}
    public decimal DailyLimit {get;set;}
}

But that's not enough, we have to add validation, and commonly it's being done in some Validator, like the one from FluentValidation.

但这还不够,我们必须添加验证,并且通常是在某些Validator中完成的,例如FluentValidation Validator FluentValidation

The rules are simple:

规则很简单:

  • Card number is required and must be a 16-digit string.

    卡号为必填项,且必须为16位数字的字符串。
  • Name is required and must contain only letters and can contain spaces in the middle.

    名称是必填项,并且只能包含字母,中间可以包含空格。
  • Month and year have to satisfy boundaries.

    月份和年份必须满足界限。
  • Account info must be present when the card is active and absent when the card is deactivated. If you are wondering why, it's simple: when card is deactivated, it shouldn't be possible to change balance or daily limit.

    卡处于活动状态时,必须显示帐户信息;而停用卡时,则必须不提供帐户信息。 如果您想知道为什么,这很简单:停用卡后,就不可能更改余额或每日限额。
public class CardValidator : IValidator
{
    internal static CardNumberRegex = new Regex("^[0-9]{16}$");
    internal static NameRegex = new Regex("^[\w]+[\w ]+[\w]+$");

    public CardValidator()
    {
        RuleFor(x => x.CardNumber)
            .Must(c => !string.IsNullOrEmpty(c) && CardNumberRegex.IsMatch(c))
            .WithMessage("oh my");

        RuleFor(x => x.Name)
            .Must(c => !string.IsNullOrEmpty(c) && NameRegex.IsMatch(c))
            .WithMessage("oh no");

        RuleFor(x => x.ExpirationMonth)
            .Must(x => x >= 1 && x <= 12)
            .WithMessage("oh boy");

        RuleFor(x => x.ExpirationYear)
            .Must(x => x >= 2019 && x <= 2023)
            .WithMessage("oh boy");

        RuleFor(x => x.AccountInfo)
            .Null()
            .When(x => !x.IsActive)
            .WithMessage("oh boy");

        RuleFor(x => x.AccountInfo)
            .NotNull()
            .When(x => x.IsActive)
            .WithMessage("oh boy");
    }
}

Now there're several problems with this approach:

现在,这种方法存在一些问题:

  • Validation is separated from type declaration, which means to see the full picture of what card really is we have to navigate through code and recreate this image in our head. It's not a big problem when it happens only once, but when we have to do that for every single entity in a big project, well, it's very time consuming.

    验证与类型声明是分开的,这意味着要查看真正是什么卡的全貌,我们必须浏览代码并在脑海中重新创建此图像。 当它只发生一次时,这不是一个大问题,但是当我们必须对一个大项目中的每个实体都这样做时,这是非常耗时的。

  • This validation isn't forced, we have to keep in mind to use it everywhere. We can ensure this with tests, but then again, you have to remember about it when you write tests.

    这种验证不是强制性的,我们必须牢记在任何地方都使用它。 我们可以通过测试来确保这一点,但是再次,您在编写测试时必须记住这一点。
  • When we want to validate card number in other places, we have to do same thing all over again. Sure, we can keep regex in a common place, but still we have to call it in every validator.

    当我们想在其他地方验证卡号时,我们必须再次做同样的事情。 当然,我们可以将regex放在一个普通的地方,但是仍然必须在每个验证器中都调用它。

In F# we can do it in a different way:

在F#中,我们可以通过其他方式实现:

(*{-
First we define a type for CardNumber with private constructor
and public factory which receives string and returns `Result<CardNumber, string>`.
Normally we would use `ValidationError` instead, but string is good enough for example
-}*)
type CardNumber = private CardNumber of string
    with
    member this.Value = match this with CardNumber s -> s
    static member create str =
        match str with
        | (null|"") -> Error "card number can't be empty"
        | str ->
            if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok
            else Error "Card number must be a 16 digits string"

(*{-
Then in here we express this logic 
"when card is deactivated, balance and daily limit manipulations aren't available".
Note that this is way easier to grasp that reading `RuleFor()` in validators.
-}*)

type CardAccountInfo =
    | Active of AccountInfo
    | Deactivated

(*{-
And then that's it. The whole set of rules is here, and it's described in a static way.
We don't need tests for that, the compiler is our test.
And we can't accidentally miss this validation.
-}*)
type Card =
    { CardNumber: CardNumber
      Name: LetterString //-- LetterString is another type with built-in validation
      HolderId: UserId
      Expiration: (Month * Year)
      AccountDetails: CardAccountInfo }

Of course some things from here we can do in C#. We can create CardNumber class which will throw ValidationException in there too. But that trick with CardAccountInfo can't be done in C# in easy way. Another thing — C# heavily relies on exceptions. There are several problems with that:

当然,从这里开始,我们可以使用C#进行一些操作。 我们可以创建CardNumber类,该类也将在其中抛出ValidationException 。 但是CardAccountInfo这个技巧不能用C#轻松实现。 另一件事-C#严重依赖异常。 有几个问题:

  • Exceptions have "go to" semantics. One moment you're here in this method, another — you ended up in some global handler.

    异常具有“转到”语义。 您使用此方法的那一刻,另一刻–您遇到了某个全局处理程序。
  • They don't appear in method signature. Exceptions like ValidationException or InvalidUserOperationException are part of the contract, but you don't know that until you read implementation. And it's a major problem, because quite often you have to use code written by someone else, and instead of reading just signature, you have to navigate all the way to the bottom of the call stack, which takes a lot of time.

    它们不会出现在方法签名中。 诸如ValidationExceptionInvalidUserOperationException类的异常是合同的一部分,但是直到您阅读了Implementation才知道。 这是一个主要问题,因为很多时候您必须使用别人编写的代码,而不仅仅是阅读签名,而是必须一直浏览到调用堆栈的底部,这需要很多时间。

And this is what bothers me: whenever I implement some new feature, implementation process itself doesn't take much time, the majority of it goes to two things:

这就是让我感到困扰的事情:每当我实现一些新功能时,实现过程本身就不会花费很多时间,其中大部分用于两件事:

  • Reading other people's code and figuring out business logic rules.

    阅读其他人的代码并弄清楚业务逻辑规则。
  • Making sure nothing is broken.

    确保没有损坏。

It may sound like a symptom of a bad code design, but same thing what happens even on decently written projects. Okay, but we can try use same Result thing in C#. The most obvious implementation would look like this:

这听起来像是代码设计不佳的征兆,但是即使在编写得体的项目上也一样。 好的,但是我们可以尝试在C#中使用相同的Result东西。 最明显的实现如下所示:

public class Result<TOk, TError>
{
    public TOk Ok {get;set;}
    public TError Error {get;set;}
}

and it's a pure garbage, it doesn't prevent us from setting both Ok and Error and allows error to be completely ignored. The proper version would be something like this:

而且这纯粹是垃圾,它不会阻止我们同时设置“ Ok和“ Error并允许错误被完全忽略。 正确的版本应该是这样的:

public abstract class Result<TOk, TError>
{
    public abstract bool IsOk { get; }

    private sealed class OkResult : Result<TOk, TError>
    {
        public readonly TOk _ok;
        public OkResult(TOk ok) { _ok = ok; }

        public override bool IsOk => true;
    }
    private sealed class ErrorResult : Result<TOk, TError>
    {
        public readonly TError _error;
        public ErrorResult(TError error) { _error = error; }

        public override bool IsOk => false;
    }

    public static Result<TOk, TError> Ok(TOk ok) => new OkResult(ok);
    public static Result<TOk, TError> Error(TError error) => new ErrorResult(error);

    public Result<T, TError> Map<T>(Func<TOk, T> map)
    {
        if (this.IsOk)
        {
            var value = ((OkResult)this)._ok;
            return Result<T, TError>.Ok(map(value));
        }
        else
        {
            var value = ((ErrorResult)this)._error;
            return Result<T, TError>.Error(value);
        }
    }

    public Result<TOk, T> MapError<T>(Func<TError, T> mapError)
    {
        if (this.IsOk)
        {
            var value = ((OkResult)this)._ok;
            return Result<TOk, T>.Ok(value);
        }
        else
        {
            var value = ((ErrorResult)this)._error;
            return Result<TOk, T>.Error(mapError(value));
        }
    }
}

Pretty cumbersome, right? And I didn't even implement the void versions for Map and MapError. The usage would look like this:

很累吧? 而且我什至没有实现MapMapErrorvoid版本。 用法如下所示:

void Test(Result<int, string> result)
{
    var squareResult = result.Map(x => x * x);
}

Not so bad, uh? Well, now imagine you have three results and you want to do something with them when all of them are Ok. Nasty. So that's hardly an option. F# version:

还不错吧? 好吧,现在假设您有三个结果,并且当所有结果都Ok时,您想对它们进行处理。 讨厌。 因此,这几乎不是一种选择。 F#版本:

//-- this type is in standard library, but declaration looks like this:
type Result<'ok, 'error> =
    | Ok of 'ok
    | Error of 'error
//-- and usage:
let test res1 res2 res3 =
    match res1, res2, res3 with
    | Ok ok1, Ok ok2, Ok ok3 -> printfn "1: %A 2: %A 3: %A" ok1 ok2 ok3
    | _ -> printfn "fail"

Basically, you have to choose whether you write reasonable amount of code, but the code is obscure, relies on exceptions, reflection, expressions and other "magic", or you write much more code, which is hard to read, but it's more durable and straight forward. When such a project gets big you just can't fight it, not in languages with C#-like type systems. Let's consider a simple scenario: you have some entity in your codebase for a while. Today you want to add a new required field. Naturally you need to initialize this field everywhere this entity is created, but compiler doesn't help you at all, since class is mutable and null is a valid value. And libraries like AutoMapper make it even harder. This mutability allows us to partially initialize objects in one place, then push it somewhere else and continue initialization there. That's another source of bugs.

基本上,您必须选择是否编写合理数量的代码,但是代码晦涩难懂,依赖于异常,反射,表达式和其他“魔术”,或者编写更多的代码(虽然很难阅读,但更耐用)直截了当。 当这样的项目发展壮大时,您将无法抗拒它,而不是使用具有C#类类型系统的语言。 让我们考虑一个简单的场景:在代码库中有一段时间的实体。 今天,您想添加一个新的必填字段。 自然地,您需要在创建该实体的任何地方都初始化该字段,但是编译器根本无法帮助您,因为class是可变的并且null是有效值。 像AutoMapper这样的库使它变得更加困难。 这种可变性使我们可以在一个地方部分地初始化对象,然后将其推到其他地方并在那里继续初始化。 那是错误的另一个来源。

Meanwhile language feature comparison is nice, however it's not what this article about. If you're interested in it, I covered that topic in my previous article. But language features themselves shouldn't be a reason to switch technology.

同时,语言功能比较不错,但这不是本文的目的。 如果您对此感兴趣,我会在上一篇文章中介绍了该主题。 但是语言功能本身不应该成为转换技术的理由。

So that brings us to these questions:

因此,我们想到了以下问题:

  1. Why do we really need to switch from modern OOP?

    为什么我们真的需要从现代OOP转换?
  2. Why should we switch to FP?

    为什么要切换到FP?

Answer to first question is using common OOP languages for modern applications gives you a lot of troubles, because they were designed for a different purposes. It results in time and money you spend to fight their design along with fighting complexity of your application.

第一个问题的答案是将常见的OOP语言用于现代应用程序会给您带来很多麻烦,因为它们是为不同的目的而设计的。 这会导致您花费大量时间和金钱来与他们的设计抗衡,同时还与应用程序的复杂性作斗争。

And the second answer is FP languages give you an easy way to design your features so they work like a clock, and if a new feature breaks existing logic, it breaks the code, hence you know that immediately.

第二个答案是FP语言为您提供了一种简单的方法来设计功能,使它们像时钟一样工作,并且如果新功能破坏了现有逻辑,则会破坏代码,因此您立即就知道了。



However those answers aren't enough. As my friend pointed out during one of our discussions, switching to FP would be useless when you don't know best practices. Our big industry produced tons of articles, books and tutorials about designing OOP applications, and we have production experience with OOP, so we know what to expect from different approaches. Unfortunately, it's not the case for functional programming, so even if you switch to FP, your first attempts most likely would be awkward and certainly wouldn't bring you the desired result: fast and painless developing of complex systems.

但是,这些答案还不够。 正如我的朋友在我们的一次讨论中所指出的那样,如果您不了解最佳实践,则切换到FP毫无用处。 我们这个庞大的行业生产了有关设计OOP应用程序的大量文章,书籍和教程,并且我们具有OOP的生产经验,因此我们知道对不同方法的期望。 不幸的是,函数编程并非如此,因此,即使您切换到FP,您的第一次尝试也很可能会很尴尬,并且肯定不会为您带来预期的结果:快速而轻松地开发复杂的系统。

Well, that's precisely what this article is about. As I said, we're gonna build production-like application to see the difference.

好吧,这正是本文的主题。 正如我说的,我们将构建类似于生产的应用程序以了解差异。

我们如何设计应用程序? (How do we design application?)

A lot of this ideas I used in design process I borrowed from the great book Domain Modeling Made Functional, so I strongly encourage you to read it.

我在出色的《 领域建模使功能》一书中借鉴了许多设计过程中使用的想法,因此强烈建议您阅读。

Full source code with comments is here. Naturally, I'm not going to put all of it in here, so I'll just walk through key points.

带有注释的完整源代码在这里 。 自然,我不会将所有内容都放在这里,因此我将仅介绍关键点。

We'll have 4 main projects: business layer, data access layer, infrastructure and, of course, common. Every solution has it, right?

我们将有4个主要项目:业务层,数据访问层,基础结构,当然还有公共项目。 每个解决方案都有,对不对?

We begin with modeling our domain. At this point we don't know and don't care about database. It's done on purpose, because having specific database in mind we tend to design our domain according to it, we bring this entity-table relation in business layer, which later brings problems. You only need implement mapping domain -> DAL once, while wrong design will trouble us constantly until the point we fix it. So here's what we do: we create a project named CardManagement (very creative, I know), and immediately turn on the setting <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in project file. Why do we need this? Well, we're gonna use discriminated unions heavily, and when you do pattern matching, compiler gives us a warning, if we didn't cover all the possible cases:

我们从建模领域开始。 在这一点上,我们不知道也不在乎数据库。 这样做是有目的的,因为考虑到特定的数据库,我们倾向于根据该数据库设计域,因此我们在业务层中引入了这种实体表关系,随后又带来了问题。 您只需要实现映射domain -> DAL一次,而错误的设计将不断困扰我们,直到我们修复它为止。 所以,这就是我们的工作:创建一个名为CardManagement的项目(我知道非常有创意),然后立即在项目文件中打开设置<TreatWarningsAsErrors>true</TreatWarningsAsErrors> 。 我们为什么需要这个? 好吧,我们将大量使用区分的并集,当您进行模式匹配时,如果我们没有涵盖所有可能的情况,则编译器会警告我们:

let fail result =
    match result with
    | Ok v -> printfn "%A" v
    //-- warning: Incomplete pattern matches on this expression.
    //-- For example, the value 'Error' may indicate a case not covered by the pattern(s).

With this setting on, this code just won't compile, which is exactly what we need, when we extend existing functionality and want it to be adjusted everywhere. Next thing we do is creating module (it compiles in a static class) CardDomain. In this file we describe domain types and nothing more. Keep in mind that in F#, code and file order matters: by default you can use only what you declared earlier.

启用此设置后,当我们扩展现有功能并希望在任何地方对其进行调整时,这些代码就不会编译,这正是我们需要的。 接下来要做的是创建CardDomain模块(在静态类中编译)。 在此文件中,我们描述域类型,仅此而已。 请记住,在F#中,代码和文件顺序很重要:默认情况下,您只能使用之前声明的内容。

域类型 (Domain types)

We begin defining our types with CardNumber I showed before, although we're gonna need more practical Error than just a string, so we'll use ValidationError.

我们将使用之前显示的CardNumber定义类型,尽管我们将需要的不仅仅是字符串,还需要更多实际的Error ,所以我们将使用ValidationError

type ValidationError =
    { FieldPath: string
      Message: string }

let validationError field message = { FieldPath = field; Message = message }

(*{-
Actually we should use here Luhn's algorithm, but I leave it to you as an exercise,
so you can see for yourself how easy is updating code to new requirements.
-}*)
let private cardNumberRegex = new Regex("^[0-9]{16}$", RegexOptions.Compiled)

type CardNumber = private CardNumber of string
    with
    member this.Value = match this with CardNumber s -> s
    static member create fieldName str =
        match str with
        | (null|"") -> validationError fieldName "card number can't be empty"
        | str ->
            if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok
            else validationError fieldName "Card number must be a 16 digits string"

Then we of course define Card which is the heart of our domain. We know that card has some permanent attributes like number, expiration date and name on card, and some changeable information like balance and daily limit, so we encapsulate that changeable info in other type:

然后,我们当然将Card定义为我们领域的核心。 我们知道卡具有一些永久属性,例如卡上的数字,有效期限和名称,以及一些可更改的信息(例如余额和每日限额),因此我们将这种可更改的信息封装为其他类型:

type AccountInfo =
    { HolderId: UserId
      Balance: Money
      DailyLimit: DailyLimit }

type Card =
    { CardNumber: CardNumber
      Name: LetterString
      HolderId: UserId
      Expiration: (Month * Year)
      AccountDetails: CardAccountInfo }

Now, there're several types here, which we haven't declared yet:

现在,这里有几种类型,我们尚未声明:

  1. Money

    We could use decimal (and we will, but no directly), but decimal is less descriptive. Besides, it can be used for representation of other things than money, and we don't want it to be mixed up. So we use custom type type [<Struct>] Money = Money of decimal.

    我们可以使用decimal (我们将使用,但不能直接使用),但是decimal的描述较少。 此外,它可以用于表示金钱以外的其他事物,我们不希望将其混淆。 因此,我们使用自定义类型type [<Struct>] Money = Money of decimal

  2. DailyLimit

    每日限额

    Daily limit can be either set to a specific amount or to be absent at all. If it's present, it must be positive. Instead of using decimal or Money we define this type:

    每日限额可以设置为特定数量,也可以完全不设置。 如果存在,则必须为正。 我们定义这种类型而不是使用decimalMoney

    [<Struct>]
    type DailyLimit =
        private //-- private constructor so it can't be created directly outside of module
        | Limit of Money
        | Unlimited
        with
        static member ofDecimal dec =
            if dec > 0m then Money dec |> Limit
            else Unlimited
        member this.ToDecimalOption() =
            match this with
            | Unlimited -> None
            | Limit limit -> Some limit.Value

    It is more descriptive than just implying that 0M means that there's no limit, since it also could mean that you can't spend money on this card. The only problem is since we've hidden the constructor, we can't do pattern matching. But no worries, we can use Active Patterns:

    它具有更多的描述性,而不仅仅是暗示0M意味着没有限制,因为这还意味着您无法在此卡上花钱。 唯一的问题是,由于我们隐藏了构造函数,因此无法进行模式匹配。 但是不用担心,我们可以使用Active Patterns

    let (|Limit|Unlimited|) limit =
        match limit with
        | Limit dec -> Limit dec
        | Unlimited -> Unlimited

    Now we can pattern match DailyLimit everywhere as a regular DU.

    现在我们可以在任何地方将常规的DailyLimit模式匹配为常规DU。

  3. LetterString

    LetterString

    That one is simple. We use same technique as in CardNumber. One little thing though: LetterString is hardly about credit cards, it's a rather thing and we should move it in Common project in CommonTypes module. Time comes we move ValidationError into separate place as well.

    那很简单。 我们使用与CardNumber相同的技术。 不过有一点要注意: LetterString几乎与信用卡LetterString ,这是一回事,我们应该将其移至CommonTypes模块的Common项目中。 时间到了,我们也将ValidationError移到单独的位置。

  4. UserId

    用户身份

    That one is just an alias type UserId = System.Guid. We use it for descriptiveness only.

    那只是别名type UserId = System.Guid 。 我们仅将其用于描述性目的。

  5. Month and Year

    月和年

    Those have to go to Common too. Month is gonna be a discriminated union with methods to convert it to and from unsigned int16, Year is going to be like CardNumber but for uint16 instead of string.

    那些也必须去CommonMonth是要和方法将其转换为和可识别联合unsigned int16Year会是像CardNumberuint16 ,而不是字符串。

Now let's finish our domain types declaration. We need User with some user information and card collection, we need balance operations for top-ups and payments.

现在,让我们完成域类型声明。 我们需要为User提供一些用户信息和卡收集信息,我们需要余额操作来充值和付款。

type UserInfo =
        { Name: LetterString
          Id: UserId
          Address: Address }

    type User =
        { UserInfo : UserInfo
          Cards: Card list }

    [<Struct>]
    type BalanceChange =
        //-- another common type with validation for positive amount
        | Increase of increase: MoneyTransaction 
        | Decrease of decrease: MoneyTransaction
        with
        member this.ToDecimal() =
            match this with
            | Increase i -> i.Value
            | Decrease d -> -d.Value

    [<Struct>]
    type BalanceOperation =
        { CardNumber: CardNumber
          Timestamp: DateTimeOffset
          BalanceChange: BalanceChange
          NewBalance: Money }

Good, we designed our types in a way that invalid state is unrepresentable. Now whenever we deal with instance of any of these types we are sure that data in there is valid and we don't have to validate it again. Now we can proceed to business logic!

好的,我们以一种无法表示无效状态的方式设计了类型。 现在,无论何时处理任何这些类型的实例,我们都可以确保其中的数据有效,而不必再次进行验证。 现在我们可以进行业务逻辑了!

商业逻辑 (Business logic)

We'll have an unbreakable rule here: all business logic is gonna be coded in pure functions. A pure function is a function which satisfies following criteria:

我们这里有一条牢不可破的规则:所有业务逻辑都将用纯函数编码。 纯函数是满足以下条件的函数:

  • The only thing it does is computes output value. It has no side effects at all.

    它唯一要做的就是计算输出值。 它根本没有副作用。
  • It always produces same output for the same input.

    对于相同的输入,它总是产生相同的输出。

Hence pure functions don't throw exceptions, don't produce random values, don't interact with outside world at any form, be it database or a simple DateTime.Now. Of course interacting with impure function automatically renders calling function impure. So what shall we implement?

因此,纯函数不会引发异常,不会产生随机值,不会以任何形式与外界交互,无论是数据库还是简单的DateTime.Now 。 当然,与不纯函数交互会自动使调用函数不纯。 那么我们应该执行什么呢?

Here's a list of requirements we have:

以下是我们的要求列表:

  • Activate/deactivate card

    激活/停用卡

  • Process payments

    处理付款

    We can process payment if:

    在以下情况下,我们可以处理付款:

    1. Card isn't expired

      卡未过期
    2. Card is active

      卡已激活
    3. There's enough money for the payment

      有足够的钱来付款
    4. Spendings for today haven't exceeded daily limit.

      今天的支出尚未超过每日限额。
  • Top up balance

    充值余额

    We can top up balance for active and not expired card.

    我们可以为有效和未过期的卡充值。

  • Set daily limit

    设定每日限额

    User can set daily limit if card isn't expired and is active.

    如果卡未过期且处于活动状态,则用户可以设置每日限额。

When operation can't be completed we have to return an error, so we need to define OperationNotAllowedError:

当操作无法完成时,我们必须返回一个错误,因此我们需要定义OperationNotAllowedError

type OperationNotAllowedError =
        { Operation: string
          Reason: string }

    //-- and a helper function to wrap it in `Error` which is a case for `Result<'ok,'error> type
    let operationNotAllowed operation reason = { Operation = operation; Reason = reason } |> Error

In this module with business logic that would be the only type of error we return. We don't do validation in here, don't interact with database — just executing operations if we can otherwise return OperationNotAllowedError.

在具有业务逻辑的模块中,这将是我们返回的唯一错误类型。 我们在此不进行验证,也不与数据库进行交互-如果我们可以返回OperationNotAllowedError则仅执行OperationNotAllowedError

Full module can be found here. I'll list here the trickiest case here: processPayment. We have to check for expiration, active/deactivated status, money spent today and current balance. Since we can't interact with outer world, we have to pass all the necessary information as parameters. That way this logic would be very easy to test, and allows you to do property based testing.

完整的模块可以在这里找到。 我在这里列出最棘手的情况: processPayment 。 我们必须检查到期时间,有效/停用状态,今天花费的资金和当前余额。 由于我们无法与外部世界互动,因此我们必须传递所有必要的信息作为参数。 这样,此逻辑将非常易于测试,并允许您进行基于属性的测试

let processPayment (currentDate: DateTimeOffset) (spentToday: Money) card (paymentAmount: MoneyTransaction) =
    //-- first check for expiration
    if isCardExpired currentDate card then
        cardExpiredMessage card.CardNumber |> processPaymentNotAllowed
    else
    //-- then active/deactivated
    match card.AccountDetails with
    | Deactivated ->
          cardDeactivatedMessage card.CardNumber |> processPaymentNotAllowed
    | Active accInfo ->
        //-- if active then check balance
        if paymentAmount.Value > accInfo.Balance.Value then
            sprintf "Insufficent funds on card %s" card.CardNumber.Value
            |> processPaymentNotAllowed
        else
        //-- if balance is ok check limit and money spent today
        match accInfo.DailyLimit with
        | Limit limit when limit < spentToday + paymentAmount ->
            sprintf "Daily limit is exceeded for card %s with daily limit %M. Today was spent %M"
                card.CardNumber.Value limit.Value spentToday.Value
            |> processPaymentNotAllowed
        (*{-
          We could use here the ultimate wild card case like this:
          | _ ->
          but it's dangerous because if a new case appears in `DailyLimit` type,
          we won't get a compile error here, which would remind us to process this
          new case in here. So this is a safe way to do the same thing.
        -}*)
        | Limit _ | Unlimited ->
            let newBalance = accInfo.Balance - paymentAmount
            let updatedCard =
                { card with AccountDetails = 
                      Active { accInfo with Balance = newBalance } }
            //-- note that we have to return balance operation,
            //-- so it can be stored to DB later.
            let balanceOperation =
                { Timestamp = currentDate
                    CardNumber = card.CardNumber
                    NewBalance = newBalance
                    BalanceChange = Decrease paymentAmount }
            Ok (updatedCard, balanceOperation)

This spentToday — we'll have to calculate it from BalanceOperation collection we'll keep in database. So we'll need module for that, which will basically have 1 public function:

今天已spentToday -我们必须从我们将保存在数据库中的BalanceOperation集合中进行计算。 因此,我们需要为此模块,该模块基本上具有1个公共功能:

let private isDecrease change =
        match change with
        | Increase _ -> false
        | Decrease _ -> true

    let spentAtDate (date: DateTimeOffset) cardNumber operations =
        let date = date.Date
        let operationFilter { CardNumber = number; BalanceChange = change; Timestamp = timestamp } =
            isDecrease change && number = cardNumber && timestamp.Date = date
        let spendings = List.filter operationFilter operations
        List.sumBy (fun s -> -s.BalanceChange.ToDecimal()) spendings |> Money

Good. Now that we're done with all the business logic implementation, time to think about mapping. A lot of our types use discriminated unions, some of our types have no public constructor, so we can't expose them as is to the outside world. We'll need to deal with (de)serialization. Besides that, right now we have only one bounded context in our application, but later on in real life you would want to build a bigger system with multiple bounded contexts, and they have to interact with each other through public contracts, which should be comprehensible for everyone, including other programming languages.

好。 既然我们已经完成了所有业务逻辑的实现,接下来就该考虑映射了。 我们的许多类型都使用区分联合,我们的某些类型没有公共构造函数,因此我们无法将它们按原样暴露给外界。 我们需要处理(反序列化)。 除此之外,目前我们的应用程序中只有一个有界上下文,但是在现实生活中,您将需要构建一个具有多个有界上下文的更大的系统,并且它们必须通过公共合同相互交互,这应该是可理解的适用于所有人,包括其他编程语言。

We have to do both way mapping: from public models to domain and vise versa. While mapping from domain to models is pretty strait forward, the other direction has a bit of a pickle: models can have invalid data, after all we use plain types that can be serialized to json. Don't worry, we'll have to build our validation in that mapping. The very fact that we use different types for possibly invalid data and data, that's always valid means, that compiler won't let us forget to execute validation.

我们必须同时进行两种方式的映射:从公共模型到域,反之亦然。 虽然从领域到模型的映射相当困难,但另一个方向有点麻烦:模型可以包含无效数据,毕竟我们使用了可以序列化为json的普通类型。 不用担心,我们将不得不在该映射中建立验证。 我们对可能无效的数据和数据使用不同类型的事实,这始终是有效的,这意味着编译器不会让我们忘记执行验证。

Here's what it looks like:

看起来是这样的:

(*{-
 You can use type aliases to annotate your functions.
 This is just an example, but sometimes it makes code more readable
-}*)
    type ValidateCreateCardCommand = CreateCardCommandModel -> ValidationResult<Card>
    let validateCreateCardCommand : ValidateCreateCardCommand =
        fun cmd ->
(*{- that's a computation expression for `Result<>` type.
 Thanks to this we don't have to chose between short code and strait forward one,
 like we have to do in C# -}*)
        result {
            let! name = LetterString.create "name" cmd.Name
            let! number = CardNumber.create "cardNumber" cmd.CardNumber
            let! month = Month.create "expirationMonth" cmd.ExpirationMonth
            let! year = Year.create "expirationYear" cmd.ExpirationYear
            return
                { Card.CardNumber = number
                  Name = name
                  HolderId = cmd.UserId
                  Expiration = month,year
                  AccountDetails =
                     AccountInfo.Default cmd.UserId
                     |> Active }
        }

Full module for mappings and validations is here and module for mapping to models is here.

映射和验证全模块是在这里和映射模型模块这里

At this point we have implementation for all the business logic, mappings, validation and so on, and so far all of this is completely isolated from real world: it's written in pure functions entirely. Now you're maybe wondering, how exactly are we gonna make use of this? Because we do have to interact with outside world. More than that, during a workflow execution we have to make some decisions based on outcome of those real-world interactions. So the question is how do we assemble all of this? In OOP they use IoC containers to take care of that, but here we can't do that, since we don't even have objects, we have static functions.

至此,我们已经为所有业务逻辑,映射,验证等实现了,到目前为止,所有这些都与现实世界完全隔离:它完全由纯函数编写。 现在您可能想知道,我们将如何利用这一点? 因为我们必须与外界互动。 不仅如此,在执行工作流期间,我们还必须根据这些实际交互的结果做出一些决策。 所以问题是我们如何组装所有这些? 在OOP中,他们使用IoC容器来解决这个问题,但是在这里我们无法做到这一点,因为我们什至没有对象,所以我们有静态函数。

We are gonna use Interpreter pattern for that! It's a bit tricky, mostly because it's unfamiliar, but I'll do my best to explain this pattern. First, let's talk about function composition. For instance we have a function int -> string. This means that function expects int as a parameter and returns string. Now let's say we have another function string -> char. At this point we can chain them, i.e. execute first one, take it's output and feed it to the second function, and there's even an operator for that: >>. Here's how it works:

我们将为此使用Interpreter pattern ! 这有点棘手,主要是因为它不熟悉,但是我会尽力解释这种模式。 首先,让我们谈谈功能组合。 例如,我们有一个函数int -> string 。 这意味着函数期望将int作为参数并返回字符串。 现在,我们有另一个函数string -> char 。 在这一点上,我们可以将它们链接起来,即执行第一个,将其输出并提供给第二个函数,甚至还有一个运算符: >> 。 运作方式如下:

let intToString (i: int) = i.ToString()
let firstCharOrSpace (s: string) =
    match s with
    | (null| "") -> ' '
    | s -> s.[0]

let firstDigitAsChar = intToString >> firstCharOrSpace

//-- And you can chain as many functions as you like
let alwaysTrue = intToString >> firstCharOrSpace >> Char.IsDigit

However we can't use simple chaining in some scenarios, e.g. activating card. Here's a sequence of actions:

但是,在某些情况下,例如激活卡,我们不能使用简单的链接。 以下是一系列操作:

  • validate input card number. If it's valid, then

    验证输入卡号。 如果有效的话
  • try to get card by this number. If there's one

    尝试通过此号码获取卡。 如果有一个
  • activate it.

    激活它。
  • save results. If it's ok then

    保存结果。 如果可以的话
  • map to model and return.

    映射到模型并返回。

The first two steps have that If it's ok then.... That's the reason why direct chaining is not working.

前两个步骤都If it's ok then...If it's ok then... 这就是为什么直接链接不起作用的原因。

We could simply inject as parameters those functions, like this:

我们可以简单地将这些函数作为参数注入,如下所示:

let activateCard getCardAsync saveCardAsync cardNumber = ...

But there're certain problems with that. First, number of dependencies can grow big and function signature will look ugly. Second, we are tied to specific effects in here: we have to choose if it's a Task or Async or just plain sync calls. Third, it's easy to mess things up when you have that many functions to pass: e.g. createUserAsync and replaceUserAsync have same signature but different effects, so when you have to pass them hundreds of times you can make a mistake with really weird symptoms. Because of those reasons we go for interpreter.

但这有一些问题。 首先,依赖项的数量可能会变大,并且函数签名看起来会很丑陋。 其次,我们在这里与特定的效果相关:我们必须选择是Task还是Async还是普通的同步调用。 第三,当您传递许多函数时,很容易弄乱事情:例如createUserAsyncreplaceUserAsync具有相同的签名但效果不同,因此当您必须传递数百次时,您可能会犯一个真正奇怪的症状。 由于这些原因,我们选择了口译员。

The idea is that we divide our composition code in 2 parts: execution tree and interpreter for that tree. Every node in this tree is a place for a function with effect we want to inject, like getUserFromDatabase. Those nodes are defined by name, e.g. getCard, input parameter type, e.g. CardNumber and return type, e.g. Card option. We don't specify here Task or Async, that's not the part of the tree, it's a part of interpreter. Every edge of this tree is some series of pure transformations, like validation or business logic function execution. The edges also have some input, e.g. raw string card number, then there's validation, which can give us an error or a valid card number. If there's an error, we are gonna interrupt that edge, if not, it leads us to the next node: getCard. If this node will return Some card, we can continue to the next edge, which would be activation, and so on.

想法是将合成代码分为两部分:执行树和该树的解释器。 该树中的每个节点都是一个函数的位置,该函数具有我们要注入的效果,例如getUserFromDatabase 。 这些节点由名称(例如getCard ,输入参数类型(例如CardNumber和返回类型(例如Card option 。 我们此处未指定TaskAsync ,这不是树的一部分, 而是解释器的一部分 。 这棵树的每一个边缘都是一系列纯转换,例如验证或业务逻辑功能执行。 边缘也有一些输入,例如原始的字符串卡号,然后是验证,这可能会给我们带来错误或有效的卡号。 如果有错误,我们将中断该边缘,如果没有,它将导致我们到达下一个节点: getCard 。 如果此节点将返回Some card ,我们可以继续到下一个边缘,即激活,依此类推。

For every scenario like activateCard or processPayment or topUp we are gonna build a separate tree. When those trees are built, their nodes are kinda blank, they don't have real functions in them, they have a place for those functions. The goal of interpreter is to fill up those nodes, simple as that. Interpreter knows effects we use, e.g. Task, and it knows which real function to put in a given node. When it visits a node, it executes corresponding real function, awaits it in case of Task or Async, and passes the result to the next edge. That edge may lead to another node, and then it's a work for interpreter again, until this interpreter reaches the stop node, the bottom of our recursion, where we just return the result of the whole execution of our tree.

对于像每一个场景activateCardprocessPaymenttopUp我们要建立一个独立的树。 当这些树被构建时,它们的节点有点空白,它们中没有真正的功能, 它们有放置这些功能的位置。 解释器的目标是填充这些节点,就这么简单。 解释器知道我们使用的效果,例如Task ,并且知道要在给定节点中放置哪个实函数。 当它访问节点时,它执行相应的实函数,在TaskAsync情况下等待它,并将结果传递到下一个边缘。 该边缘可能会导致另一个节点,然后再次为解释器工作,直到该解释器到达停止节点(递归的底部)为止,在该节点我们只返回树的整个执行结果。

The whole tree would be represented with discriminated union, and a node would look like this:

整个树将用有区别的联合表示,并且一个节点如下所示:

type Program<'a> =
        | GetCard of CardNumber * (Card option -> Program<'a>) //-- <- THE NODE
        | ... //-- ANOTHER NODE

It's always gonna be a tuple, where the first element is an input for your dependency, and the last element is a function, which receives the result of that dependency. That "space" between those elements of tuple is where your dependency will fit in, like in those composition examples, where you have function 'a -> 'b, 'c -> 'd and you need to put another one 'b -> 'c in between to connect them.

它始终是一个元组,其中第一个元素是依赖项的输入,最后一个元素是function ,该函数接收该依赖项的结果。 元组的这些元素之间的“空格”就是您的依赖项所适合的地方,例如在那些合成示例中,您具有函数'a -> 'b'c -> 'd并且您需要放置另一个'b -> 'c在两者之间进行连接。

Since we are inside of our bounded context, we shouldn't have too many dependencies, and if we do — it's probably a time to split our context into smaller ones.

由于我们处于有限的上下文中,因此我们不应有太多的依赖关系,如果有的话,这可能是时候将上下文拆分成较小的依赖项了。

Here's what it looks like, full source is here:

看起来是这样,完整的源代码在这里

type Program<'a> =
        | GetCard of CardNumber * (Card option -> Program<'a>)
        | GetCardWithAccountInfo of CardNumber * ((Card*AccountInfo) option -> Program<'a>)
        | CreateCard of (Card*AccountInfo) * (Result<unit, DataRelatedError> -> Program<'a>)
        | ReplaceCard of Card * (Result<unit, DataRelatedError> -> Program<'a>)
        | GetUser of UserId * (User option -> Program<'a>)
        | CreateUser of UserInfo * (Result<unit, DataRelatedError> -> Program<'a>)
        | GetBalanceOperations of (CardNumber * DateTimeOffset * DateTimeOffset) * (BalanceOperation list -> Program<'a>)
        | SaveBalanceOperation of BalanceOperation * (Result<unit, DataRelatedError> -> Program<'a>)
        | Stop of 'a

(*{-
This bind function allows you to pass a continuation 
for current node of your expression tree
the code is basically a boiler plate, as you can see.
-}*)
    let rec bind f instruction =
        match instruction with
        | GetCard (x, next) -> GetCard (x, (next >> bind f))
        | GetCardWithAccountInfo (x, next) -> GetCardWithAccountInfo (x, (next >> bind f))
        | CreateCard (x, next) -> CreateCard (x, (next >> bind f))
        | ReplaceCard (x, next) -> ReplaceCard (x, (next >> bind f))
        | GetUser (x, next) -> GetUser (x,(next >> bind f))
        | CreateUser (x, next) -> CreateUser (x,(next >> bind f))
        | GetBalanceOperations (x, next) -> GetBalanceOperations (x,(next >> bind f))
        | SaveBalanceOperation (x, next) -> SaveBalanceOperation (x,(next >> bind f))
        | Stop x -> f x

(*{-
This is a set of basic functions.
Use them in your expression tree builder to represent dependency call
-}*)
    let stop x = Stop x
    let getCardByNumber number = GetCard (number, stop)
    let getCardWithAccountInfo number = GetCardWithAccountInfo (number, stop)
    let createNewCard (card, acc) = CreateCard ((card, acc), stop)
    let replaceCard card = ReplaceCard (card, stop)
    let getUserById id = GetUser (id, stop)
    let createNewUser user = CreateUser (user, stop)
    let getBalanceOperations (number, fromDate, toDate) =
         GetBalanceOperations ((number, fromDate, toDate), stop)
    let saveBalanceOperation op = SaveBalanceOperation (op, stop)

With a help of computation expressions, we now have a very easy way to build our workflows without having to care about implementation of real-world interactions. We do that in CardWorkflow module:

借助计算表达式 ,我们现在有了一种非常简单的方法来构建工作流,而不必关心实际交互的实现。 我们在CardWorkflow模块中做到这一点

(*{-
`program` is the name of our computation expression.
In every `let!` binding we unwrap the result of operation, which can be
either `Program<'a>` or `Program<Result<'a, Error>>`.
What we unwrap would be of type 'a.
If, however, an operation returns `Error`,
we stop the execution at this very step and return it.
The only thing we have to take care of is making sure
that type of error is the same in every operation we call
-}*)
let processPayment (currentDate: DateTimeOffset, payment) =
    program {
        (*{- You can see these `expectValidationError` and `expectDataRelatedErrors` functions here.
            What they do is map different errors into `Error` type, since every execution branch
            must return the same type, in this case `Result<'a, Error>`.
            They also help you quickly understand what's going on in every line of code:
            validation, logic or calling external storage. -}*)
        let! cmd = validateProcessPaymentCommand payment |> expectValidationError
        let! card = tryGetCard cmd.CardNumber
        let today = currentDate.Date |> DateTimeOffset
        let tomorrow = currentDate.Date.AddDays 1. |> DateTimeOffset
        let! operations = getBalanceOperations (cmd.CardNumber, today, tomorrow)
        let spentToday =
            BalanceOperation.spentAtDate currentDate cmd.CardNumber operations
        let! (card, op) =
            CardActions.processPayment currentDate spentToday card cmd.PaymentAmount
            |> expectOperationNotAllowedError
        do! saveBalanceOperation op |> expectDataRelatedErrorProgram
        do! replaceCard card |> expectDataRelatedErrorProgram
        return card |> toCardInfoModel |> Ok
    }

This module is the last thing we need to implement in business layer. Also, I've done some refactoring: I moved errors and common types to Common project. About time we moved on to implementing data access layer.

该模块是我们在业务层中需要实现的最后一件事。 另外,我还进行了一些重构:我将错误和常见类型移至Common project 。 关于时间,我们继续执行数据访问层。

资料存取层 (Data access layer)

The design of entities in this layer may depend on our database or framework we use to interact with it. Therefore domain layer doesn't know anything about these entities, which means we have to take care of mapping to and from domain models in here. Which is quite convenient for consumers of our DAL API. For this application I've chosen MongoDB, not because it's a best choice for this kind of task, but because there're many examples of using SQL DBs already and I wanted to add something different. We are gonna use C# driver.

该层中实体的设计可能取决于我们用于与其交互的数据库或框架。 因此,域层对这些实体一无所知,这意味着我们必须在此处处理与域模型之间的映射。 对于我们的DAL API的使用者来说,这非常方便。 对于此应用程序,我选择了MongoDB,不是因为它是执行此类任务的最佳选择,而是因为已经有许多使用SQL DB的示例,并且我想添加一些其他内容。 我们将使用C#驱动程序。

For the most part it's gonna be pretty strait forward, the only tricky moment is with Card. When it's active it has an AccountInfo inside, when it's not it doesn't. So we have to split it in two documents: CardEntity and CardAccountInfoEntity, so that deactivating card doesn't erase information about balance and daily limit.

在大多数情况下,它会变得遥遥领先,唯一棘手的时刻是Card 。 当它处于活动状态时,它内部有一个AccountInfo ,否则,它没有。 因此,我们必须将其分为两个文档: CardEntityCardAccountInfoEntity ,以便停用卡不会删除有关余额和每日限额的信息。

Other than that we just gonna use primitive types instead of discriminated unions and types with built-in validation.

除此之外,我们将使用原始类型,而不是带有内置验证的已区分联合和类型。

There're also few things we need to take care of, since we are using C# library:

由于我们使用的是C#库,因此我们还需要处理的事情很少:

  • Convert nulls to Option<'a>

    null转换为Option<'a>

  • Catch expected exceptions and convert them to our errors and wrap it in Result<_,_>

    捕获预期的异常并将其转换为我们的错误,并将其包装在Result<_,_>

We start with CardDomainEntities module, where we define our entities:

我们从CardDomainEntities模块开始,在其中定义我们的实体:

[<CLIMutable>]
    type CardEntity =
        { [<BsonId>]
          CardNumber: string
          Name: string
          IsActive: bool
          ExpirationMonth: uint16
          ExpirationYear: uint16
          UserId: UserId }
        with
        //-- we're gonna need this in every entity for error messages
        member this.EntityId = this.CardNumber.ToString()
(*{-
we use this Id comparer quotation 
(F# alternative to C# Expression) for updating entity by id,
since for different entities identifier has different name and type
-}*)
        member this.IdComparer = <@ System.Func<_,_> (fun c -> c.CardNumber = this.CardNumber) @>

Those fields EntityId and IdComparer we are gonna use with a help of SRTP. We'll define functions that will retrieve them from any type that has those fields define, without forcing every entity to implement some interface:

我们将在SRTP的帮助下使用这些字段EntityIdIdComparer 。 我们将定义函数,这些函数将从定义了这些字段的任何类型中检索它们,而不会强制每个实体实现某些接口:

let inline (|HasEntityId|) x =
        fun () -> (^a : (member EntityId: string) x)

    let inline entityId (HasEntityId f) = f()

    let inline (|HasIdComparer|) x =
        fun () -> (^a : (member IdComparer: Quotations.Expr<Func< ^a, bool>>) x)

    //-- We need to convert F# quotations to C# expressions
    //-- which C# mongo db driver understands.
    let inline idComparer (HasIdComparer id) =
        id()
        |> LeafExpressionConverter.QuotationToExpression 
        |> unbox<Expression<Func<_,_>>>

As for null and Option thing, since we use record types, F# compiler doesn't allow using null value, neither for assigning nor for comparison. At the same time record types are just another CLR types, so technically we can and will get a null value, thanks to C# and design of this library. We can solve this in 2 ways: use AllowNullLiteral attribute, or use Unchecked.defaultof<'a>. I went for the second choice since this null situation should be localized as much as possible:

至于nullOption件事,由于我们使用记录类型,因此F#编译器不允许使用null值,无论是用于赋值还是用于比较。 同时,记录类型只是另一种CLR类型,因此在技术上,由于C#和此库的设计,我们可以并且将获得null值。 我们可以通过两种方法解决此问题:使用AllowNullLiteral属性,或使用Unchecked.defaultof<'a> 。 我选择了第二个选项,因为这种null情况应尽可能地本地化:

let isNullUnsafe (arg: 'a when 'a: not struct) =
        arg = Unchecked.defaultof<'a>

    //-- then we have this function to convert nulls to option,
    //-- therefore we limited this toxic null thing in here.
    let unsafeNullToOption a =
        if isNullUnsafe a then None else Some a

In order to deal with expected exception for duplicate key, we use Active Patterns again:

为了处理重复密钥的预期异常,我们再次使用活动模式:

//-- First we define a function which checks, whether exception is about duplicate key
    let private isDuplicateKeyException (ex: Exception) =
        ex :? MongoWriteException && (ex :?> MongoWriteException).WriteError.Category = ServerErrorCategory.DuplicateKey

    //-- Then we have to check wrapping exceptions for this
    let rec private (|DuplicateKey|_|) (ex: Exception) =
        match ex with
        | :? MongoWriteException as ex when isDuplicateKeyException ex ->
            Some ex
        | :? MongoBulkWriteException as bex when bex.InnerException
              |> isDuplicateKeyException ->
            Some (bex.InnerException :?> MongoWriteException)
        | :? AggregateException as aex when aex.InnerException
              |> isDuplicateKeyException ->
            Some (aex.InnerException :?> MongoWriteException)
        | _ -> None

    //-- And here's the usage:
    let inline private executeInsertAsync (func: 'a -> Async<unit>) arg =
        async {
            try
                do! func(arg)
                return Ok ()
            with
            | DuplicateKey ex ->
                    return EntityAlreadyExists (arg.GetType().Name, (entityId arg)) |> Error
        }

After mapping is implemented we have everything we need to assemble API for our data access layer, which looks like this:

实施映射后,我们将为数据访问层组装API所需的一切,如下所示:

//-- `MongoDb` is a type alias for `IMongoDatabase`
    let replaceUserAsync (mongoDb: MongoDb) : ReplaceUserAsync =
        fun user ->
        user |> DomainToEntityMapping.mapUserToEntity
        |> CommandRepository.replaceUserAsync mongoDb

    let getUserInfoAsync (mongoDb: MongoDb) : GetUserInfoAsync =
        fun userId ->
        async {
            let! userInfo = QueryRepository.getUserInfoAsync mongoDb userId
            return userInfo |> Option.map EntityToDomainMapping.mapUserInfoEntity
        }

The last moment I mention is when we do mapping Entity -> Domain, we have to instantiate types with built-in validation, so there can be validation errors. In this case we won't use Result<_,_> because if we've got invalid data in DB, it's a bug, not something we expect. So we just throw an exception. Other than that nothing really interesting is happening in here. The full source code of data access layer you'll find here.

我提到的最后一刻是当我们映射Entity -> Domain ,我们必须使用内置验证实例化类型,因此可能会有验证错误。 在这种情况下,我们将不使用Result<_,_>因为如果数据库中有无效数据,那是一个错误,不是我们期望的。 所以我们只是抛出一个异常。 除此之外,这里没有发生任何真正有趣的事情。 您可以在此处找到数据访问层的完整源代码。

组成,伐木及所有其他 (Composition, logging and all the rest)

As you remember, we're not gonna use DI framework, we went for interpreter pattern. If you want to know why, here's some reasons:

您还记得,我们不会使用DI框架,而是使用了解释器模式。 如果您想知道原因,则有以下几种原因:

  • IoC container operates in runtime. So until you run your program you can't know that all the dependencies are satisfied.

    IoC容器在运行时运行。 因此,在运行程序之前,您无法知道是否满足所有依赖关系。
  • It's a powerful tool which is very easy to abuse: you can do property injection, use lazy dependencies, and sometimes even some business logic can find it's way in dependency registering/resolving (yeah, I've witnessed it). All of that makes code maintaining extremely hard.

    这是一个非常容易滥用的功能强大的工具:您可以进行属性注入,使用惰性依赖项,有时甚至某些业务逻辑也可以找到依赖项注册/解析的方式(是的,我已经看到了)。 所有这些使代码维护变得异常困难。

That means we need a place for that functionality. We could place it on a top level in our Web Api, but in my opinion it's not a best choice: right now we are dealing with only 1 bounded context, but if there's more, this global place with all the interpreters for each context will become cumbersome. Besides, there's single responsibility rule, and web api project should be responsible for web, right? So we create CardManagement.Infrastructure project.

这意味着我们需要一个用于该功能的地方。 我们可以将其放置在Web Api的顶层,但我认为这不是最佳选择:目前我们只处理1个有界上下文,但是如果还有更多的话,这个具有每个上下文所有解释器的全局位置将变得笨拙。 此外,还有单一责任规则,Web api项目应该负责Web,对吗? 因此,我们创建了CardManagement.Infrastructure项目

Here we will do several things:

在这里,我们将做几件事:

  • Composing our functionality

    组成我们的功能
  • App configuration

    应用程式设定
  • Logging

    记录中

If we had more than 1 context, app configuration and log configuration should be moved to global infrastructure project, and the only thing happening in this project would be assembling API for our bounded context, but in our case this separation is not necessary.

如果我们有多个上下文,则应将应用程序配置和日志配置移到全局基础结构项目中,并且该项目中唯一发生的事情就是为我们的受限上下文组装API,但是在我们这种情况下,不需要分开。

Let's get down to composition. We've built execution trees in our domain layer, now we have to interpret them. Every node in that tree represents some dependency call, in our case a call to database. If we had a need to interact with 3rd party api, that would be in here also. So our interpreter has to know how to handle every node in that tree, which is verified in compile time, thanks to <TreatWarningsAsErrors> setting. Here's what it looks like:

让我们开始构图。 我们已经在域层中构建了执行树,现在我们必须对其进行解释。 该树中的每个节点都代表一些依赖项调用,在我们的例子中是对数据库的调用。 如果我们需要与3rd party api进行交互,那也将在此处。 因此,由于<TreatWarningsAsErrors>设置,我们的解释器必须知道如何处理该树中的每个节点,该节点在编译时已得到验证。 看起来是这样的:

(*{-
Those `bindAsync (next >> interpretCardProgram mongoDb)` work pretty simple:
we execute async function to the left of this expression, await that operation
and pass the result to the next node, after which we interpret that node as well,
until we reach the bottom of this recursion: `Stop a` node.
-}*)
    let rec private interpretCardProgram mongoDb prog =
        match prog with
        | GetCard (cardNumber, next) ->
            cardNumber |> getCardAsync mongoDb 
            |> bindAsync (next >> interpretCardProgram mongoDb)
        | GetCardWithAccountInfo (number, next) ->
            number |> getCardWithAccInfoAsync mongoDb 
            |> bindAsync (next >> interpretCardProgram mongoDb)
        | CreateCard ((card,acc), next) ->
            (card, acc) |> createCardAsync mongoDb 
            |> bindAsync (next >> interpretCardProgram mongoDb)
        | ReplaceCard (card, next) ->
            card |> replaceCardAsync mongoDb
            |> bindAsync (next >> interpretCardProgram mongoDb)
        | GetUser (id, next) ->
            getUserAsync mongoDb id 
            |> bindAsync (next >> interpretCardProgram mongoDb)
        | CreateUser (user, next) ->
            user |> createUserAsync mongoDb
            |> bindAsync (next >> interpretCardProgram mongoDb)
        | GetBalanceOperations (request, next) ->
            getBalanceOperationsAsync mongoDb request
            |> bindAsync (next >> interpretCardProgram mongoDb)
        | SaveBalanceOperation (op, next) ->
             saveBalanceOperationAsync mongoDb op 
             |> bindAsync (next >> interpretCardProgram mongoDb)
        | Stop a -> async.Return a

    let interpret prog =
        try
            let interpret = interpretCardProgram (getMongoDb())
            interpret prog
        with
        | failure -> Bug failure |> Error |> async.Return

Note that this interpreter is the place where we have this async thing. We can do another interpreter with Task or just a plain sync version of it. Now you're probably wondering, how we can cover this with unit-test, since familiar mock libraries ain't gonna help us. Well, it's easy: you have to make another interpreter. Here's what it can look like:

请注意,这个解释器是我们拥有async内容的地方。 我们可以使用Task或它的纯同步版本执行其他解释器。 现在您可能想知道,我们如何用单元测试来解决这个问题,因为熟悉的模拟库无法帮助我们。 好吧,这很容易:您必须另建一个口译员。 如下所示:

type SaveResult = Result<unit, DataRelatedError>

    type TestInterpreterConfig =
        { GetCard: Card option
          GetCardWithAccountInfo: (Card*AccountInfo) option
          CreateCard: SaveResult
          ReplaceCard: SaveResult
          GetUser: User option
          CreateUser: SaveResult
          GetBalanceOperations: BalanceOperation list
          SaveBalanceOperation: SaveResult }

    let defaultConfig =
        { GetCard = Some card
          GetUser = Some user
          GetCardWithAccountInfo = (card, accountInfo) |> Some
          CreateCard = Ok()
          GetBalanceOperations = balanceOperations
          SaveBalanceOperation = Ok()
          ReplaceCard = Ok()
          CreateUser = Ok() }

    let testInject a = fun _ -> a

    let rec interpretCardProgram config (prog: Program<'a>) =
        match prog with
        | GetCard (cardNumber, next) ->
            cardNumber |> testInject config.GetCard 
            |> (next >> interpretCardProgram config)
        | GetCardWithAccountInfo (number, next) ->
            number |> testInject config.GetCardWithAccountInfo 
            |> (next >> interpretCardProgram config)
        | CreateCard ((card,acc), next) ->
            (card, acc) |> testInject config.CreateCard 
            |> (next >> interpretCardProgram config)
        | ReplaceCard (card, next) ->
            card |> testInject config.ReplaceCard 
            |> (next >> interpretCardProgram config)
        | GetUser (id, next) ->
            id |> testInject config.GetUser 
            |> (next >> interpretCardProgram config)
        | CreateUser (user, next) ->
            user |> testInject config.CreateUser 
            |> (next >> interpretCardProgram config)
        | GetBalanceOperations (request, next) ->
            testInject config.GetBalanceOperations request 
            |> (next >> interpretCardProgram config)
        | SaveBalanceOperation (op, next) ->
            testInject config.SaveBalanceOperation op 
            |> (next >> interpretCardProgram config)
        | Stop a -> a

We've created TestInterpreterConfig which holds desired results of every operation we want to inject. You can easily change that config for every given test and then just run interpreter. This interpreter is sync, since there's no reason to bother with Task or Async.

我们创建了TestInterpreterConfig ,它保存了我们要注入的每个操作的期望结果。 您可以为每个给定的测试轻松更改该配置,然后运行解释器。 这个解释器是同步的,因为没有理由去烦扰TaskAsync

There's nothing really tricky about the logging, but you can find it in this module. The approach is that we wrap the function in logging: we log function name, parameters and log result. If result is ok, it's info, if error it's a warning and if it's a Bug then it's an error. That's pretty much it.

日志记录确实没有什么棘手的问题,但是您可以在本模块中找到它。 方法是将函数包装在日志中:记录函数名称,参数和结果。 如果结果正常,则为信息;如果错误,则为警告;如果为Bug ,则为错误。 就是这样。

One last thing is to make a facade, since we don't want to expose raw interpreter calls. Here's the whole thing:

最后一件事是制作外观,因为我们不想公开原始的解释器调用。 Here's the whole thing:

let createUser arg =
        arg |> (CardWorkflow.createUser >> CardProgramInterpreter.interpret
                    |> logifyResultAsync "CardApi.createUser")
    let createCard arg =
        arg |> (CardWorkflow.createCard >> CardProgramInterpreter.interpret 
                    |> logifyResultAsync "CardApi.createCard")
    let activateCard arg =
        arg |> (CardWorkflow.activateCard >> CardProgramInterpreter.interpret 
                    |> logifyResultAsync "CardApi.activateCard")
    let deactivateCard arg =
        arg |> (CardWorkflow.deactivateCard >> CardProgramInterpreter.interpret  
                    |> logifyResultAsync "CardApi.deactivateCard")
    let processPayment arg =
        arg |> (CardWorkflow.processPayment >> CardProgramInterpreter.interpret  
                    |> logifyResultAsync "CardApi.processPayment")
    let topUp arg =
        arg |> (CardWorkflow.topUp >> CardProgramInterpreter.interpret  
                    |> logifyResultAsync "CardApi.topUp")
    let setDailyLimit arg =
        arg |> (CardWorkflow.setDailyLimit >> CardProgramInterpreter.interpret  
                    |> logifyResultAsync "CardApi.setDailyLimit")
    let getCard arg =
        arg |> (CardWorkflow.getCard >> CardProgramInterpreter.interpret  
                    |> logifyResultAsync "CardApi.getCard")
    let getUser arg =
        arg |> (CardWorkflow.getUser >> CardProgramInterpreter.interpretSimple  
                    |> logifyResultAsync "CardApi.getUser")

All the dependencies here are injected, logging is taken care of, no exceptions is thrown — that's it. For web api I used Giraffe framework. Web project is here.

All the dependencies here are injected, logging is taken care of, no exceptions is thrown — that's it. For web api I used Giraffe framework. Web project is here .

结论 (Conclusion)

We have built an application with validation, error handling, logging, business logic — all those things you usually have in your application. The difference is this code is way more durable and easy to refactor. Note that we haven't used reflection or code generation, no exceptions, but still our code isn't verbose. It's easy to read, easy to understand and hard to break. As soon as you add another field in your model, or another case in one of our union types, the code won't compile until you update every usage. Sure it doesn't mean you're totally safe or that you don't need any kind of testing at all, it just means that you're gonna have fewer problems when you develope new features or do some refactoring. The development process will be both cheaper and more interesting, because this tool allows you to focus on your domain and business tasks, instead of drugging focus on keeping an eye out that nothing is broken.

We have built an application with validation, error handling, logging, business logic — all those things you usually have in your application. The difference is this code is way more durable and easy to refactor. Note that we haven't used reflection or code generation, no exceptions, but still our code isn't verbose. It's easy to read, easy to understand and hard to break. As soon as you add another field in your model, or another case in one of our union types, the code won't compile until you update every usage. Sure it doesn't mean you're totally safe or that you don't need any kind of testing at all, it just means that you're gonna have fewer problems when you develope new features or do some refactoring. The development process will be both cheaper and more interesting, because this tool allows you to focus on your domain and business tasks, instead of drugging focus on keeping an eye out that nothing is broken.

Another thing: I don't claim that OOP is completely useless and we don't need it, that's not true. I'm saying that we don't need it for solving every single task we have, and that a big portion of our tasks can be better solved with FP. And truth is, as always, in balance: we can't solve everything efficiently with only one tool, so a good programming language should have a decent support of both FP and OOP. And, unfortunately, a lot of most popular languages today have only lambdas and async programming from functional world.

Another thing: I don't claim that OOP is completely useless and we don't need it, that's not true. I'm saying that we don't need it for solving every single task we have, and that a big portion of our tasks can be better solved with FP. And truth is, as always, in balance: we can't solve everything efficiently with only one tool, so a good programming language should have a decent support of both FP and OOP. And, unfortunately, a lot of most popular languages today have only lambdas and async programming from functional world.

翻译自: https://habr.com/en/post/458730/

软件开发怎么对抗抄袭

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值