作者 | Anish Karandikar、Roman
译者 | 弯月,责编 | 屠敏
出品 | CSDN(ID:CSDNnews)
// First we define a type for CardNumber with private constructor
// and public factory which receives string and returns `Result`.
// 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 }
然而,这段代码纯属垃圾,它不会阻止我们同时设置Ok和Error,而且还允许我们完全忽略错误。正确的编写方式如下:
void Test(Result<int, string> result)
{
var squareResult = result.Map(x => x * x);
}
// 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"
如何设计应用程序?
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).
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"
type AccountInfo =
{ HolderId: UserId
Balance: Money
DailyLimit: DailyLimit }
type Card =
{ CardNumber: CardNumber
Name: LetterString
HolderId: UserId
Expiration: (Month * Year)
AccountDetails: CardAccountInfo }
[ ]
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
let (|Limit|Unlimited|) limit =
match limit with
| Limit dec -> Limit dec
| Unlimited -> Unlimited
type UserInfo =
{ Name: LetterString
Id: UserId
Address: Address }
type User =
{ UserInfo : UserInfo
Cards: Card list }
[]
type BalanceChange =
| Increase of increase: MoneyTransaction // another common type with validation for positive amount
| Decrease of decrease: MoneyTransaction
with
member this.ToDecimal() =
match this with
| Increase i -> i.Value
| Decrease d -> -d.Value
[]
type BalanceOperation =
{ CardNumber: CardNumber
Timestamp: DateTimeOffset
BalanceChange: BalanceChange
NewBalance: Money }
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
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)
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
// 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
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 }
}
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
let activateCard getCardAsync saveCardAsync cardNumber = ...
type Program<'a> =
| GetCard of CardNumber * (Card option -> Program<'a>)
| GetCardWithAccountInfo of CardNumber * ((Card*AccountInfo) option -> Program<'a>)
| CreateCard of (Card*AccountInfo) * (Result-> Program<' a>)
| ReplaceCard of Card * (Result-> Program<'a>)
| GetUser of UserId * (User option -> Program<'a>)
| CreateUser of UserInfo * (Result-> Program<'a>)
| GetBalanceOperations of (CardNumber * DateTimeOffset * DateTimeOffset) * (BalanceOperation list -> Program<'a>)
| SaveBalanceOperation of BalanceOperation * (Result-> 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)
// `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>`. 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
}
// 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) arg =
async {
try
do! func(arg)
return Ok ()
with
| DuplicateKey ex ->
return EntityAlreadyExists (arg.GetType().Name, (entityId arg)) |> Error
}
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")
结论
本文由 投稿者 创作,文章地址:https://blog.isoyu.com/archives/ruhezhanshengruanjiankaifadefuzaxing.html
采用知识共享署名4.0 国际许可协议进行许可。除注明转载/出处外,均为本站原创或翻译,转载前请务必署名。最后编辑时间为:8 月 13, 2019 at 08:45 下午