diff --git a/.editorconfig b/.editorconfig index 278f9bd..671120f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,14 +1,15 @@ +root = true [*] -charset = utf-8-bom +charset = utf-8 end_of_line = lf -trim_trailing_whitespace = false -insert_final_newline = false +trim_trailing_whitespace = true +insert_final_newline = true indent_style = space indent_size = 4 # Microsoft .NET properties -csharp_preferred_modifier_order = public, private, protected, internal, abstract, virtual, sealed, override, static, new, readonly, extern, unsafe, volatile, async:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion csharp_style_var_elsewhere = true:suggestion csharp_style_var_for_built_in_types = true:suggestion csharp_style_var_when_type_is_apparent = true:suggestion @@ -22,9 +23,72 @@ dotnet_style_qualification_for_field = false:suggestion dotnet_style_qualification_for_method = false:suggestion dotnet_style_qualification_for_property = false:suggestion dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +dotnet_sort_system_directives_first = true +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +csharp_prefer_simple_default_expression = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_constructors = true:silent +csharp_style_expression_bodied_operators = true:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = true:silent + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Other features +csharp_style_prefer_index_operator = false:none +csharp_style_prefer_range_operator = false:none +csharp_style_pattern_local_over_anonymous_function = false:none + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false # ReSharper properties -resharper_autodetect_indent_settings = true resharper_braces_for_for = required resharper_braces_for_foreach = required resharper_braces_for_ifelse = required @@ -45,36 +109,298 @@ resharper_keep_existing_initializer_arrangement = false resharper_keep_existing_invocation_parens_arrangement = false resharper_keep_existing_switch_expression_arrangement = false resharper_max_initializer_elements_on_line = 2 -resharper_modifiers_order = public private protected internal abstract virtual sealed override static new readonly extern unsafe volatile async resharper_place_accessorholder_attribute_on_same_line = false resharper_place_field_attribute_on_same_line = false resharper_place_linq_into_on_new_line = false resharper_place_simple_anonymousmethod_on_single_line = false resharper_place_simple_embedded_statement_on_same_line = false resharper_space_between_attribute_sections = false -resharper_use_indent_from_vs = false resharper_wrap_before_arrow_with_expressions = true resharper_wrap_before_extends_colon = true resharper_wrap_before_linq_expression = true resharper_wrap_chained_binary_expressions = chop_if_long # ReSharper inspection severities -resharper_arrange_redundant_parentheses_highlighting = hint -resharper_arrange_this_qualifier_highlighting = hint -resharper_arrange_type_member_modifiers_highlighting = hint -resharper_arrange_type_modifiers_highlighting = hint -resharper_built_in_type_reference_style_for_member_access_highlighting = hint -resharper_built_in_type_reference_style_highlighting = hint -resharper_mvc_view_component_view_not_resolved_highlighting = none -resharper_redundant_base_qualifier_highlighting = warning -resharper_suggest_var_or_type_built_in_types_highlighting = hint -resharper_suggest_var_or_type_elsewhere_highlighting = hint -resharper_suggest_var_or_type_simple_types_highlighting = hint -resharper_web_config_module_not_resolved_highlighting = warning -resharper_web_config_type_not_resolved_highlighting = warning -resharper_web_config_wrong_module_highlighting = warning - -[*.{appxmanifest,asax,ascx,aspx,axaml,build,c,c++,cc,cginc,compute,cp,cpp,cs,cshtml,cu,cuh,cxx,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,master,ml,mli,mpp,mq4,mq5,mqh,nuspec,paml,razor,resw,resx,skin,tpp,usf,ush,vb,xaml,xamlx,xoml,xsd}] -indent_style = space -indent_size = 4 -tab_width = 4 + +[*.{xml,csproj}] +indent_size = 2 +tab_width = 2 + +#### C# Coding Conventions #### +[*.cs] + +# Expression-bodied members +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent + +# Pattern matching preferences +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences + +# Modifier preferences +csharp_prefer_static_local_function = true:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = false:none + +# Expression-level preferences +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4723361 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[bug] Bug Report" +labels: bug +assignees: ikesnowy + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..f2a6f4b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,10 @@ +--- +name: Documentation +about: Request improvement for documentation +title: "[docs]" +labels: documentation +assignees: ikesnowy + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..3660ec4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[feat] Feature Request" +labels: enhancement +assignees: ikesnowy + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8c23067 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "nuget" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6697e17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test-net8: + runs-on: ubuntu-latest + container: mcr.microsoft.com/dotnet/sdk:8.0 + + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Build + run: ./build.sh net8.0 + - name: Test + run: ./test.sh net8.0 + + test-net9: + runs-on: ubuntu-latest + container: mcr.microsoft.com/dotnet/sdk:9.0 + + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Build + run: ./build.sh net9.0 + - name: Test + run: ./test.sh net9.0 + diff --git a/.github/workflows/main.yml b/.github/workflows/pack.yml similarity index 65% rename from .github/workflows/main.yml rename to .github/workflows/pack.yml index 0988ba3..124a3e3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/pack.yml @@ -10,18 +10,16 @@ jobs: environment: nuget runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-dotnet@v3.0.3 + - uses: actions/checkout@v3 + - uses: actions/setup-dotnet@v3 with: - dotnet-version: '7' + dotnet-version: '9' - name: Nuget Push env: nuget_key: ${{ secrets.NUGETAPIKEY }} run: | Version=${GITHUB_REF:10} dotnet build -c Release - sed -i "s/\(\)[^<]*\(<\/MongoAnalyzerRuleSetVersion>\)/\1$version\2/" src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer/build/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer.props - dotnet pack Cnblogs.Architecture.sln -p:Version="${Version:1}" -c Release -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg --include-source -o "output" + sed -i "s/\(\)[^<]*\(<\/MongoAnalyzerRuleSetVersion>\)/\1${Version:1}\2/" src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer/build/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer.props + dotnet pack Cnblogs.Architecture.sln -p:Version="${Version:1}" -c Release -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg --include-source --property:PackageOutputPath=../../output dotnet nuget push ./output/*.* -s https://api.nuget.org/v3/index.json -k $nuget_key --skip-duplicate - - diff --git a/Cnblogs.Architecture.sln b/Cnblogs.Architecture.sln index 560d570..520af13 100644 --- a/Cnblogs.Architecture.sln +++ b/Cnblogs.Architecture.sln @@ -28,8 +28,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Cq EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Infrastructure.RedLock", "src\Cnblogs.Architecture.Ddd.Infrastructure.RedLock\Cnblogs.Architecture.Ddd.Infrastructure.RedLock.csproj", "{98B77844-CBA5-4E57-96B1-AD538788EA6D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock", "src\Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock\Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock.csproj", "{AAF07CE2-7124-4A19-BA2B-361506FA54CC}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.EventBus.Abstractions", "src\Cnblogs.Architecture.Ddd.EventBus.Abstractions\Cnblogs.Architecture.Ddd.EventBus.Abstractions.csproj", "{D4894BA9-BFFB-4731-A12D-C742C8308F8C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.EventBus.Dapr", "src\Cnblogs.Architecture.Ddd.EventBus.Dapr\Cnblogs.Architecture.Ddd.EventBus.Dapr.csproj", "{5B20CD42-148C-440E-A961-9909CC1517E7}" @@ -42,12 +40,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Integr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory", "src\Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory\Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory.csproj", "{749EA5B9-69BE-44E0-802D-8BEAE2EA5E77}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory", "src\Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory\Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory.csproj", "{ACE6DB92-C943-4D99-909E-DCEC551E4394}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer", "src\Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer\Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer.csproj", "{AC1BB624-1B37-4A21-9CA4-FB79E4D30C43}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr", "src\Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr\Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr.csproj", "{5AE74304-8F14-4CF7-9BA7-89AB345AFE29}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent", "src\Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent\Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj", "{F2B0D83B-9A90-4FA4-A407-3B0708731903}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.TestIntegrationEvents", "test\Cnblogs.Architecture.TestIntegrationEvents\Cnblogs.Architecture.TestIntegrationEvents.csproj", "{FCAE0DFB-1585-4ABA-A6FC-6A78311E3FE6}" @@ -62,6 +56,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.In EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.TestShared", "test\Cnblogs.Architecture.TestShared\Cnblogs.Architecture.TestShared.csproj", "{3B22F0CC-9A61-4D95-8ED9-F41B7FCBFC6F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse", "src\Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse\Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse.csproj", "{73665E32-3D10-4F71-B893-4C65F36332D0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse", "src\Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse\Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse.csproj", "{4BD98FBF-FB98-4172-B352-BB7BF8761FCB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss", "src\Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss\Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss.csproj", "{9C76E136-1D79-408C-A17F-FD63632B00A9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis", "src\Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis\Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis.csproj", "{1FF58B65-6C83-4F0C-909A-6606B4C754B8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -77,16 +79,13 @@ Global {92E2453C-2A6A-43E2-978C-C28E9025D0D9} = {772497F8-2CB1-4EA6-AEB8-482C3ECD0A9D} {870A738A-19CD-43D9-986A-D696263E3254} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {98B77844-CBA5-4E57-96B1-AD538788EA6D} = {D3A6DF01-017E-4088-936C-B3791F41DF53} - {AAF07CE2-7124-4A19-BA2B-361506FA54CC} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {D4894BA9-BFFB-4731-A12D-C742C8308F8C} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {5B20CD42-148C-440E-A961-9909CC1517E7} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {A9983E36-786D-4273-98EA-DD5FB410C206} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {0114DFFF-47DB-4D01-A88F-8812672A5B4D} = {772497F8-2CB1-4EA6-AEB8-482C3ECD0A9D} {F150EBB2-8237-4C68-B2DC-B86E6A9F7444} = {772497F8-2CB1-4EA6-AEB8-482C3ECD0A9D} {749EA5B9-69BE-44E0-802D-8BEAE2EA5E77} = {D3A6DF01-017E-4088-936C-B3791F41DF53} - {ACE6DB92-C943-4D99-909E-DCEC551E4394} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {AC1BB624-1B37-4A21-9CA4-FB79E4D30C43} = {D3A6DF01-017E-4088-936C-B3791F41DF53} - {5AE74304-8F14-4CF7-9BA7-89AB345AFE29} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {F2B0D83B-9A90-4FA4-A407-3B0708731903} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {FCAE0DFB-1585-4ABA-A6FC-6A78311E3FE6} = {772497F8-2CB1-4EA6-AEB8-482C3ECD0A9D} {2D8FE8C4-EA9A-4232-8767-6FFC68C87816} = {D3A6DF01-017E-4088-936C-B3791F41DF53} @@ -94,6 +93,10 @@ Global {E7ABC399-AF71-46A6-A4D4-A38972BC7D50} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {A6A8FDC5-20E7-4776-9CB6-A2E43DCCBE7B} = {D3A6DF01-017E-4088-936C-B3791F41DF53} {3B22F0CC-9A61-4D95-8ED9-F41B7FCBFC6F} = {772497F8-2CB1-4EA6-AEB8-482C3ECD0A9D} + {73665E32-3D10-4F71-B893-4C65F36332D0} = {D3A6DF01-017E-4088-936C-B3791F41DF53} + {4BD98FBF-FB98-4172-B352-BB7BF8761FCB} = {D3A6DF01-017E-4088-936C-B3791F41DF53} + {9C76E136-1D79-408C-A17F-FD63632B00A9} = {D3A6DF01-017E-4088-936C-B3791F41DF53} + {1FF58B65-6C83-4F0C-909A-6606B4C754B8} = {D3A6DF01-017E-4088-936C-B3791F41DF53} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {54D9D850-1CFC-485E-97FE-87F41C220523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -132,10 +135,6 @@ Global {98B77844-CBA5-4E57-96B1-AD538788EA6D}.Debug|Any CPU.Build.0 = Debug|Any CPU {98B77844-CBA5-4E57-96B1-AD538788EA6D}.Release|Any CPU.ActiveCfg = Release|Any CPU {98B77844-CBA5-4E57-96B1-AD538788EA6D}.Release|Any CPU.Build.0 = Release|Any CPU - {AAF07CE2-7124-4A19-BA2B-361506FA54CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AAF07CE2-7124-4A19-BA2B-361506FA54CC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AAF07CE2-7124-4A19-BA2B-361506FA54CC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AAF07CE2-7124-4A19-BA2B-361506FA54CC}.Release|Any CPU.Build.0 = Release|Any CPU {D4894BA9-BFFB-4731-A12D-C742C8308F8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D4894BA9-BFFB-4731-A12D-C742C8308F8C}.Debug|Any CPU.Build.0 = Debug|Any CPU {D4894BA9-BFFB-4731-A12D-C742C8308F8C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -160,18 +159,10 @@ Global {749EA5B9-69BE-44E0-802D-8BEAE2EA5E77}.Debug|Any CPU.Build.0 = Debug|Any CPU {749EA5B9-69BE-44E0-802D-8BEAE2EA5E77}.Release|Any CPU.ActiveCfg = Release|Any CPU {749EA5B9-69BE-44E0-802D-8BEAE2EA5E77}.Release|Any CPU.Build.0 = Release|Any CPU - {ACE6DB92-C943-4D99-909E-DCEC551E4394}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {ACE6DB92-C943-4D99-909E-DCEC551E4394}.Debug|Any CPU.Build.0 = Debug|Any CPU - {ACE6DB92-C943-4D99-909E-DCEC551E4394}.Release|Any CPU.ActiveCfg = Release|Any CPU - {ACE6DB92-C943-4D99-909E-DCEC551E4394}.Release|Any CPU.Build.0 = Release|Any CPU {AC1BB624-1B37-4A21-9CA4-FB79E4D30C43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AC1BB624-1B37-4A21-9CA4-FB79E4D30C43}.Debug|Any CPU.Build.0 = Debug|Any CPU {AC1BB624-1B37-4A21-9CA4-FB79E4D30C43}.Release|Any CPU.ActiveCfg = Release|Any CPU {AC1BB624-1B37-4A21-9CA4-FB79E4D30C43}.Release|Any CPU.Build.0 = Release|Any CPU - {5AE74304-8F14-4CF7-9BA7-89AB345AFE29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5AE74304-8F14-4CF7-9BA7-89AB345AFE29}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5AE74304-8F14-4CF7-9BA7-89AB345AFE29}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5AE74304-8F14-4CF7-9BA7-89AB345AFE29}.Release|Any CPU.Build.0 = Release|Any CPU {F2B0D83B-9A90-4FA4-A407-3B0708731903}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F2B0D83B-9A90-4FA4-A407-3B0708731903}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2B0D83B-9A90-4FA4-A407-3B0708731903}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -200,5 +191,21 @@ Global {3B22F0CC-9A61-4D95-8ED9-F41B7FCBFC6F}.Debug|Any CPU.Build.0 = Debug|Any CPU {3B22F0CC-9A61-4D95-8ED9-F41B7FCBFC6F}.Release|Any CPU.ActiveCfg = Release|Any CPU {3B22F0CC-9A61-4D95-8ED9-F41B7FCBFC6F}.Release|Any CPU.Build.0 = Release|Any CPU + {73665E32-3D10-4F71-B893-4C65F36332D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73665E32-3D10-4F71-B893-4C65F36332D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73665E32-3D10-4F71-B893-4C65F36332D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73665E32-3D10-4F71-B893-4C65F36332D0}.Release|Any CPU.Build.0 = Release|Any CPU + {4BD98FBF-FB98-4172-B352-BB7BF8761FCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BD98FBF-FB98-4172-B352-BB7BF8761FCB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BD98FBF-FB98-4172-B352-BB7BF8761FCB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BD98FBF-FB98-4172-B352-BB7BF8761FCB}.Release|Any CPU.Build.0 = Release|Any CPU + {9C76E136-1D79-408C-A17F-FD63632B00A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C76E136-1D79-408C-A17F-FD63632B00A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C76E136-1D79-408C-A17F-FD63632B00A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C76E136-1D79-408C-A17F-FD63632B00A9}.Release|Any CPU.Build.0 = Release|Any CPU + {1FF58B65-6C83-4F0C-909A-6606B4C754B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FF58B65-6C83-4F0C-909A-6606B4C754B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FF58B65-6C83-4F0C-909A-6606B4C754B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FF58B65-6C83-4F0C-909A-6606B4C754B8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Cnblogs.Architecture.sln.DotSettings b/Cnblogs.Architecture.sln.DotSettings new file mode 100644 index 0000000..67e92cc --- /dev/null +++ b/Cnblogs.Architecture.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index a113f46..5b2e9d9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,16 +1,19 @@ - + - net7.0 + net8.0;net9.0 enable enable Cnblogs Cnblogs.Architecture https://github.com/cnblogs/Architecture + MIT + https://github.com/cnblogs/Architecture + git - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6908b50 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e +dotnet restore -p:TargetFramework="$1" +dotnet build -c Release -p:TargetFramework="$1" --no-restore diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheBehavior.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheBehavior.cs index ad01237..03e01b6 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheBehavior.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheBehavior.cs @@ -1,17 +1,17 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 缓存行为定义。 +/// Options for handing . /// public enum CacheBehavior { /// - /// 不存在时获取新的。 + /// Update cache after cache missed, this is the default behavior. /// UpdateCacheIfMiss = 1, /// - /// 不使用缓存。 + /// Do not cache this request. /// DisabledCache = 2 } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheableRequestBehavior.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheableRequestBehavior.cs index 9668ff0..20f286a 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheableRequestBehavior.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheableRequestBehavior.cs @@ -9,12 +9,12 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 对实现了 的请求进行处理。 +/// Handler for . /// -/// 实现了 的请求。 -/// 请求的结果。 +/// Request that implements . +/// Cached result for . public class CacheableRequestBehavior : IPipelineBehavior - where TRequest : ICacheableRequest, IRequest + where TRequest : ICachableRequest, IRequest { private readonly IDateTimeProvider _dateTimeProvider; private readonly ILocalCacheProvider? _local; @@ -23,12 +23,12 @@ public class CacheableRequestBehavior : IPipelineBehavior> _logger; /// - /// 构建一个 。 + /// Create . /// - /// 缓存提供器。 - /// 时间提供器。 - /// 缓存配置项。 - /// 日志记录器。 + /// Cache providers. + /// Datetime provider. + /// Options for cache behavior. + /// logger. public CacheableRequestBehavior( IEnumerable providers, IDateTimeProvider dateTimeProvider, @@ -71,7 +71,7 @@ public async Task Handle( }) { // cache disabled - return await next(); + return await next(cancellationToken); } CacheEntry? result = null; @@ -92,7 +92,7 @@ public async Task Handle( return result.Value; } - result = new CacheEntry(await next(), _dateTimeProvider.Now().ToUnixTimeSeconds()); + result = new CacheEntry(await next(cancellationToken), _dateTimeProvider.Now().ToUnixTimeSeconds()); if (request.LocalCacheBehavior is CacheBehavior.UpdateCacheIfMiss) { @@ -197,4 +197,4 @@ private async Task UpdateCacheEntryAsync( } } } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheableRequestOptions.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheableRequestOptions.cs index 8bb9332..c2bdf1c 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheableRequestOptions.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CacheableRequestOptions.cs @@ -1,22 +1,22 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 缓存配置。 +/// Options for handling . /// public class CacheableRequestOptions { /// - /// 如果获取失败抛出异常。 + /// Rethrow exception if getting cached result failed. /// public bool ThrowIfFailedOnGet { get; set; } /// - /// 如果更新失败则抛出异常。 + /// Rethrow exception if updating cache failed. /// public bool ThrowIfFailedOnUpdate { get; set; } /// - /// 如果清除缓存失败则抛出异常,可能被 中的 覆盖。 + /// Rethrow exception if removing cache failed, this option can be overriden by for specific type of request. /// public bool ThrowIfFailedOnRemove { get; set; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/Cnblogs.Architecture.Ddd.Cqrs.Abstractions.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/Cnblogs.Architecture.Ddd.Cqrs.Abstractions.csproj index ec87da7..e51bb1b 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/Cnblogs.Architecture.Ddd.Cqrs.Abstractions.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/Cnblogs.Architecture.Ddd.Cqrs.Abstractions.csproj @@ -1,13 +1,32 @@ - - - - - - - - - + + + Provides building blocks to archive CQRS pattern. + Commonly used types: + Cnblogs.Architecture.Ddd.Cqrs.Abstractions.ICommand + Cnblogs.Architecture.Ddd.Cqrs.Abstractions.IQuery + Cnblogs.Architecture.Ddd.Cqrs.Abstractions.IPageableQuery + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CommandResponse.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CommandResponse.cs index e67c1f5..614059b 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CommandResponse.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/CommandResponse.cs @@ -3,56 +3,59 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 命令返回的结果。 +/// Response returned by . /// -public abstract record CommandResponse : IValidationResponse, ILockableResponse +public record CommandResponse : IValidationResponse, ILockableResponse { /// - /// 是否出现验证错误。 + /// Check if validation fails. /// public bool IsValidationError { get; init; } /// - /// 是否出现并发错误。 + /// Check if concurrent error happened. /// public bool IsConcurrentError { get; init; } /// - /// 错误信息。 + /// The error message returned by handler, return empty if no error or no error message. /// + /// + /// Do not rely on this property to determine if executed successful, use for this purpose. + /// public string ErrorMessage { get; init; } = string.Empty; /// - public ValidationError? ValidationError { get; init; } + public ValidationErrors ValidationErrors { get; init; } = new(); /// public bool LockAcquired { get; set; } /// - /// 执行是否成功。 + /// Check if command executed successfully. /// - /// + /// Return true if executed successfully, else return false. public virtual bool IsSuccess() { return IsValidationError == false && string.IsNullOrEmpty(ErrorMessage) && IsConcurrentError == false; } /// - /// 获取错误信息。 + /// Get error message. /// - /// + /// The error message, return if no error. public virtual string GetErrorMessage() => ErrorMessage; } /// -/// 命令返回的结果。 +/// Response returned by . /// -/// 错误枚举类型。 +/// The enumeration presenting errors. public record CommandResponse : CommandResponse where TError : Enumeration { /// - /// 构造一个 。 + /// Create a successful . /// public CommandResponse() { @@ -60,24 +63,25 @@ public CommandResponse() } /// - /// 构造一个 。 + /// Create a with given error. /// - /// 错误码。 + /// The error. public CommandResponse(TError errorCode) { ErrorCode = errorCode; + ErrorMessage = errorCode.Name; } /// - /// 错误码。 + /// The error returned by handler, can be null if execution succeeded. /// public TError? ErrorCode { get; set; } /// - /// 构造一个代表命令执行失败的 + /// Create a failed with given error. /// - /// 错误码。 - /// 代表命令执行失败的 + /// The error. + /// A failed with given error. public static CommandResponse Fail(TError errorCode) { return new CommandResponse(errorCode); @@ -96,9 +100,9 @@ public override string GetErrorMessage() } /// - /// 构造一个代表命令执行成功的 。 + /// Create a successful . /// - /// 代表命令执行成功的 + /// A successful . public static CommandResponse Success() { return new CommandResponse(); @@ -106,70 +110,74 @@ public static CommandResponse Success() } /// -/// 命令返回的结果。 +/// Response returned by . /// -/// 命令执行成功时返回的结果类型。 -/// 错误类型。 +/// The model type been returned if execution completed without error. +/// The enumeration type representing errors. public record CommandResponse : CommandResponse, IObjectResponse where TError : Enumeration { /// - /// 构造一个 。 + /// Create a . /// public CommandResponse() { } /// - /// 构造一个 。 + /// Create a with given error. /// - /// 错误码。 + /// The error. public CommandResponse(TError errorCode) : base(errorCode) { } /// - /// 构造一个 。 + /// Create a with given model. /// - /// 命令返回结果。 + /// The execution result. private CommandResponse(TView response) { Response = response; } /// - /// 命令执行结果。 + /// The result been returned by command handler. /// - public TView? Response { get; } + /// + /// This property can be null even if execution completed with no error. + /// + // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global + public TView? Response { get; init; } /// - /// 构造一个代表执行失败的 。 + /// Create a with given error. /// - /// 错误码。 - /// + /// The error. + /// A with given error. public static new CommandResponse Fail(TError errorCode) { return new CommandResponse(errorCode); } /// - /// 构造一个代表执行成功的 。 + /// Create a with no result nor error. /// - /// 代表执行成功的 + /// The public static new CommandResponse Success() { return new CommandResponse(); } /// - /// 构造一个代表执行成功的 。 + /// Create a with given result. /// - /// 执行结果。 - /// - public static CommandResponse Success(TView view) + /// The model to return. + /// A with given result. + public static CommandResponse Success(TView? view) { - return new CommandResponse(view); + return view is null ? Success() : new CommandResponse(view); } /// @@ -177,4 +185,4 @@ public static CommandResponse Success(TView view) { return Response; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICachableRequest.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICachableRequest.cs new file mode 100644 index 0000000..d805bd0 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICachableRequest.cs @@ -0,0 +1,48 @@ +namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; + +/// +/// Definition for cachable request. +/// +public interface ICachableRequest +{ + /// + /// Configuration for local cache provider. + /// + CacheBehavior LocalCacheBehavior { get; } + + /// + /// Configuration for remote cache provider. + /// + CacheBehavior RemoteCacheBehavior { get; } + + /// + /// The expire time for local cache. + /// + TimeSpan? LocalExpires { get; } + + /// + /// The expire time for remote cache. + /// + TimeSpan? RemoteExpires { get; } + + /// + /// Generate key for cache group, return null for no group. + /// + /// + string? CacheGroupKey(); + + /// + /// Generate cache key for each request. + /// + /// The cache key for current request. + string CacheKey() + { + return string.Join('-', GetCacheKeyParameters().Select(p => p?.ToString()?.ToLower())); + } + + /// + /// Get parameters for generating cache key, will call to each object been provided. + /// + /// The parameter array. + object?[] GetCacheKeyParameters(); +} \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICacheableRequest.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICacheableRequest.cs deleted file mode 100644 index 6a22cf5..0000000 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICacheableRequest.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; - -/// -/// 定义可缓存的请求 -/// -public interface ICacheableRequest -{ - /// - /// 本地缓存配置。 - /// - CacheBehavior LocalCacheBehavior { get; set; } - - /// - /// 远程缓存配置。 - /// - CacheBehavior RemoteCacheBehavior { get; set; } - - /// - /// 本地缓存过期时间。 - /// - TimeSpan? LocalExpires { get; set; } - - /// - /// 远程缓存过期时间。 - /// - TimeSpan? RemoteExpires { get; set; } - - /// - /// 获取缓存分组键,null 代表不分组。 - /// - /// - string? CacheGroupKey(); - - /// - /// 获取缓存键。 - /// - /// - string CacheKey() - { - return string.Join('-', GetCacheKeyParameters().Select(p => p?.ToString()?.ToLower())); - } - - /// - /// 获取组成缓存键的参数。 - /// - /// - object?[] GetCacheKeyParameters(); -} \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommand.Generic.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommand.Generic.cs index a33dfd3..03ede81 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommand.Generic.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommand.Generic.cs @@ -5,15 +5,18 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义 CQRS 中的命令相关的行为。 +/// Definitions of command in CQRS. /// -/// 命令执行成功时返回的结果。 -/// 命令失败时返回的错误码类型。 +/// The result type for command. +/// The error code type when command failed. public interface ICommand : IRequest> where TError : Enumeration { /// - /// 命令是否只进行验证。 + /// Only execute validation logic. /// + /// + /// This logic must be implemented manually in command handler and not guaranteed by framework. + /// public bool ValidateOnly { get; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommand.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommand.cs index d26aa85..fe5f0c3 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommand.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommand.cs @@ -5,14 +5,17 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义 CQRS 中的命令相关的行为。 +/// Definition for command. /// -/// 命令失败时返回的错误码类型。 +/// The error type when command execution failed. public interface ICommand : IRequest> where TError : Enumeration { /// - /// 命令是否只执行验证。 + /// Only execute validation logic. /// + /// + /// This logic must be implemented manually in command handler and not guaranteed by framework. + /// public bool ValidateOnly { get; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommandHandler.Generic.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommandHandler.Generic.cs index 4017321..a1005ea 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommandHandler.Generic.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommandHandler.Generic.cs @@ -5,13 +5,11 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义 的实际处理逻辑。 +/// Definitions of handler that handles 。 /// -/// 该 Handler 能够处理的命令类型。 -/// 命令返回的结果类型。 -/// 该 Handler 返回的错误码类型。 +/// The command type for this handler. +/// The result type for this handler. +/// The error type for this handler. public interface ICommandHandler : IRequestHandler> where TCommand : ICommand - where TError : Enumeration -{ -} \ No newline at end of file + where TError : Enumeration; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommandHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommandHandler.cs index a18f28e..34bc3a7 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommandHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ICommandHandler.cs @@ -5,12 +5,10 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义 的实际处理逻辑。 +/// Definitions of handler that handles . /// -/// 该 Handler 能够处理的命令类型。 -/// 该 Handler 返回的错误码类型。 +/// The command type for this handler. +/// The error type for this handler. public interface ICommandHandler : IRequestHandler> where TCommand : ICommand - where TError : Enumeration -{ -} \ No newline at end of file + where TError : Enumeration; diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IConfigurableLockableRequest.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IConfigurableLockableRequest.cs index 5e4e8a8..0d4bbf9 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IConfigurableLockableRequest.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IConfigurableLockableRequest.cs @@ -1,12 +1,12 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 可配置的分布式锁请求。 +/// Definitions of a with some configurations. /// public interface IConfigurableLockableRequest : ILockableRequest { /// - /// 锁过期时间。 + /// The maximum waiting time for requiring a lock. /// TimeSpan ExpiresIn { get; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IDomainEventHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IDomainEventHandler.cs index 4722e78..07b6821 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IDomainEventHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IDomainEventHandler.cs @@ -5,10 +5,8 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 领域事件处理器。 +/// Definitions of handler for . /// -/// 要订阅的领域事件。 +/// The domain event type for this handler to handle. public interface IDomainEventHandler : INotificationHandler - where TDomainEvent : DomainEvent -{ -} \ No newline at end of file + where TDomainEvent : DomainEvent; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IListQuery.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IListQuery.cs index 2a9882a..78bb192 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IListQuery.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IListQuery.cs @@ -3,9 +3,7 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义返回多个结果的查询。 +/// Represents a query returns a list of items. /// -/// 查询结果类型,通常是一个列表类型。 -public interface IListQuery : IRequest -{ -} \ No newline at end of file +/// The list to return, usually a . +public interface IListQuery : IRequest; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IListQueryHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IListQueryHandler.cs index 55cc1f7..5f6f3f9 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IListQueryHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IListQueryHandler.cs @@ -3,11 +3,9 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义 的处理逻辑。 +/// Represents a handler for . /// -/// 该 Handler 能够处理的 类型。 -/// 查询结果类型。 +/// The been handled. +/// The result type of . public interface IListQueryHandler : IRequestHandler - where TQuery : IListQuery -{ -} \ No newline at end of file + where TQuery : IListQuery; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ILockableRequest.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ILockableRequest.cs index aebc2e4..f100b05 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ILockableRequest.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ILockableRequest.cs @@ -1,13 +1,13 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义需要分布式锁的请求 +/// Represents a request that needs distributed locks. /// public interface ILockableRequest { /// - /// 获取锁的 Key。 + /// Get the key of distributed lock. /// - /// + /// The key of distributed lock. string GetLockKey(); } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ILockableResponse.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ILockableResponse.cs index ffaefd2..ea46f6e 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ILockableResponse.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ILockableResponse.cs @@ -1,17 +1,17 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 使用分布式锁后的响应。 +/// Represents response for . /// public interface ILockableResponse { /// - /// 是否出现并发错误(获取不到锁) + /// Indicates whether lock was required successfully. /// bool IsConcurrentError { get; init; } /// - /// 是否成功获取到锁。 + /// Indicates whether lock was required. /// bool LockAcquired { get; set; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IObjectResponse.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IObjectResponse.cs index ebb5cdc..977e027 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IObjectResponse.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IObjectResponse.cs @@ -1,13 +1,13 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 包含结果对象的响应。 +/// Represents response that contains object result. /// public interface IObjectResponse { /// - /// 获取结果。 + /// Get object result. /// - /// 结果。 + /// The resulting object. public object? GetResult(); } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IOrderedQuery.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IOrderedQuery.cs index ae81c9c..d017d68 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IOrderedQuery.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IOrderedQuery.cs @@ -1,13 +1,14 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义返回已排序的多个结果的查询。 +/// Represents a with ordered results. /// -/// 查询结果类型,通常是列表类型。 +/// The querying type, usually a list type. public interface IOrderedQuery : IListQuery { /// - /// 排序字符串。 + /// The string indicates the order. /// + /// Order by string can be like "-dateadded"(order by dateadded desc) or "id"(order by id asc). string? OrderByString { get; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IPageableQuery.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IPageableQuery.cs index e057140..6a2c88b 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IPageableQuery.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IPageableQuery.cs @@ -3,13 +3,13 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义返回分页结果的查询类型。 +/// Represents a with paged results. /// -/// 单个查询结果的类型。 +/// The type for each item in results. public interface IPageableQuery : IOrderedQuery> { /// - /// 分页参数。 + /// The paging parameters, include page index and page size. /// PagingParams? PagingParams { get; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IPageableQueryHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IPageableQueryHandler.cs index 5f5f154..7a4b37a 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IPageableQueryHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IPageableQueryHandler.cs @@ -3,11 +3,9 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义处理分页查询的逻辑。 +/// Represents the handler for . /// -/// 查询类型,需要继承 -/// 单个查询结果类型,将返回 IPagedList<TView>。 +/// The to handle. +/// The type for each item in . public interface IPageableQueryHandler : IListQueryHandler> - where TQuery : IPageableQuery -{ -} \ No newline at end of file + where TQuery : IPageableQuery; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IQuery.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IQuery.cs index 1ba6434..0df4e00 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IQuery.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IQuery.cs @@ -3,9 +3,7 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义获取单个结果的查询。 +/// Represents query for single item. /// -/// 结果类型。 -public interface IQuery : IRequest -{ -} \ No newline at end of file +/// The type of item to query. +public interface IQuery : IRequest; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IQueryHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IQueryHandler.cs index 2b6ea41..dc16e4e 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IQueryHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IQueryHandler.cs @@ -3,11 +3,9 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义处理 的逻辑。 +/// Represents handler that handles . /// -/// 查询类型,需要继承 -/// 结果类型。 +/// The type to handle. +/// The type of item to query. public interface IQueryHandler : IRequestHandler - where TQuery : IQuery -{ -} \ No newline at end of file + where TQuery : IQuery; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IValidatable.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IValidatable.cs index af66da1..96fc360 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IValidatable.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IValidatable.cs @@ -1,12 +1,13 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 定义可验证的类型。 +/// Represents a request that can be validated. /// public interface IValidatable { /// - /// 验证方法,出错时返回 ,否则返回 null。 + /// Validate the object, validate will pass if is empty. /// - ValidationError? Validate(); -} \ No newline at end of file + /// The validation error collection. + void Validate(ValidationErrors validationErrors); +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IValidationResponse.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IValidationResponse.cs index 2b1bb37..98c2ed9 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IValidationResponse.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/IValidationResponse.cs @@ -1,22 +1,22 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 的返回类型。 +/// Represents response for . /// public interface IValidationResponse { /// - /// 验证是否失败。 + /// Indicates whether validation is failed. /// bool IsValidationError { get; init; } /// - /// 错误信息。 + /// Contain error message if validation fails. /// string ErrorMessage { get; init; } /// - /// 错误信息对象。 + /// The validation results, empty if validation was passed. /// - ValidationError? ValidationError { get; init; } + ValidationErrors ValidationErrors { get; init; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/InvalidCacheRequest.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/InvalidCacheRequest.cs index 1b89624..e7037d7 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/InvalidCacheRequest.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/InvalidCacheRequest.cs @@ -3,9 +3,12 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 清除缓存请求。 +/// Represents request to invalid caches. /// +/// The request that been cached. +/// Invalid cache for the group that was in. +/// Throw exceptions if fails, overrides same options in . public record InvalidCacheRequest( - ICacheableRequest Request, + ICachableRequest Request, bool InvalidWholeGroup = false, bool? ThrowIfFailed = null) : IRequest; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/InvalidCacheRequestHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/InvalidCacheRequestHandler.cs index dd963ed..dd89c6f 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/InvalidCacheRequestHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/InvalidCacheRequestHandler.cs @@ -9,7 +9,7 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 清除缓存。 +/// The default handler for . /// public class InvalidCacheRequestHandler : IRequestHandler { @@ -20,12 +20,12 @@ public class InvalidCacheRequestHandler : IRequestHandler private readonly ILogger _logger; /// - /// 构建一个 。 + /// Create a . /// - /// 缓存提供器。 - /// 时间提供器。 - /// 缓存配置。 - /// 日志记录器。 + /// Cache providers. + /// Datetime providers. + /// Cache options. + /// log provider. public InvalidCacheRequestHandler( IEnumerable providers, IDateTimeProvider dateTimeProvider, @@ -56,7 +56,7 @@ public InvalidCacheRequestHandler( } /// - public async Task Handle(InvalidCacheRequest request, CancellationToken cancellationToken) + public async Task Handle(InvalidCacheRequest request, CancellationToken cancellationToken) { try { @@ -94,7 +94,5 @@ public async Task Handle(InvalidCacheRequest request, CancellationToken ca throw; } } - - return Unit.Value; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/LockableRequestBehavior.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/LockableRequestBehavior.cs index f041178..0662600 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/LockableRequestBehavior.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/LockableRequestBehavior.cs @@ -7,10 +7,10 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 处理需要分布式锁的请求。 +/// Handle requests that require distributed lock. /// -/// 请求类型。 -/// 响应类型。 +/// The type of request. +/// The type of response. public class LockableRequestBehavior : IPipelineBehavior where TRequest : ILockableRequest, IRequest where TResponse : ILockableResponse, new() @@ -19,10 +19,10 @@ public class LockableRequestBehavior : IPipelineBehavior> _logger; /// - /// 创建一个新的 实例。 + /// Create a new instance. /// - /// 分布式锁提供器。 - /// 日志记录器。 + /// Distributed lock provider. + /// log provider. public LockableRequestBehavior( IDistributedLockProvider distributedLockProvider, ILogger> logger) diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/LoggingBehavior.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/LoggingBehavior.cs index 14de343..c0fbf50 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/LoggingBehavior.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/LoggingBehavior.cs @@ -5,19 +5,19 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 记录命令/查询日志 +/// Middleware for logging requests and events. /// -/// 请求类型。 -/// 返回类型。 +/// The type of request. +/// The type of response. public class LoggingBehavior : IPipelineBehavior where TRequest : IRequest { private readonly ILogger> _logger; /// - /// 新建一个 类型的实例。 + /// Create a new instance. /// - /// 日志记录器。 + /// Log provider. public LoggingBehavior(ILogger> logger) { _logger = logger; @@ -34,4 +34,4 @@ public async Task Handle( _logger.LogDebug("Handled {@Request}", request); return result; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/PageableQueryHandlerBase.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/PageableQueryHandlerBase.cs index da0e68c..a6bf8a0 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/PageableQueryHandlerBase.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/PageableQueryHandlerBase.cs @@ -5,11 +5,11 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 用于实现 的基类。 +/// Base class for implementing . /// -/// 查询类型。 -/// 实体类型。 -/// 返回类型。 +/// The type of query. +/// The type of entity to query. +/// The type of projected view model. public abstract class PageableQueryHandlerBase : IPageableQueryHandler where TQuery : IPageableQuery { @@ -45,54 +45,54 @@ public async Task> Handle(TQuery request, CancellationToken can } /// - /// 获取总数的查询。 + /// Query for total count. /// - /// 查询条件。 - /// 过滤好的 。 - /// 总数。 + /// The query parameters. + /// Filtered . + /// The total count of items. protected abstract Task CountAsync(TQuery query, IQueryable queryable); /// - /// 默认的排序条件,如果没有指定 ,将会使用这一语句。 + /// The default order by field, used when is not present. /// - /// 查询条件。 - /// 返回的。 - /// 排序好的 + /// The query parameters. + /// returned by . + /// Ordered . protected abstract IQueryable DefaultOrderBy(TQuery query, IQueryable queryable); /// - /// 获取并过滤,返回 + /// Create queryable and apply filter, return filtered . /// - /// 输入的查询条件。 - /// 过滤后的 + /// The query parameter. + /// Filtered . protected abstract IQueryable Filter(TQuery query); /// - /// 获取并过滤,返回 + /// Create queryable and apply filter asynchronously, return filtered . /// - /// 输入的查询条件。 - /// 过滤后的 + /// The query parameter. + /// Filtered . protected virtual Task> FilterAsync(TQuery query) { return Task.FromResult(Filter(query)); } /// - /// 投射结果,返回 。 + /// Project item to view model, return projected . /// - /// 查询条件。 - /// 过滤并排序完成的 。 - /// 投射好的 + /// The query parameter. + /// Filtered and ordered . + /// Projected . protected virtual IQueryable ProjectToView(TQuery query, IQueryable queryable) { return queryable.ProjectToType(); } /// - /// 执行实际的查询。 + /// Execute query and projections, get the actual results. /// - /// 查询条件。 - /// 投射好的 - /// 查询结果。 + /// The query parameter. + /// Projected . + /// The query result. protected abstract Task> ToListAsync(TQuery query, IQueryable queryable); } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationBehavior.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationBehavior.cs index 2ab3998..5b25fee 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationBehavior.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationBehavior.cs @@ -5,10 +5,10 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 对实现了 进行验证。 +/// Validate requests that implements . /// -/// 请求类型。 -/// 结果类型。 +/// The type of request. +/// The type of response. public class ValidationBehavior : IPipelineBehavior where TRequest : IValidatable, IRequest where TResponse : IValidationResponse, new() @@ -16,9 +16,9 @@ public class ValidationBehavior : IPipelineBehavior> _logger; /// - /// 构造一个 。 + /// Create a new . /// - /// + /// The log provider. public ValidationBehavior(ILogger> logger) { _logger = logger; @@ -31,8 +31,9 @@ public async Task Handle( CancellationToken cancellationToken) { _logger.LogInformation("----- Validating request {RequestType}", request.GetType().Name); - var error = request.Validate(); - if (error is null) + var errors = new ValidationErrors(); + request.Validate(errors); + if (errors.Count == 0) { return await next(); } @@ -41,13 +42,13 @@ public async Task Handle( "----- Validation failed with error, type: {RequestType}, Request: {Request}, Message: {Message}", request.GetType().Name, request, - error.Message); + errors.First().Message); return new TResponse { IsValidationError = true, - ErrorMessage = error.Message, - ValidationError = error + ErrorMessage = errors.First().Message, + ValidationErrors = errors }; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationError.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationError.cs index 37df214..6ba218b 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationError.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationError.cs @@ -1,8 +1,8 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; /// -/// 验证错误。 +/// A Validation error returned by . /// -/// 错误信息。 -/// 参数名称。 +/// The error message. +/// The parameter name that failed to validate. public record ValidationError(string Message, string? ParameterName); \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationErrors.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationErrors.cs new file mode 100644 index 0000000..4e56344 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/ValidationErrors.cs @@ -0,0 +1,49 @@ +using System.Collections; + +namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; + +/// +/// Collection of . +/// +public class ValidationErrors : ICollection +{ + private readonly List _validationErrors = new(); + + /// + /// Add a new validation error. + /// + /// The validation error. + public void Add(ValidationError validationError) + { + _validationErrors.Add(validationError); + } + + /// + /// Clear all validation errors. + /// + public void Clear() + { + _validationErrors.Clear(); + } + + /// + public bool Contains(ValidationError item) => _validationErrors.Contains(item); + + /// + public void CopyTo(ValidationError[] array, int arrayIndex) => _validationErrors.CopyTo(array, arrayIndex); + + /// + public bool Remove(ValidationError item) => _validationErrors.Remove(item); + + /// + public int Count => _validationErrors.Count; + + /// + public bool IsReadOnly => false; + + /// + public IEnumerator GetEnumerator() => _validationErrors.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs index ef6dbcf..2d11543 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs @@ -1,21 +1,33 @@ using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; using Cnblogs.Architecture.Ddd.Domain.Abstractions; - using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// -/// Controller 基类,提供自动处理 的方法。 +/// A base class for an API controller with methods that return based ons . /// [ApiController] public class ApiControllerBase : ControllerBase { + private CqrsHttpOptions? _cqrsHttpOptions; + + private CqrsHttpOptions CqrsHttpOptions + { + get + { + _cqrsHttpOptions ??= HttpContext.RequestServices.GetRequiredService>().Value; + return _cqrsHttpOptions; + } + } + /// - /// 处理 CommandResponse 并返回对应的状态码,成功-204,错误-400。 + /// Handle command response and return 204 if success, 400 if error. /// - /// 任务结果。 - /// 错误类型。 + /// The command response. + /// The type of error. /// protected IActionResult HandleCommandResponse(CommandResponse response) where TError : Enumeration @@ -29,18 +41,18 @@ protected IActionResult HandleCommandResponse(CommandResponse re } /// - /// 自动处理命令返回的结果,成功-200,失败-400。 + /// Handle command response and return 200 if success, 400 if error. /// - /// 命令执行结果。 - /// 返回类型。 - /// 错误类型。 + /// The command response. + /// The response type when success. + /// The error type. /// protected IActionResult HandleCommandResponse(CommandResponse response) where TError : Enumeration { if (response.IsSuccess()) { - return Ok(response.Response); + return Request.Headers.CqrsVersion() > 1 ? new CqrsObjectResult(response) : Ok(response.Response); } return HandleCommandResponse((CommandResponse)response); @@ -48,17 +60,102 @@ protected IActionResult HandleCommandResponse(CommandResponse private IActionResult HandleErrorCommandResponse(CommandResponse response) where TError : Enumeration + { + var errorResponseType = CqrsHttpOptions.CommandErrorResponseType; + if (Request.Headers.Accept.Contains("application/cqrs") || Request.Headers.CqrsVersion() > 1) + { + errorResponseType = ErrorResponseType.Cqrs; + } + + return errorResponseType switch + { + ErrorResponseType.PlainText => MapErrorCommandResponseToPlainText(response), + ErrorResponseType.ProblemDetails => MapErrorCommandResponseToProblemDetails(response), + ErrorResponseType.Cqrs => MapErrorCommandResponseToCqrsResponse(response), + ErrorResponseType.Custom => CustomErrorCommandResponseMap(response), + _ => throw new ArgumentOutOfRangeException( + $"Unsupported CommandErrorResponseType: {CqrsHttpOptions.CommandErrorResponseType}") + }; + } + + /// + /// Provides custom map logic that mapping error to when is . + /// The CqrsHttpOptions.CustomCommandErrorResponseMapper will be used as default implementation if configured. PlainText mapper will be used as the final fallback. + /// + /// The in error state. + /// The error type. + /// + protected virtual IActionResult CustomErrorCommandResponseMap(CommandResponse response) + where TError : Enumeration + { + if (CqrsHttpOptions.CustomCommandErrorResponseMapper != null) + { + var result = CqrsHttpOptions.CustomCommandErrorResponseMapper.Invoke(response, HttpContext); + return new HttpActionResult(result); + } + + return MapErrorCommandResponseToPlainText(response); + } + + private IActionResult MapErrorCommandResponseToCqrsResponse(CommandResponse response) + where TError : Enumeration + { + if (response is { IsConcurrentError: true, LockAcquired: false }) + { + return StatusCode(429); + } + + return BadRequest(response); + } + + private IActionResult MapErrorCommandResponseToProblemDetails(CommandResponse response) + where TError : Enumeration + { + if (response.IsValidationError) + { + foreach (var (message, parameterName) in response.ValidationErrors) + { + ModelState.AddModelError(parameterName ?? "command", message); + } + + return ValidationProblem(); + } + + if (response is { IsConcurrentError: true, LockAcquired: false }) + { + return Problem( + "The lock can not be acquired within time limit, please try later.", + null, + 429, + "Concurrent error"); + } + + return Problem(response.GetErrorMessage(), null, 400, "Execution failed"); + } + + private IActionResult MapErrorCommandResponseToPlainText(CommandResponse response) + where TError : Enumeration { if (response.IsValidationError) { - return BadRequest(response.ValidationError!.Message); + return BadRequest(string.Join('\n', response.ValidationErrors.Select(x => x.Message))); } - if (response.IsConcurrentError && response.LockAcquired == false) + if (response is { IsConcurrentError: true, LockAcquired: false }) { return StatusCode(429); } return BadRequest(response.ErrorCode?.Name ?? response.ErrorMessage); } -} \ No newline at end of file + + private static IActionResult BadRequest(string text) + { + return new ContentResult + { + Content = text, + ContentType = "text/plain", + StatusCode = 400 + }; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiVersioningInjectors.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiVersioningInjectors.cs index 5ff52e0..4213b10 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiVersioningInjectors.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiVersioningInjectors.cs @@ -1,17 +1,16 @@ using Asp.Versioning; using Asp.Versioning.Conventions; -using Microsoft.Extensions.DependencyInjection; - -namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; /// -/// Api Versioning 注入扩展 +/// Extension methods to inject api versioning. /// public static class ApiVersioningInjectors { /// - /// 添加 API Versioning,默认使用 + /// Add API Versioning, use by default. /// /// /// @@ -27,4 +26,4 @@ public static IApiVersioningBuilder AddCnblogsApiVersioning(this IServiceCollect o.SubstituteApiVersionInUrl = true; }); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/AssemblyInfo.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/AssemblyInfo.cs new file mode 100644 index 0000000..f8afacf --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Cnblogs.Architecture.IntegrationTests")] diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore.csproj index 244ffce..e2a9e3a 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore.csproj @@ -1,10 +1,26 @@ - - - - - - - - + + + Base classes and extensions for CQRS and AspNetCore integrations. + Commonly used types: + Cnblogs.Architecture.Ddd.Cqrs.AspNetCore.ApiControllerBase + + + + + + + + + + + + + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs index 38f15cc..41b628c 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs @@ -1,26 +1,16 @@ using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; - using MediatR; - using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// -/// 命令执行器,自动将返回内容提交给 mediator 并返回结果。 +/// Execute command returned by endpoint handler, and then map command response to HTTP response. /// -public class CommandEndpointHandler : IEndpointFilter +public class CommandEndpointHandler(IMediator mediator, IOptions options) : IEndpointFilter { - private readonly IMediator _mediator; - - /// - /// 构造一个命令执行器。 - /// - /// - public CommandEndpointHandler(IMediator mediator) - { - _mediator = mediator; - } + private readonly CqrsHttpOptions _options = options.Value; /// public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) @@ -32,7 +22,13 @@ public CommandEndpointHandler(IMediator mediator) "Expected ICommand<>, but got null, check if your delegate in MapCommand(route, delegate) returned non-null command"); } - var response = await _mediator.Send(command); + if (command is not IBaseRequest) + { + // not command, return as-is + return command; + } + + var response = await mediator.Send(command); if (response is null) { // should not be null @@ -50,20 +46,53 @@ public CommandEndpointHandler(IMediator mediator) // check if response has result if (commandResponse is IObjectResponse objectResponse) { - return Results.Ok(objectResponse.GetResult()); + return context.HttpContext.Request.Headers.CqrsVersion() > 1 + ? Results.Extensions.Cqrs(response, _options.DefaultJsonSerializerOptions) + : Results.Json(objectResponse.GetResult(), _options.DefaultJsonSerializerOptions); } return Results.NoContent(); } - return HandleErrorCommandResponse(commandResponse); + return HandleErrorCommandResponse(commandResponse, context.HttpContext); + } + + private IResult HandleErrorCommandResponse(CommandResponse response, HttpContext context) + { + var errorResponseType = _options.CommandErrorResponseType; + if (context.Request.Headers.Accept.Contains("application/cqrs") + || context.Request.Headers.Accept.Contains("application/cqrs-v2")) + { + errorResponseType = ErrorResponseType.Cqrs; + } + + return errorResponseType switch + { + ErrorResponseType.PlainText => HandleErrorCommandResponseWithPlainText(response), + ErrorResponseType.ProblemDetails => HandleErrorCommandResponseWithProblemDetails(response), + ErrorResponseType.Cqrs => HandleErrorCommandResponseWithCqrs(response), + ErrorResponseType.Custom => _options.CustomCommandErrorResponseMapper?.Invoke(response, context) + ?? HandleErrorCommandResponseWithPlainText(response), + _ => throw new ArgumentOutOfRangeException( + $"Unsupported CommandErrorResponseType: {_options.CommandErrorResponseType}") + }; + } + + private static IResult HandleErrorCommandResponseWithCqrs(CommandResponse response) + { + if (response is { IsConcurrentError: true, LockAcquired: false }) + { + return Results.StatusCode(429); + } + + return Results.BadRequest((object)response); } - private static IResult HandleErrorCommandResponse(CommandResponse response) + private static IResult HandleErrorCommandResponseWithPlainText(CommandResponse response) { if (response.IsValidationError) { - return Results.BadRequest(response.ValidationError!.Message); + return Results.Text(string.Join('\n', response.ValidationErrors.Select(x => x.Message)), statusCode: 400); } if (response is { IsConcurrentError: true, LockAcquired: false }) @@ -71,6 +100,27 @@ private static IResult HandleErrorCommandResponse(CommandResponse response) return Results.StatusCode(429); } - return Results.BadRequest(response.GetErrorMessage()); + return Results.Text(response.GetErrorMessage(), statusCode: 400); + } + + private static IResult HandleErrorCommandResponseWithProblemDetails(CommandResponse response) + { + if (response.IsValidationError) + { + var errors = response.ValidationErrors + .GroupBy(x => x.ParameterName ?? "command") + .ToDictionary(x => x.Key, x => x.Select(y => y.Message).ToArray()); + return Results.ValidationProblem(errors, statusCode: 400); + } + + if (response is { IsConcurrentError: true, LockAcquired: false }) + { + return Results.Problem( + "The lock can not be acquired within time limit, please try later.", + statusCode: 429, + title: "Concurrent error"); + } + + return Results.Problem(response.GetErrorMessage(), statusCode: 400, title: "Execution failed"); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ControllerOptionInjector.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ControllerOptionInjector.cs index a4e8b4a..2b5ebc8 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ControllerOptionInjector.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ControllerOptionInjector.cs @@ -1,15 +1,17 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; +using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +using Microsoft.AspNetCore.Mvc; -namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; /// -/// 用于注入 Model Binder 的扩展方法。 +/// Extensions to inject custom model binder for CQRS. /// public static class ControllerOptionInjector { /// - /// 添加 CQRS 相关的 Model Binder Provider + /// Add custom model binder used for CQRS, like model binder for . /// /// public static void AddCqrsModelBinderProvider(this MvcOptions options) @@ -18,11 +20,20 @@ public static void AddCqrsModelBinderProvider(this MvcOptions options) } /// - /// 添加 CQRS 相关的 Model Binder Provider + /// Add custom model binder used for CQRS, like model binder for . /// /// - public static void AddCqrsModelBinderProvider(this IMvcBuilder builder) + public static IMvcBuilder AddCqrsModelBinderProvider(this IMvcBuilder builder) { - builder.AddMvcOptions(options => options.AddCqrsModelBinderProvider()); + return builder.AddMvcOptions(options => options.AddCqrsModelBinderProvider()); } -} \ No newline at end of file + + /// + /// Add long to string json converter. + /// + /// . + public static IMvcBuilder AddLongToStringJsonConverter(this IMvcBuilder builder) + { + return builder.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new LongToStringConverter())); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHeaderNames.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHeaderNames.cs new file mode 100644 index 0000000..e33a1bb --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHeaderNames.cs @@ -0,0 +1,6 @@ +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +internal static class CqrsHeaderNames +{ + public const string CqrsVersion = "X-Cqrs-Version"; +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptions.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptions.cs new file mode 100644 index 0000000..71ea276 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptions.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Microsoft.AspNetCore.Http; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Configure options for mapping cqrs responses into http responses. +/// +public class CqrsHttpOptions +{ + /// + /// Configure the http response type for command errors. + /// + public ErrorResponseType CommandErrorResponseType { get; set; } = ErrorResponseType.PlainText; + + /// + /// Custom logic to handle error command response. + /// + public Func? CustomCommandErrorResponseMapper { get; set; } + + /// + /// Default json serializer options for minimal api. + /// + /// + /// For Controllers, please use builder.AddControllers().AddLongToStringJsonConverter(); + /// + public JsonSerializerOptions DefaultJsonSerializerOptions { get; set; } = new(JsonSerializerDefaults.Web); +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptionsInjector.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptionsInjector.cs new file mode 100644 index 0000000..8de27bc --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptionsInjector.cs @@ -0,0 +1,57 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Extension methods to configure behaviors of mapping command/query response to http response. +/// +public static class CqrsHttpOptionsInjector +{ + /// + /// Use to represent command response. + /// + /// The . + /// + public static CqrsInjector UseProblemDetails(this CqrsInjector injector) + { + injector.Services.AddProblemDetails(); + injector.Services.Configure( + c => c.CommandErrorResponseType = ErrorResponseType.ProblemDetails); + return injector; + } + + /// + /// Use custom mapper to convert command response into HTTP response. + /// + /// The . + /// The custom map function. + /// + public static CqrsInjector UseCustomCommandErrorResponseMapper( + this CqrsInjector injector, + Func mapper) + { + injector.Services.Configure( + c => + { + c.CommandErrorResponseType = ErrorResponseType.Custom; + c.CustomCommandErrorResponseMapper = mapper; + }); + return injector; + } + + /// + /// Serialize long to string for all json output. + /// + /// + /// + public static CqrsInjector AddLongToStringJsonConverter(this CqrsInjector injector) + { + injector.Services.Configure( + o => o.DefaultJsonSerializerOptions.Converters.Add(new LongToStringConverter())); + return injector; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsModelBinderProvider.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsModelBinderProvider.cs index 1cbf86b..b5816a8 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsModelBinderProvider.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsModelBinderProvider.cs @@ -6,7 +6,7 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// -/// Model Binder Provider for custom types +/// Model Binder Provider for custom types used in CQRS. /// public class CqrsModelBinderProvider : IModelBinderProvider { @@ -20,4 +20,4 @@ public class CqrsModelBinderProvider : IModelBinderProvider return null; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsObjectResult.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsObjectResult.cs new file mode 100644 index 0000000..3395296 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsObjectResult.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Send command response as json and report current cqrs version. +/// +/// +public class CqrsObjectResult(object? value) : ObjectResult(value) +{ + /// + public override Task ExecuteResultAsync(ActionContext context) + { + context.HttpContext.Response.Headers.AppendCurrentCqrsVersion(); + return base.ExecuteResultAsync(context); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResult.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResult.cs new file mode 100644 index 0000000..1338254 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResult.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Send object as json and append X-Cqrs-Version header +/// +/// Response body. +/// to use. +public class CqrsResult(object commandResponse, JsonSerializerOptions? options = null) : IResult +{ + /// + public Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.Headers.Append("X-Cqrs-Version", "2"); + return httpContext.Response.WriteAsJsonAsync(commandResponse, options); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResultExtensions.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResultExtensions.cs new file mode 100644 index 0000000..8f46e56 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsResultExtensions.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Extension methods for creating cqrs result. +/// +public static class CqrsResultExtensions +{ + /// + /// Write result as json and append cqrs response header. + /// + /// + /// The command response. + /// Optional json serializer options. + /// + public static IResult Cqrs(this IResultExtensions extensions, object result, JsonSerializerOptions? options = null) + { + ArgumentNullException.ThrowIfNull(extensions); + return new CqrsResult(result, options); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs index 510b7dc..2ab46a4 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsRouteMapper.cs @@ -1,15 +1,17 @@ using System.Diagnostics.CodeAnalysis; - +using System.Reflection; +using System.Text.RegularExpressions; using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; - using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// -/// 用于 Minimum API CQRS 路径注册的扩展方法。 +/// Extension methods used for register Command and Query endpoint in minimal API. /// public static class CqrsRouteMapper { @@ -17,49 +19,164 @@ public static class CqrsRouteMapper private static readonly List CommandTypes = new() { typeof(ICommand<>), typeof(ICommand<,>) }; + private static readonly string[] GetAndHeadMethods = { "GET", "HEAD" }; + + private static readonly List PostCommandPrefixes = new() + { + "Create", + "Add", + "New" + }; + + private static readonly List PutCommandPrefixes = new() + { + "Update", + "Modify", + "Replace", + "Alter" + }; + + private static readonly List DeleteCommandPrefixes = new() + { + "Delete", + "Remove", + "Clean", + "Clear", + "Purge" + }; + /// - /// 添加查询 API,使用 GET 方法访问,参数将自动从路径或查询字符串获取。 + /// Map a query API, using GET method. would been constructed from route and query string. /// /// - /// 路径模板。 - /// 查询类型。 + /// The route template for API. + /// Multiple routes should be mapped when for nullable route parameters. + /// Replace route parameter with given string to represent null. + /// Map HEAD method for the same routes. + /// The type of the query. /// + /// + /// The following code: + /// + /// app.MapQuery<ItemQuery>("apps/{appName}/instance/{instanceId}/roles", true); + /// + /// would register following routes: + /// + /// apps/-/instance/-/roles + /// apps/{appName}/instance/-/roles + /// apps/-/instance/{instanceId}/roles + /// apps/{appName}/instance/{instanceId}/roles + /// + /// public static IEndpointConventionBuilder MapQuery( this IEndpointRouteBuilder app, - [StringSyntax("Route")] string route) + [StringSyntax("Route")] string route, + MapNullableRouteParameter mapNullableRouteParameters = MapNullableRouteParameter.Disable, + string nullRouteParameterPattern = "-", + bool enableHead = false) { - return app.MapQuery(route, ([AsParameters] T query) => query); + return app.MapQuery( + route, + ([AsParameters] T query) => query, + mapNullableRouteParameters, + nullRouteParameterPattern, + enableHead); } /// - /// 添加一个命令 API,根据前缀选择 HTTP Method,错误会被自动处理。 + /// Map a query API, using GET method. /// /// - /// 路径模板。 - /// 命令类型。 + /// The route template. + /// The delegate that returns a instance. + /// Multiple routes should be mapped when for nullable route parameters. + /// Replace route parameter with given string to represent null. + /// Allow HEAD for the same routes. + /// /// + /// The following code: /// - /// app.MapCommand<CreateItemCommand>("/items"); // Starts with 'Create' or 'Add' - POST - /// app.MapCommand<UpdateItemCommand>("/items/{id:int}") // Starts with 'Update' or 'Replace' - PUT - /// app.MapCommand<DeleteCommand>("/items/{id:int}") // Starts with 'Delete' or 'Remove' - DELETE - /// app.MapCommand<ResetItemCommand>("/items/{id:int}:reset) // Others - PUT + /// app.MapQuery("apps/{appName}/instance/{instanceId}/roles", (string? appName, string? instanceId) => new ItemQuery(appName, instanceId), true); + /// + /// would register following routes: + /// + /// apps/-/instance/-/roles + /// apps/{appName}/instance/-/roles + /// apps/-/instance/{instanceId}/roles + /// apps/{appName}/instance/{instanceId}/roles /// /// - /// - public static IEndpointConventionBuilder MapCommand( + public static IEndpointConventionBuilder MapQuery( this IEndpointRouteBuilder app, - [StringSyntax("Route")] string route) + [StringSyntax("Route")] string route, + Delegate handler, + MapNullableRouteParameter mapNullableRouteParameters = MapNullableRouteParameter.Disable, + string nullRouteParameterPattern = "-", + bool enableHead = false) { - return app.MapCommand(route, ([AsParameters] T command) => command); + var (queryType, returnType) = EnsureReturnTypeIsQuery(handler); + if (mapNullableRouteParameters is MapNullableRouteParameter.Disable) + { + return MapRoutes(queryType, returnType, route); + } + + if (string.IsNullOrWhiteSpace(nullRouteParameterPattern)) + { + throw new ArgumentNullException( + nameof(nullRouteParameterPattern), + "argument must not be null or empty"); + } + + var parsedRoute = RoutePatternFactory.Parse(route); + var context = new NullabilityInfoContext(); + var nullableRouteProperties = queryType.GetProperties() + .Where( + p => p.GetMethod != null + && p.SetMethod != null + && context.Create(p.GetMethod.ReturnParameter).ReadState == NullabilityState.Nullable) + .ToList(); + var nullableRoutePattern = parsedRoute.Parameters + .Where( + x => nullableRouteProperties.Any( + y => string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase))) + .ToList(); + var subsets = GetNotEmptySubsets(nullableRoutePattern); + foreach (var subset in subsets) + { + var newRoute = subset.Aggregate( + route, + (r, x) => + { + var regex = new Regex("{" + x.Name + "[^}]*?}", RegexOptions.IgnoreCase); + return regex.Replace(r, nullRouteParameterPattern); + }); + MapRoutes(queryType, returnType, newRoute); + } + + return MapRoutes(queryType, returnType, route); + + IEndpointConventionBuilder MapRoutes(Type query, Type queryFor, string r) + { + var endpoint = enableHead ? app.MapMethods(r, GetAndHeadMethods, handler) : app.MapGet(r, handler); + var builder = endpoint.AddEndpointFilter() + .Produces(200, queryFor) + .WithTags("Queries"); + if (query.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IQuery<>))) + { + // may be null + builder.Produces(404, queryFor); + } + + return builder; + } } /// - /// 添加一个命令 API,根据前缀选择 HTTP Method,错误会被自动处理。 + /// Map a command API, using different HTTP methods based on prefix. See example for details. /// /// - /// 路径模板。 - /// 返回 的委托。 - /// 命令类型。 + /// The route template. + /// The type of the command. /// /// /// app.MapCommand<CreateItemCommand>("/items"); // Starts with 'Create' or 'Add' - POST @@ -71,41 +188,33 @@ public static IEndpointConventionBuilder MapCommand( /// public static IEndpointConventionBuilder MapCommand( this IEndpointRouteBuilder app, - [StringSyntax("Route")] string route, - Delegate handler) + [StringSyntax("Route")] string route) { - return app.MapCommand(route, handler); - } + var commandTypeName = typeof(T).Name; + if (PostCommandPrefixes.Any(x => commandTypeName.StartsWith(x))) + { + return app.MapPostCommand(route); + } - /// - /// 添加一个查询 API,使用 GET 方法访问。 - /// - /// - /// 路径模板。 - /// 构造查询的方法,需要返回 的对象。 - /// - public static IEndpointConventionBuilder MapQuery( - this IEndpointRouteBuilder app, - [StringSyntax("Route")] string route, - Delegate handler) - { - var isQuery = handler.Method.ReturnType.GetInterfaces().Where(x => x.IsGenericType) - .Any(x => QueryTypes.Contains(x.GetGenericTypeDefinition())); - if (isQuery == false) + if (PutCommandPrefixes.Any(x => commandTypeName.StartsWith(x))) { - throw new ArgumentException( - "delegate does not return a query, please make sure it returns object that implement IQuery<> or IListQuery<> or interface that inherit from them"); + return app.MapPutCommand(route); } - return app.MapGet(route, handler).AddEndpointFilter(); + if (DeleteCommandPrefixes.Any(x => commandTypeName.StartsWith(x))) + { + return app.MapDeleteCommand(route); + } + + return app.MapPutCommand(route); } /// - /// 添加一个命令 API,根据前缀选择 HTTP Method,错误会被自动处理。 + /// Map a command API, using different method based on type name prefix. /// /// - /// 路径模板。 - /// 构造命令的方法,需要返回 类型的对象。 + /// The route template. + /// The delegate that returns a instance of . /// /// /// @@ -120,19 +229,18 @@ public static IEndpointConventionBuilder MapCommand( [StringSyntax("Route")] string route, Delegate handler) { - EnsureDelegateReturnTypeIsCommand(handler); - var commandTypeName = handler.Method.ReturnType.Name; - if (commandTypeName.StartsWith("Create") || commandTypeName.StartsWith("Add")) + var commandTypeName = EnsureReturnTypeIsCommand(handler).CommandType.Name; + if (PostCommandPrefixes.Any(x => commandTypeName.StartsWith(x))) { return app.MapPostCommand(route, handler); } - if (commandTypeName.StartsWith("Update") || commandTypeName.StartsWith("Replace")) + if (PutCommandPrefixes.Any(x => commandTypeName.StartsWith(x))) { return app.MapPutCommand(route, handler); } - if (commandTypeName.StartsWith("Delete") || commandTypeName.StartsWith("Remove")) + if (DeleteCommandPrefixes.Any(x => commandTypeName.StartsWith(x))) { return app.MapDeleteCommand(route, handler); } @@ -141,61 +249,241 @@ public static IEndpointConventionBuilder MapCommand( } /// - /// 添加一个命令 API,使用 POST 方法访问,错误会被自动处理。 + /// Map a command API, using POST method and get command data from request body. + /// + /// + /// The route template. + /// The type of command. + /// + public static IEndpointConventionBuilder MapPostCommand( + this IEndpointRouteBuilder app, + [StringSyntax("Route")] string route) + { + return app.MapPostCommand(route, ([FromBody] TCommand command) => command); + } + + /// + /// Map a command API, using POST method. /// /// - /// 路径模板。 - /// 构造命令的方法,需要返回 类型的对象。 + /// The route template. + /// The delegate that returns a instance of . /// public static IEndpointConventionBuilder MapPostCommand( this IEndpointRouteBuilder app, [StringSyntax("Route")] string route, Delegate handler) { - EnsureDelegateReturnTypeIsCommand(handler); - return app.MapPost(route, handler).AddEndpointFilter(); + var (commandType, responseType, errorType) = EnsureReturnTypeIsCommand(handler); + var builder = app.MapPost(route, handler) + .AddEndpointFilter() + .AddCommandOpenApiDescriptions(commandType, responseType, errorType); + return builder; } /// - /// 添加一个命令 API,使用 PUT 方法访问,错误会被自动处理。 + /// Map a command API, using PUT method and get command data from request body. + /// + /// + /// The route template. + /// The type of command. + /// + public static IEndpointConventionBuilder MapPutCommand( + this IEndpointRouteBuilder app, + [StringSyntax("Route")] string route) + { + return app.MapPutCommand(route, ([FromBody] TCommand command) => command); + } + + /// + /// Map a command API, using PUT method. /// /// - /// 路径模板。 - /// 构造命令的方法,需要返回 类型的对象。 + /// The route template. + /// The delegate that returns a instance of . /// public static IEndpointConventionBuilder MapPutCommand( this IEndpointRouteBuilder app, [StringSyntax("Route")] string route, Delegate handler) { - EnsureDelegateReturnTypeIsCommand(handler); - return app.MapPut(route, handler).AddEndpointFilter(); + var (commandType, responseType, errorType) = EnsureReturnTypeIsCommand(handler); + return app.MapPut(route, handler).AddEndpointFilter() + .AddCommandOpenApiDescriptions(commandType, responseType, errorType); + } + + /// + /// Map a command API, using DELETE method and get command from route/query parameters. + /// + /// + /// The route template. + /// The type of command. + /// + public static IEndpointConventionBuilder MapDeleteCommand( + this IEndpointRouteBuilder app, + [StringSyntax("Route")] string route) + { + return app.MapDeleteCommand(route, ([AsParameters] TCommand command) => command); } /// - /// 添加一个命令 API,使用 DELETE 方法访问,错误会被自动处理。 + /// Map a command API, using DELETE method. /// /// - /// 路径模板。 - /// 构造命令的方法,需要返回 类型的对象。 + /// The route template. + /// The delegate that returns a instance of . /// public static IEndpointConventionBuilder MapDeleteCommand( this IEndpointRouteBuilder app, [StringSyntax("Route")] string route, Delegate handler) { - EnsureDelegateReturnTypeIsCommand(handler); - return app.MapDelete(route, handler).AddEndpointFilter(); + var (commandType, responseType, errorType) = EnsureReturnTypeIsCommand(handler); + return app.MapDelete(route, handler).AddEndpointFilter() + .AddCommandOpenApiDescriptions(commandType, responseType, errorType); + } + + /// + /// Map prefix to POST method for further MapCommand() calls. + /// + /// + /// The new prefix. + public static IEndpointRouteBuilder MapPrefixToPost(this IEndpointRouteBuilder app, string prefix) + { + PostCommandPrefixes.Add(prefix); + return app; + } + + /// + /// Stop mapping prefix to POST method for further MapCommand() calls. + /// + /// + /// The new prefix. + public static IEndpointRouteBuilder StopMappingPrefixToPost(this IEndpointRouteBuilder app, string prefix) + { + PostCommandPrefixes.Remove(prefix); + return app; + } + + /// + /// Map prefix to PUT method for further MapCommand() calls. + /// + /// + /// The new prefix. + public static IEndpointRouteBuilder MapPrefixToPut(this IEndpointRouteBuilder app, string prefix) + { + PutCommandPrefixes.Add(prefix); + return app; + } + + /// + /// Stop mapping prefix to PUT method for further MapCommand() calls. + /// + /// + /// The new prefix. + public static IEndpointRouteBuilder StopMappingPrefixToPut(this IEndpointRouteBuilder app, string prefix) + { + PutCommandPrefixes.Remove(prefix); + return app; + } + + /// + /// Map prefix to DELETE method for further MapCommand() calls. + /// + /// + /// The new prefix. + public static IEndpointRouteBuilder MapPrefixToDelete(this IEndpointRouteBuilder app, string prefix) + { + DeleteCommandPrefixes.Add(prefix); + return app; } - private static void EnsureDelegateReturnTypeIsCommand(Delegate handler) + /// + /// Stop mapping prefix to DELETE method for further MapCommand() calls. + /// + /// + /// The new prefix. + public static IEndpointRouteBuilder StopMappingPrefixToDelete(this IEndpointRouteBuilder app, string prefix) { - var isCommand = handler.Method.ReturnType.GetInterfaces().Where(x => x.IsGenericType) - .Any(x => CommandTypes.Contains(x.GetGenericTypeDefinition())); - if (isCommand == false) + DeleteCommandPrefixes.Remove(prefix); + return app; + } + + private static (Type CommandType, Type? ResponseType, Type ErrorType) EnsureReturnTypeIsCommand(Delegate handler) + { + var returnType = handler.Method.ReturnType; + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + returnType = returnType.GenericTypeArguments.First(); + } + + var commandType = returnType.GetInterfaces().Where(x => x.IsGenericType) + .FirstOrDefault(x => CommandTypes.Contains(x.GetGenericTypeDefinition())); + if (commandType == null) { throw new ArgumentException( "handler does not return command, check if delegate returns type that implements ICommand<> or ICommand<,>"); } + + Type?[] genericParams = commandType.GetGenericArguments(); + if (genericParams.Length == 1) + { + genericParams = [null, genericParams[0]]; + } + + return (returnType, genericParams[0], genericParams[1]!); + } + + private static (Type, Type) EnsureReturnTypeIsQuery(Delegate handler) + { + var returnType = handler.Method.ReturnType; + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + returnType = returnType.GenericTypeArguments.First(); + } + + var queryInterface = returnType.GetInterfaces().Where(x => x.IsGenericType) + .FirstOrDefault(x => QueryTypes.Contains(x.GetGenericTypeDefinition())); + if (queryInterface == null) + { + throw new ArgumentException( + "handler does not return query, check if delegate returns type that implements IQuery<>"); + } + + return (returnType, queryInterface.GenericTypeArguments[0]); + } + + private static List GetNotEmptySubsets(ICollection items) + { + var subsetCount = 1 << items.Count; + var results = new List(subsetCount); + for (var i = 1; i < subsetCount; i++) + { + var index = i; + var subset = items.Where((_, j) => (index & (1 << j)) > 0).ToArray(); + results.Add(subset); + } + + return results; + } + + private static RouteHandlerBuilder AddCommandOpenApiDescriptions( + this RouteHandlerBuilder builder, + Type commandType, + Type? responseType, + Type errorType) + { + var commandResponseType = responseType is null + ? typeof(CommandResponse<>).MakeGenericType(errorType) + : typeof(CommandResponse<,>).MakeGenericType(responseType, errorType); + builder.Produces(200, commandResponseType) + .Produces(400, commandResponseType) + .WithTags("Commands"); + if (commandType.GetInterfaces().Any(i => i == typeof(ILockableRequest))) + { + builder.Produces(429); + } + + return builder; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsVersionExtensions.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsVersionExtensions.cs new file mode 100644 index 0000000..f5d5fec --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsVersionExtensions.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Http; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +internal static class CqrsVersionExtensions +{ + private const int CurrentCqrsVersion = 2; + + public static int CqrsVersion(this IHeaderDictionary headers) + { + return int.TryParse(headers[CqrsHeaderNames.CqrsVersion].ToString(), out var version) ? version : 1; + } + + public static int CqrsVersion(this HttpHeaders headers) + { + if (headers.Contains(CqrsHeaderNames.CqrsVersion) == false) + { + return 1; + } + + return headers.GetValues(CqrsHeaderNames.CqrsVersion).Select(x => int.TryParse(x, out var y) ? y : 1).Max(); + } + + public static void CqrsVersion(this IHeaderDictionary headers, int version) + { + headers.Append(CqrsHeaderNames.CqrsVersion, version.ToString()); + } + + public static void AppendCurrentCqrsVersion(this IHeaderDictionary headers) + { + headers.CqrsVersion(CurrentCqrsVersion); + } + + public static void AppendCurrentCqrsVersion(this HttpHeaders headers) + { + headers.Add(CqrsHeaderNames.CqrsVersion, CurrentCqrsVersion.ToString()); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ErrorResponseType.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ErrorResponseType.cs new file mode 100644 index 0000000..3b569f1 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ErrorResponseType.cs @@ -0,0 +1,29 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Configure the response type for command errors. +/// +public enum ErrorResponseType +{ + /// + /// Returns plain text, this is the default behavior. + /// + PlainText, + + /// + /// Returns . + /// + ProblemDetails, + + /// + /// Returns + /// + Cqrs, + + /// + /// Handles command error by custom logic. + /// + Custom +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/HttpActionResult.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/HttpActionResult.cs new file mode 100644 index 0000000..50e325a --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/HttpActionResult.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Used because the same class in AspNetCore framework is internal. +/// +internal sealed class HttpActionResult : ActionResult +{ + /// + /// Gets the instance of the current . + /// + public IResult Result { get; } + + /// + /// Initializes a new instance of the class with the + /// provided. + /// + /// The instance to be used during the invocation. + public HttpActionResult(IResult result) + { + Result = result; + } + + /// + public override Task ExecuteResultAsync(ActionContext context) => Result.ExecuteAsync(context.HttpContext); +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/LongToStringConverter.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/LongToStringConverter.cs new file mode 100644 index 0000000..6bf7781 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/LongToStringConverter.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Converter between long and string +/// +internal class LongToStringConverter : JsonConverter +{ + /// + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + return reader.GetInt64(); + } + + var raw = reader.GetString(); + if (string.IsNullOrWhiteSpace(raw)) + { + throw new JsonException("string is empty"); + } + + var success = long.TryParse(raw, out var parsed); + if (success == false) + { + throw new JsonException("string value can't be converted to long"); + } + + return parsed; + } + + /// + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/MapNullableRouteParameter.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/MapNullableRouteParameter.cs new file mode 100644 index 0000000..3c010c2 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/MapNullableRouteParameter.cs @@ -0,0 +1,17 @@ +namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; + +/// +/// Defines behavior for nullable route parameters. +/// +public enum MapNullableRouteParameter +{ + /// + /// Map different routes to present null for nullable route parameters. + /// + Enable = 1, + + /// + /// Do not map extra route for nullable route parameters. + /// + Disable = 2 +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/PagingParamsModelBinder.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/PagingParamsModelBinder.cs index 665f165..df11d82 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/PagingParamsModelBinder.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/PagingParamsModelBinder.cs @@ -5,7 +5,7 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// -/// Model Binder for +/// Model Binder for /// public class PagingParamsModelBinder : IModelBinder { @@ -48,4 +48,4 @@ public Task BindModelAsync(ModelBindingContext bindingContext) bindingContext.Result = ModelBindingResult.Success(pagingParams); return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/QueryEndpointHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/QueryEndpointHandler.cs index 1834998..0316557 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/QueryEndpointHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/QueryEndpointHandler.cs @@ -1,25 +1,14 @@ using MediatR; - using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; /// -/// 查询执行器,自动将返回内容提交给 mediator 并返回结果。 +/// The query executor, auto send query to . /// -public class QueryEndpointHandler : IEndpointFilter +public class QueryEndpointHandler(IMediator mediator, IOptions cqrsHttpOptions) : IEndpointFilter { - private readonly IMediator _mediator; - - /// - /// 创建一个查询执行器。 - /// - /// - public QueryEndpointHandler(IMediator mediator) - { - _mediator = mediator; - } - /// public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { @@ -29,7 +18,14 @@ public QueryEndpointHandler(IMediator mediator) return query; } - var response = await _mediator.Send(query); - return response; + if (query is not IBaseRequest) + { + return query; + } + + var response = await mediator.Send(query); + return response == null + ? Results.NotFound() + : Results.Json(response, cqrsHttpOptions.Value.DefaultJsonSerializerOptions); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/SerilogInjector.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/SerilogInjector.cs deleted file mode 100644 index 73eb688..0000000 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/SerilogInjector.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Hosting; - -using Serilog; - -namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; - -/// -/// 注入 Serilog 的扩展方法。 -/// -public static class SerilogInjector -{ - /// - /// 添加 Serilog - /// - /// - /// - public static IHostBuilder UseCnblogsSerilog(this WebApplicationBuilder builder) - { - return builder.Host.UseCnblogsSerilog(); - } - - /// - /// 添加 Serilog - /// - /// - /// - public static IHostBuilder UseCnblogsSerilog(this IHostBuilder host) - { - return host.UseSerilog((ctx, conf) => conf.ReadFrom.Configuration(ctx.Configuration).Enrich.FromLogContext()); - } -} \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/ClickhouseContextCollection.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/ClickhouseContextCollection.cs new file mode 100644 index 0000000..d93603d --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/ClickhouseContextCollection.cs @@ -0,0 +1,14 @@ +namespace Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse; + +/// +/// The collection for clickhouse contexts. +/// +public class ClickhouseContextCollection +{ + internal List ContextTypes { get; } = new(); + + internal void Add() + { + ContextTypes.Add(typeof(TContext)); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/ClickhouseDbConnectionFactory.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/ClickhouseDbConnectionFactory.cs new file mode 100644 index 0000000..e2e43b7 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/ClickhouseDbConnectionFactory.cs @@ -0,0 +1,17 @@ +using System.Data; +using ClickHouse.Client; +using Cnblogs.Architecture.Ddd.Infrastructure.Dapper; + +namespace Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse; + +/// +/// Clickhouse connection factory. +/// +public class ClickhouseDbConnectionFactory(IClickHouseDataSource dataSource) : IDbConnectionFactory +{ + /// + public IDbConnection CreateDbConnection() + { + return dataSource.CreateConnection(); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/ClickhouseInitializeHostedService.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/ClickhouseInitializeHostedService.cs new file mode 100644 index 0000000..94420e4 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/ClickhouseInitializeHostedService.cs @@ -0,0 +1,47 @@ +using Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse; + +/// +/// Hosed service to initialize clickhouse contexts. +/// +public class ClickhouseInitializeHostedService : IHostedService +{ + private readonly ClickhouseContextCollection _collection; + private readonly IServiceProvider _serviceProvider; + + /// + /// Create a . + /// + /// The contexts been registered. + /// The provider for contexts. + public ClickhouseInitializeHostedService( + IOptions collections, + IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + _collection = collections.Value; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + foreach (var collectionContextType in _collection.ContextTypes) + { + var context = scope.ServiceProvider.GetRequiredService(collectionContextType) as ClickhouseDapperContext; + context?.Init(); + } + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse.csproj new file mode 100644 index 0000000..d482735 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse.csproj @@ -0,0 +1,14 @@ + + + + + Provides clickhouse dapper integration. + + + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/DapperConfigurationBuilderExtension.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/DapperConfigurationBuilderExtension.cs new file mode 100644 index 0000000..bf5d921 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse/DapperConfigurationBuilderExtension.cs @@ -0,0 +1,30 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Dapper.Clickhouse; +using Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse; +using Microsoft.Extensions.DependencyInjection; + +// ReSharper disable once CheckNamespace +namespace Cnblogs.Architecture.Ddd.Cqrs.Dapper; + +/// +/// Extension methods for inject clickhouse to dapper context. +/// +public static class DapperConfigurationBuilderExtension +{ + /// + /// Use clickhouse as the underlying database. + /// + /// . + /// The connection string for clickhouse. + /// The context type been used. + public static void UseClickhouse( + this DapperConfigurationBuilder builder, + string connectionString) + where TContext : ClickhouseDapperContext + { + builder.UseDbConnectionFactory(); + builder.Services.AddClickHouseDataSource(connectionString); + builder.Services.AddSingleton(new ClickhouseContextOptions(connectionString)); + builder.Services.Configure(x => x.Add()); + builder.Services.AddHostedService(); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer.csproj index 487e416..89a9658 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer.csproj @@ -1,17 +1,17 @@ - - net7.0 - enable - enable - + + + Provides SQL server provider for dapper to use. + + - - - + + + - - - + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer/DapperConfigurationBuilderExtension.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer/DapperConfigurationBuilderExtension.cs index 0e46e57..496efc5 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer/DapperConfigurationBuilderExtension.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer/DapperConfigurationBuilderExtension.cs @@ -1,19 +1,23 @@ -using Cnblogs.Architecture.Ddd.Infrastructure.Dapper; +using Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer; +using Cnblogs.Architecture.Ddd.Infrastructure.Dapper; -namespace Cnblogs.Architecture.Ddd.Cqrs.Dapper.SqlServer; +// ReSharper disable once CheckNamespace +namespace Cnblogs.Architecture.Ddd.Cqrs.Dapper; /// -/// 用于配置 Dapper Configuration 的扩展方法。 +/// Extension methods to configure dapper context. /// public static class DapperConfigurationBuilderExtension { /// - /// 使用 SqlServer 配置 + /// Configure to use sql server as underlying database. /// - /// - /// 连接字符串。 - public static void UseSqlServer(this DapperConfigurationBuilder builder, string connectionString) + /// + /// The connection string for sql server. + /// The type of context been configured. + public static void UseSqlServer(this DapperConfigurationBuilder builder, string connectionString) + where TContext : DapperContext { builder.UseDbConnectionFactory(new SqlServerDbConnectionFactory(connectionString)); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/Cnblogs.Architecture.Ddd.Cqrs.Dapper.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/Cnblogs.Architecture.Ddd.Cqrs.Dapper.csproj index f7ee27a..179bdee 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/Cnblogs.Architecture.Ddd.Cqrs.Dapper.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/Cnblogs.Architecture.Ddd.Cqrs.Dapper.csproj @@ -1,6 +1,13 @@ - - - - + + + Provides extensions to use dapper as ORM in commands and queries. + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/DapperConfigurationBuilder.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/DapperConfigurationBuilder.cs index c04f9cb..62a3542 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/DapperConfigurationBuilder.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/DapperConfigurationBuilder.cs @@ -7,20 +7,20 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.Dapper; /// /// Dapper 配置类。 /// -public class DapperConfigurationBuilder +/// The context type been configured. +public class DapperConfigurationBuilder + where TContext : DapperContext { - private readonly IServiceCollection _services; private readonly string _dapperContextTypeName; /// /// 创建一个 DapperConfigurationBuilder。 /// - /// 正在配置的 DapperContext 名称。 /// - public DapperConfigurationBuilder(string dapperContextTypeName, IServiceCollection services) + public DapperConfigurationBuilder(IServiceCollection services) { - _dapperContextTypeName = dapperContextTypeName; - _services = services; + _dapperContextTypeName = typeof(TContext).Name; + Services = services; } /// @@ -29,9 +29,27 @@ public DapperConfigurationBuilder(string dapperContextTypeName, IServiceCollecti /// 工厂对象。 /// 工厂类型。 public void UseDbConnectionFactory(TFactory factory) - where TFactory : IDbConnectionFactory + where TFactory : class, IDbConnectionFactory { - _services.Configure( - c => c.AddDbConnectionFactory(_dapperContextTypeName, factory)); + Services.AddSingleton(factory); + Services.Configure( + c => c.AddDbConnectionFactory(_dapperContextTypeName, typeof(TFactory))); } -} \ No newline at end of file + + /// + /// Add as and get instance from DI when used. + /// + /// The factory type. + public void UseDbConnectionFactory() + where TFactory : class, IDbConnectionFactory + { + Services.AddSingleton(); + Services.Configure( + c => c.AddDbConnectionFactory(_dapperContextTypeName, typeof(TFactory))); + } + + /// + /// The underlying . + /// + public IServiceCollection Services { get; } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/ServiceCollectionInjector.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/ServiceCollectionInjector.cs index ff1e181..f5a3080 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/ServiceCollectionInjector.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Dapper/ServiceCollectionInjector.cs @@ -1,8 +1,8 @@ -using Cnblogs.Architecture.Ddd.Infrastructure.Dapper; +using Cnblogs.Architecture.Ddd.Cqrs.Dapper; +using Cnblogs.Architecture.Ddd.Infrastructure.Dapper; -using Microsoft.Extensions.DependencyInjection; - -namespace Cnblogs.Architecture.Ddd.Cqrs.Dapper; +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; /// /// ServiceCollection 注入类。 @@ -15,10 +15,10 @@ public static class ServiceCollectionInjector /// 。 /// 的实现类型。 /// - public static DapperConfigurationBuilder AddDapperContext(this IServiceCollection services) + public static DapperConfigurationBuilder AddDapperContext(this IServiceCollection services) where TContext : DapperContext { services.AddScoped(); - return new DapperConfigurationBuilder(typeof(TContext).Name, services); + return new DapperConfigurationBuilder(services); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr.csproj deleted file mode 100644 index c62e9b0..0000000 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock.csproj deleted file mode 100644 index 738386f..0000000 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock.csproj +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.csproj index 82245a0..2e1fff9 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.csproj @@ -1,11 +1,13 @@ + + + CQRS extensions for ASP.NET Core. + + - - - - - - - + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/CqrsInjector.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/CqrsInjector.cs index 1e6ad67..b144618 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/CqrsInjector.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/CqrsInjector.cs @@ -1,9 +1,7 @@ using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; using Cnblogs.Architecture.Ddd.Domain.Abstractions; using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; - using MediatR; - using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -65,7 +63,7 @@ public CqrsInjector AddDistributionLock() } /// - /// 启用缓存中间件,自动处理和缓存实现了 接口的请求。 + /// 启用缓存中间件,自动处理和缓存实现了 接口的请求。 /// /// 本地缓存提供器。 /// 缓存配置。 @@ -79,7 +77,7 @@ public CqrsInjector AddLocalQueryCache(Action? } /// - /// 启用缓存中间件,自动处理和缓存实现了 接口的请求。 + /// 启用缓存中间件,自动处理和缓存实现了 接口的请求。 /// /// 本地缓存提供器。 /// 远程缓存提供器。 @@ -96,7 +94,7 @@ public CqrsInjector AddQueryCache(Action - /// 启用缓存中间件,自动处理和缓存实现了 接口的请求。 + /// 启用缓存中间件,自动处理和缓存实现了 接口的请求。 /// /// 远程缓存提供器。 /// 缓存配置。 @@ -109,6 +107,39 @@ public CqrsInjector AddRemoteQueryCache(Action return this; } + /// + /// Use default implementation of that accesses file system directly. + /// + /// + public CqrsInjector AddDefaultFileProvider() + { + return AddFileProvider(); + } + + /// + /// Use given implementation of . + /// + /// The implementation type. + /// + public CqrsInjector AddFileProvider() + where TProvider : class, IFileProvider + { + Services.AddScoped(); + return this; + } + + /// + /// Use given implementation of . + /// + /// The type of implementation. + /// + public CqrsInjector AddFileDeliveryProvider() + where TProvider : class, IFileDeliveryProvider + { + Services.AddScoped(); + return this; + } + /// /// 添加自定义随机数提供器。 /// @@ -140,4 +171,4 @@ private void AddCacheBehaviorPipeline(Action? configure }); } } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/ServiceCollectionInjector.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/ServiceCollectionInjector.cs index ab9e3b0..64b92af 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/ServiceCollectionInjector.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection/ServiceCollectionInjector.cs @@ -1,12 +1,10 @@ using System.Reflection; - using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; - +using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; using MediatR; -using Microsoft.Extensions.DependencyInjection; - -namespace Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; /// /// 依赖注入扩展方法。 @@ -23,7 +21,13 @@ public static CqrsInjector AddCqrs(this IServiceCollection services, params Asse { services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); - services.AddMediatR(assemblies); + if (assemblies.Length == 0) + { + // mediator needs at least one assembly to inject from + assemblies = [typeof(CqrsInjector).Assembly]; + } + + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(assemblies)); return new CqrsInjector(services); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.EntityFramework/Cnblogs.Architecture.Ddd.Cqrs.EntityFramework.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.EntityFramework/Cnblogs.Architecture.Ddd.Cqrs.EntityFramework.csproj index 92fa536..9e7f6b2 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.EntityFramework/Cnblogs.Architecture.Ddd.Cqrs.EntityFramework.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.EntityFramework/Cnblogs.Architecture.Ddd.Cqrs.EntityFramework.csproj @@ -1,8 +1,15 @@ - - - - + + + Provides pageable query handler with EntityFramework. + Commonly used types: + Cnblogs.Architecture.Ddd.Cqrs.EntityFramework.EfPageableQueryHandler + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer.csproj index 656ee2a..8bb43b7 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.Analyzer.csproj @@ -1,16 +1,19 @@ - - true - - - - - + + true + + Provide mongodb analyzer with Cnblogs flavored configuration. + + - - - - + + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.csproj index cf92da9..456ab52 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/Cnblogs.Architecture.Ddd.Cqrs.MongoDb.csproj @@ -1,6 +1,13 @@ - - - - + + + Provide MongoDb base classes for implemnting command and queries. + Commonly used types: + Cnblogs.Architecture.Ddd.Cqrs.MongoDb.MongoPageableQueryHandler + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/MongoPageableQueryHandler.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/MongoPageableQueryHandler.cs index 5f9e6e1..a572c01 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/MongoPageableQueryHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/MongoPageableQueryHandler.cs @@ -1,6 +1,5 @@ using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; -using MongoDB.Driver; using MongoDB.Driver.Linq; namespace Cnblogs.Architecture.Ddd.Cqrs.MongoDb; @@ -18,12 +17,12 @@ public abstract class MongoPageableQueryHandler /// protected override Task CountAsync(TQuery query, IQueryable queryable) { - return queryable.AsMongoQueryable().CountAsync(); + return queryable.CountAsync(); } /// protected override Task> ToListAsync(TQuery query, IQueryable queryable) { - return queryable.AsMongoQueryable().ToListAsync(); + return queryable.ToListAsync(); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/MongoQueryableExtensions.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/MongoQueryableExtensions.cs deleted file mode 100644 index ba74d14..0000000 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.MongoDb/MongoQueryableExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -using MongoDB.Driver.Linq; - -namespace Cnblogs.Architecture.Ddd.Cqrs.MongoDb; - -/// -/// MongoDb Queryable 扩展方法。 -/// -public static class MongoQueryableExtensions -{ - /// - /// 将 转换为 。 - /// - /// 输入的 IQueryable。 - /// 查询类型。 - /// 转换后的 - /// 输入的 不是 - public static IMongoQueryable AsMongoQueryable(this IQueryable queryable) - { - if (queryable is IMongoQueryable mongoQueryable) - { - return mongoQueryable; - } - - throw new InvalidCastException("input is not mongo queryable"); - } -} \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj index 1721ba5..d96fb77 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj @@ -1,8 +1,30 @@ + + + Provides abstrations for implementing service agent. + + - + + + + + + + + + + + + + + CqrsHeaderNames.cs + + + CqrsVersionExtensions.cs + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/CqrsServiceAgent.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/CqrsServiceAgent.cs new file mode 100644 index 0000000..19f4da5 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/CqrsServiceAgent.cs @@ -0,0 +1,316 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; +using Cnblogs.Architecture.Ddd.Domain.Abstractions; +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; + +namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; + +/// +/// Base Class for CQRS Service Agent. +/// +public abstract class CqrsServiceAgent : CqrsServiceAgent +{ + /// + /// Create a Cqrs service agent. + /// + /// The underlying http client. + protected CqrsServiceAgent(HttpClient httpClient) + : base(httpClient) + { + } +} + +/// +/// Service Agent for CQRS +/// +/// The type of error for this service. +public abstract class CqrsServiceAgent + where TError : Enumeration +{ + /// + /// The underlying . + /// + protected HttpClient HttpClient { get; } + + /// + /// Create a service agent for cqrs api. + /// + /// The underlying HttpClient. + protected CqrsServiceAgent(HttpClient httpClient) + { + HttpClient = httpClient; + } + + /// + /// Execute a command with DELETE method. + /// + /// The url. + /// Response type. + /// The response. + public async Task> DeleteCommandAsync(string url) + { + var response = await HttpClient.DeleteAsync(url); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with DELETE method. + /// + /// The route of the API. + public async Task> DeleteCommandAsync(string url) + { + var response = await HttpClient.DeleteAsync(url); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with POST method. + /// + /// The route of the API. + public async Task> PostCommandAsync(string url) + { + var response = await HttpClient.PostAsync(url, new StringContent(string.Empty)); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with POST method and payload. + /// + /// The route of the API. + /// The request body. + /// The type of request body. + public async Task> PostCommandAsync(string url, TPayload payload) + { + var response = await HttpClient.PostAsJsonAsync(url, payload); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with POST method and payload. + /// + /// The route of the API. + /// The request body. + /// The type of response body. + /// The type of request body. + /// The response body. + public async Task> PostCommandAsync( + string url, + TPayload payload) + { + var response = await HttpClient.PostAsJsonAsync(url, payload); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with PUT method and payload. + /// + /// The route of API. + public async Task> PutCommandAsync(string url) + { + var response = await HttpClient.PutAsync(url, new StringContent(string.Empty)); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with PUT method and payload. + /// + /// The route of API. + /// The request body. + /// The type of request body. + /// The command response. + public async Task> PutCommandAsync(string url, TPayload payload) + { + var response = await HttpClient.PutAsJsonAsync(url, payload); + return await HandleCommandResponseAsync(response); + } + + /// + /// Execute a command with PUT method and payload. + /// + /// The route of API. + /// The request body. + /// The type of response body. + /// The type of request body. + /// The response body. + public async Task> PutCommandAsync( + string url, + TPayload payload) + { + var response = await HttpClient.PutAsJsonAsync(url, payload); + return await HandleCommandResponseAsync(response); + } + + /// + /// Query item with GET method. + /// + /// The route of the API. + /// The type of item to get. + /// The query result, can be null if item does not exists or status code is 404. + public async Task GetItemAsync(string url) + { + var response = await HttpClient.GetAsync(url); + return response.StatusCode switch + { + HttpStatusCode.OK => await response.Content.ReadFromJsonAsync(), + HttpStatusCode.NotFound => default, + _ => default + }; + } + + /// + /// Query item with HEAD method. + /// + /// The route of the API. + /// True if status code is 2xx, False if status code is 404. + public async Task HasItemAsync(string url) + { + var request = new HttpRequestMessage(HttpMethod.Head, url); + var response = await HttpClient.SendAsync(request); + if (response.IsSuccessStatusCode) + { + return true; + } + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + response.EnsureSuccessStatusCode(); // throw for other status code + return false; + } + + /// + /// Batch get items with GET method. + /// + /// The route of the API. + /// The name of id field. + /// The id list. + /// The type of the query result item. + /// The type of the id. + /// A list of items that contains id that in , the order or count of the items are not guaranteed. + public async Task> BatchGetItemsAsync( + string url, + string paramName, + IEnumerable ids) + where TId : notnull + { + var query = string.Join( + '&', + ids.Select(i => $"{WebUtility.UrlEncode(paramName)}={WebUtility.UrlEncode(i.ToString())}")); + url = $"{url}{(url.Contains('?') ? '&' : '?')}{query}"; + return await HttpClient.GetFromJsonAsync>(url) ?? new List(); + } + + /// + /// Get paged list of items based on url. + /// + /// The route of the API. + /// The paging parameters, including page size and page index. + /// Specifies the order of items to return. + /// The type of items to query. + /// The paged list of items. An empty list is returned when there is no result. + public async Task> ListPagedItemsAsync( + string url, + PagingParams? pagingParams = null, + string? orderByString = null) + { + return await ListPagedItemsAsync(url, pagingParams?.PageIndex, pagingParams?.PageSize, orderByString); + } + + /// + /// Get paged list of items. + /// + /// The route of the API. + /// The page index. + /// The page size. + /// Specifies the order of items to return. + /// The type of items to query. + /// The paged list of items. An empty list is returned when there is no result. + public async Task> ListPagedItemsAsync( + string url, + int? pageIndex, + int? pageSize, + string? orderByString = null) + { + if (pageIndex.HasValue && pageSize.HasValue) + { + var query = $"pageIndex={pageIndex}&pageSize={pageSize}&orderByString={orderByString}"; + url = url.Contains('?') ? url + "&" + query : url + "?" + query; + } + + return await HttpClient.GetFromJsonAsync>(url) ?? new PagedList(); + } + + /// + /// Get list of items. + /// + /// The url to send GET request. + /// The type of list. + /// The fetched list. + public async Task ListItemsAsync(string url) + where TList : new() + { + return await HttpClient.GetFromJsonAsync(url) ?? new TList(); + } + + private static async Task> HandleCommandResponseAsync( + HttpResponseMessage httpResponseMessage) + { + if (httpResponseMessage.StatusCode == HttpStatusCode.NoContent) + { + return CommandResponse.Success(); + } + + try + { + if (httpResponseMessage.StatusCode == HttpStatusCode.OK && httpResponseMessage.Headers.CqrsVersion() == 1) + { + var result = await httpResponseMessage.Content.ReadFromJsonAsync(); + return CommandResponse.Success(result); + } + + var response = await httpResponseMessage.Content.ReadFromJsonAsync>(); + if (response is null) + { + throw new InvalidOperationException( + $"Could not deserialize error from response, response: {await httpResponseMessage.Content.ReadAsStringAsync()}"); + } + + return response; + } + catch (JsonException) + { + throw new InvalidOperationException( + $"Deserialize response failed, status code: {httpResponseMessage.StatusCode}, Body:{await httpResponseMessage.Content.ReadAsStringAsync()}"); + } + } + + private static async Task> HandleCommandResponseAsync(HttpResponseMessage message) + { + if (message.IsSuccessStatusCode) + { + return CommandResponse.Success(); + } + + try + { + var response = await message.Content.ReadFromJsonAsync>(); + if (response is null) + { + throw new InvalidOperationException( + $"Could not deserialize error from response, response: {await message.Content.ReadAsStringAsync()}"); + } + + return response; + } + catch (JsonException) + { + throw new InvalidOperationException( + $"Deserialize response failed, status code: {message.StatusCode}, Body:{await message.Content.ReadAsStringAsync()}"); + } + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/IApiException.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/IApiException.cs index bda97a7..47f3328 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/IApiException.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/IApiException.cs @@ -3,42 +3,43 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; /// -/// API 异常接口 +/// Defines exceptions threw when doing an API call. /// -/// 异常类型。 +/// The type of this API exception. +[Obsolete("Try migrate to CqrsServiceAgent")] public interface IApiException where TException : Exception, IApiException { /// - /// HTTP 状态码,不适用则为 -1。 + /// The HTTP status code, -1 if not applied. /// int StatusCode { get; } /// - /// 错误信息。 + /// The raw error message. /// string Message { get; } /// - /// 显示给用户的错误信息。 + /// The error message to display, can be null if such message is not available. /// string? UserFriendlyMessage { get; } /// - /// 抛出异常。 + /// Throw a . /// - /// HTTP 状态码,若不适用则为 -1。 - /// 错误信息。 - /// 给用户显示的错误信息。 + /// HTTP status code, -1 if not available. + /// The error message. + /// The error message to display, can be null if such message is not available. [DoesNotReturn] - abstract static void Throw(int statusCode = -1, string message = "", string? userFriendlyMessage = null); + static abstract void Throw(int statusCode = -1, string message = "", string? userFriendlyMessage = null); /// - /// 创建异常。 + /// Create(but not throw) a . /// - /// HTTP 状态码,若不适用则为 -1。 - /// 错误信息。 - /// 给用户显示的错误信息。 - /// - abstract static TException Create(int statusCode = -1, string message = "", string? userFriendlyMessage = null); -} \ No newline at end of file + /// HTTP status code, -1 if not available. + /// The error message. + /// The error message to display, can be null if such message is not available. + /// A new instance of . + static abstract TException Create(int statusCode = -1, string message = "", string? userFriendlyMessage = null); +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/InjectExtensions.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/InjectExtensions.cs new file mode 100644 index 0000000..a8ed4e5 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/InjectExtensions.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http.Headers; +using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; +using Microsoft.Extensions.DependencyInjection; +using Polly; +using Polly.Extensions.Http; + +namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; + +/// +/// Inject helper for service agent +/// +public static class InjectExtensions +{ + /// + /// Inject a service agent to services. + /// + /// The . + /// The base uri for api. + /// The polly policy for underlying httpclient. + /// The type of service agent + /// + public static IHttpClientBuilder AddServiceAgent( + this IServiceCollection services, + string baseUri, + IAsyncPolicy? policy = null) + where TClient : class + { + policy ??= GetDefaultPolicy(); + return services.AddHttpClient( + h => + { + h.BaseAddress = new Uri(baseUri); + h.AddCqrsAcceptHeaders(); + }).AddPolicyHandler(policy); + } + + /// + /// Inject a service agent to services. + /// + /// The . + /// The base uri for api. + /// The polly policy for underlying httpclient. + /// The type of api client. + /// The type of service agent + /// + public static IHttpClientBuilder AddServiceAgent( + this IServiceCollection services, + string baseUri, + IAsyncPolicy? policy = null) + where TClient : class + where TImplementation : class, TClient + { + policy ??= GetDefaultPolicy(); + return services.AddHttpClient( + h => + { + h.BaseAddress = new Uri(baseUri); + h.AddCqrsAcceptHeaders(); + }).AddPolicyHandler(policy); + } + + private static void AddCqrsAcceptHeaders(this HttpClient h) + { + h.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + h.DefaultRequestHeaders.AppendCurrentCqrsVersion(); + } + + private static IAsyncPolicy GetDefaultPolicy() + { + return HttpPolicyExtensions.HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(1500)); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentBase.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentBase.cs index a99e0d1..2d60860 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentBase.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentBase.cs @@ -1,38 +1,38 @@ using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Http.Json; - using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; /// -/// ServiceAgent 的基础类。 +/// Base class for service agent. /// -/// 异常类型。 +/// The type of exception that this service agent throws. +[Obsolete("Try migrate to CqrsServiceAgent")] public abstract class ServiceAgentBase where TException : Exception, IApiException { /// - /// 构造一个 + /// Create a . /// - /// 用于访问 API 的 。 + /// The underlying used to access the API. protected ServiceAgentBase(HttpClient httpClient) { HttpClient = httpClient; } /// - /// 用于访问 API 的 。 + /// The underlying . /// protected HttpClient HttpClient { get; } /// - /// 发送一个 DELETE 请求。 + /// Execute a command with DELETE method. /// - /// 目标 API 路径。 - /// 返回结果类型。 - /// 返回结果。 + /// The url. + /// Response type. + /// The response. public async Task DeleteCommandAsync(string url) { try @@ -47,9 +47,9 @@ protected ServiceAgentBase(HttpClient httpClient) } /// - /// 发起一个 DELETE 请求。 + /// Execute a command with DELETE method. /// - /// API 路径。 + /// The route of the API. public async Task DeleteCommandAsync(string url) { HttpResponseMessage response; @@ -71,9 +71,9 @@ public async Task DeleteCommandAsync(string url) } /// - /// 发起一个 POST 请求。 + /// Execute a command with POST method. /// - /// 路径。 + /// The route of the API. public async Task PostCommandAsync(string url) { HttpResponseMessage response; @@ -95,11 +95,11 @@ public async Task PostCommandAsync(string url) } /// - /// 发起一个带 Body 的 POST 请求。 + /// Execute a command with POST method and payload. /// - /// 路径。 - /// 请求。 - /// 请求类型。 + /// The route of the API. + /// The request body. + /// The type of request body. public async Task PostCommandAsync(string url, TPayload payload) { HttpResponseMessage response; @@ -121,13 +121,13 @@ public async Task PostCommandAsync(string url, TPayload payload) } /// - /// 发起一个带 Body 的 POST 请求。 + /// Execute a command with POST method and payload. /// - /// 路径。 - /// 请求。 - /// 返回类型。 - /// 请求类型。 - /// + /// The route of the API. + /// The request body. + /// The type of response body. + /// The type of request body. + /// The response body. public async Task PostCommandAsync(string url, TPayload payload) { HttpResponseMessage response; @@ -160,13 +160,63 @@ public async Task PostCommandAsync(string url, T } /// - /// 发起一个 PUT 请求。 + /// Execute a command with PUT method and payload. + /// + /// The route of API. + public async Task PutCommandAsync(string url) + { + HttpResponseMessage response; + try + { + response = await HttpClient.PutAsync(url, new StringContent(string.Empty)); + } + catch (Exception e) + { + ThrowApiException(HttpMethod.Put, url, e); + return; + } + + if (response.IsSuccessStatusCode == false) + { + var content = await response.Content.ReadAsStringAsync(); + ThrowApiException(HttpMethod.Put, response.StatusCode, url, content); + } + } + + /// + /// Execute a command with PUT method and payload. + /// + /// The route of API. + /// The request body. + /// The type of request body. + public async Task PutCommandAsync(string url, TPayload payload) + { + HttpResponseMessage response; + try + { + response = await HttpClient.PutAsJsonAsync(url, payload); + } + catch (Exception e) + { + ThrowApiException(HttpMethod.Put, url, payload, e); + return; + } + + if (response.IsSuccessStatusCode == false) + { + var content = await response.Content.ReadAsStringAsync(); + ThrowApiException(HttpMethod.Put, response.StatusCode, url, payload, content); + } + } + + /// + /// Execute a command with PUT method and payload. /// - /// 路径。 - /// 请求内容。 - /// 返回结果类型。 - /// 请求类型。 - /// + /// The route of API. + /// The request body. + /// The type of response body. + /// The type of request body. + /// The response body. public async Task PutCommandAsync(string url, TPayload payload) { HttpResponseMessage response; @@ -199,10 +249,11 @@ public async Task PutCommandAsync(string url, TP } /// - /// 获取内容。 + /// Query item with GET method. /// - /// 路径。 - /// 结果类型。 + /// The route of the API. + /// The type of item to get. + /// The query result, can be null if item does not exists or status code is 404. public async Task GetItemAsync(string url) { try @@ -222,14 +273,14 @@ public async Task PutCommandAsync(string url, TP } /// - /// 批量获取实体。 + /// Batch get items with GET method. /// - /// 路径。 - /// 参数名称。 - /// 主键列表。 - /// 返回类型。 - /// 主键类型。 - /// + /// The route of the API. + /// The name of id field. + /// The id list. + /// The type of the query result item. + /// The type of the id. + /// A list of items that contains id that in , the order or count of the items are not guaranteed. public async Task> BatchGetItemsAsync( string url, string paramName, @@ -260,13 +311,13 @@ public async Task> BatchGetItemsAsync( } /// - /// 获取分页列表。 + /// Get paged list of items based on url. /// - /// 路径。 - /// 页码。 - /// 分页大小。 - /// 实体类型。 - /// + /// The route of the API. + /// The paging parameters, including page size and page index. + /// Specifies the order of items to return. + /// The type of items to query. + /// The paged list of items. An empty list is returned when there is no result. public async Task> ListPagedItemsAsync( string url, PagingParams? pagingParams = null, @@ -276,13 +327,14 @@ public async Task> ListPagedItemsAsync( } /// - /// 获取分页列表。 + /// Get paged list of items based on url. /// - /// 路径。 - /// 页码。 - /// 分页大小。 - /// 排序字符串。 - /// 实体类型。 + /// The route of the API. + /// The page index. + /// The page size. + /// Specifies the order of items to return. + /// The type of items to query. + /// The paged list of items. An empty list is returned when there is no result. public async Task> ListPagedItemsAsync( string url, int? pageIndex, @@ -315,14 +367,14 @@ public async Task> ListPagedItemsAsync( } /// - /// 处理抛出异常的情况。 + /// Throw exceptions. /// - /// 请求方法。 - /// 状态码,若不适用则是 -1。 - /// 请求的 Url - /// 请求内容。 - /// 返回内容。 - /// 异常。 + /// The method for this request. + /// HTTP status code, -1 if not available. + /// The URL to request. + /// The request body. + /// The response body. + /// The exception. [DoesNotReturn] protected virtual void ThrowApiException( HttpMethod method, @@ -333,7 +385,7 @@ protected virtual void ThrowApiException( Exception? e) { var message = response ?? e?.Message; - throw TException.Create(statusCode, $"{method} {url} failed with error: {message}"); + throw TException.Create(statusCode, $"{method} {url} failed with error: {message}", message); } private void ThrowApiException(HttpMethod method, HttpStatusCode statusCode, string url, string responseString) @@ -352,4 +404,4 @@ private void ThrowApiException( private void ThrowApiException(HttpMethod method, string url, object? body, Exception e) => ThrowApiException(method, -1, url, body, null, e); -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentError.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentError.cs new file mode 100644 index 0000000..d014f7c --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/ServiceAgentError.cs @@ -0,0 +1,24 @@ +using Cnblogs.Architecture.Ddd.Domain.Abstractions; + +namespace Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent; + +/// +/// ServiceAgent errors. +/// +public class ServiceAgentError : Enumeration +{ + /// + /// The default error code. + /// + public static readonly ServiceAgentError UnknownError = new(-1, "Unknown error"); + + /// + /// Create a service agent error. + /// + /// The error code. + /// The error name. + public ServiceAgentError(int id, string name) + : base(id, name) + { + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/Cnblogs.Architecture.Ddd.Domain.Abstractions.csproj b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/Cnblogs.Architecture.Ddd.Domain.Abstractions.csproj index 6eee1a7..9c20865 100644 --- a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/Cnblogs.Architecture.Ddd.Domain.Abstractions.csproj +++ b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/Cnblogs.Architecture.Ddd.Domain.Abstractions.csproj @@ -1,7 +1,20 @@ - - - + + + Provides abstractions for implementing DDD patterns. + Commonly used types: + Cnblogs.Architecture.Ddd.Domain.Abstrations.Entity + Cnblogs.Architecture.Ddd.Domain.Abstrations.DomainEvent + Cnblogs.Architecture.Ddd.Domain.Abstrations.IRepository + Cnblogs.Architecture.Ddd.Domain.Abstrations.IAggregateRoot + Cnblogs.Architecture.Ddd.Domain.Abstrations.IDateTimeProvider + Cnblogs.Architecture.Ddd.Domain.Abstrations.IRandomProvider + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/DefaultDateTimeProvider.cs b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/DefaultDateTimeProvider.cs index 258cd6e..58fee8a 100644 --- a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/DefaultDateTimeProvider.cs +++ b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/DefaultDateTimeProvider.cs @@ -11,9 +11,16 @@ public DateTimeOffset Now() return DateTimeOffset.Now; } + /// + public DateTimeOffset Today() + { + var now = Now(); + return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, now.Offset); + } + /// public long UnixSeconds() { return Now().ToUnixTimeSeconds(); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/DomainEvent.cs b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/DomainEvent.cs index b32805f..6de33b0 100644 --- a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/DomainEvent.cs +++ b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/DomainEvent.cs @@ -1,11 +1,9 @@ -using MediatR; - -namespace Cnblogs.Architecture.Ddd.Domain.Abstractions; +namespace Cnblogs.Architecture.Ddd.Domain.Abstractions; /// /// 领域事件基类。 /// -public abstract record DomainEvent : IDomainEvent, INotification +public abstract record DomainEvent : IDomainEvent { /// /// 领域事件生成时间。 diff --git a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IAggregateRoot.cs b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IAggregateRoot.cs index 33b12eb..0a60713 100644 --- a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IAggregateRoot.cs +++ b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IAggregateRoot.cs @@ -3,6 +3,4 @@ /// /// 聚合根标记。 /// -public interface IAggregateRoot -{ -} \ No newline at end of file +public interface IAggregateRoot; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IDateTimeProvider.cs b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IDateTimeProvider.cs index 8d4bcf6..81f448a 100644 --- a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IDateTimeProvider.cs +++ b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IDateTimeProvider.cs @@ -1,19 +1,25 @@ namespace Cnblogs.Architecture.Ddd.Domain.Abstractions; /// -/// 时间生成器。 +/// Provides system date time. /// public interface IDateTimeProvider { /// - /// 获取当前时间。 + /// Get current time. /// - /// 当前时间。 + /// Current time. DateTimeOffset Now(); /// - /// 获取当前距离 1970-01-01T00:00:00Z 的秒数。 + /// Get today's date. /// - /// + /// Today's date. + DateTimeOffset Today(); + + /// + /// Get number of seconds that have elapsed since 1970-01-01T00:00:00Z. + /// + /// The number of seconds that have elapsed since 1970-01-01T00:00:00Z. long UnixSeconds(); -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IDomainEvent.cs b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IDomainEvent.cs index 88fc372..06ee06c 100644 --- a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IDomainEvent.cs +++ b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IDomainEvent.cs @@ -1,8 +1,8 @@ -namespace Cnblogs.Architecture.Ddd.Domain.Abstractions; +using MediatR; + +namespace Cnblogs.Architecture.Ddd.Domain.Abstractions; /// /// 领域事件标记。 /// -public interface IDomainEvent -{ -} \ No newline at end of file +public interface IDomainEvent : INotification; diff --git a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/INavigationRepository.cs b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/INavigationRepository.cs index 69b37c5..b86f475 100644 --- a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/INavigationRepository.cs +++ b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/INavigationRepository.cs @@ -18,4 +18,12 @@ public interface INavigationRepository : IRepository要额外加载的其他实体。 /// 对应的实体。 Task GetAsync(TKey key, params Expression>[] includes); -} \ No newline at end of file + + /// + /// Get entity by key. + /// + /// The key of entity. + /// Include strings. + /// The entity with key equals to . + Task GetAsync(TKey key, IEnumerable includes); +} diff --git a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/ISqlRepository.cs b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/ISqlRepository.cs new file mode 100644 index 0000000..fd3d5b7 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/ISqlRepository.cs @@ -0,0 +1,36 @@ +namespace Cnblogs.Architecture.Ddd.Domain.Abstractions; + +/// +/// Repository that support raw sql execution. +/// +/// The type of entity. +/// The type of key. +public interface ISqlRepository : IRepository + where TEntity : EntityBase, IAggregateRoot + where TKey : IComparable +{ + /// + /// Query entity with raw sql. + /// + /// The sql string. + /// The parameters + /// + IQueryable SqlQuery(string sql, params object[] parameters); + + /// + /// Query with raw sql. + /// + /// The sql string. + /// The parameters. + /// The type of query result. + /// + IQueryable SqlQuery(string sql, params object[] parameters); + + /// + /// Execute raw sql. + /// + /// The sql string. + /// The parameters. + /// The number of rows affected. + Task ExecuteSqlAsync(string sql, params object[] parameters); +} diff --git a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IUnitOfWork.cs b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IUnitOfWork.cs index 85e2897..d993245 100644 --- a/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IUnitOfWork.cs +++ b/src/Cnblogs.Architecture.Ddd.Domain.Abstractions/IUnitOfWork.cs @@ -20,7 +20,6 @@ public interface IUnitOfWork /// 添加实体,调用 后才会写入数据库。 /// /// 要添加实体。 - /// 实体类型。 /// 被添加的实体。 TEntity Add(TEntity entity); @@ -28,7 +27,6 @@ public interface IUnitOfWork /// 更新实体,调用 后才会写入数据库。 /// /// 要更新的实体。 - /// 实体类型。 /// 被更新的实体。 TEntity Update(TEntity entity); @@ -36,7 +34,6 @@ public interface IUnitOfWork /// 删除实体,调用 后才会写入数据库。 /// /// 要删除的实体。 - /// 实体类型。 /// TEntity Delete(TEntity entity); @@ -53,4 +50,4 @@ public interface IUnitOfWork /// 。 /// 提交是否成功。 Task SaveEntitiesAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/AssemblyAppNameAttribute.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/AssemblyAppNameAttribute.cs index 7e8f176..f99300c 100644 --- a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/AssemblyAppNameAttribute.cs +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/AssemblyAppNameAttribute.cs @@ -6,7 +6,10 @@ [AttributeUsage(AttributeTargets.Assembly)] public class AssemblyAppNameAttribute : Attribute { - /// + /// + /// 配置应用名称。 + /// + /// 应用名称。 public AssemblyAppNameAttribute(string name) { Name = name; diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/BufferedIntegrationEvent.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/BufferedIntegrationEvent.cs new file mode 100644 index 0000000..47c56b7 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/BufferedIntegrationEvent.cs @@ -0,0 +1,8 @@ +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// The integration event stored in buffer. +/// +/// The event name. +/// The event data. +public record BufferedIntegrationEvent(string Name, IntegrationEvent Event); diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/Cnblogs.Architecture.Ddd.EventBus.Abstractions.csproj b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/Cnblogs.Architecture.Ddd.EventBus.Abstractions.csproj index e4c4c72..8f83a44 100644 --- a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/Cnblogs.Architecture.Ddd.EventBus.Abstractions.csproj +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/Cnblogs.Architecture.Ddd.EventBus.Abstractions.csproj @@ -1,5 +1,11 @@ - - - + + + + Provides abstractions for implementing distributed message system. + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBus.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/DefaultEventBus.cs similarity index 50% rename from src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBus.cs rename to src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/DefaultEventBus.cs index 952aca5..f9277bc 100644 --- a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBus.cs +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/DefaultEventBus.cs @@ -1,61 +1,44 @@ -using Cnblogs.Architecture.Ddd.EventBus.Abstractions; -using Dapr.Client; using MediatR; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -namespace Cnblogs.Architecture.Ddd.EventBus.Dapr; +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; /// -/// Dapr EventBus 实现。 +/// Default implementation for /// -public class DaprEventBus : IEventBus +public class DefaultEventBus : IEventBus { - private readonly DaprClient _daprClient; - private readonly DaprOptions _daprOptions; + private readonly IEventBuffer _eventBuffer; private readonly IMediator _mediator; - private readonly ILogger _logger; + private readonly ILogger _logger; /// - /// 创建一个 DaprEventBus + /// Create a instance. /// - /// - /// - /// 日志记录器。 - /// - public DaprEventBus( - IOptions daprOptions, - DaprClient daprClient, - IMediator mediator, - ILogger logger) + /// The underlying event buffer. + /// The IMediator. + /// The logger. + public DefaultEventBus(IEventBuffer eventBuffer, IMediator mediator, ILogger logger) { - _daprClient = daprClient; + _eventBuffer = eventBuffer; _logger = logger; _mediator = mediator; - _daprOptions = daprOptions.Value; } /// - public async Task PublishAsync(TEvent @event) + public Task PublishAsync(TEvent @event) where TEvent : IntegrationEvent { - await PublishAsync(typeof(TEvent).Name, @event); + return PublishAsync(typeof(TEvent).Name, @event); } /// - public async Task PublishAsync(string eventName, TEvent @event) + public Task PublishAsync(string eventName, TEvent @event) where TEvent : IntegrationEvent { - _logger.LogInformation( - "Publishing IntegrationEvent, Name: {EventName}, Body: {Event}, TraceId: {TraceId}", - eventName, - @event, - @event.TraceId ?? @event.Id); - var eventToSend = @event.TraceId = TraceId; - await _daprClient.PublishEventAsync( - DaprOptions.PubSubName, - DaprUtils.GetDaprTopicName(_daprOptions.AppName, eventName), - eventToSend); + @event.TraceId = TraceId; + _eventBuffer.Add(eventName, @event); + return Task.CompletedTask; } /// @@ -97,4 +80,4 @@ public Task ReceiveAsync(TEvent receivedEvent) /// public Guid? TraceId { get; set; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBufferOverflowException.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBufferOverflowException.cs new file mode 100644 index 0000000..8107b2a --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBufferOverflowException.cs @@ -0,0 +1,15 @@ +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// The exception that is thrown when reaches its maximum capacity configured in . +/// +public sealed class EventBufferOverflowException : Exception +{ + /// + /// Creates an . + /// + public EventBufferOverflowException() + : base("Event buffer reached its maximum capacity") + { + } +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBusOptions.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBusOptions.cs new file mode 100644 index 0000000..e4756d1 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBusOptions.cs @@ -0,0 +1,37 @@ +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// Options for event bus. +/// +public class EventBusOptions +{ + /// + /// Interval for publish integration event. Defaults to 1000ms. + /// + public int Interval { get; set; } = 1000; + + /// + /// Maximum number of events that can be sent in one cycle. Pass null to disable limit. Defaults to null. + /// + public int? MaximumBatchSize { get; set; } + + /// + /// Maximum number of events that can be stored in buffer. An would be thrown when the number of events in buffer exceeds this limit. Pass null to disable limit. Defaults to null. + /// + public int? MaximumBufferSize { get; set; } + + /// + /// The maximum number of failure before downgrade. Defaults to 5. + /// + public int FailureCountBeforeDowngrade { get; set; } = 5; + + /// + /// Interval when downgraded. Defaults to 60000ms(1min). + /// + public int DowngradeInterval { get; set; } = 60 * 1000; + + /// + /// The maximum number of success before recover. Defaults to 1. + /// + public int SuccessCountBeforeRecover { get; set; } = 1; +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBusOptionsBuilder.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBusOptionsBuilder.cs new file mode 100644 index 0000000..1c8d549 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBusOptionsBuilder.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// Builder for . +/// +public class EventBusOptionsBuilder +{ + /// + /// Create a . + /// + /// + public EventBusOptionsBuilder(IServiceCollection services) + { + Services = services; + } + + /// + /// Internal service collection. + /// + public IServiceCollection Services { get; } + + /// + /// The interval in milliseconds for checking pending integration events. + /// + public int Interval { get; set; } = 1000; + + internal Action GetConfiguration() + { + return o => + { + o.Interval = Interval; + }; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBusServiceInjector.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBusServiceInjector.cs new file mode 100644 index 0000000..357cbf0 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/EventBusServiceInjector.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// Extension methods for injecting to service collection. +/// +public static class EventBusServiceInjector +{ + /// + /// Add event bus for integration event support. + /// + /// The services. + /// Extra configurations for event bus. + /// The assemblies for handlers. + /// . + public static IServiceCollection AddEventBus( + this IServiceCollection services, + Action? configuration = null, + params Assembly[] handlerAssemblies) + { + services.TryAddSingleton(); + services.TryAddScoped(); + services.AddHostedService(); + var builder = new EventBusOptionsBuilder(services); + configuration?.Invoke(builder); + services.Configure(builder.GetConfiguration()); + if (handlerAssemblies.Length > 0) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(handlerAssemblies)); + } + + return services; + } + + /// + /// Add event bus for integration event support. + /// + /// The . + /// The configuration. + /// The assemblies for handlers. + /// + public static CqrsInjector AddEventBus( + this CqrsInjector cqrsInjector, + Action? configuration = null, + params Assembly[] handlerAssemblies) + { + cqrsInjector.Services.AddEventBus(configuration, handlerAssemblies); + return cqrsInjector; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IEventBuffer.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IEventBuffer.cs new file mode 100644 index 0000000..dc34d49 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IEventBuffer.cs @@ -0,0 +1,34 @@ +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// Buffer for integration events. +/// +public interface IEventBuffer +{ + /// + /// Number of pending events. + /// + int Count { get; } + + /// + /// Add an event to buffer. + /// + /// The name of integration event. + /// The event. + /// The type of integration event. + /// Throws when the number of events in buffer exceeds . + void Add(string name, TEvent @event) + where TEvent : IntegrationEvent; + + /// + /// Get an integration event without removing it. + /// + /// The integration event, null will be returned if buffer is empty. + BufferedIntegrationEvent? Peek(); + + /// + /// Get an integration event and remove it. + /// + /// The integration event, null will be returned if buffer is empty. + BufferedIntegrationEvent? Pop(); +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IEventBusProvider.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IEventBusProvider.cs new file mode 100644 index 0000000..527c19e --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IEventBusProvider.cs @@ -0,0 +1,15 @@ +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// Provider contract for event bus. +/// +public interface IEventBusProvider +{ + /// + /// Emit an integration event. + /// + /// The name of the event. + /// The event body. + /// + Task PublishAsync(string eventName, IntegrationEvent @event); +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IEventBusRequestHandler.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IEventBusRequestHandler.cs new file mode 100644 index 0000000..8281513 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IEventBusRequestHandler.cs @@ -0,0 +1,6 @@ +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// The empty interface as a generic type constraint +/// +public interface IEventBusRequestHandler; diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IIntegrationEventHandler.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IIntegrationEventHandler.cs index 17d17d2..3d116d0 100644 --- a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IIntegrationEventHandler.cs +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/IIntegrationEventHandler.cs @@ -1,4 +1,4 @@ -using MediatR; +using MediatR; namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; @@ -6,7 +6,5 @@ namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; /// 集成事件处理器。 /// /// 集成事件。 -public interface IIntegrationEventHandler : INotificationHandler - where TEvent : IntegrationEvent -{ -} \ No newline at end of file +public interface IIntegrationEventHandler : INotificationHandler, IEventBusRequestHandler + where TEvent : IntegrationEvent; diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/InMemoryEventBuffer.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/InMemoryEventBuffer.cs new file mode 100644 index 0000000..35b9f15 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/InMemoryEventBuffer.cs @@ -0,0 +1,49 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; + +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// Implementation of using . +/// +public class InMemoryEventBuffer : IEventBuffer +{ + private readonly ConcurrentQueue _queue = new(); + private readonly EventBusOptions _options; + + /// + /// Creates an . + /// + /// The Eventbus options. + public InMemoryEventBuffer(IOptions options) + { + _options = options.Value; + } + + /// + public int Count => _queue.Count; + + /// + public void Add(string name, TEvent @event) + where TEvent : IntegrationEvent + { + if (_queue.Count >= _options.MaximumBufferSize) + { + throw new EventBufferOverflowException(); + } + + _queue.Enqueue(new BufferedIntegrationEvent(name, @event)); + } + + /// + public BufferedIntegrationEvent? Peek() + { + return _queue.TryPeek(out var @event) ? @event : null; + } + + /// + public BufferedIntegrationEvent? Pop() + { + return _queue.TryDequeue(out var @event) ? @event : null; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/PublishIntegrationEventHostedService.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/PublishIntegrationEventHostedService.cs new file mode 100644 index 0000000..b760d53 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Abstractions/PublishIntegrationEventHostedService.cs @@ -0,0 +1,120 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +/// +/// The hosted service for publishing integration event at background. +/// +public sealed class PublishIntegrationEventHostedService : BackgroundService +{ + private readonly EventBusOptions _options; + private readonly IServiceProvider _serviceProvider; + private readonly IEventBuffer _eventBuffer; + private readonly ILogger _logger; + + /// + /// Create a . + /// + /// The event bus options. + /// The service provider. + /// The logger. + /// The buffer for integration events. + public PublishIntegrationEventHostedService( + IOptions options, + IServiceProvider serviceProvider, + ILogger logger, + IEventBuffer eventBuffer) + { + _options = options.Value; + _serviceProvider = serviceProvider; + _logger = logger; + _eventBuffer = eventBuffer; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Integration event publisher running"); + var watch = new Stopwatch(); + var failureCounter = 0; + var successCounter = 0; + using var normalTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(_options.Interval)); + using var failedTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(_options.DowngradeInterval)); + var currentTimer = normalTimer; + var downgraded = false; + while (await currentTimer.WaitForNextTickAsync(stoppingToken)) + { + try + { + watch.Restart(); + var sent = await PublishEventAsync(); + watch.Stop(); + var afterCount = _eventBuffer.Count; + if (sent > 0) + { + successCounter++; + _logger.LogInformation( + "Published {PublishedEventCount} events in {Duration} ms, resting count: {RestingEventCount}", + sent, + watch.ElapsedMilliseconds, + afterCount); + } + } + catch (Exception e) + { + failureCounter++; + _logger.LogWarning( + e, + "Publish integration event failed, pending count: {Count}, failure count: {FailureCount}", + _eventBuffer.Count, + failureCounter); + } + + if (downgraded == false && failureCounter >= _options.FailureCountBeforeDowngrade) + { + _logger.LogError("Integration event publisher downgraded"); + downgraded = true; + currentTimer = failedTimer; + successCounter = 0; + } + + if (downgraded && successCounter > _options.SuccessCountBeforeRecover) + { + downgraded = false; + currentTimer = normalTimer; + failureCounter = 0; + _logger.LogWarning("Integration event publisher recovered from downgrade"); + } + } + } + + private async Task PublishEventAsync() + { + if (_eventBuffer.Count == 0) + { + return 0; + } + + using var scope = _serviceProvider.CreateScope(); + var provider = scope.ServiceProvider.GetRequiredService(); + var publishedEventCount = 0; + while (_eventBuffer.Count > 0 && publishedEventCount != _options.MaximumBatchSize) + { + var buffered = _eventBuffer.Peek(); + if (buffered is null) + { + break; + } + + await provider.PublishAsync(buffered.Name, buffered.Event); + _eventBuffer.Pop(); + publishedEventCount++; + } + + return publishedEventCount; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/Cnblogs.Architecture.Ddd.EventBus.Dapr.csproj b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/Cnblogs.Architecture.Ddd.EventBus.Dapr.csproj index 5ada880..44ef61a 100644 --- a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/Cnblogs.Architecture.Ddd.EventBus.Dapr.csproj +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/Cnblogs.Architecture.Ddd.EventBus.Dapr.csproj @@ -1,9 +1,14 @@ - - - - - - - + + + Implements distributed message system with dapr. + + + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr/CqrsInjectorExtensions.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/CqrsInjectorExtensions.cs similarity index 56% rename from src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr/CqrsInjectorExtensions.cs rename to src/Cnblogs.Architecture.Ddd.EventBus.Dapr/CqrsInjectorExtensions.cs index ffc2e07..c81873a 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr/CqrsInjectorExtensions.cs +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/CqrsInjectorExtensions.cs @@ -1,11 +1,8 @@ using System.Reflection; - +using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; using Cnblogs.Architecture.Ddd.EventBus.Abstractions; -using Cnblogs.Architecture.Ddd.EventBus.Dapr; - -using Microsoft.Extensions.DependencyInjection; -namespace Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.EventBus.Dapr; +namespace Cnblogs.Architecture.Ddd.EventBus.Dapr; /// /// 添加 Dapr 到 @@ -18,16 +15,10 @@ public static class CqrsInjectorExtensions /// /// 集成事件所在的 Assembly。 /// + [Obsolete("Use builder.AddCqrs().AddEventBus(o => o.UseDapr(assembly)) instead.", true)] public static CqrsInjector AddDaprEventBus(this CqrsInjector cqrsInjector, Assembly integrationEventAssembly) { - var appName = integrationEventAssembly.GetCustomAttribute(); - if (appName is null) - { - throw new InvalidOperationException( - "No AssemblyAppNameAttribute was found, add attribute to Assembly or specify AppName with AddDaprEventBus(string appName)"); - } - - return cqrsInjector.AddDaprEventBus(appName.Name); + return cqrsInjector.AddEventBus(o => o.UseDapr(integrationEventAssembly)); } /// @@ -36,11 +27,10 @@ public static CqrsInjector AddDaprEventBus(this CqrsInjector cqrsInjector, Assem /// /// 发布事件时使用的 appName。 /// + [Obsolete("Use builder.AddCqrs().AddEventBus(o => o.UseDapr(appName)) instead.", true)] public static CqrsInjector AddDaprEventBus(this CqrsInjector cqrsInjector, string appName) { - cqrsInjector.Services.Configure(o => o.AppName = appName); - cqrsInjector.Services.AddControllers().AddDapr(); - cqrsInjector.Services.AddScoped(); + cqrsInjector.AddEventBus(o => o.UseDapr(appName)); return cqrsInjector; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBusInjector.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBusInjector.cs new file mode 100644 index 0000000..cb5d981 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBusInjector.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Cnblogs.Architecture.Ddd.EventBus.Dapr; + +/// +/// Injector methods for dapr event bus. +/// +public static class DaprEventBusInjector +{ + /// + /// Use dapr as event bus provider. + /// + /// The . + /// The assembly of integration events of current app. + /// + public static EventBusOptionsBuilder UseDapr(this EventBusOptionsBuilder builder, Assembly integrationEventAssembly) + { + var appName = integrationEventAssembly.GetCustomAttribute(); + if (appName is null) + { + throw new InvalidOperationException( + "No AssemblyAppNameAttribute was found, add attribute to Assembly or specify AppName with AddDaprEventBus(string appName)"); + } + + return builder.UseDapr(appName.Name); + } + + /// + /// Use dapr as event bus provider. + /// + /// The . + /// The name of current app. + /// + public static EventBusOptionsBuilder UseDapr(this EventBusOptionsBuilder builder, string appName) + { + var services = builder.Services; + services.Configure(o => o.AppName = appName); + services.AddControllers().AddDapr(); + services.AddScoped(); + return builder; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBusProvider.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBusProvider.cs new file mode 100644 index 0000000..e871e05 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBusProvider.cs @@ -0,0 +1,49 @@ +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Dapr.Client; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Cnblogs.Architecture.Ddd.EventBus.Dapr; + +/// +/// Implementations for using Dapr. +/// +public class DaprEventBusProvider : IEventBusProvider +{ + private readonly DaprClient _daprClient; + private readonly DaprOptions _daprOptions; + private readonly ILogger _logger; + + /// + /// Create a . + /// + /// The underlying dapr client. + /// The options for dapr. + /// The logger. + public DaprEventBusProvider( + DaprClient daprClient, + IOptions daprOptions, + ILogger logger) + { + _daprClient = daprClient; + _daprOptions = daprOptions.Value; + _logger = logger; + } + + /// + public async Task PublishAsync(string eventName, IntegrationEvent @event) + { + _logger.LogInformation( + "Publishing IntegrationEvent, PubSub: {PubSubName}, TopicName: {TopicName}, Name: {EventName}, Body: {Event}, TraceId: {TraceId}", + DaprOptions.PubSubName, + DaprUtils.GetDaprTopicName(_daprOptions.AppName, eventName), + eventName, + @event, + @event.TraceId ?? @event.Id); + object data = @event; // do not provide type information to serializer since it's base class. + await _daprClient.PublishEventAsync( + DaprOptions.PubSubName, + DaprUtils.GetDaprTopicName(_daprOptions.AppName, eventName), + data); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBusServiceCollectionExtensions.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBusServiceCollectionExtensions.cs new file mode 100644 index 0000000..7c8f69f --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprEventBusServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Cnblogs.Architecture.Ddd.EventBus.Dapr; +using Dapr.Client; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// IServiceCollection extensions for DaprEventBus +/// +public static class DaprEventBusServiceCollectionExtensions +{ + /// + /// Register and . + /// The alternative is using services.AddCqrs().AddDaprEventBus() in . + /// + /// + /// The app name used when publishing integration events. + /// Assemblies to scan by MediatR + /// + [Obsolete("use services.AddEventBus(o => o.UseDapr(), assemblies) instead.", true)] + public static IServiceCollection AddDaprEventBus( + this IServiceCollection services, + string appName, + params Assembly[] assemblies) + { + services.AddEventBus(o => o.UseDapr(appName), assemblies); + return services; + } +} diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprOptions.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprOptions.cs index 6236c33..ce21a14 100644 --- a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprOptions.cs +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/DaprOptions.cs @@ -18,5 +18,7 @@ public class DaprOptions /// /// 是否调用过 app.MapSubscribeHandler() /// - internal static bool IsDaprSubscribeHandlerMapped { get; set; } + internal bool IsDaprSubscribeHandlerMapped { get; set; } + + internal bool IsEventBusRegistered { get; set; } } \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/EndPointExtensions.cs b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/EndPointExtensions.cs index 3eb5171..fb3923f 100644 --- a/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/EndPointExtensions.cs +++ b/src/Cnblogs.Architecture.Ddd.EventBus.Dapr/EndPointExtensions.cs @@ -1,9 +1,13 @@ using System.Reflection; using Cnblogs.Architecture.Ddd.EventBus.Abstractions; -using Microsoft.AspNetCore.Builder; +using Cnblogs.Architecture.Ddd.EventBus.Dapr; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; -namespace Cnblogs.Architecture.Ddd.EventBus.Dapr; +// ReSharper disable once CheckNamespace +namespace Microsoft.AspNetCore.Builder; /// /// 用于事件订阅的扩展方法。 @@ -16,20 +20,11 @@ public static class EndPointExtensions /// /// 事件类型。 /// - public static IEndpointConventionBuilder Subscribe(this IEndpointRouteBuilder builder) + public static IEndpointRouteBuilder Subscribe(this IEndpointRouteBuilder builder) where TEvent : IntegrationEvent { - var attr = typeof(TEvent).Assembly - .GetCustomAttributes(typeof(AssemblyAppNameAttribute), false) - .Cast() - .FirstOrDefault(); - if (attr is null || string.IsNullOrEmpty(attr.Name)) - { - throw new InvalidOperationException( - $"No AppName was configured in assembly for event: {typeof(TEvent).Name}, either use Subscribe(string appName) method to set AppName manually or add [assembly:AssemblyAppName()] to the Assembly that {typeof(TEvent).Name} belongs to"); - } - - return builder.Subscribe(attr.Name); + var appName = typeof(TEvent).Assembly.GetAppName(); + return builder.Subscribe(appName); } /// @@ -39,7 +34,7 @@ public static IEndpointConventionBuilder Subscribe(this IEndpointRouteBu /// 事件隶属名称。 /// 事件类型。 /// - public static IEndpointConventionBuilder Subscribe(this IEndpointRouteBuilder builder, string appName) + public static IEndpointRouteBuilder Subscribe(this IEndpointRouteBuilder builder, string appName) where TEvent : IntegrationEvent { var eventName = typeof(TEvent).Name; @@ -54,17 +49,20 @@ public static IEndpointConventionBuilder Subscribe(this IEndpointRouteBu /// 应用名称。 /// 事件类型。 /// - public static IEndpointConventionBuilder Subscribe( + public static IEndpointRouteBuilder Subscribe( this IEndpointRouteBuilder builder, string route, string appName) where TEvent : IntegrationEvent { - EnsureDaprSubscribeHandlerMapped(builder); - var result = builder + builder.EnsureDaprEventBus(); + + builder .MapPost(route, (TEvent receivedEvent, IEventBus eventBus) => eventBus.ReceiveAsync(receivedEvent)) - .WithTopic(DaprOptions.PubSubName, DaprUtils.GetDaprTopicName(appName)); - return result; + .WithTopic(DaprOptions.PubSubName, DaprUtils.GetDaprTopicName(appName)) + .WithTags("Subscriptions"); + + return builder; } /// @@ -72,31 +70,91 @@ public static IEndpointConventionBuilder Subscribe( /// /// /// - public static void Subscribe(this IEndpointRouteBuilder builder, params Assembly[] assemblies) + public static IEndpointRouteBuilder Subscribe(this IEndpointRouteBuilder builder, params Assembly[] assemblies) { - var method = typeof(EndPointExtensions).GetMethod( - nameof(Subscribe), - new[] { typeof(IEndpointRouteBuilder), typeof(string) })!; + builder.EnsureDaprEventBus(); + + var method = GetSubscribeMethod(); + foreach (var assembly in assemblies) { var events = assembly.GetTypes().Where(x => x.IsSubclassOf(typeof(IntegrationEvent))).ToList(); - var attr = assembly - .GetCustomAttributes(typeof(AssemblyAppNameAttribute), false) - .Cast() - .FirstOrDefault(); - if (attr is null || string.IsNullOrEmpty(attr.Name)) + var appName = assembly.GetAppName(); + events.ForEach(e => method.InvokeSubscribe(e, builder, appName)); + } + + return builder; + } + + /// + /// Subscribes integration events that the TEventHandler implements + /// + /// The integration event handler that implements ]]> + /// + public static IEndpointRouteBuilder SubscribeByEventHandler(this IEndpointRouteBuilder builder) + where TEventHandler : IEventBusRequestHandler + { + return builder.SubscribeByEventHandler(typeof(TEventHandler)); + } + + /// + /// Subscribes integration events that event handlers implement in assemblies + /// + /// + /// assemblies that event handlers reside + /// + public static IEndpointRouteBuilder SubscribeByEventHandler(this IEndpointRouteBuilder builder, params Assembly[] assemblies) + { + foreach (var assembly in assemblies) + { + foreach (Type type in assembly.GetTypes()) { - throw new InvalidOperationException( - $"No AppName was configured in assembly: {assembly.FullName}, either use Subscribe(string appName) method to set AppName manually or add [assembly:AssemblyAppName()] to the Assembly"); + builder.SubscribeByEventHandler(type); } + } + + return builder; + } - events.ForEach(e => method.MakeGenericMethod(e).Invoke(null, new object[] { builder, attr.Name })); + private static IEndpointRouteBuilder SubscribeByEventHandler(this IEndpointRouteBuilder builder, Type type) + { + var interfaces = type.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IIntegrationEventHandler<>)); + + foreach (var handlerInterface in interfaces) + { + var eventType = handlerInterface.GetGenericArguments().FirstOrDefault(); + if (eventType != null) + { + var assembly = eventType.Assembly; + var appName = assembly.GetAppName(); + GetSubscribeMethod().InvokeSubscribe(eventType, builder, appName); + } + } + + return builder; + } + + private static void EnsureEventBusRegistered(this IEndpointRouteBuilder builder, DaprOptions daprOptions) + { + if (daprOptions.IsEventBusRegistered) + { + return; + } + + var serviceCheck = builder.ServiceProvider.GetRequiredService(); + if (!serviceCheck.IsService(typeof(IEventBus))) + { + throw new InvalidOperationException( + $"{nameof(IEventBus)} has not been registered. Did you forget to call IServiceCollection.AddEventBus()?"); } + + daprOptions.IsEventBusRegistered = true; } - private static void EnsureDaprSubscribeHandlerMapped(IEndpointRouteBuilder builder) + private static void EnsureDaprSubscribeHandlerMapped(this IEndpointRouteBuilder builder, DaprOptions daprOptions) { - if (DaprOptions.IsDaprSubscribeHandlerMapped) + if (daprOptions.IsDaprSubscribeHandlerMapped) { return; } @@ -107,6 +165,44 @@ private static void EnsureDaprSubscribeHandlerMapped(IEndpointRouteBuilder build } builder.MapSubscribeHandler(); - DaprOptions.IsDaprSubscribeHandlerMapped = true; + daprOptions.IsDaprSubscribeHandlerMapped = true; + } + + private static DaprOptions GetDaprOptions(this IEndpointRouteBuilder builder) + => builder.ServiceProvider.GetRequiredService>().Value; + + private static void EnsureDaprEventBus(this IEndpointRouteBuilder builder) + { + var options = builder.GetDaprOptions(); + builder.EnsureDaprSubscribeHandlerMapped(options); + builder.EnsureEventBusRegistered(options); + } + + private static MethodInfo GetSubscribeMethod() + { + return typeof(EndPointExtensions).GetMethod( + nameof(Subscribe), + new[] { typeof(IEndpointRouteBuilder), typeof(string) })!; + } + + private static void InvokeSubscribe(this MethodInfo method, Type eventType, IEndpointRouteBuilder builder, string appName) + { + method.MakeGenericMethod(eventType).Invoke(null, new object[] { builder, appName }); + } + + private static string GetAppName(this Assembly assembly) + { + var appName = assembly + .GetCustomAttributes(typeof(AssemblyAppNameAttribute), false) + .Cast() + .FirstOrDefault()?.Name; + + if (string.IsNullOrEmpty(appName)) + { + throw new InvalidOperationException( + $"No AppName was configured in assembly: {assembly.FullName}, either use Subscribe(string appName) method to set AppName manually or add [assembly:AssemblyAppName()] to the Assembly"); + } + + return appName; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions.csproj index c23ba06..0858748 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions.csproj @@ -1,11 +1,21 @@ + + + Provides abstractions for implementing infrastructure layer in DDD pattern. + Commonly used types: + Cnblogs.Architecture.Ddd.Infratstructure.Abstractions.PagingParams + Cnblogs.Architecture.Ddd.Infratstructure.Abstractions.QueryStringBuilder + Cnblogs.Architecture.Ddd.Infratstructure.Abstractions.IRemoteCacheProvider + + - - - - - - - + + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/DefaultFileProvider.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/DefaultFileProvider.cs new file mode 100644 index 0000000..e66fcbc --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/DefaultFileProvider.cs @@ -0,0 +1,65 @@ +using Stream = System.IO.Stream; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; + +/// +/// Use default file provider. +/// +public class DefaultFileProvider : IFileProvider +{ + /// + public Task GetFileStreamAsync(string filename) + { + return Task.FromResult(File.OpenRead(filename)); + } + + /// + public async Task GetFileBytesAsync(string filename) + { + var file = await File.ReadAllBytesAsync(filename); + return file; + } + + /// + public async Task SaveFileAsync(string filename, Stream filestream) + { + var file = File.OpenWrite(filename); + await filestream.CopyToAsync(file); + await file.FlushAsync(); + file.Close(); + } + + /// + public async Task SaveFileAsync(string filename, byte[] bytes) + { + await File.WriteAllBytesAsync(filename, bytes); + } + + /// + public Task FileExistsAsync(string filename) + { + var file = new FileInfo(filename); + return Task.FromResult(file.Exists); + } + + /// + public Task DeleteFileAsync(string filename) + { + var file = new FileInfo(filename); + if (file.Exists) + { + file.Delete(); + } + + return Task.CompletedTask; + } + + /// + public async Task DeleteFilesAsync(IList filenames) + { + foreach (var filename in filenames) + { + await DeleteFileAsync(filename); + } + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/DispatchDomainEventExtensions.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/DispatchDomainEventExtensions.cs index 292c838..a42203a 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/DispatchDomainEventExtensions.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/DispatchDomainEventExtensions.cs @@ -1,11 +1,10 @@ -using Cnblogs.Architecture.Ddd.Domain.Abstractions; +using Cnblogs.Architecture.Ddd.Domain.Abstractions; -using MediatR; - -namespace Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +// ReSharper disable once CheckNamespace +namespace MediatR; /// -/// 发布领域时间的拓展方法。 +/// 发布领域事件的拓展方法。 /// public static class DispatchDomainEventExtensions { @@ -16,7 +15,7 @@ public static class DispatchDomainEventExtensions /// 要发布的领域事件。 public static async Task DispatchDomainEventsAsync(this IMediator mediator, IEnumerable events) { - Exception? e = null; + List? exceptions = null; foreach (var domainEvent in events) { try @@ -25,13 +24,14 @@ public static async Task DispatchDomainEventsAsync(this IMediator mediator, IEnu } catch (Exception exception) { - e ??= exception; + exceptions ??= new List(); + exceptions.Add(exception); } } - if (e is not null) + if (exceptions?.Count > 0) { - throw e; + throw new AggregateException(exceptions); } } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IFileDeliveryProvider.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IFileDeliveryProvider.cs new file mode 100644 index 0000000..f48f3d4 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IFileDeliveryProvider.cs @@ -0,0 +1,15 @@ +namespace Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; + +/// +/// File provider that can create public url for user to download +/// +public interface IFileDeliveryProvider +{ + /// + /// Get public url to download with validate time. + /// + /// The file filename. + /// Duration of url availability. + /// + public Task GetDownloadUrlAsync(string filename, TimeSpan duration); +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IFileProvider.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IFileProvider.cs new file mode 100644 index 0000000..fde8db5 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IFileProvider.cs @@ -0,0 +1,60 @@ +namespace Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; + +/// +/// Provides abstractions for accessing file system. +/// +public interface IFileProvider +{ + /// + /// Get file content by filename. + /// + /// The filename. + /// File's content stream. + /// Throw if file with filename does not exist. + Task GetFileStreamAsync(string filename); + + /// + /// Get file content by filename. + /// + /// The filename. + /// File's content in byte array. + /// Throw if file with filename does not exist. + Task GetFileBytesAsync(string filename); + + /// + /// Save file to given filename. + /// + /// The path to save file to. + /// The file content. + /// + Task SaveFileAsync(string filename, Stream filestream); + + /// + /// Save file to given filename. + /// + /// The path to save file to. + /// The file content in byte array. + /// + Task SaveFileAsync(string filename, byte[] bytes); + + /// + /// Check if file exists. + /// + /// The filename to check. + /// True if file exists. + Task FileExistsAsync(string filename); + + /// + /// Delete file with certain filename. + /// + /// The filename to delete. + /// + Task DeleteFileAsync(string filename); + + /// + /// Bulk delete files by filenames. + /// + /// The files to be deleted. + /// + Task DeleteFilesAsync(IList filenames); +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/ILocalCacheProvider.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/ILocalCacheProvider.cs index 3c75287..d31d3ff 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/ILocalCacheProvider.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/ILocalCacheProvider.cs @@ -3,6 +3,4 @@ /// /// 本地缓存提供器, 的一个别名。 /// -public interface ILocalCacheProvider : ICacheProvider -{ -} \ No newline at end of file +public interface ILocalCacheProvider : ICacheProvider; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IRemoteCacheProvider.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IRemoteCacheProvider.cs index ee774d2..f5472da 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IRemoteCacheProvider.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/IRemoteCacheProvider.cs @@ -3,6 +3,4 @@ /// /// 远程缓存提供器, 的一个别名。 /// -public interface IRemoteCacheProvider : ICacheProvider -{ -} \ No newline at end of file +public interface IRemoteCacheProvider : ICacheProvider; \ No newline at end of file diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/PagedList.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/PagedList.cs index a0325bb..c09aa9d 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/PagedList.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/PagedList.cs @@ -38,13 +38,21 @@ public PagedList(IReadOnlyCollection items, int pageIndex, int pageSize, int /// 包含的元素。 /// 分页参数。 /// 元素总数。 - public PagedList(IReadOnlyCollection items, PagingParams pagingParams, int totalCount) + public PagedList(IReadOnlyCollection items, PagingParams? pagingParams, int totalCount) { Items = items; TotalCount = totalCount; - (var pageIndex, var pageSize) = pagingParams; - PageIndex = pageIndex; - PageSize = pageSize; + if (pagingParams is null) + { + PageIndex = 1; + PageSize = totalCount; + } + else + { + var (pageIndex, pageSize) = pagingParams; + PageIndex = pageIndex; + PageSize = pageSize; + } } /// diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryOrderer.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryOrderer.cs index 5b6e76c..82cc628 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryOrderer.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryOrderer.cs @@ -1,6 +1,8 @@ using System.Linq.Expressions; +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; -namespace Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +// ReSharper disable once CheckNamespace +namespace System.Linq; /// /// 用于 的扩展方法。 @@ -26,7 +28,7 @@ private static string GetThenByMethodName(bool isDesc) /// 排好序的 public static IQueryable OrderBy(this IQueryable queryable, OrderBySegment segment) { - (var isDesc, var exp) = segment; + var (isDesc, exp) = segment; var method = GetOrderByMethodName(isDesc); Type[] types = { queryable.ElementType, exp.Body.Type }; var rs = Expression.Call(typeof(Queryable), method, types, queryable.Expression, exp); @@ -62,7 +64,7 @@ public static IQueryable OrderBy( IEnumerable segments) { var isFirst = true; - foreach ((var isDesc, var exp) in segments) + foreach (var (isDesc, exp) in segments) { var method = isFirst ? GetOrderByMethodName(isDesc) : GetThenByMethodName(isDesc); Type[] types = { queryable.ElementType, exp.Body.Type }; diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryPager.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryPager.cs index b835fb6..d1949aa 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryPager.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryPager.cs @@ -1,4 +1,7 @@ -namespace Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; + +// ReSharper disable once CheckNamespace +namespace System.Linq; /// /// 用于 的扩展方法。 @@ -14,7 +17,7 @@ public static class QueryPager /// 分页后的列表。 public static IQueryable Paging(this IQueryable queryable, PagingParams pagingParams) { - (var pageIndex, var pageSize) = pagingParams; + var (pageIndex, pageSize) = pagingParams; return queryable.Paging(pageIndex, pageSize); } diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory.csproj index 005e5ac..4354146 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory.csproj @@ -1,11 +1,23 @@ - - - + + + Provides cache provider that implemented with ASP.NET Core MemoryCache. + + + + + + + + + + + + + + + - - - diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory/Injectors.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Injectors.cs similarity index 81% rename from src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory/Injectors.cs rename to src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Injectors.cs index 71f2026..dbb8448 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory/Injectors.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Injectors.cs @@ -1,7 +1,7 @@ using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; -using Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory; +using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; -namespace Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory; +namespace Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory; /// /// 的扩展方法。 diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis.csproj new file mode 100644 index 0000000..9f9c587 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis.csproj @@ -0,0 +1,18 @@ + + + + + Provides remote cache provider that implemented with Redis + + + + + + + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Injectors.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Injectors.cs new file mode 100644 index 0000000..7b405cb --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Injectors.cs @@ -0,0 +1,80 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; +using Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Injectors for redis cache provider. +/// +public static class Injectors +{ + /// + /// Add redis cache as remote cache. + /// + /// The injector. + /// The root configuration. + /// The configuration section name for redis, defaults to Redis. + /// The optional configuration. + /// + public static CqrsInjector AddRedisCache( + this CqrsInjector injector, + IConfiguration configuration, + string sectionName = "Redis", + Action? configure = null) + { + return AddRedisCache(injector, configuration.GetSection(sectionName), configure); + } + + /// + /// Add redis cache as remote cache. + /// + /// The injector. + /// The configuration section for redis. + /// The optional configuration. + /// + public static CqrsInjector AddRedisCache( + this CqrsInjector injector, + IConfigurationSection section, + Action? configure = null) + { + injector.Services.Configure(section); + return AddRedisCache(injector, configure); + } + + /// + /// Add redis cache as remote cache. + /// + /// The injector. + /// The connection string. + /// Optional configuration for redis options. + /// The configure for cacheable request options. + /// + public static CqrsInjector AddRedisCache( + this CqrsInjector injector, + string connectionString, + Action? redisConfigure = null, + Action? configure = null) + { + var options = ConfigurationOptions.Parse(connectionString, true); + injector.Services.Configure(o => + { + o.Configure = options; + redisConfigure?.Invoke(o); + }); + return AddRedisCache(injector, configure); + } + + private static CqrsInjector AddRedisCache( + this CqrsInjector injector, + Action? configure = null) + { + injector.Services.AddSingleton( + sp => ConnectionMultiplexer.Connect(sp.GetRequiredService>().Value.Configure)); + return injector.AddRemoteQueryCache(configure); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/RedisCacheProvider.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/RedisCacheProvider.cs new file mode 100644 index 0000000..d17eae5 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/RedisCacheProvider.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Cnblogs.Architecture.Ddd.Domain.Abstractions; +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis; + +/// +/// Remote cache provider implemented with Redis. +/// +public class RedisCacheProvider + : IRemoteCacheProvider +{ + private readonly RedisOptions _options; + private readonly IDatabaseAsync _database; + private readonly IDateTimeProvider _dateTimeProvider; + + /// + /// Remote cache provider implemented with Redis. + /// + /// The underlying multiplexer. + /// The options for this provider. + /// The datetime provider. + public RedisCacheProvider( + ConnectionMultiplexer multiplexer, + IOptions options, + IDateTimeProvider dateTimeProvider) + { + _dateTimeProvider = dateTimeProvider; + _options = options.Value; + _database = multiplexer.GetDatabase(_options.Database); + } + + /// + public Task AddAsync(string cacheKey, TResult value) + { + return _database.StringSetAsync(GetCacheKey(cacheKey), Serialize(value)); + } + + /// + public Task AddAsync(string cacheKey, TimeSpan expires, TResult value) + { + return _database.StringSetAsync(GetCacheKey(cacheKey), Serialize(value), expires); + } + + /// + public async Task?> GetAsync(string cacheKey) + { + var json = await _database.StringGetAsync(GetCacheKey(cacheKey)); + if (json.IsNullOrEmpty) + { + return null; + } + + return DeSerialize(json!); + } + + /// + public Task RemoveAsync(string cacheKey) + { + return _database.KeyDeleteAsync(GetCacheKey(cacheKey)); + } + + /// + public Task UpdateAsync(string cacheKey, TResult value) + { + return AddAsync(cacheKey, value); + } + + /// + public Task UpdateAsync(string cacheKey, TResult value, TimeSpan expires) + { + return AddAsync(cacheKey, expires, value); + } + + private string GetCacheKey(string key) => $"{_options.Prefix}{key}"; + + private string Serialize(TResult result) + => JsonSerializer.Serialize(new CacheEntry(result, _dateTimeProvider.UnixSeconds())); + + private static CacheEntry? DeSerialize(string json) + => JsonSerializer.Deserialize>(json); +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/RedisOptions.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/RedisOptions.cs new file mode 100644 index 0000000..e021af3 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/RedisOptions.cs @@ -0,0 +1,24 @@ +using StackExchange.Redis; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis; + +/// +/// Options for redis connection. +/// +public class RedisOptions +{ + /// + /// Prefix for all redis keys. + /// + public string Prefix { get; set; } = "cache_"; + + /// + /// The number of database to use. + /// + public int Database { get; set; } = -1; + + /// + /// The redis configuration, https://stackexchange.github.io/StackExchange.Redis/Configuration + /// + public ConfigurationOptions Configure { get; set; } = new(); +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseContextOptions.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseContextOptions.cs new file mode 100644 index 0000000..6f1081d --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseContextOptions.cs @@ -0,0 +1,44 @@ +namespace Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse; + +/// +/// The options for clickhouse context. +/// +/// The type of been configured. +// ReSharper disable once UnusedTypeParameter +public class ClickhouseContextOptions : ClickhouseContextOptions + where TContext : ClickhouseDapperContext +{ + /// + /// Create a with given connection string. + /// + /// The connection string for clickhouse. + public ClickhouseContextOptions(string connectionString) + : base(connectionString) + { + } +} + +/// +/// The options for . +/// +public class ClickhouseContextOptions +{ + private readonly Dictionary _entityConfigurations = new(); + + internal ClickhouseContextOptions(string connectionString) + { + ConnectionString = connectionString; + } + + internal string ConnectionString { get; } + + internal void Add(Type type, ClickhouseEntityConfiguration configuration) + { + _entityConfigurations.Add(type, configuration); + } + + internal ClickhouseEntityConfiguration? GetConfiguration() + { + return _entityConfigurations.GetValueOrDefault(typeof(T)); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseDapperContext.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseDapperContext.cs new file mode 100644 index 0000000..d95ec15 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseDapperContext.cs @@ -0,0 +1,70 @@ +using ClickHouse.Client.Copy; +using Microsoft.Extensions.Options; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse; + +/// +/// DapperContext that specialized for clickhouse. +/// +public abstract class ClickhouseDapperContext : DapperContext +{ + private readonly ClickhouseContextOptions _options; + + /// + /// Create a . + /// + /// The underlying collection. + /// The options used for this context. + /// The service provider to use. + protected ClickhouseDapperContext( + IOptions dbConnectionFactoryCollection, + ClickhouseContextOptions options, + IServiceProvider serviceProvider) + : base(dbConnectionFactoryCollection, serviceProvider) + { + _options = options; + } + + /// + /// Init context, register models, etc. + /// + public void Init() + { + var builder = new ClickhouseModelCollectionBuilder(); + ConfigureModels(builder); + builder.Build(_options); + } + + /// + /// Configure models that related to this context. + /// + /// . + protected abstract void ConfigureModels(ClickhouseModelCollectionBuilder builder); + + /// + /// Bulk write entities to clickhouse. + /// + /// The entity to be written. + /// The type of entity. + /// Throw when is not registered. + public async Task BulkWriteAsync(IEnumerable entities) + where T : class + { + var configuration = _options.GetConfiguration(); + if (configuration is null) + { + throw new InvalidOperationException( + $"The model type {typeof(T).Name} is not registered, make sure you have called builder.Entity() at ConfigureModels()"); + } + + using var bulkCopyInterface = new ClickHouseBulkCopy(_options.ConnectionString) + { + DestinationTableName = configuration.TableName, + ColumnNames = configuration.ColumnNames + }; + + var objs = entities.Select(x => configuration.ToObjectArray(x)); + await bulkCopyInterface.InitAsync(); + await bulkCopyInterface.WriteToServerAsync(objs); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseEntityConfiguration.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseEntityConfiguration.cs new file mode 100644 index 0000000..2cb45aa --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseEntityConfiguration.cs @@ -0,0 +1,11 @@ +using System.Reflection; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse; + +internal record ClickhouseEntityConfiguration(string TableName, PropertyInfo[] Properties, string[] ColumnNames) +{ + internal object?[] ToObjectArray(object entity) + { + return Properties.Select(x => x.GetValue(entity)).ToArray(); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseModelBuilder.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseModelBuilder.cs new file mode 100644 index 0000000..f73f679 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseModelBuilder.cs @@ -0,0 +1,64 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse; + +/// +/// Configure mapping between clickhouse and C# class. +/// +/// The type of model been configured. +public class ClickhouseModelBuilder : IClickhouseModelBuilder +{ + private readonly Dictionary> _propertyBuilders; + private string _tableName; + + internal ClickhouseModelBuilder() + { + _tableName = typeof(T).Name; + _propertyBuilders = typeof(T).GetProperties().Where(x => x.GetGetMethod() != null) + .Select(x => new ClickhouseModelPropertyBuilder(x)).ToDictionary(x => x.PropertyInfo.Name, x => x); + } + + /// + /// Map model type to specific table. + /// + /// The full name of table, includes database name. e.x. <database>.<table> + /// . + public ClickhouseModelBuilder ToTable(string tableName) + { + _tableName = tableName; + return this; + } + + /// + /// Start configure property. + /// + /// The property been configured. + /// The type of property. + /// . + /// When no suitable property was fount by . + public ClickhouseModelPropertyBuilder Property(Expression> propertyGetter) + { + if ((propertyGetter.Body as MemberExpression)?.Member is not PropertyInfo propertyInfo) + { + throw new InvalidOperationException("No suitable property can be found from given expression"); + } + + var builder = _propertyBuilders.GetValueOrDefault(propertyInfo.Name); + if (builder is null) + { + throw new InvalidOperationException($"No suitable property was found by name: {propertyInfo.Name}"); + } + + return builder; + } + + ClickhouseEntityConfiguration IClickhouseModelBuilder.Build() + { + var builders = _propertyBuilders.Values.Where(x => x.IsIgnored == false).ToArray(); + return new ClickhouseEntityConfiguration( + _tableName, + builders.Select(x => x.PropertyInfo).ToArray(), + builders.Select(x => x.ColumnName).ToArray()); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseModelCollectionBuilder.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseModelCollectionBuilder.cs new file mode 100644 index 0000000..985ff7d --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseModelCollectionBuilder.cs @@ -0,0 +1,36 @@ +namespace Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse; + +/// +/// Configure models for clickhouse. +/// +public class ClickhouseModelCollectionBuilder +{ + private readonly Dictionary _builders = new(); + + /// + /// Register an entity to context. + /// + /// The type of entity. + /// . + public ClickhouseModelBuilder Entity() + where T : class + { + var type = typeof(T); + if (_builders.TryGetValue(type, out var builder)) + { + return (ClickhouseModelBuilder)builder; + } + + var modelBuilder = new ClickhouseModelBuilder(); + _builders.Add(typeof(T), modelBuilder); + return modelBuilder; + } + + internal void Build(ClickhouseContextOptions options) + { + foreach (var (key, value) in _builders) + { + options.Add(key, value.Build()); + } + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseModelPropertyBuilder.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseModelPropertyBuilder.cs new file mode 100644 index 0000000..36522f2 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/ClickhouseModelPropertyBuilder.cs @@ -0,0 +1,47 @@ +using System.Reflection; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse; + +/// +/// Configuration builder for clickhouse model property; +/// +/// The entity type that property belongs. +public class ClickhouseModelPropertyBuilder +{ + /// + /// Create a ClickhouseModelPropertyBuilder from entity builder. + /// + /// The property been configured. + public ClickhouseModelPropertyBuilder(PropertyInfo propertyInfo) + { + PropertyInfo = propertyInfo; + ColumnName = propertyInfo.Name; + } + + /// + /// Configure column name for this property. + /// + /// New column name. + /// + public ClickhouseModelPropertyBuilder HasColumnName(string name) + { + ColumnName = name; + return this; + } + + /// + /// Ignore this property from mapping. + /// + /// + public ClickhouseModelPropertyBuilder Ignore() + { + IsIgnored = true; + return this; + } + + internal string ColumnName { get; private set; } + + internal PropertyInfo PropertyInfo { get; } + + internal bool IsIgnored { get; private set; } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse.csproj new file mode 100644 index 0000000..7966982 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse.csproj @@ -0,0 +1,19 @@ + + + + + Provides implementation for persistance layer of DDD with Dapper and Clickhouse. + Commonly used types: + Cnblogs.Architecture.Ddd.Infrasturcture.Dapper.Clickhouse.ClickhouseDapperContext + + + + + + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/IClickhouseModelBuilder.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/IClickhouseModelBuilder.cs new file mode 100644 index 0000000..1e9a36d --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse/IClickhouseModelBuilder.cs @@ -0,0 +1,6 @@ +namespace Cnblogs.Architecture.Ddd.Infrastructure.Dapper.Clickhouse; + +internal interface IClickhouseModelBuilder +{ + ClickhouseEntityConfiguration Build(); +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.csproj index c7cb152..d0bdaba 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.csproj @@ -1,14 +1,23 @@ - - net7.0 - enable - enable - - - - - - + + + Provides implementations for persistence layer of DDD with Dapper. + Commonly used types: + Cnblogs.Architecture.Ddd.Infrastructure.Dapper.DapperContext + + + + + + + + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/DapperContext.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/DapperContext.cs index f00ab04..effe5be 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/DapperContext.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/DapperContext.cs @@ -1,5 +1,5 @@ using System.Data; - +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace Cnblogs.Architecture.Ddd.Infrastructure.Dapper; @@ -13,9 +13,13 @@ public abstract class DapperContext /// 创建一个 DapperContext。 /// /// 数据库连接工厂集合。 - protected DapperContext(IOptions dbConnectionFactoryCollection) + /// The service provider to get connection factory + protected DapperContext(IOptions dbConnectionFactoryCollection, IServiceProvider sp) { - DbConnectionFactory = dbConnectionFactoryCollection.Value.GetFactory(GetType().Name); + var type = dbConnectionFactoryCollection.Value.GetFactory(GetType().Name); + DbConnectionFactory = sp.GetRequiredService(type) as IDbConnectionFactory + ?? throw new InvalidOperationException( + $"No DbConnectionFactory(type: {type.Name}) configured."); } /// @@ -28,4 +32,4 @@ protected DapperContext(IOptions dbConnectionFact /// /// public IDbConnection CreateDbConnection() => DbConnectionFactory.CreateDbConnection(); -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/DbConnectionFactoryCollection.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/DbConnectionFactoryCollection.cs index d99f62c..9af85ef 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/DbConnectionFactoryCollection.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/DbConnectionFactoryCollection.cs @@ -5,19 +5,20 @@ /// public class DbConnectionFactoryCollection { - private readonly Dictionary _factories = new(); + private readonly Dictionary _factories = new(); /// /// 添加数据库连接工厂。 /// /// 名称。 /// 工厂示例。 - public void AddDbConnectionFactory(string name, IDbConnectionFactory factory) + /// Throw when already been registered with other factory. + public void AddDbConnectionFactory(string name, Type factory) { - if (_factories.ContainsKey(name)) + if (_factories.TryGetValue(name, out var value)) { - _factories[name] = factory; - return; + throw new InvalidOperationException( + $"The dapper context already configured with db connection factory: {value.Name}"); } _factories.Add(name, factory); @@ -28,8 +29,8 @@ public void AddDbConnectionFactory(string name, IDbConnectionFactory factory) /// /// 名称。 /// 工厂示例。 - public IDbConnectionFactory GetFactory(string name) + public Type GetFactory(string name) { return _factories[name]; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/BaseRepository.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/BaseRepository.cs index d19bc7f..9475502 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/BaseRepository.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/BaseRepository.cs @@ -1,6 +1,5 @@ using System.Linq.Expressions; using Cnblogs.Architecture.Ddd.Domain.Abstractions; -using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; using MediatR; using Microsoft.EntityFrameworkCore; @@ -13,7 +12,7 @@ namespace Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework; /// 该类管理的实体。 /// 的主键。 public abstract class BaseRepository - : INavigationRepository, IUnitOfWork + : INavigationRepository, IUnitOfWork, ISqlRepository where TContext : DbContext where TEntity : EntityBase, IEntity, IAggregateRoot where TKey : IComparable @@ -53,7 +52,7 @@ public IQueryable GetNoTrackingQueryable() public async Task AddAsync(TEntity entity) { await Context.AddAsync(entity); - await SaveEntitiesInternalAsync(true); + await SaveEntitiesInternalAsync(); return entity; } @@ -62,11 +61,11 @@ public async Task AddRangeAsync(TEnumerable entities) where TEnumerable : IEnumerable { await Context.AddRangeAsync(entities); - await SaveEntitiesInternalAsync(true); + await SaveEntitiesInternalAsync(); return entities; } - /// + /// public async Task GetAsync(TKey key) { return await Context.Set().FirstOrDefaultAsync(e => e.Id.Equals(key)); @@ -78,17 +77,23 @@ public async Task AddRangeAsync(TEnumerable entities) return await Context.Set().AggregateIncludes(includes).FirstOrDefaultAsync(e => e.Id.Equals(key)); } + /// + public async Task GetAsync(TKey key, IEnumerable includes) + { + return await Context.Set().AggregateIncludes(includes).FirstOrDefaultAsync(e => e.Id.Equals(key)); + } + /// public async Task UpdateAsync(TEntity entity) { - await SaveEntitiesInternalAsync(true); + await SaveEntitiesInternalAsync(); return entity; } /// public async Task> UpdateRangeAsync(IEnumerable entities) { - await SaveEntitiesInternalAsync(true); + await SaveEntitiesInternalAsync(); return entities; } @@ -96,7 +101,7 @@ public async Task> UpdateRangeAsync(IEnumerable en public async Task DeleteAsync(TEntity entity) { Context.Remove(entity); - await SaveEntitiesInternalAsync(true); + await SaveEntitiesInternalAsync(); return entity; } @@ -131,7 +136,7 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de /// public Task SaveEntitiesAsync(CancellationToken cancellationToken = default) { - return SaveEntitiesInternalAsync(false, cancellationToken); + return SaveEntitiesInternalAsync(true, cancellationToken); } /// @@ -146,27 +151,32 @@ protected virtual Task BeforeDispatchDomainEventAsync(List events, } private async Task SaveEntitiesInternalAsync( - bool dispatchDomainEventFirst, + bool dispatchDomainEventFirst = false, CancellationToken cancellationToken = default) { - var entities = Context.ExtractDomainEventSources(); - var domainEvents = entities.SelectMany(x => x.DomainEvents!.OfType()).ToList(); - entities.ForEach(x => x.ClearDomainEvents()); if (dispatchDomainEventFirst) { + await ExtraAndPublishDomainEventsAsync(); await SaveChangesAsync(cancellationToken); } - - await BeforeDispatchDomainEventAsync(domainEvents, Context); - await _mediator.DispatchDomainEventsAsync(domainEvents); - if (dispatchDomainEventFirst == false) + else { await SaveChangesAsync(cancellationToken); + await ExtraAndPublishDomainEventsAsync(); } return true; } + private async Task ExtraAndPublishDomainEventsAsync() + { + var entities = Context.ExtractDomainEventSources(); + var domainEvents = entities.SelectMany(x => x.DomainEvents!.OfType()).ToList(); + entities.ForEach(x => x.ClearDomainEvents()); + await BeforeDispatchDomainEventAsync(domainEvents, Context); + await _mediator.DispatchDomainEventsAsync(domainEvents); + } + private void CallBeforeUpdate() { var domainEntities = Context.ChangeTracker @@ -175,4 +185,22 @@ private void CallBeforeUpdate() .ToList(); domainEntities.ForEach(x => x.Entity.BeforeUpdate()); } -} \ No newline at end of file + + /// + public IQueryable SqlQuery(string sql, params object[] parameters) + { + return Context.Set().FromSqlRaw(sql, parameters); + } + + /// + public IQueryable SqlQuery(string sql, params object[] parameters) + { + return Context.Database.SqlQueryRaw(sql, parameters); + } + + /// + public Task ExecuteSqlAsync(string sql, params object[] parameters) + { + return Context.Database.ExecuteSqlRawAsync(sql, parameters); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework.csproj index 9584753..112d612 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework.csproj @@ -1,11 +1,19 @@ - - - + + + Provides implementations for persistence layer of DDD with EntityFramework Core. + Commonly used types: + Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework.BaseRepository + + - - - + + + + + + + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/QueryableExtensions.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/QueryableExtensions.cs index 3a32d79..b1e382a 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/QueryableExtensions.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/QueryableExtensions.cs @@ -23,4 +23,19 @@ public static IQueryable AggregateIncludes( { return includes.Aggregate(query, (queryable, include) => queryable.Include(include)); } -} \ No newline at end of file + + /// + /// Apply multiple includes to . + /// + /// The source queryable. + /// Include strings. + /// The type of entity. + /// + public static IQueryable AggregateIncludes( + this IQueryable query, + IEnumerable includes) + where TEntity : class + { + return includes.Aggregate(query, (queryable, include) => queryable.Include(include)); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/AliyunOssFileDeliveryProvider.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/AliyunOssFileDeliveryProvider.cs new file mode 100644 index 0000000..62cfc52 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/AliyunOssFileDeliveryProvider.cs @@ -0,0 +1,40 @@ +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +using Cuiliang.AliyunOssSdk; +using Microsoft.Extensions.Options; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss; + +/// +/// Aliyun OSS implementation of . +/// +public class AliyunOssFileDeliveryProvider : IFileDeliveryProvider +{ + private readonly OssClient _client; + private readonly AliyunOssOptions _options; + + /// + /// Create a . + /// + /// The oss client. + /// The options for oss client. + public AliyunOssFileDeliveryProvider(OssClient client, IOptions options) + { + _client = client; + _options = options.Value; + } + + /// + public async Task GetDownloadUrlAsync(string filename, TimeSpan duration) + { + var meta = await _client.GetObjectMetaAsync(_options.BucketInfo, filename); + if (meta.IsSuccess == false) + { + throw new FileNotFoundException(meta.ErrorMessage, filename, meta.InnerException); + } + + return _client.GetFileDownloadLink( + _options.BucketInfo, + filename, + (int)Math.Ceiling(duration.TotalSeconds)); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/AliyunOssFileProvider.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/AliyunOssFileProvider.cs new file mode 100644 index 0000000..ce37985 --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/AliyunOssFileProvider.cs @@ -0,0 +1,91 @@ +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +using Cuiliang.AliyunOssSdk; +using Cuiliang.AliyunOssSdk.Api; +using Microsoft.Extensions.Options; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss; + +/// +/// An implementation using Aliyun OSS. +/// +public class AliyunOssFileProvider : IFileProvider +{ + private readonly OssClient _ossClient; + private readonly AliyunOssOptions _options; + + /// + /// Create a based on Aliyun OSS. + /// + /// The underlying Aliyun OSS client. + /// The Aliyun OSS options. + public AliyunOssFileProvider(OssClient ossClient, IOptions options) + { + _ossClient = ossClient; + _options = options.Value; + } + + /// + public async Task GetFileStreamAsync(string filename) + { + var file = await _ossClient.GetObjectAsync(_options.BucketInfo, filename); + if (file.IsSuccess == false) + { + throw NewFileNotFoundException(filename, file); + } + + return await file.SuccessResult.Content.ReadAsStreamAsync(); + } + + /// + public async Task GetFileBytesAsync(string filename) + { + var file = await _ossClient.GetObjectAsync(_options.BucketInfo, filename); + if (file.IsSuccess == false) + { + throw NewFileNotFoundException(filename, file); + } + + return await file.SuccessResult.Content.ReadAsByteArrayAsync(); + } + + /// + public async Task SaveFileAsync(string filename, Stream filestream) + { + var result = await _ossClient.PutObjectAsync(_options.BucketInfo, filename, filestream); + if (result.IsSuccess == false) + { + throw new InvalidOperationException(result.ErrorMessage, result.InnerException); + } + } + + /// + public async Task SaveFileAsync(string filename, byte[] bytes) + { + var stream = new MemoryStream(bytes); + await SaveFileAsync(filename, stream); + } + + /// + public async Task FileExistsAsync(string filename) + { + var result = await _ossClient.GetObjectMetaAsync(_options.BucketInfo, filename); + return result.IsSuccess; + } + + /// + public async Task DeleteFilesAsync(IList filenames) + { + await _ossClient.DeleteMultipleObjectsAsync(_options.BucketInfo, filenames, true); + } + + /// + public async Task DeleteFileAsync(string filename) + { + await _ossClient.DeleteObjectAsync(_options.BucketInfo, filename); + } + + private static FileNotFoundException NewFileNotFoundException(string path, OssResult result) + { + return new FileNotFoundException(result.ErrorMessage, path, result.InnerException); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/AliyunOssOptions.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/AliyunOssOptions.cs new file mode 100644 index 0000000..4c5ad7f --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/AliyunOssOptions.cs @@ -0,0 +1,52 @@ +using Cuiliang.AliyunOssSdk.Entites; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss; + +/// +/// The aliyun oss options. +/// +public class AliyunOssOptions +{ + private BucketInfo? _bucketInfo; + + /// + /// OSS access key id. + /// + public string AccessKeyId { get; set; } = string.Empty; + + /// + /// OSS access key secret. + /// + public string AccessKeySecret { get; set; } = string.Empty; + + /// + /// OSS security token. + /// + public string SecurityToken { get; set; } = string.Empty; + + /// + /// The bucket name. + /// + public string BucketName { get; set; } = string.Empty; + + /// + /// The region that bucket belongs to. + /// + public string Region { get; set; } = OssRegions.HangZhou; + + /// + /// True if HTTPS is enabled. + /// + public bool UseHttps { get; set; } + + /// + /// True if OSS is used by internal resources. + /// + public bool UseInternal { get; set; } + + /// + /// The bucket info of OSS. + /// + public BucketInfo BucketInfo + => _bucketInfo ??= BucketInfo.CreateByRegion(Region, BucketName, UseHttps, UseInternal); +} diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss.csproj similarity index 60% rename from src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory.csproj rename to src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss.csproj index 47a2b0f..93f0ce2 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.CacheProviders.InMemory.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss.csproj @@ -1,8 +1,12 @@ + + + + - + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/CqrsInjectorExtensions.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/CqrsInjectorExtensions.cs new file mode 100644 index 0000000..9fd535e --- /dev/null +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss/CqrsInjectorExtensions.cs @@ -0,0 +1,30 @@ +using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cnblogs.Architecture.Ddd.Infrastructure.FileProviders.AliyunOss; + +/// +/// Extension methods to inject Aliyun OSS provider to CQRS injector. +/// +public static class CqrsInjectorExtensions +{ + /// + /// Use aliyun oss as default implementation of and . + /// + /// + /// + /// + /// + public static CqrsInjector UseAliyunOssFileProvider( + this CqrsInjector injector, + IConfiguration configuration, + string configurationSectionName = "ossClient") + { + injector.Services.AddOssClient(configuration, configurationSectionName); + injector.Services.Configure(configuration.GetSection(configurationSectionName)); + return injector.AddFileProvider() + .AddFileDeliveryProvider(); + } +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.csproj index 8412d72..969709c 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.csproj @@ -1,7 +1,16 @@ + + + Provides implementations for persistence layer of DDD with MongoDb. + Commonly used types: + Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.MongoContext + Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.MongoBaseRepository + + + - + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/MongoBaseRepository.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/MongoBaseRepository.cs index f2387b5..2d426e0 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/MongoBaseRepository.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/MongoBaseRepository.cs @@ -1,5 +1,4 @@ using Cnblogs.Architecture.Ddd.Domain.Abstractions; -using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; using MediatR; using MongoDB.Driver; @@ -78,7 +77,7 @@ public async Task DeleteAsync(TEntity entity) return entity; } - /// + /// public async Task GetAsync(TKey key) { return await Context.Set().Find(Builders.Filter.Eq(x => x.Id, key)).FirstOrDefaultAsync(); @@ -187,14 +186,7 @@ public TEntity Update(TEntity entity) } _toUpdate ??= new Dictionary(); - if (_toUpdate.ContainsKey(entity.Id)) - { - _toUpdate[entity.Id] = entity; - } - else - { - _toUpdate.Add(entity.Id, entity); - } + _toUpdate[entity.Id] = entity; return entity; } @@ -210,14 +202,7 @@ public TEntity Delete(TEntity entity) } _toDelete ??= new Dictionary(); - if (_toDelete.ContainsKey(entity.Id)) - { - _toDelete[entity.Id] = entity; - } - else - { - _toDelete.Add(entity.Id, entity); - } + _toDelete[entity.Id] = entity; return entity; } diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/MongoContextOptions.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/MongoContextOptions.cs index 17341ed..535ad17 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/MongoContextOptions.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/MongoContextOptions.cs @@ -1,5 +1,4 @@ using MongoDB.Driver; -using MongoDB.Driver.Linq; namespace Cnblogs.Architecture.Ddd.Infrastructure.MongoDb; @@ -7,6 +6,7 @@ namespace Cnblogs.Architecture.Ddd.Infrastructure.MongoDb; /// MongoContext 的配置文件。 /// /// 要配置的 MongoContext。 +// ReSharper disable once UnusedTypeParameter public class MongoContextOptions : MongoContextOptions where TContext : MongoContext { @@ -40,7 +40,6 @@ protected MongoContextOptions(string connectionString, string databaseName) { _databaseName = databaseName; _settings = MongoClientSettings.FromConnectionString(connectionString); - _settings.LinqProvider = LinqProvider.V3; } /// @@ -50,14 +49,7 @@ protected MongoContextOptions(string connectionString, string databaseName) public void MapEntity(string collectionName) { var type = typeof(TEntity); - if (_collectionMap.ContainsKey(type)) - { - _collectionMap[type] = collectionName; - } - else - { - _collectionMap.Add(type, collectionName); - } + _collectionMap[type] = collectionName; } /// @@ -68,4 +60,4 @@ private IMongoClient GetClient() _mongoClient ??= new MongoClient(_settings); return _mongoClient; } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.RedLock/Cnblogs.Architecture.Ddd.Infrastructure.RedLock.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.RedLock/Cnblogs.Architecture.Ddd.Infrastructure.RedLock.csproj index a941575..04011f1 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.RedLock/Cnblogs.Architecture.Ddd.Infrastructure.RedLock.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.RedLock/Cnblogs.Architecture.Ddd.Infrastructure.RedLock.csproj @@ -1,6 +1,15 @@ + + + + Provides distributed lock with RedLock. + Commonly used types: + Cnblogs.Architecture.Ddd.Infrastructure.RedLock.RedLockDistributionLockProvider + + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock/CqrsInjectorExtensions.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.RedLock/CqrsInjectorExtensions.cs similarity index 91% rename from src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock/CqrsInjectorExtensions.cs rename to src/Cnblogs.Architecture.Ddd.Infrastructure.RedLock/CqrsInjectorExtensions.cs index 540b02a..6102ba0 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock/CqrsInjectorExtensions.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.RedLock/CqrsInjectorExtensions.cs @@ -1,15 +1,12 @@ -using Cnblogs.Architecture.Ddd.Infrastructure.RedLock; - +using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; - using RedLockNet.SERedis; using RedLockNet.SERedis.Configuration; - using StackExchange.Redis; -namespace Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection.RedLock; +namespace Cnblogs.Architecture.Ddd.Infrastructure.RedLock; /// /// 用于向 注入 RedLock 的扩展方法。 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ac09a8e..ad13ac9 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,6 +4,16 @@ true + true + true + snupkg + + Please check release notes at: https://github.com/cnblogs/Architecture/releases + + + + + diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..4c41aad --- /dev/null +++ b/test.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -e +dotnet test -c Release -f "$1" --no-build --no-restore diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CommandHandlers.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CommandHandlers.cs index 5213a15..fec1c5c 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CommandHandlers.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CommandHandlers.cs @@ -1,33 +1,38 @@ using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; using Cnblogs.Architecture.IntegrationTestProject.Application.Errors; +using Cnblogs.Architecture.IntegrationTestProject.Domain.Events; +using MediatR; namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands; -public class CommandHandlers - : ICommandHandler, ICommandHandler, - ICommandHandler +public class CommandHandlers(IMediator mediator) + : ICommandHandler, ICommandHandler, + ICommandHandler { /// - public async Task> Handle(CreateCommand request, CancellationToken cancellationToken) + public async Task> Handle(CreateCommand request, CancellationToken cancellationToken) { + await mediator.Publish(new StringCreatedDomainEvent(request.Data ?? string.Empty), cancellationToken); return request.NeedError - ? CommandResponse.Fail(TestError.Default) - : CommandResponse.Success(); + ? CommandResponse.Fail(TestError.Default) + : CommandResponse.Success("create success"); } /// - public async Task> Handle(UpdateCommand request, CancellationToken cancellationToken) + public Task> Handle(UpdateCommand request, CancellationToken cancellationToken) { - return request.NeedError - ? CommandResponse.Fail(TestError.Default) - : CommandResponse.Success(); + return Task.FromResult( + request.NeedExecutionError + ? CommandResponse.Fail(TestError.Default) + : CommandResponse.Success("update success")); } /// - public async Task> Handle(DeleteCommand request, CancellationToken cancellationToken) + public Task> Handle(DeleteCommand request, CancellationToken cancellationToken) { - return request.NeedError - ? CommandResponse.Fail(TestError.Default) - : CommandResponse.Success(); + return Task.FromResult( + request.NeedError + ? CommandResponse.Fail(TestError.Default) + : CommandResponse.Success("delete success")); } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateCommand.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateCommand.cs index 9909c70..964f08d 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateCommand.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateCommand.cs @@ -3,4 +3,4 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands; -public record CreateCommand(bool NeedError, bool ValidateOnly = false) : ICommand; \ No newline at end of file +public record CreateCommand(bool NeedError, string? Data = null, bool ValidateOnly = false) : ICommand; diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommand.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommand.cs new file mode 100644 index 0000000..4c139a9 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommand.cs @@ -0,0 +1,7 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject.Application.Errors; +using Cnblogs.Architecture.IntegrationTestProject.Models; + +namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands; + +public record CreateLongToStringCommand(long Id, bool ValidateOnly = false) : ICommand; diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommandHandler.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommandHandler.cs new file mode 100644 index 0000000..10a1b3d --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CreateLongToStringCommandHandler.cs @@ -0,0 +1,16 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject.Application.Errors; +using Cnblogs.Architecture.IntegrationTestProject.Models; + +namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands; + +public class CreateLongToStringCommandHandler : ICommandHandler +{ + /// + public Task> Handle( + CreateLongToStringCommand request, + CancellationToken cancellationToken) + { + return Task.FromResult(CommandResponse.Success(new LongToStringModel() { Id = request.Id })); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/DeleteCommand.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/DeleteCommand.cs index d137b40..94816b9 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/DeleteCommand.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/DeleteCommand.cs @@ -3,4 +3,4 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands; -public record DeleteCommand(int Id, bool NeedError, bool ValidateOnly = false) : ICommand; \ No newline at end of file +public record DeleteCommand(int Id, bool NeedError, bool ValidateOnly = false) : ICommand; diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/UpdateCommand.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/UpdateCommand.cs index 96b7789..9121554 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/UpdateCommand.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/UpdateCommand.cs @@ -3,4 +3,19 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands; -public record UpdateCommand(int Id, bool NeedError, bool ValidateOnly = false) : ICommand; \ No newline at end of file +public record UpdateCommand( + int Id, + bool NeedValidationError, + bool NeedExecutionError, + bool ValidateOnly = false) + : ICommand, IValidatable +{ + /// + public void Validate(ValidationErrors errors) + { + if (NeedValidationError) + { + errors.Add(new ValidationError("need validation error", nameof(NeedValidationError))); + } + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/EventHandlers/StringCreatedEventHandler.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/EventHandlers/StringCreatedEventHandler.cs new file mode 100644 index 0000000..57ce6cb --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/EventHandlers/StringCreatedEventHandler.cs @@ -0,0 +1,22 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject.Domain.Events; +using Cnblogs.Architecture.TestIntegrationEvents; + +namespace Cnblogs.Architecture.IntegrationTestProject.Application.EventHandlers; + +public class StringCreatedEventHandler : IDomainEventHandler +{ + private readonly IEventBus _eventBus; + + public StringCreatedEventHandler(IEventBus eventBus) + { + _eventBus = eventBus; + } + + /// + public async Task Handle(StringCreatedDomainEvent notification, CancellationToken cancellationToken) + { + await _eventBus.PublishAsync(new TestIntegrationEvent(Guid.NewGuid(), DateTimeOffset.Now, notification.Data)); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQuery.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQuery.cs new file mode 100644 index 0000000..ac2ac59 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQuery.cs @@ -0,0 +1,6 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject.Models; + +namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries; + +public record GetLongToStringQuery(long Id) : IQuery; diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQueryHandler.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQueryHandler.cs new file mode 100644 index 0000000..3583d8e --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetLongToStringQueryHandler.cs @@ -0,0 +1,13 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject.Models; + +namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries; + +public class GetLongToStringQueryHandler : IQueryHandler +{ + /// + public Task Handle(GetLongToStringQuery request, CancellationToken cancellationToken) + { + return Task.FromResult((LongToStringModel?)new LongToStringModel() { Id = request.Id }); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQuery.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQuery.cs index 87f20a5..b59639a 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQuery.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQuery.cs @@ -2,4 +2,4 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries; -public record GetStringQuery() : IQuery; \ No newline at end of file +public record GetStringQuery(string? AppId = null, int? StringId = null, bool Found = true) : IQuery; diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQueryHandler.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQueryHandler.cs index 9288d68..6971aa1 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQueryHandler.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/GetStringQueryHandler.cs @@ -5,8 +5,8 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries; public class GetStringQueryHandler : IQueryHandler { /// - public async Task Handle(GetStringQuery request, CancellationToken cancellationToken) + public Task Handle(GetStringQuery request, CancellationToken cancellationToken) { - return "Hello"; + return request.Found ? Task.FromResult((string?)"Hello") : Task.FromResult((string?)null); } } \ No newline at end of file diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/ListStringsQueryHandler.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/ListStringsQueryHandler.cs index 005598d..3fd69fc 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/ListStringsQueryHandler.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Application/Queries/ListStringsQueryHandler.cs @@ -6,8 +6,8 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Application.Queries; public class ListStringsQueryHandler : IPageableQueryHandler { /// - public async Task> Handle(ListStringsQuery request, CancellationToken cancellationToken) + public Task> Handle(ListStringsQuery request, CancellationToken cancellationToken) { - return new PagedList(new[] { "hello" }); + return Task.FromResult(new PagedList(new[] { "hello" })); } } \ No newline at end of file diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Cnblogs.Architecture.IntegrationTestProject.csproj b/test/Cnblogs.Architecture.IntegrationTestProject/Cnblogs.Architecture.IntegrationTestProject.csproj index fe3d98e..9c4c9b5 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Cnblogs.Architecture.IntegrationTestProject.csproj +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Cnblogs.Architecture.IntegrationTestProject.csproj @@ -1,11 +1,11 @@ - + + + + + + + - - - - - - diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Constants.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Constants.cs new file mode 100644 index 0000000..c149ddb --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Constants.cs @@ -0,0 +1,12 @@ +namespace Cnblogs.Architecture.IntegrationTestProject; + +public static class Constants +{ + public const string AppName = "test-web"; + public const string IntegrationEventIdHeaderName = "X-IntegrationEvent-Id"; + + public static class LogTemplates + { + public const string HandledIntegratonEvent = "Handled integration event {@event}."; + } +} \ No newline at end of file diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Controllers/TestController.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Controllers/TestController.cs index dc73dc1..621e2e5 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Controllers/TestController.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Controllers/TestController.cs @@ -1,19 +1,43 @@ using Asp.Versioning; - +using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; - +using Cnblogs.Architecture.IntegrationTestProject.Application.Commands; +using Cnblogs.Architecture.IntegrationTestProject.Application.Queries; +using Cnblogs.Architecture.IntegrationTestProject.Models; +using Cnblogs.Architecture.IntegrationTestProject.Payloads; +using MediatR; using Microsoft.AspNetCore.Mvc; namespace Cnblogs.Architecture.IntegrationTestProject.Controllers; [ApiVersion("1")] -[ApiController] -[Route("/api/v{version:apiVersion}")] -public class TestController : ControllerBase +[Route("/api/v{version:apiVersion}/mvc")] +public class TestController(IMediator mediator) : ApiControllerBase { [HttpGet("paging")] public Task PagingParamsAsync([FromQuery] PagingParams? pagingParams) { return Task.FromResult(pagingParams); } -} \ No newline at end of file + + [HttpPut("strings/{id:int}")] + public async Task PutStringAsync(int id, [FromBody] UpdatePayload payload) + { + var response = + await mediator.Send(new UpdateCommand(id, payload.NeedValidationError, payload.NeedExecutionError)); + return HandleCommandResponse(response); + } + + [HttpGet("json/long-to-string/{id:long}")] + public async Task GetLongToStringModelAsync(long id) + { + return await mediator.Send(new GetLongToStringQuery(id)); + } + + [HttpPost("json/long-to-string")] + public async Task CreateLongToStringModelAsync([FromBody] LongToStringModel model) + { + var response = await mediator.Send(new CreateLongToStringCommand(model.Id)); + return HandleCommandResponse(response); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Domain/Events/StringCreatedDomainEvent.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Domain/Events/StringCreatedDomainEvent.cs new file mode 100644 index 0000000..fa075ca --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Domain/Events/StringCreatedDomainEvent.cs @@ -0,0 +1,5 @@ +using Cnblogs.Architecture.Ddd.Domain.Abstractions; + +namespace Cnblogs.Architecture.IntegrationTestProject.Domain.Events; + +public record StringCreatedDomainEvent(string Data) : DomainEvent; diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/EventHandlers/TestIntegrationEventHandler.cs b/test/Cnblogs.Architecture.IntegrationTestProject/EventHandlers/TestIntegrationEventHandler.cs new file mode 100644 index 0000000..72e469c --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/EventHandlers/TestIntegrationEventHandler.cs @@ -0,0 +1,30 @@ +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Cnblogs.Architecture.TestIntegrationEvents; +using static Cnblogs.Architecture.IntegrationTestProject.Constants; + +namespace Cnblogs.Architecture.IntegrationTestProject.EventHandlers; + +public class TestIntegrationEventHandler : IIntegrationEventHandler, + IIntegrationEventHandler +{ + private readonly ILogger _logger; + + public TestIntegrationEventHandler(ILogger logger) + { + _logger = logger; + } + + public Task Handle(TestIntegrationEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation(LogTemplates.HandledIntegratonEvent, notification); + + return Task.CompletedTask; + } + + public Task Handle(BlogPostCreatedIntegrationEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation(LogTemplates.HandledIntegratonEvent, notification); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Models/LongToStringModel.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Models/LongToStringModel.cs new file mode 100644 index 0000000..7b543b5 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Models/LongToStringModel.cs @@ -0,0 +1,6 @@ +namespace Cnblogs.Architecture.IntegrationTestProject.Models; + +public class LongToStringModel +{ + public long Id { get; set; } +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Payloads/CreatePayload.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Payloads/CreatePayload.cs index 6cabfc4..65b7eac 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Payloads/CreatePayload.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Payloads/CreatePayload.cs @@ -1,3 +1,3 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Payloads; -public record CreatePayload(bool NeedError); \ No newline at end of file +public record CreatePayload(bool NeedError, string? Data); diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Payloads/UpdatePayload.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Payloads/UpdatePayload.cs index 9551396..957b574 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Payloads/UpdatePayload.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Payloads/UpdatePayload.cs @@ -1,3 +1,3 @@ namespace Cnblogs.Architecture.IntegrationTestProject.Payloads; -public record UpdatePayload(bool NeedError); \ No newline at end of file +public record UpdatePayload(bool NeedExecutionError = false, bool NeedValidationError = false); \ No newline at end of file diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs index 2bab07c..fed4166 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs @@ -1,47 +1,63 @@ +using System.Reflection; using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; -using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection; +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Cnblogs.Architecture.Ddd.EventBus.Dapr; +using Cnblogs.Architecture.IntegrationTestProject; using Cnblogs.Architecture.IntegrationTestProject.Application.Commands; using Cnblogs.Architecture.IntegrationTestProject.Application.Queries; using Cnblogs.Architecture.IntegrationTestProject.Payloads; +using Cnblogs.Architecture.TestIntegrationEvents; +using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddCqrs(typeof(Cnblogs.Architecture.IntegrationTestProject.Program).Assembly) - .AddDefaultDateTimeAndRandomProvider(); -builder.Services.AddControllers().AddCqrsModelBinderProvider(); +builder.Services.AddCqrs(Assembly.GetExecutingAssembly(), typeof(TestIntegrationEvent).Assembly) + .AddLongToStringJsonConverter() + .AddDefaultDateTimeAndRandomProvider() + .AddEventBus(o => o.UseDapr(Constants.AppName)); +builder.Services.AddControllers().AddCqrsModelBinderProvider().AddLongToStringJsonConverter(); + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddCnblogsApiVersioning(); -builder.Services.AddSwaggerGen(); var app = builder.Build(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - app.UseAuthorization(); app.MapControllers(); + +app.Subscribe(); + var apis = app.NewVersionedApi(); var v1 = apis.MapGroup("/api/v{version:apiVersion}").HasApiVersion(1); -v1.MapQuery("strings/{id:int}"); +v1.MapQuery( + "apps/{appId}/strings/{stringId:int}/value", + MapNullableRouteParameter.Enable, + enableHead: true); +v1.MapQuery( + "strings/{stringId:int}", + async (int stringId, [FromQuery] bool found = true) + => await Task.FromResult(new GetStringQuery(StringId: stringId, Found: found))); v1.MapQuery("strings"); -v1.MapCommand("strings", (CreatePayload payload) => new CreateCommand(payload.NeedError)); -v1.MapCommand( +v1.MapQuery("long-to-string/{id:long}"); +v1.MapCommand("long-to-string"); +v1.MapCommand( + "strings", + (CreatePayload payload) => Task.FromResult(new CreateCommand(payload.NeedError, payload.Data))); +v1.MapCommand( "strings/{id:int}", - (int id, UpdatePayload payload) => new UpdateCommand(id, payload.NeedError)); + (int id, UpdatePayload payload) => new UpdateCommand(id, payload.NeedValidationError, payload.NeedExecutionError)); v1.MapCommand("strings/{id:int}"); +// generic command map +v1.MapPostCommand("generic-map/strings"); +v1.MapPutCommand("generic-map/strings"); +v1.MapDeleteCommand("generic-map/strings/{id:int}"); + app.Run(); namespace Cnblogs.Architecture.IntegrationTestProject { - public partial class Program - { - } -} \ No newline at end of file + // ReSharper disable once PartialTypeWithSinglePart + public partial class Program; +} diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Properties/launchSettings.json b/test/Cnblogs.Architecture.IntegrationTestProject/Properties/launchSettings.json index 2b3e363..cc7ea7c 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Properties/launchSettings.json +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Properties/launchSettings.json @@ -14,7 +14,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "https://localhost:7200;http://localhost:5200", + "applicationUrl": "https://localhost:8200;http://localhost:5200", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/test/Cnblogs.Architecture.IntegrationTests/Cnblogs.Architecture.IntegrationTests.csproj b/test/Cnblogs.Architecture.IntegrationTests/Cnblogs.Architecture.IntegrationTests.csproj index 41490f0..9ea8bc3 100644 --- a/test/Cnblogs.Architecture.IntegrationTests/Cnblogs.Architecture.IntegrationTests.csproj +++ b/test/Cnblogs.Architecture.IntegrationTests/Cnblogs.Architecture.IntegrationTests.csproj @@ -1,22 +1,26 @@ - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + - - - - + + + + diff --git a/test/Cnblogs.Architecture.IntegrationTests/CommandResponseHandlerTests.cs b/test/Cnblogs.Architecture.IntegrationTests/CommandResponseHandlerTests.cs new file mode 100644 index 0000000..6a64974 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/CommandResponseHandlerTests.cs @@ -0,0 +1,286 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; +using Cnblogs.Architecture.IntegrationTestProject; +using Cnblogs.Architecture.IntegrationTestProject.Application.Commands; +using Cnblogs.Architecture.IntegrationTestProject.Application.Errors; +using Cnblogs.Architecture.IntegrationTestProject.Application.Queries; +using Cnblogs.Architecture.IntegrationTestProject.Payloads; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Cnblogs.Architecture.IntegrationTests; + +public class CommandResponseHandlerTests +{ + public static IEnumerable ErrorPayloads { get; } = new List + { + new object[] { true, false }, new object[] { false, true } + }; + + [Fact] + public async Task MinimalApi_NoCqrsVersionHeader_RawResultAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + + // Act + var response = await client.PutAsJsonAsync("/api/v1/strings/1", new UpdatePayload()); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + content.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task MinimalApi_CqrsV2_CommandResponseAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + client.DefaultRequestHeaders.AppendCurrentCqrsVersion(); + + // Act + var response = await client.PutAsJsonAsync("/api/v1/strings/1", new UpdatePayload()); + var content = await response.Content.ReadFromJsonAsync>(); + + // Assert + response.Headers.CqrsVersion().Should().BeGreaterThan(1); + content.Should().NotBeNull(); + content.Response.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Mvc_NoCqrsVersionHeader_RawResultAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + + // Act + var response = await client.PutAsJsonAsync("/api/v1/mvc/strings/1", new UpdatePayload()); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + response.Should().BeSuccessful(); + content.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Mvc_CurrentCqrsVersion_CommandResponseAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + client.DefaultRequestHeaders.AppendCurrentCqrsVersion(); + + // Act + var response = await client.PutAsJsonAsync("/api/v1/mvc/strings/1", new UpdatePayload()); + var content = await response.Content.ReadFromJsonAsync>(); + + // Assert + response.Should().BeSuccessful(); + response.Headers.CqrsVersion().Should().BeGreaterThan(1); + content!.Response.Should().NotBeNullOrEmpty(); + } + + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task MinimalApi_HavingError_BadRequestAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PutAsJsonAsync( + "/api/v1/strings/1", + new UpdatePayload(needExecutionError, needValidationError)); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + response.Should().HaveClientError(); + content.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task MinimalApi_Success_OkAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PutAsJsonAsync("/api/v1/strings/1", new UpdatePayload()); + + // Assert + response.Should().BeSuccessful(); + } + + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task MinimalApi_HavingError_ProblemDetailsAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var builder = new WebApplicationFactory().WithWebHostBuilder( + w => w.ConfigureTestServices(s => s.AddCqrs(typeof(GetStringQuery).Assembly).UseProblemDetails())); + + // Act + var response = await builder.CreateClient().PutAsJsonAsync( + "/api/v1/strings/1", + new UpdatePayload(needExecutionError, needValidationError)); + var content = await response.Content.ReadFromJsonAsync(); + + // Assert + response.Should().HaveClientError(); + content.Should().NotBeNull(); + } + + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task MinimalApi_HavingError_CommandResponseAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + var response = await client.PutAsJsonAsync( + "/api/v1/strings/1", + new UpdatePayload(needExecutionError, needValidationError)); + var commandResponse = await response.Content.ReadFromJsonAsync>(); + + // Assert + response.Should().HaveClientError(); + commandResponse.Should().NotBeNull(); + commandResponse.IsSuccess().Should().BeFalse(); + commandResponse.Should().BeEquivalentTo(new { IsValidationError = needValidationError }); + (commandResponse.ErrorCode != null).Should().Be(needExecutionError); + } + + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task MinimalApi_HavingError_CustomContentAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var error = new TestError(1, "testError"); + var builder = new WebApplicationFactory().WithWebHostBuilder( + w => w.ConfigureTestServices( + s => s.AddCqrs(typeof(GetStringQuery).Assembly) + .UseCustomCommandErrorResponseMapper((_, _) => Results.BadRequest(error)))); + + // Act + var response = await builder.CreateClient().PutAsJsonAsync( + "/api/v1/strings/1", + new UpdatePayload(needValidationError, needExecutionError)); + var content = await response.Content.ReadFromJsonAsync(); + + // Assert + response.Should().HaveClientError(); + content.Should().BeEquivalentTo(error); + } + + [Fact] + public async Task Mvc_Success_OkAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PutAsJsonAsync("/api/v1/mvc/strings/1", new UpdatePayload()); + + // Assert + response.Should().BeSuccessful(); + } + + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task Mvc_HavingError_TextAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PutAsJsonAsync( + "/api/v1/mvc/strings/1", + new UpdatePayload(needValidationError, needExecutionError)); + var content = await response.Content.ReadAsStringAsync(); + + // Assert + response.Should().HaveClientError(); + content.Should().NotBeNullOrEmpty(); + } + + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task Mvc_HavingError_ProblemDetailAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var builder = new WebApplicationFactory().WithWebHostBuilder( + w => w.ConfigureTestServices(s => s.AddCqrs(typeof(UpdateCommand).Assembly).UseProblemDetails())); + + // Act + var response = await builder.CreateClient().PutAsJsonAsync( + "/api/v1/mvc/strings/1", + new UpdatePayload(needValidationError, needExecutionError)); + var content = await response.Content.ReadFromJsonAsync(); + + // Assert + response.Should().HaveClientError(); + content.Should().NotBeNull(); + } + + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task Mvc_HavingError_CommandResponseAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var client = builder.CreateClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/cqrs")); + var response = await client.PutAsJsonAsync( + "/api/v1/mvc/strings/1", + new UpdatePayload(needExecutionError, needValidationError)); + var content = await response.Content.ReadFromJsonAsync>(); + + // Assert + response.Should().HaveClientError(); + content.Should().NotBeNull(); + content.IsSuccess().Should().BeFalse(); + content.Should().BeEquivalentTo(new { IsValidationError = needValidationError }); + (content.ErrorCode != null).Should().Be(needExecutionError); + } + + [Theory] + [MemberData(nameof(ErrorPayloads))] + public async Task Mvc_HavingError_CustomContentAsync(bool needValidationError, bool needExecutionError) + { + // Arrange + var error = TestError.Default; + var builder = new WebApplicationFactory().WithWebHostBuilder( + w => w.ConfigureTestServices( + s => s.AddCqrs(typeof(UpdateCommand).Assembly) + .UseCustomCommandErrorResponseMapper((_, _) => Results.BadRequest(error)))); + + // Act + var response = await builder.CreateClient().PutAsJsonAsync( + "/api/v1/mvc/strings/1", + new UpdatePayload(needValidationError, needExecutionError)); + var content = await response.Content.ReadFromJsonAsync(); + + // Assert + response.Should().HaveClientError(); + content.Should().BeEquivalentTo(error); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTests/CqrsRouteMapperTests.cs b/test/Cnblogs.Architecture.IntegrationTests/CqrsRouteMapperTests.cs index 68fd3a9..1292aa5 100644 --- a/test/Cnblogs.Architecture.IntegrationTests/CqrsRouteMapperTests.cs +++ b/test/Cnblogs.Architecture.IntegrationTests/CqrsRouteMapperTests.cs @@ -1,7 +1,9 @@ -using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +using System.Net; +using System.Net.Http.Json; +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; using Cnblogs.Architecture.IntegrationTestProject; +using Cnblogs.Architecture.IntegrationTestProject.Application.Commands; using FluentAssertions; - using Microsoft.AspNetCore.Mvc.Testing; namespace Cnblogs.Architecture.IntegrationTests; @@ -23,6 +25,19 @@ public async Task GetItem_SuccessAsync() content.Should().NotBeNullOrEmpty(); } + [Fact] + public async Task GetItem_NotFondAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().GetAsync("/api/v1/strings/1?found=false"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + [Fact] public async Task ListItems_SuccessAsync() { @@ -36,7 +51,7 @@ public async Task ListItems_SuccessAsync() // Assert response.Should().BeSuccessful(); content.Should().NotBeNull(); - content!.Items.Should().NotBeNullOrEmpty(); + content.Items.Should().NotBeNullOrEmpty(); } [Fact] @@ -77,4 +92,88 @@ public async Task DeleteItem_SuccessAsync() // Assert response.Should().BeSuccessful(); } -} \ No newline at end of file + + [Fact] + public async Task GetItem_NullableRouteValue_SuccessAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var responses = new List + { + await builder.CreateClient().GetAsync("/api/v1/apps/-/strings/-/value"), + await builder.CreateClient().GetAsync("/api/v1/apps/-/strings/1/value"), + await builder.CreateClient().GetAsync("/api/v1/apps/someApp/strings/-/value"), + await builder.CreateClient().GetAsync("/api/v1/apps/someApp/strings/1/value") + }; + + // Assert + responses.Should().Match(x => x.All(y => y.IsSuccessStatusCode)); + } + + [Fact] + public async Task GetItem_MapHeadAndGet_SuccessAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var uris = new[] + { + "/api/v1/apps/-/strings/-/value", "/api/v1/apps/-/strings/1/value", "/api/v1/apps/someApp/strings/-/value", "/api/v1/apps/someApp/strings/1/value" + }.Select(x => new HttpRequestMessage(HttpMethod.Head, x)); + var responses = new List(); + foreach (var uri in uris) + { + responses.Add(await builder.CreateClient().SendAsync(uri, HttpCompletionOption.ResponseHeadersRead)); + } + + // Assert + responses.Should().Match(x => x.All(y => y.IsSuccessStatusCode)); + } + + [Fact] + public async Task PostItem_GenericMap_SuccessAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PostAsJsonAsync( + "/api/v1/generic-map/strings", + new CreateCommand(false, "data")); + + // Assert + response.Should().BeSuccessful(); + } + + [Fact] + public async Task PutItem_GenericMap_SuccessAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PutAsJsonAsync( + "/api/v1/generic-map/strings", + new UpdateCommand(1, false, false)); + + // Assert + response.Should().BeSuccessful(); + } + + [Fact] + public async Task DeleteCommand_GenericMap_SuccessAsync() + { + // Arrange + var builder = new WebApplicationFactory(); + + // Act + var queryBuilder = new QueryStringBuilder().Add("needError", false); + var response = await builder.CreateClient().DeleteAsync("/api/v1/generic-map/strings/1" + queryBuilder.Build()); + + // Assert + response.Should().BeSuccessful(); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTests/CustomJsonConverterTests.cs b/test/Cnblogs.Architecture.IntegrationTests/CustomJsonConverterTests.cs new file mode 100644 index 0000000..dd7aa41 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/CustomJsonConverterTests.cs @@ -0,0 +1,96 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Cnblogs.Architecture.IntegrationTestProject; +using Cnblogs.Architecture.IntegrationTestProject.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Cnblogs.Architecture.IntegrationTests; + +public class CustomJsonConverterTests +{ + private static readonly JsonSerializerOptions WebDefaults = new(JsonSerializerDefaults.Web); + + [Theory] + [InlineData("/api/v1/mvc/json/long-to-string/")] + [InlineData("/api/v1/long-to-string/")] + public async Task LongToJson_WriteLongToString_CanBeParsedByServerAsync(string baseUrl) + { + // Arrange + const long id = 202410267558024668; + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().GetAsync(baseUrl + id); + var serverObject = await response.Content.ReadFromJsonAsync(WebDefaults); + + // Assert + serverObject.Should().BeEquivalentTo(new LongToStringModel() { Id = id }); + } + + [Theory] + [InlineData("/api/v1/mvc/json/long-to-string/")] + [InlineData("/api/v1/long-to-string/")] + public async Task LongToJson_WriteLongToString_IsStringInJsonAsync(string baseUrl) + { + // Arrange + const long id = 202410267558024668; + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().GetAsync(baseUrl + id); + var browserObject = await response.Content.ReadFromJsonAsync(WebDefaults); + + // Assert + browserObject.EnumerateObject().First().Value.GetString().Should().Be(id.ToString()); + } + + [Theory] + [InlineData("/api/v1/mvc/json/long-to-string/")] + [InlineData("/api/v1/long-to-string/")] + public async Task LongToJson_ReadLongFromString_SuccessAsync(string url) + { + // Arrange + const string json = """ + { + "id": "202410267558024668" + } + """; + + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PostAsync( + url, + new StringContent(json, Encoding.UTF8, "application/json")); + var model = await response.Content.ReadFromJsonAsync(WebDefaults); + + // Assert + model.EnumerateObject().First().Value.GetString().Should().Be("202410267558024668"); + } + + [Theory] + [InlineData("/api/v1/mvc/json/long-to-string/")] + [InlineData("/api/v1/long-to-string/")] + public async Task LongToJson_ReadLongFromNumber_SuccessAsync(string url) + { + // Arrange + const string json = """ + { + "id": 202410267558024668 + } + """; + + var builder = new WebApplicationFactory(); + + // Act + var response = await builder.CreateClient().PostAsync( + url, + new StringContent(json, Encoding.UTF8, "application/json")); + var model = await response.Content.ReadFromJsonAsync(WebDefaults); + + // Assert + model.EnumerateObject().First().Value.GetString().Should().Be("202410267558024668"); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTests/CustomModelBinderTests.cs b/test/Cnblogs.Architecture.IntegrationTests/CustomModelBinderTests.cs index ccfae90..b8b79c5 100644 --- a/test/Cnblogs.Architecture.IntegrationTests/CustomModelBinderTests.cs +++ b/test/Cnblogs.Architecture.IntegrationTests/CustomModelBinderTests.cs @@ -1,5 +1,5 @@ using System.Net; - +using System.Net.Http.Json; using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; using Cnblogs.Architecture.IntegrationTestProject; using FluentAssertions; @@ -18,7 +18,7 @@ public async Task PagingParamsModelBinder_Normal_NotNullAsync() // Act var response = await builder.CreateClient() - .GetFromJsonAsync("/api/v1/paging?pageIndex=1&pageSize=30"); + .GetFromJsonAsync("/api/v1/mvc/paging?pageIndex=1&pageSize=30"); // Assert response.Should().BeEquivalentTo(new PagingParams(1, 30)); @@ -34,7 +34,7 @@ public async Task PagingParamsModelBinder_NoPageIndexOrPageSize_NullAsync(string var builder = new WebApplicationFactory(); // Act - var response = await builder.CreateClient().GetAsync($"/api/v1/paging{query}"); + var response = await builder.CreateClient().GetAsync($"/api/v1/mvc/paging{query}"); // Assert response.StatusCode.Should().Be(HttpStatusCode.NoContent); @@ -49,7 +49,7 @@ public async Task PagingParamsModelBinder_PageIndexInvalid_FailAsync(string page var builder = new WebApplicationFactory(); // Act - var response = await builder.CreateClient().GetAsync($"/api/v1/paging?pageIndex={pageIndex}&pageSize=10"); + var response = await builder.CreateClient().GetAsync($"/api/v1/mvc/paging?pageIndex={pageIndex}&pageSize=10"); // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -64,7 +64,7 @@ public async Task PagingParamsModelBinder_PageSizeInvalid_FailAsync(string pageS var builder = new WebApplicationFactory(); // Act - var response = await builder.CreateClient().GetAsync($"/api/v1/paging?pageIndex=1&pageSize={pageSize}"); + var response = await builder.CreateClient().GetAsync($"/api/v1/mvc/paging?pageIndex=1&pageSize={pageSize}"); // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); diff --git a/test/Cnblogs.Architecture.IntegrationTests/DaprTests.cs b/test/Cnblogs.Architecture.IntegrationTests/DaprTests.cs new file mode 100644 index 0000000..8930ffd --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/DaprTests.cs @@ -0,0 +1,90 @@ +using System.Net; +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Cnblogs.Architecture.Ddd.EventBus.Dapr; +using Cnblogs.Architecture.IntegrationTestProject.EventHandlers; +using Cnblogs.Architecture.TestIntegrationEvents; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Cnblogs.Architecture.IntegrationTests; + +public class DaprTests +{ + [Theory] + [InlineData(SubscribeType.ByEvent)] + [InlineData(SubscribeType.ByEventAssemblies)] + [InlineData(SubscribeType.ByEventHandler)] + [InlineData(SubscribeType.ByEventHandlerAssemblies)] + public async Task Dapr_SubscribeEndpoint_OkAsync(SubscribeType subscribeType) + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCqrs(typeof(TestIntegrationEvent).Assembly).AddEventBus(o => o.UseDapr(nameof(DaprTests))); + builder.WebHost.UseTestServer(); + + await using var app = builder.Build(); + + _ = subscribeType switch + { + SubscribeType.ByEvent => app.Subscribe().Subscribe(), + SubscribeType.ByEventAssemblies => app.Subscribe(typeof(TestIntegrationEvent).Assembly), + SubscribeType.ByEventHandler => app.SubscribeByEventHandler(), + SubscribeType.ByEventHandlerAssemblies => app.SubscribeByEventHandler(typeof(TestIntegrationEventHandler).Assembly), + _ => app + }; + + await app.StartAsync(); + var httpClient = app.GetTestClient(); + + // Act + var response = await httpClient.GetAsync("/dapr/subscribe"); + + // Assert + response.Should().BeSuccessful(); + var responseText = await response.Content.ReadAsStringAsync(); + responseText.Should().Contain(nameof(TestIntegrationEvent)); + responseText.Should().Contain(nameof(BlogPostCreatedIntegrationEvent)); + } + + [Fact] + public async Task Dapr_SubscribeWithoutAnyAssembly_OkAsync() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCqrs().AddEventBus(o => o.UseDapr(nameof(DaprTests))); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.Subscribe(); + await app.StartAsync(); + var httpClient = app.GetTestClient(); + + // Act + var response = await httpClient.GetAsync("/dapr/subscribe"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Dapr_MapSubscribeHandler_OkAsync() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Services.AddDaprClient(); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.MapSubscribeHandler(); + await app.StartAsync(); + var httpClient = app.GetTestClient(); + + // Act + var response = await httpClient.GetAsync("/dapr/subscribe"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTests/IntegrationEventHandlerTests.cs b/test/Cnblogs.Architecture.IntegrationTests/IntegrationEventHandlerTests.cs new file mode 100644 index 0000000..f078764 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/IntegrationEventHandlerTests.cs @@ -0,0 +1,55 @@ +using System.Net.Http.Json; +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Cnblogs.Architecture.Ddd.EventBus.Dapr; +using Cnblogs.Architecture.IntegrationTestProject.EventHandlers; +using Cnblogs.Architecture.TestIntegrationEvents; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Sinks.InMemory; +using Serilog.Sinks.InMemory.Assertions; +using Xunit.Abstractions; +using static Cnblogs.Architecture.IntegrationTestProject.Constants; + +namespace Cnblogs.Architecture.IntegrationTests; + +public class IntegrationEventHandlerTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public IntegrationEventHandlerTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public async Task IntegrationEventHandler_TestIntegrationEvent_SuccessAsync() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + builder.Logging.AddSerilog(logger => logger.WriteTo.InMemory().WriteTo.Console()); + builder.Services.AddEventBus( + o => o.UseDapr(nameof(IntegrationEventHandlerTests)), + typeof(TestIntegrationEventHandler).Assembly); + builder.WebHost.UseTestServer(); + var app = builder.Build(); + app.Subscribe(); + await app.StartAsync(); + var client = app.GetTestClient(); + var @event = new TestIntegrationEvent(Guid.NewGuid(), DateTimeOffset.Now, $"Hello World! {Guid.NewGuid()}"); + + // Act + var subscriptions = await client.GetFromJsonAsync("/dapr/subscribe"); + var sub = subscriptions!.First(x => x.Route.Contains(nameof(TestIntegrationEvent))); + var response = await client.PostAsJsonAsync(sub.Route, @event); + _testOutputHelper.WriteLine("Subscription Route: " + sub.Route); + + // Assert + response.Should().BeSuccessful(); + InMemorySink.Instance + .Should().HaveMessage(LogTemplates.HandledIntegratonEvent).Appearing().Once() + .WithProperty("event").HavingADestructuredObject().WithProperty("Id").WithValue(@event.Id); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTests/IntegrationEventPublishTests.cs b/test/Cnblogs.Architecture.IntegrationTests/IntegrationEventPublishTests.cs new file mode 100644 index 0000000..8fbb04c --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/IntegrationEventPublishTests.cs @@ -0,0 +1,196 @@ +using System.Net; +using System.Net.Http.Json; +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Cnblogs.Architecture.IntegrationTestProject; +using Cnblogs.Architecture.IntegrationTestProject.Payloads; +using Cnblogs.Architecture.TestIntegrationEvents; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Cnblogs.Architecture.IntegrationTests; + +public class IntegrationEventPublishTests +{ + [Fact] + public async Task EventBus_PublishEvent_SuccessAsync() + { + // Arrange + const string data = "hello"; + var builder = new WebApplicationFactory(); + var eventBusMock = Substitute.For(); + builder = builder.WithWebHostBuilder( + b => b.ConfigureServices( + services => + { + services.RemoveAll(); + services.AddScoped(_ => eventBusMock); + })); + + // Act + var response = await builder.CreateClient().PostAsJsonAsync( + "/api/v1/strings", + new CreatePayload(false, data)); + var content = await response.Content.ReadAsStringAsync(); + await Task.Delay(1500); + + // Assert + response.Should().BeSuccessful(); + content.Should().NotBeNullOrEmpty(); + await eventBusMock.Received(1).PublishAsync( + Arg.Any(), + Arg.Is(t => t.Message == data)); + } + + [Fact] + public async Task EventBus_Downgrading_DowngradeAsync() + { + // Arrange + const string data = "hello"; + var builder = new WebApplicationFactory(); + var eventBusMock = Substitute.For(); + builder = builder.WithWebHostBuilder( + b => b.ConfigureServices( + services => + { + services.RemoveAll(); + services.AddScoped(_ => eventBusMock); + services.Configure( + o => + { + o.FailureCountBeforeDowngrade = 1; + o.DowngradeInterval = 3000; + }); + })); + eventBusMock.PublishAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException()); + + // Act + var response = await builder.CreateClient().PostAsJsonAsync( + "/api/v1/strings", + new CreatePayload(false, data)); + var content = await response.Content.ReadAsStringAsync(); + await Task.Delay(3000); // hit at 1000ms and 3000ms + + // Assert + response.Should().BeSuccessful(); + content.Should().NotBeNullOrEmpty(); + await eventBusMock.Received(2).PublishAsync( + Arg.Any(), + Arg.Is(t => t.Message == data)); + } + + [Fact] + public async Task EventBus_MaximumBufferSizeReached_ThrowAsync() + { + // Arrange + const string data = "hello"; + var builder = new WebApplicationFactory(); + var eventBusMock = Substitute.For(); + builder = builder.WithWebHostBuilder( + b => b.ConfigureServices( + services => + { + services.RemoveAll(); + services.AddScoped(_ => eventBusMock); + services.Configure( + o => + { + o.MaximumBufferSize = 1; + o.FailureCountBeforeDowngrade = 1; + o.DowngradeInterval = 3000; + }); + })); + eventBusMock.PublishAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException()); + var client = builder.CreateClient(); + await client.PostAsJsonAsync( + "/api/v1/strings", + new CreatePayload(false, data)); + + // Act + var response = await client.PostAsJsonAsync("/api/v1/strings", new CreatePayload(false, data)); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + } + + [Fact] + public async Task EventBus_MaximumBatchSize_OneBatchAsync() + { + // Arrange + const string data = "hello"; + var builder = new WebApplicationFactory(); + var eventBusMock = Substitute.For(); + builder = builder.WithWebHostBuilder( + b => b.ConfigureServices( + services => + { + services.RemoveAll(); + services.AddScoped(_ => eventBusMock); + services.Configure( + o => + { + o.MaximumBatchSize = 1; + o.FailureCountBeforeDowngrade = 1; + o.DowngradeInterval = 3000; + }); + })); + var client = builder.CreateClient(); + for (var i = 0; i < 3; i++) + { + // put 3 events + await client.PostAsJsonAsync("/api/v1/strings", new CreatePayload(false, data)); + } + + // Act + await Task.Delay(1000); + + // Assert + await eventBusMock.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EventBus_DowngradeThenRecover_RecoverAsync() + { + // Arrange + const string data = "hello"; + var builder = new WebApplicationFactory(); + var eventBusMock = Substitute.For(); + builder = builder.WithWebHostBuilder( + b => b.ConfigureServices( + services => + { + services.RemoveAll(); + services.AddScoped(_ => eventBusMock); + services.Configure( + o => + { + o.FailureCountBeforeDowngrade = 1; + o.DowngradeInterval = 4000; + }); + })); + eventBusMock.PublishAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException()); + await builder.CreateClient().PostAsJsonAsync( + "/api/v1/strings", + new CreatePayload(false, data)); + await Task.Delay(1000); // failed, now it is downgraded + + // Act + eventBusMock.PublishAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + eventBusMock.ClearReceivedCalls(); + await Task.Delay(2000); // recover + await builder.CreateClient().PostAsJsonAsync( + "/api/v1/strings", + new CreatePayload(false, data)); + await Task.Delay(1000); + + // Assert + await eventBusMock.Received(2) + .PublishAsync(Arg.Any(), Arg.Is(t => t.Message == data)); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTests/IntegrationTestCollection.cs b/test/Cnblogs.Architecture.IntegrationTests/IntegrationTestCollection.cs new file mode 100644 index 0000000..3ad7ace --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/IntegrationTestCollection.cs @@ -0,0 +1,7 @@ +namespace Cnblogs.Architecture.IntegrationTests; + +[CollectionDefinition(Name)] +public class IntegrationTestCollection : ICollectionFixture +{ + public const string Name = nameof(IntegrationTestCollection); +} diff --git a/test/Cnblogs.Architecture.IntegrationTests/IntegrationTestFactory.cs b/test/Cnblogs.Architecture.IntegrationTests/IntegrationTestFactory.cs new file mode 100644 index 0000000..a278663 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/IntegrationTestFactory.cs @@ -0,0 +1,6 @@ +using Cnblogs.Architecture.IntegrationTestProject; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Cnblogs.Architecture.IntegrationTests; + +public class IntegrationTestFactory : WebApplicationFactory; diff --git a/test/Cnblogs.Architecture.IntegrationTests/IntegrationTestFramework.cs b/test/Cnblogs.Architecture.IntegrationTests/IntegrationTestFramework.cs new file mode 100644 index 0000000..d2b7b04 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/IntegrationTestFramework.cs @@ -0,0 +1,19 @@ +using System.Diagnostics; +using System.Text; +using Cnblogs.Architecture.IntegrationTests; +using Xunit.Abstractions; +using Xunit.Sdk; + +[assembly: TestFramework($"Cnblogs.Architecture.IntegrationTests.{nameof(IntegrationTestFramework)}", "Cnblogs.Architecture.IntegrationTests")] + +namespace Cnblogs.Architecture.IntegrationTests; + +public class IntegrationTestFramework : XunitTestFramework +{ + public IntegrationTestFramework(IMessageSink messageSink) + : base(messageSink) + { + Console.OutputEncoding = Encoding.UTF8; + Trace.Listeners.Add(new ConsoleTraceListener()); + } +} diff --git a/test/Cnblogs.Architecture.IntegrationTests/SubscribeType.cs b/test/Cnblogs.Architecture.IntegrationTests/SubscribeType.cs new file mode 100644 index 0000000..708d112 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/SubscribeType.cs @@ -0,0 +1,10 @@ +namespace Cnblogs.Architecture.IntegrationTests; + +public enum SubscribeType +{ + None, + ByEvent, + ByEventAssemblies, + ByEventHandler, + ByEventHandlerAssemblies, +} \ No newline at end of file diff --git a/test/Cnblogs.Architecture.IntegrationTests/Subscription.cs b/test/Cnblogs.Architecture.IntegrationTests/Subscription.cs new file mode 100644 index 0000000..2fe8215 --- /dev/null +++ b/test/Cnblogs.Architecture.IntegrationTests/Subscription.cs @@ -0,0 +1,22 @@ +namespace Cnblogs.Architecture.IntegrationTests; + +/// +/// This class defines subscribe endpoint response for dapr +/// +internal class Subscription +{ + /// + /// Gets or sets the topic name. + /// + public string Topic { get; set; } = string.Empty; + + /// + /// Gets or sets the pubsub name + /// + public string PubsubName { get; set; } = string.Empty; + + /// + /// Gets or sets the route + /// + public string Route { get; set; } = string.Empty; +} diff --git a/test/Cnblogs.Architecture.TestIntegrationEvents/AssemblyInfo.cs b/test/Cnblogs.Architecture.TestIntegrationEvents/AssemblyInfo.cs index a804d50..3f39a5c 100644 --- a/test/Cnblogs.Architecture.TestIntegrationEvents/AssemblyInfo.cs +++ b/test/Cnblogs.Architecture.TestIntegrationEvents/AssemblyInfo.cs @@ -1,3 +1,3 @@ using Cnblogs.Architecture.Ddd.EventBus.Abstractions; -[assembly:AssemblyAppName("test")] \ No newline at end of file +[assembly: AssemblyAppName("test")] \ No newline at end of file diff --git a/test/Cnblogs.Architecture.TestIntegrationEvents/BlogPostCreatedIntegrationEvent.cs b/test/Cnblogs.Architecture.TestIntegrationEvents/BlogPostCreatedIntegrationEvent.cs new file mode 100644 index 0000000..06af883 --- /dev/null +++ b/test/Cnblogs.Architecture.TestIntegrationEvents/BlogPostCreatedIntegrationEvent.cs @@ -0,0 +1,5 @@ +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; + +namespace Cnblogs.Architecture.TestIntegrationEvents; + +public record BlogPostCreatedIntegrationEvent(Guid Id, DateTimeOffset CreatedTime, string Title) : IntegrationEvent(Id, CreatedTime); \ No newline at end of file diff --git a/test/Cnblogs.Architecture.TestIntegrationEvents/TestIntegrationEvent.cs b/test/Cnblogs.Architecture.TestIntegrationEvents/TestIntegrationEvent.cs index 14df8dd..6e76c83 100644 --- a/test/Cnblogs.Architecture.TestIntegrationEvents/TestIntegrationEvent.cs +++ b/test/Cnblogs.Architecture.TestIntegrationEvents/TestIntegrationEvent.cs @@ -2,4 +2,4 @@ namespace Cnblogs.Architecture.TestIntegrationEvents; -public record TestIntegrationEvent(Guid Id, DateTimeOffset CreatedTime) : IntegrationEvent(Id, CreatedTime); \ No newline at end of file +public record TestIntegrationEvent(Guid Id, DateTimeOffset CreatedTime, string Message) : IntegrationEvent(Id, CreatedTime); \ No newline at end of file diff --git a/test/Cnblogs.Architecture.TestShared/Cnblogs.Architecture.TestShared.csproj b/test/Cnblogs.Architecture.TestShared/Cnblogs.Architecture.TestShared.csproj index 2621cfa..438360a 100644 --- a/test/Cnblogs.Architecture.TestShared/Cnblogs.Architecture.TestShared.csproj +++ b/test/Cnblogs.Architecture.TestShared/Cnblogs.Architecture.TestShared.csproj @@ -1,6 +1,16 @@ - - - - - \ No newline at end of file + + + + + + + + + + + + + + + diff --git a/test/Cnblogs.Architecture.UnitTests/Cnblogs.Architecture.UnitTests.csproj b/test/Cnblogs.Architecture.UnitTests/Cnblogs.Architecture.UnitTests.csproj index 654b351..477a915 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cnblogs.Architecture.UnitTests.csproj +++ b/test/Cnblogs.Architecture.UnitTests/Cnblogs.Architecture.UnitTests.csproj @@ -1,14 +1,12 @@ - + - - - - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/CacheBehaviorTests.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/CacheBehaviorTests.cs index 1a5b5db..82f6644 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/CacheBehaviorTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/CacheBehaviorTests.cs @@ -9,8 +9,8 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; - -using Moq; +using NSubstitute; +using NSubstitute.ExceptionExtensions; namespace Cnblogs.Architecture.UnitTests.Cqrs.Behaviors; @@ -20,9 +20,11 @@ public class CacheBehaviorTests public async Task CacheBehavior_DisableCache_NotCacheAsync() { // Arrange - var local = new Mock(); + var local = Substitute.For(); local.AddCacheValue("cacheKey", "cacheValue"); - var behavior = GetBehavior, string>(new List { local.Object }); + var remote = Substitute.For(); + remote.AddCacheValue("cacheKey", "cacheValue"); + var behavior = GetBehavior, string>([local, remote]); // Act var result = await behavior.Handle( @@ -31,7 +33,7 @@ public async Task CacheBehavior_DisableCache_NotCacheAsync() LocalCacheBehavior = CacheBehavior.DisabledCache, RemoteCacheBehavior = CacheBehavior.DisabledCache }, - () => Task.FromResult("noCache"), + _ => Task.FromResult("noCache"), CancellationToken.None); // Assert @@ -42,8 +44,8 @@ public async Task CacheBehavior_DisableCache_NotCacheAsync() public async Task CacheBehavior_EnableLocal_NoCache_UpdateAsync() { // Arrange - var local = new Mock(); - var behavior = GetBehavior, string>(new List { local.Object }); + var local = Substitute.For(); + var behavior = GetBehavior, string>([local]); // Act var result = await behavior.Handle( @@ -53,22 +55,22 @@ public async Task CacheBehavior_EnableLocal_NoCache_UpdateAsync() LocalExpires = TimeSpan.FromSeconds(1), RemoteCacheBehavior = CacheBehavior.DisabledCache }, - () => Task.FromResult("noCache"), + _ => Task.FromResult("noCache"), CancellationToken.None); // Assert result.Should().Be("noCache"); - local.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + await local.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task CacheBehavior_EnableLocal_HasCache_UseCacheAsync() { // Arrange - var local = new Mock(); + var local = Substitute.For(); local.AddCacheValue("cacheKey", "cacheValue"); - var remote = new Mock(); - var behavior = GetBehavior, string>(new List { local.Object, remote.Object }); + var remote = Substitute.For(); + var behavior = GetBehavior, string>([local, remote]); // Act var result = await behavior.Handle( @@ -78,22 +80,22 @@ public async Task CacheBehavior_EnableLocal_HasCache_UseCacheAsync() LocalExpires = TimeSpan.FromSeconds(1), RemoteCacheBehavior = CacheBehavior.DisabledCache }, - () => Task.FromResult("noCache"), + _ => Task.FromResult("noCache"), CancellationToken.None); // Assert result.Should().Be("cacheValue"); - local.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - remote.Verify(x => x.GetAsync(It.IsAny()), Times.Never); + await local.Received(0).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await remote.Received(0).GetAsync(Arg.Any()); } [Fact] public async Task CacheBehavior_EnableRemote_NoCache_UpdateAsync() { // Arrange - var remote = new Mock(); - var local = new Mock(); - var behavior = GetBehavior, string>(new List { local.Object, remote.Object }); + var remote = Substitute.For(); + var local = Substitute.For(); + var behavior = GetBehavior, string>([local, remote]); // Act var result = await behavior.Handle( @@ -103,22 +105,22 @@ public async Task CacheBehavior_EnableRemote_NoCache_UpdateAsync() RemoteCacheBehavior = CacheBehavior.UpdateCacheIfMiss, RemoteExpires = TimeSpan.FromSeconds(1) }, - () => Task.FromResult("noCache"), + _ => Task.FromResult("noCache"), CancellationToken.None); // Assert result.Should().Be("noCache"); - local.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - remote.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + await local.Received(0).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await remote.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task CacheBehavior_EnableRemote_HasCache_UseCacheAsync() { // Arrange - var remote = new Mock().AddCacheValue("cacheKey", "cacheValue"); - var local = new Mock(); - var behavior = GetBehavior, string>(new List { local.Object, remote.Object }); + var remote = Substitute.For().AddCacheValue("cacheKey", "cacheValue"); + var local = Substitute.For(); + var behavior = GetBehavior, string>([local, remote]); // Act var result = await behavior.Handle( @@ -128,22 +130,22 @@ public async Task CacheBehavior_EnableRemote_HasCache_UseCacheAsync() RemoteCacheBehavior = CacheBehavior.UpdateCacheIfMiss, RemoteExpires = TimeSpan.FromSeconds(1) }, - () => Task.FromResult("noCache"), + _ => Task.FromResult("noCache"), CancellationToken.None); // Assert result.Should().Be("cacheValue"); - local.Verify(x => x.GetAsync(It.IsAny()), Times.Never); + await local.Received(0).GetAsync(Arg.Any()); } [Fact] public async Task CacheBehavior_ThrowOnGet_ThrowAsync() { // Arrange - var remote = new Mock(); - remote.Setup(x => x.GetAsync(It.IsAny())).ThrowsAsync(new Exception("test")); + var remote = Substitute.For(); + remote.GetAsync(Arg.Any()).ThrowsAsync(new Exception("test")); var behavior = GetBehavior, string>( - new List() { remote.Object }, + [remote], o => o.ThrowIfFailedOnGet = true); // Act @@ -154,7 +156,7 @@ public async Task CacheBehavior_ThrowOnGet_ThrowAsync() RemoteCacheBehavior = CacheBehavior.UpdateCacheIfMiss, RemoteExpires = TimeSpan.FromSeconds(1) }, - () => Task.FromResult("noCache"), + _ => Task.FromResult("noCache"), CancellationToken.None); // Assert @@ -165,10 +167,10 @@ public async Task CacheBehavior_ThrowOnGet_ThrowAsync() public async Task CacheBehavior_ThrowOnGet_NoThrowAsync() { // Arrange - var remote = new Mock(); - remote.Setup(x => x.GetAsync(It.IsAny())).ThrowsAsync(new Exception("test")); + var remote = Substitute.For(); + remote.GetAsync(Arg.Any()).ThrowsAsync(new Exception("test")); var behavior = GetBehavior, string>( - new List() { remote.Object }, + [remote], o => o.ThrowIfFailedOnGet = false); // Act @@ -179,7 +181,7 @@ public async Task CacheBehavior_ThrowOnGet_NoThrowAsync() RemoteCacheBehavior = CacheBehavior.UpdateCacheIfMiss, RemoteExpires = TimeSpan.FromSeconds(1) }, - () => Task.FromResult("noCache"), + _ => Task.FromResult("noCache"), CancellationToken.None); // Assert @@ -190,11 +192,11 @@ public async Task CacheBehavior_ThrowOnGet_NoThrowAsync() public async Task CacheBehavior_ThrowOnUpdate_ThrowAsync() { // Arrange - var remote = new Mock(); - remote.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + var remote = Substitute.For(); + remote.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("test")); var behavior = GetBehavior, string>( - new List { remote.Object }, + [remote], o => o.ThrowIfFailedOnUpdate = true); // Act @@ -205,7 +207,7 @@ public async Task CacheBehavior_ThrowOnUpdate_ThrowAsync() RemoteCacheBehavior = CacheBehavior.UpdateCacheIfMiss, RemoteExpires = TimeSpan.FromSeconds(1) }, - () => Task.FromResult("noCache"), + _ => Task.FromResult("noCache"), CancellationToken.None); // Assert @@ -216,11 +218,11 @@ public async Task CacheBehavior_ThrowOnUpdate_ThrowAsync() public async Task CacheBehavior_NotThrowOnUpdate_NotThrowAsync() { // Arrange - var remote = new Mock(); - remote.Setup(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + var remote = Substitute.For(); + remote.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new Exception("test")); var behavior = GetBehavior, string>( - new List { remote.Object }, + [remote], o => o.ThrowIfFailedOnUpdate = false); // Act @@ -231,7 +233,7 @@ public async Task CacheBehavior_NotThrowOnUpdate_NotThrowAsync() RemoteCacheBehavior = CacheBehavior.UpdateCacheIfMiss, RemoteExpires = TimeSpan.FromSeconds(1) }, - () => Task.FromResult("noCache"), + _ => Task.FromResult("noCache"), CancellationToken.None); // Assert @@ -242,7 +244,7 @@ public async Task CacheBehavior_NotThrowOnUpdate_NotThrowAsync() public void CacheBehavior_NoProvider_Throw() { // Act - var act = () => GetBehavior, string>(new List()); + var act = () => GetBehavior, string>([]); // Assert act.Should().Throw(); @@ -251,7 +253,7 @@ public void CacheBehavior_NoProvider_Throw() private static CacheableRequestBehavior GetBehavior( List providers, Action? optionConfigure = null) - where TRequest : ICacheableRequest, IRequest + where TRequest : ICachableRequest, IRequest { var option = new CacheableRequestOptions(); optionConfigure?.Invoke(option); @@ -262,4 +264,4 @@ private static CacheableRequestBehavior GetBehavior(option), NullLogger>.Instance); } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/LoggerBehaviorTests.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/LoggerBehaviorTests.cs index 0998fd5..f487c84 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/LoggerBehaviorTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/LoggerBehaviorTests.cs @@ -1,9 +1,7 @@ using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; using Cnblogs.Architecture.UnitTests.Cqrs.FakeObjects; - using Microsoft.Extensions.Logging; - -using Moq; +using NSubstitute; namespace Cnblogs.Architecture.UnitTests.Cqrs.Behaviors; @@ -13,21 +11,56 @@ public class LoggerBehaviorTests public async Task LoggerBehavior_ShouldLogDebugAsync() { // Arrange - var logger = new Mock, string>>>(); - var behavior = new LoggingBehavior, string>(logger.Object); + var logger = Substitute.For, string>>>(); + var behavior = + new LoggingBehavior, string>( + new TestLogger, string>>(logger)); var request = new FakeQuery(null, "test"); // Act - await behavior.Handle(request, () => Task.FromResult("done"), default); + await behavior.Handle(request, _ => Task.FromResult("done"), CancellationToken.None); // Assert - logger.Verify( - x => x.Log( - LogLevel.Debug, - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny>()), - Times.Exactly(2)); + logger.Received(2).Log( + LogLevel.Debug, + Arg.Any(), + Arg.Any(), + null, + Arg.Any>()); + } + + private class TestLogger : ILogger + { + private readonly ILogger _logger; + + // ReSharper disable once ContextualLoggerProblem + public TestLogger(ILogger logger) + { + _logger = logger; + } + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return _logger.BeginScope(state); + } + + /// + public virtual bool IsEnabled(LogLevel logLevel) + { + return _logger.IsEnabled(logLevel); + } + + /// + public virtual void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + _logger.Log(logLevel, eventId, state!, exception, (_, _) => string.Empty); + } } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/ValidationBehaviorTests.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/ValidationBehaviorTests.cs index c96cbed..841117c 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/ValidationBehaviorTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/Behaviors/ValidationBehaviorTests.cs @@ -1,8 +1,6 @@ using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; using Cnblogs.Architecture.UnitTests.Cqrs.FakeObjects; - using FluentAssertions; - using Microsoft.Extensions.Logging.Abstractions; namespace Cnblogs.Architecture.UnitTests.Cqrs.Behaviors; @@ -13,16 +11,17 @@ public class ValidationBehaviorTests public async Task ValidationBehavior_ValidationFailed_ReturnObjectAsync() { // Arrange - var request = new FakeQuery(() => new ValidationError("failed", "parameter")); + var error = new ValidationError("failed", "parameter"); + var request = new FakeQuery(() => error); var behavior = new ValidationBehavior, FakeResponse>( NullLogger, FakeResponse>>.Instance); // Act - var result = await behavior.Handle(request, () => Task.FromResult(new FakeResponse()), default); + var result = await behavior.Handle(request, _ => Task.FromResult(new FakeResponse()), CancellationToken.None); // Assert - result.Should().BeEquivalentTo( - new { IsValidationError = true, ValidationError = new ValidationError("failed", "parameter") }); + var errors = new ValidationErrors { error }; + result.Should().BeEquivalentTo(new { IsValidationError = true, ValidationErrors = errors }); } [Fact] @@ -34,10 +33,9 @@ public async Task ValidationBehavior_ValidationSuccess_ReturnNextAsync() NullLogger, FakeResponse>>.Instance); // Act - var result = await behavior.Handle(request, () => Task.FromResult(new FakeResponse()), default); + var result = await behavior.Handle(request, _ => Task.FromResult(new FakeResponse()), CancellationToken.None); // Assert - result.Should().BeEquivalentTo( - new { IsValidationError = false, ValidationError = (ValidationError?)null }); + result.Should().BeEquivalentTo(new { IsValidationError = false, ValidationErrors = new ValidationErrors() }); } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/CacheMockExtensions.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/CacheMockExtensions.cs index b991114..740fd7e 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/CacheMockExtensions.cs +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/CacheMockExtensions.cs @@ -1,26 +1,21 @@ using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; - -using Moq; +using NSubstitute; namespace Cnblogs.Architecture.UnitTests.Cqrs.FakeObjects; public static class CacheMockExtensions { - public static Mock AddCacheValue(this Mock mock, string key, T value) + public static ICacheProvider AddCacheValue(this ILocalCacheProvider mock, string key, T value) { - mock.As() - .Setup(x => x.GetAsync(key.ToLower())) - .ReturnsAsync(new CacheEntry(value, DateTimeOffset.Now.ToUnixTimeSeconds())); - mock.As() - .Setup(x => x.GetAsync(key.ToLower())) - .ReturnsAsync(new CacheEntry(value, DateTimeOffset.Now.ToUnixTimeSeconds())); + mock.GetAsync(key.ToLower()) + .Returns(new CacheEntry(value, DateTimeOffset.Now.ToUnixTimeSeconds())); return mock; } - public static Mock AddCacheValue(this Mock mock, string key, T value) + public static IRemoteCacheProvider AddCacheValue(this IRemoteCacheProvider mock, string key, T value) { - mock.Setup(x => x.GetAsync(key.ToLower())) - .ReturnsAsync(new CacheEntry(value, DateTimeOffset.Now.ToUnixTimeSeconds())); + mock.GetAsync(key.ToLower()) + .Returns(new CacheEntry(value, DateTimeOffset.Now.ToUnixTimeSeconds())); return mock; } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakeQuery.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakeQuery.cs index e6fefc9..e9c3ddd 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakeQuery.cs +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakeQuery.cs @@ -4,7 +4,7 @@ namespace Cnblogs.Architecture.UnitTests.Cqrs.FakeObjects; -public class FakeQuery : ICacheableRequest, IRequest, IValidatable +public class FakeQuery : ICachableRequest, IRequest, IValidatable { private readonly string? _cacheGroupKey; private readonly string _cacheKey; @@ -55,8 +55,12 @@ public FakeQuery(string? cacheGroupKey, string cacheKey) } /// - public ValidationError? Validate() + public void Validate(ValidationErrors validationErrors) { - return ValidateFunction.Invoke(); + var error = ValidateFunction.Invoke(); + if (error is not null) + { + validationErrors.Add(error); + } } } \ No newline at end of file diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakeResponse.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakeResponse.cs index afd06cb..7f9e565 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakeResponse.cs +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakeResponse.cs @@ -11,5 +11,5 @@ public class FakeResponse : IValidationResponse public string ErrorMessage { get; init; } = string.Empty; /// - public ValidationError? ValidationError { get; init; } + public ValidationErrors ValidationErrors { get; init; } = new(); } \ No newline at end of file diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/Handlers/InvalidCacheRequestHandlerTests.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/Handlers/InvalidCacheRequestHandlerTests.cs index 15045d5..9461e6c 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cqrs/Handlers/InvalidCacheRequestHandlerTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/Handlers/InvalidCacheRequestHandlerTests.cs @@ -7,8 +7,8 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; - -using Moq; +using NSubstitute; +using NSubstitute.ExceptionExtensions; namespace Cnblogs.Architecture.UnitTests.Cqrs.Handlers; @@ -18,11 +18,11 @@ public class InvalidCacheRequestHandlerTests public Task InvalidCache_ThrowOnRemove_ThrowAsync() { // Arrange - var provider = new Mock(); - provider.Setup(x => x.RemoveAsync(It.IsAny())) + var provider = Substitute.For(); + provider.RemoveAsync(Arg.Any()) .ThrowsAsync(new InvalidOperationException()); var handler = CreateInvalidCacheRequestHandler( - new List() { provider.Object }, + new List() { provider }, o => o.ThrowIfFailedOnRemove = true); // Act @@ -31,18 +31,18 @@ public Task InvalidCache_ThrowOnRemove_ThrowAsync() CancellationToken.None); // Assert - return Assert.ThrowsAsync(act); + return act.Should().ThrowAsync(); } [Fact] public Task InvalidCache_ThrowOnRemove_NotThrowAsync() { // Arrange - var provider = new Mock(); - provider.Setup(x => x.RemoveAsync(It.IsAny())) + var provider = Substitute.For(); + provider.RemoveAsync(Arg.Any()) .ThrowsAsync(new InvalidOperationException()); var handler = CreateInvalidCacheRequestHandler( - new List() { provider.Object }, + new List() { provider }, o => o.ThrowIfFailedOnRemove = false); // Act @@ -58,11 +58,11 @@ public Task InvalidCache_ThrowOnRemove_NotThrowAsync() public Task InvalidCache_ThrowOnRemove_OverrideByRequest_NotThrowAsync() { // Arrange - var provider = new Mock(); - provider.Setup(x => x.RemoveAsync(It.IsAny())) + var provider = Substitute.For(); + provider.RemoveAsync(Arg.Any()) .ThrowsAsync(new InvalidOperationException()); var handler = CreateInvalidCacheRequestHandler( - new List() { provider.Object }, + new List() { provider }, o => o.ThrowIfFailedOnRemove = true); // Act @@ -78,10 +78,10 @@ public Task InvalidCache_ThrowOnRemove_OverrideByRequest_NotThrowAsync() public async Task InvalidCache_RemoveCacheAsync() { // Arrange - var remote = new Mock(); - var local = new Mock(); + var remote = Substitute.For(); + var local = Substitute.For(); var handler = CreateInvalidCacheRequestHandler( - new List() { remote.Object, local.Object }, + new List() { remote, local }, o => o.ThrowIfFailedOnRemove = false); // Act @@ -90,18 +90,18 @@ await handler.Handle( CancellationToken.None); // Assert - local.Verify(x => x.RemoveAsync(It.IsAny()), Times.Once); - remote.Verify(x => x.RemoveAsync(It.IsAny()), Times.Once); + await local.Received(1).RemoveAsync(Arg.Any()); + await remote.Received(1).RemoveAsync(Arg.Any()); } [Fact] public async Task InvalidCache_RemoveGroupCacheAsync() { // Arrange - var remote = new Mock(); - var local = new Mock(); + var remote = Substitute.For(); + var local = Substitute.For(); var handler = CreateInvalidCacheRequestHandler( - new List() { remote.Object, local.Object }, + new List() { remote, local }, o => o.ThrowIfFailedOnRemove = false); // Act @@ -110,10 +110,10 @@ await handler.Handle( CancellationToken.None); // Assert - local.Verify(x => x.RemoveAsync(It.IsAny()), Times.Once); - local.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Once); - remote.Verify(x => x.RemoveAsync(It.IsAny()), Times.Once); - remote.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Once); + await local.Received(1).RemoveAsync(Arg.Any()); + await local.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); + await remote.Received(1).RemoveAsync(Arg.Any()); + await remote.Received(1).UpdateAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -138,4 +138,4 @@ private InvalidCacheRequestHandler CreateInvalidCacheRequestHandler( new OptionsWrapper(option), NullLogger.Instance); } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/EventBus/AssemblyAttributeTests.cs b/test/Cnblogs.Architecture.UnitTests/EventBus/AssemblyAttributeTests.cs index 0753290..47325a3 100644 --- a/test/Cnblogs.Architecture.UnitTests/EventBus/AssemblyAttributeTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/EventBus/AssemblyAttributeTests.cs @@ -1,9 +1,9 @@ -using Cnblogs.Architecture.Ddd.EventBus.Dapr; +using Cnblogs.Architecture.Ddd.EventBus.Abstractions; +using Cnblogs.Architecture.Ddd.EventBus.Dapr; using Cnblogs.Architecture.TestIntegrationEvents; - using FluentAssertions; - using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; namespace Cnblogs.Architecture.UnitTests.EventBus; @@ -14,6 +14,7 @@ public void SubscribeByAssemblyMeta_Success() { // Arrange var builder = WebApplication.CreateBuilder(); + builder.Services.AddCqrs().AddEventBus(o => o.UseDapr(nameof(AssemblyAttributeTests))); var app = builder.Build(); // Act @@ -22,4 +23,18 @@ public void SubscribeByAssemblyMeta_Success() // Assert act.Should().NotThrow(); } -} \ No newline at end of file + + [Fact] + public void SubscribeByAssemblyMeta_Throw() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + + // Act + var act = () => app.Subscribe(); + + // Assert + act.Should().Throw(); + } +} diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/EntityFramework/BaseRepositoryTests.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/EntityFramework/BaseRepositoryTests.cs index 2501fbe..bd412e1 100644 --- a/test/Cnblogs.Architecture.UnitTests/Infrastructure/EntityFramework/BaseRepositoryTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/EntityFramework/BaseRepositoryTests.cs @@ -1,21 +1,97 @@ using Cnblogs.Architecture.Ddd.Domain.Abstractions; using Cnblogs.Architecture.TestShared; using Cnblogs.Architecture.UnitTests.Infrastructure.FakeObjects; - using FluentAssertions; - using MediatR; - using Microsoft.EntityFrameworkCore; - -using Moq; +using NSubstitute; +using NSubstitute.ExceptionExtensions; namespace Cnblogs.Architecture.UnitTests.Infrastructure.EntityFramework; public class BaseRepositoryTests { [Fact] - public async Task SaveEntitiesAsync_CallBeforeUpdateForRelatedEntityAsync() + public async Task GetEntityAsync_Include_GetEntityAsync() + { + // Arrange + var entity = new EntityGenerator(new FakeBlog()) + .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1)) + .HasManyForEachEntity( + x => x.Posts, + x => x.Blog, + new EntityGenerator(new FakePost()) + .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) + .GenerateSingle(); + var db = new FakeDbContext( + new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + db.Add(entity); + await db.SaveChangesAsync(); + var repository = new TestRepository(Substitute.For(), db); + + // Act + var got = await repository.GetAsync(entity.Id, e => e.Posts); + + // Assert + got.Should().NotBeNull(); + got.Posts.Should().BeEquivalentTo(entity.Posts); + } + + [Fact] + public async Task GetEntityAsync_StringBasedInclude_NotNullAsync() + { + // Arrange + var entity = new EntityGenerator(new FakeBlog()) + .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1)) + .HasManyForEachEntity( + x => x.Posts, + x => x.Blog, + new EntityGenerator(new FakePost()) + .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) + .GenerateSingle(); + var db = new FakeDbContext( + new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + db.Add(entity); + await db.SaveChangesAsync(); + var repository = new TestRepository(Substitute.For(), db); + + // Act + var got = await repository.GetAsync(entity.Id, new List() { nameof(entity.Posts) }); + + // Assert + got.Should().NotBeNull(); + got.Posts.Should().BeEquivalentTo(entity.Posts); + } + + [Fact] + public async Task GetEntityAsync_ThenInclude_NotNullAsync() + { + // Arrange + var entity = new EntityGenerator(new FakeBlog()) + .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1)) + .HasManyForEachEntity( + x => x.Posts, + x => x.Blog, + new EntityGenerator(new FakePost()) + .HasManyForEachEntity(x => x.Tags, new EntityGenerator(new FakeTag())) + .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) + .GenerateSingle(); + var db = new FakeDbContext( + new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + db.Add(entity); + await db.SaveChangesAsync(); + var repository = new TestRepository(Substitute.For(), db); + + // Act + var got = await repository.GetAsync(entity.Id, new List() { "Posts.Tags" }); + + // Assert + got.Should().NotBeNull(); + got.Posts.Should().BeEquivalentTo(entity.Posts); + } + + [Fact] + public async Task SaveEntitiesAsync_CallBeforeUpdateForRelatedEntity_UpdateDateUpdatedAsync() { // Arrange var entity = new EntityGenerator(new FakeBlog()) @@ -30,7 +106,7 @@ public async Task SaveEntitiesAsync_CallBeforeUpdateForRelatedEntityAsync() new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); db.Add(entity); await db.SaveChangesAsync(); - var repository = new TestRepository(Mock.Of(), db); + var repository = new TestRepository(Substitute.For(), db); // Act entity.Title = "new title"; @@ -43,7 +119,7 @@ public async Task SaveEntitiesAsync_CallBeforeUpdateForRelatedEntityAsync() } [Fact] - public async Task SaveEntitiesAsync_DispatchEntityDomainEventsAsync() + public async Task SaveEntitiesAsync_DispatchEntityDomainEvents_DispatchAllAsync() { // Arrange var entity = new EntityGenerator(new FakeBlog()) @@ -58,8 +134,8 @@ public async Task SaveEntitiesAsync_DispatchEntityDomainEventsAsync() new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); db.Add(entity); await db.SaveChangesAsync(); - var mediator = new Mock(); - var repository = new TestRepository(mediator.Object, db); + var mediator = Substitute.For(); + var repository = new TestRepository(mediator, db); // Act entity.Title = "new title"; @@ -68,16 +144,16 @@ public async Task SaveEntitiesAsync_DispatchEntityDomainEventsAsync() await repository.UpdateAsync(entity); // Assert - mediator.Verify( - x => x.Publish(It.Is(d => ((FakeDomainEvent)d).FakeValue == 1), It.IsAny()), - Times.Once); - mediator.Verify( - x => x.Publish(It.Is(d => ((FakeDomainEvent)d).FakeValue == 2), It.IsAny()), - Times.Once); + await mediator.Received(1).Publish( + Arg.Is(d => ((FakeDomainEvent)d).FakeValue == 1), + Arg.Any()); + await mediator.Received(1).Publish( + Arg.Is(d => ((FakeDomainEvent)d).FakeValue == 2), + Arg.Any()); } [Fact] - public async Task SaveEntitiesAsync_DispatchRelatedEntityDomainEventsAsync() + public async Task SaveEntitiesAsync_DispatchRelatedEntityDomainEvents_DispatchAllAsync() { // Arrange var entity = new EntityGenerator(new FakeBlog()) @@ -92,8 +168,8 @@ public async Task SaveEntitiesAsync_DispatchRelatedEntityDomainEventsAsync() new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); db.Add(entity); await db.SaveChangesAsync(); - var mediator = new Mock(); - var repository = new TestRepository(mediator.Object, db); + var mediator = Substitute.For(); + var repository = new TestRepository(mediator, db); // Act entity.Title = "new title"; @@ -103,16 +179,16 @@ public async Task SaveEntitiesAsync_DispatchRelatedEntityDomainEventsAsync() await repository.UpdateAsync(entity); // Assert - mediator.Verify( - x => x.Publish(It.Is(d => ((FakeDomainEvent)d).FakeValue == 1), It.IsAny()), - Times.Once); - mediator.Verify( - x => x.Publish(It.Is(d => ((FakeDomainEvent)d).FakeValue == 2), It.IsAny()), - Times.Once); + await mediator.Received(1).Publish( + Arg.Is(d => ((FakeDomainEvent)d).FakeValue == 1), + Arg.Any()); + await mediator.Received(1).Publish( + Arg.Is(d => ((FakeDomainEvent)d).FakeValue == 2), + Arg.Any()); } [Fact] - public async Task SaveEntitiesAsync_DispatchEntityDomainEventsWithGeneratedIdAsync() + public async Task SaveEntitiesAsync_DispatchEntityDomainEventsWithGeneratedId_DispatchAllAsync() { // Arrange var entity = new EntityGenerator(new FakeBlog()) @@ -125,8 +201,8 @@ public async Task SaveEntitiesAsync_DispatchEntityDomainEventsWithGeneratedIdAsy .GenerateSingle(); var db = new FakeDbContext( new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); - var mediator = new Mock(); - var repository = new TestRepository(mediator.Object, db); + var mediator = Substitute.For(); + var repository = new TestRepository(mediator, db); // Act entity.AddDomainEvent(id => new FakeDomainEvent(id, 1)); @@ -134,15 +210,47 @@ public async Task SaveEntitiesAsync_DispatchEntityDomainEventsWithGeneratedIdAsy await repository.AddAsync(entity); // Assert - mediator.Verify( - x => x.Publish( - It.Is(d => ((FakeDomainEvent)d).Id != 0 && ((FakeDomainEvent)d).FakeValue == 1), - It.IsAny()), - Times.Once); - mediator.Verify( - x => x.Publish( - It.Is(d => ((FakeDomainEvent)d).Id != 0 && ((FakeDomainEvent)d).FakeValue == 2), - It.IsAny()), - Times.Exactly(entity.Posts.Count)); + await mediator.Received(1).Publish( + Arg.Is(d => ((FakeDomainEvent)d).Id != 0 && ((FakeDomainEvent)d).FakeValue == 1), + Arg.Any()); + await mediator.Received(entity.Posts.Count).Publish( + Arg.Is(d => ((FakeDomainEvent)d).Id != 0 && ((FakeDomainEvent)d).FakeValue == 2), + Arg.Any()); + } + + [Fact] + public async Task SaveEntitiesAsync_DispatchEntityDomainEventsWithMultipleExceptions_ThrowAggregateExceptionsAsync() + { + // Arrange + var entity = new EntityGenerator(new FakeBlog()) + .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1)) + .HasManyForEachEntity( + x => x.Posts, + x => x.Blog, + new EntityGenerator(new FakePost()).Setup( + x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) + .GenerateSingle(); + var db = new FakeDbContext( + new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + var mediator = Substitute.For(); + mediator.Publish(Arg.Any(), Arg.Any()) + .ThrowsAsync(); + var repository = new TestRepository(mediator, db); + + // Act + entity.AddDomainEvent(id => new FakeDomainEvent(id, 1)); + entity.Posts.ForEach(x => x.AddDomainEvent(id => new FakeDomainEvent(id, 2))); + var act = async () => await repository.AddAsync(entity); + + // Assert + var eventCount = 1 + entity.Posts.Count; + (await act.Should().ThrowAsync()).And.InnerExceptions.Should() + .HaveCount(eventCount); + await mediator.Received(1).Publish( + Arg.Is(d => ((FakeDomainEvent)d).Id != 0 && ((FakeDomainEvent)d).FakeValue == 1), + Arg.Any()); + await mediator.Received(entity.Posts.Count).Publish( + Arg.Is(d => ((FakeDomainEvent)d).Id != 0 && ((FakeDomainEvent)d).FakeValue == 2), + Arg.Any()); } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeBlog.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeBlog.cs index 70cfb9a..4117edb 100644 --- a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeBlog.cs +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeBlog.cs @@ -8,4 +8,4 @@ public class FakeBlog : Entity, IAggregateRoot // navigations public List Posts { get; set; } = null!; -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeDbContext.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeDbContext.cs index 0406cc2..95f6495 100644 --- a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeDbContext.cs +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeDbContext.cs @@ -14,9 +14,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity().HasKey(x => x.Id); modelBuilder.Entity().Property(x => x.Id).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(x => x.Title).HasMaxLength(30); modelBuilder.Entity().HasMany(x => x.Posts).WithOne(x => x.Blog).HasForeignKey(x => x.BlogId); modelBuilder.Entity().HasKey(x => x.Id); modelBuilder.Entity().Property(x => x.Id).ValueGeneratedOnAdd(); + modelBuilder.Entity().Property(x => x.Title).HasMaxLength(100); + modelBuilder.Entity().HasMany(x => x.Tags).WithOne().HasForeignKey(x => x.PostId); + + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().Property(x => x.Id).ValueGeneratedOnAdd(); } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeMongoDbContext.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeMongoDbContext.cs index be9a1ee..6210ca0 100644 --- a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeMongoDbContext.cs +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeMongoDbContext.cs @@ -1,57 +1,57 @@ using Cnblogs.Architecture.Ddd.Infrastructure.MongoDb; using MongoDB.Driver; -using Moq; +using NSubstitute; namespace Cnblogs.Architecture.UnitTests.Infrastructure.FakeObjects; public class FakeMongoDbContext : MongoContext { - public Mock MongoDatabaseMock { get; } - public Mock MongoClientMock { get; } - public Mock ClientSessionHandleMock { get; } - public Mock OptionsMock { get; } + public IMongoDatabase MongoDatabaseMock { get; } + public IMongoClient MongoClientMock { get; } + public IClientSessionHandle ClientSessionHandleMock { get; } + public IMongoContextOptions OptionsMock { get; } public FakeMongoDbContext() : this(MockOptions()) { } - public FakeMongoDbContext(Mock mockOptionsMock) + public FakeMongoDbContext(IMongoContextOptions mockOptionsMock) : this(mockOptionsMock, MockDatabase(mockOptionsMock)) { } - public FakeMongoDbContext(Mock mockOptionsMock, Mock mongoDatabase) - : base(mockOptionsMock.Object) + public FakeMongoDbContext(IMongoContextOptions mockOptionsMock, IMongoDatabase mongoDatabase) + : base(mockOptionsMock) { OptionsMock = mockOptionsMock; MongoDatabaseMock = mongoDatabase; - ClientSessionHandleMock = new Mock(); - MongoClientMock = new Mock(); - MongoClientMock - .Setup(x => x.StartSessionAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(ClientSessionHandleMock.Object); - MongoDatabaseMock.Setup(x => x.Client).Returns(MongoClientMock.Object); - MongoDatabaseMock - .Setup(x => x.GetCollection(It.IsAny(), null)) - .Returns(Mock.Of>()); + ClientSessionHandleMock = Substitute.For(); + MongoClientMock = Substitute.For(); + MongoClientMock.StartSessionAsync(Arg.Any(), Arg.Any()) + .Returns(ClientSessionHandleMock); + MongoDatabaseMock.Client.Returns(MongoClientMock); + MongoDatabaseMock.GetCollection(Arg.Any()) + .Returns(Substitute.For>()); } /// protected override void ConfigureModels(MongoModelBuilder builder) { builder.Entity("fakeBlog"); + builder.Entity("fakePost"); + builder.Entity("fakeTag"); } - private static Mock MockOptions() + private static IMongoContextOptions MockOptions() { - return new Mock(); + return Substitute.For(); } - private static Mock MockDatabase(Mock mongoContextOptionsMock) + private static IMongoDatabase MockDatabase(IMongoContextOptions mongoContextOptionsMock) { - var mock = new Mock(); - mongoContextOptionsMock.Setup(x => x.GetDatabase()).Returns(mock.Object); + var mock = Substitute.For(); + mongoContextOptionsMock.GetDatabase().Returns(mock); return mock; } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakePost.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakePost.cs index 005c80f..bad93d8 100644 --- a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakePost.cs +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakePost.cs @@ -9,4 +9,5 @@ public class FakePost : Entity // navigations public FakeBlog Blog { get; set; } = null!; -} \ No newline at end of file + public List Tags { get; set; } = null!; +} diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeTag.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeTag.cs new file mode 100644 index 0000000..4c8a447 --- /dev/null +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeTag.cs @@ -0,0 +1,9 @@ +using Cnblogs.Architecture.Ddd.Domain.Abstractions; + +namespace Cnblogs.Architecture.UnitTests.Infrastructure.FakeObjects; + +public class FakeTag : Entity +{ + public int BlogId { get; set; } + public int PostId { get; set; } +} diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/TestMongoRepository.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/TestMongoRepository.cs index ae68908..4a426aa 100644 --- a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/TestMongoRepository.cs +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/TestMongoRepository.cs @@ -1,23 +1,23 @@ using Cnblogs.Architecture.Ddd.Infrastructure.MongoDb; using MediatR; -using Moq; +using NSubstitute; namespace Cnblogs.Architecture.UnitTests.Infrastructure.FakeObjects; public class TestMongoRepository : MongoBaseRepository { - public Mock MediatorMock { get; } + public IMediator MediatorMock { get; } public FakeMongoDbContext MongoDbContext { get; } public TestMongoRepository() - : this(new FakeMongoDbContext(), new Mock()) + : this(new FakeMongoDbContext(), Substitute.For()) { } - public TestMongoRepository(FakeMongoDbContext fakeDbContext, Mock mediator) - : base(fakeDbContext, mediator.Object) + public TestMongoRepository(FakeMongoDbContext fakeDbContext, IMediator mediator) + : base(fakeDbContext, mediator) { MongoDbContext = fakeDbContext; MediatorMock = mediator; } -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/MongoDb/MongoBaseRepositoryTests.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/MongoDb/MongoBaseRepositoryTests.cs index 97515fb..736370c 100644 --- a/test/Cnblogs.Architecture.UnitTests/Infrastructure/MongoDb/MongoBaseRepositoryTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/MongoDb/MongoBaseRepositoryTests.cs @@ -2,7 +2,7 @@ using Cnblogs.Architecture.UnitTests.Infrastructure.FakeObjects; using FluentAssertions; using MongoDB.Driver; -using Moq; +using NSubstitute; namespace Cnblogs.Architecture.UnitTests.Infrastructure.MongoDb; @@ -21,12 +21,13 @@ public async Task AddAsync_WithDomainEvent_SaveThenPublishAsync() // Assert response.Should().NotBeNull(); - Mock.Get(repository.MongoDbContext.MongoDatabaseMock.Object.GetCollection(string.Empty)).Verify( - x => x.InsertOneAsync(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Once); - repository.MediatorMock.Verify( - x => x.Publish(It.IsAny(), It.IsAny()), - Times.Once); + await repository.MongoDbContext.MongoDatabaseMock.GetCollection(string.Empty) + .Received(1) + .InsertOneAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await repository.MediatorMock.Received(1).Publish(Arg.Any(), Arg.Any()); } [Fact] @@ -42,16 +43,14 @@ public async Task AddRangeAsync_WithDomainEvent_SaveThenPublishAsync() // Assert response.Should().HaveSameCount(blogs); - Mock.Get(repository.MongoDbContext.MongoDatabaseMock.Object.GetCollection(string.Empty)) - .Verify( - x => x.InsertManyAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny()), - Times.Once); - repository.MediatorMock.Verify( - x => x.Publish(It.IsAny(), It.IsAny()), - Times.Exactly(blogs.Count)); + await repository.MongoDbContext.MongoDatabaseMock.GetCollection(string.Empty) + .Received(1) + .InsertManyAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any()); + await repository.MediatorMock.Received(blogs.Count) + .Publish(Arg.Any(), Arg.Any()); } [Fact] @@ -67,11 +66,10 @@ public async Task DeleteAsync_WithDomainEvent_SaveThenPublishAsync() // Assert response.Should().NotBeNull(); - Mock.Get(repository.MongoDbContext.MongoDatabaseMock.Object.GetCollection(string.Empty)) - .Verify( - x => x.DeleteOneAsync(It.IsAny>(), It.IsAny()), - Times.Once); - repository.MediatorMock.Verify(x => x.Publish(It.IsAny(), It.IsAny())); + await repository.MongoDbContext.MongoDatabaseMock.GetCollection(string.Empty) + .Received(1) + .DeleteOneAsync(Arg.Any>(), Arg.Any()); + await repository.MediatorMock.Received().Publish(Arg.Any(), Arg.Any()); } [Fact] @@ -87,15 +85,14 @@ public async Task UpdateAsync_WithDomainEvent_SaveThenPublishAsync() // Assert response.Should().NotBeNull(); - Mock.Get(repository.MongoDbContext.MongoDatabaseMock.Object.GetCollection(string.Empty)) - .Verify( - x => x.ReplaceOneAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny()), - Times.Once); - repository.MediatorMock.Verify(x => x.Publish(It.IsAny(), It.IsAny())); + await repository.MongoDbContext.MongoDatabaseMock.GetCollection(string.Empty) + .Received(1) + .ReplaceOneAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await repository.MediatorMock.Received().Publish(Arg.Any(), Arg.Any()); } [Fact] @@ -111,16 +108,13 @@ public async Task UpdateRangeAsync_WithDomainEvent_SaveThenPublishAsync() // Assert response.Should().HaveSameCount(blogs); - Mock.Get(repository.MongoDbContext.MongoDatabaseMock.Object.GetCollection(string.Empty)) - .Verify( - x => x.BulkWriteAsync( - It.IsAny>>(), - It.IsAny(), - It.IsAny()), - Times.Once); - repository.MediatorMock.Verify( - x => x.Publish(It.IsAny(), It.IsAny()), - Times.Exactly(blogs.Count)); + await repository.MongoDbContext.MongoDatabaseMock.GetCollection(string.Empty).Received(1) + .BulkWriteAsync( + Arg.Any>>(), + Arg.Any(), + Arg.Any()); + await repository.MediatorMock.Received(blogs.Count) + .Publish(Arg.Any(), Arg.Any()); } [Fact] @@ -138,20 +132,15 @@ public async Task Uow_Insert_CommitAsync() // Assert response.Should().BeTrue(); - repository.MongoDbContext.ClientSessionHandleMock.Verify( - x => x.CommitTransactionAsync(It.IsAny()), - Times.Once); - Mock.Get(repository.MongoDbContext.MongoDatabaseMock.Object.GetCollection(string.Empty)) - .Verify( - x => x.BulkWriteAsync( - It.IsAny(), - It.IsAny>>(), - It.IsAny(), - It.IsAny()), - Times.Once); - repository.MediatorMock.Verify( - x => x.Publish(It.IsAny(), It.IsAny()), - Times.Once); + await repository.MongoDbContext.ClientSessionHandleMock.Received(1) + .CommitTransactionAsync(Arg.Any()); + await repository.MongoDbContext.MongoDatabaseMock.GetCollection(string.Empty).Received(1) + .BulkWriteAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any(), + Arg.Any()); + await repository.MediatorMock.Received(1).Publish(Arg.Any(), Arg.Any()); } [Fact] @@ -170,17 +159,13 @@ public async Task Uow_InsertThenRemove_NoChangeAsync() // Assert response.Should().BeTrue(); - Mock.Get(repository.MongoDbContext.MongoDatabaseMock.Object.GetCollection(string.Empty)) - .Verify( - x => x.BulkWriteAsync( - It.IsAny(), - It.IsAny>>(), - It.IsAny(), - It.IsAny()), - Times.Never); - repository.MediatorMock.Verify( - x => x.Publish(It.IsAny(), It.IsAny()), - Times.Never); + await repository.MongoDbContext.MongoDatabaseMock.GetCollection(string.Empty).Received(0) + .BulkWriteAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any(), + Arg.Any()); + await repository.MediatorMock.Received(0).Publish(Arg.Any(), Arg.Any()); } [Fact] @@ -199,17 +184,13 @@ public async Task Uow_DeleteThenInsert_UpdateAsync() // Assert response.Should().BeTrue(); - Mock.Get(repository.MongoDbContext.MongoDatabaseMock.Object.GetCollection(string.Empty)) - .Verify( - x => x.BulkWriteAsync( - It.IsAny(), - It.Is>>(y => y.Any(z => z is ReplaceOneModel)), - It.IsAny(), - It.IsAny()), - Times.Once); - repository.MediatorMock.Verify( - x => x.Publish(It.IsAny(), It.IsAny()), - Times.Once); + await repository.MongoDbContext.MongoDatabaseMock.GetCollection(string.Empty).Received(1) + .BulkWriteAsync( + Arg.Any(), + Arg.Is>>(y => y.Any(z => z is ReplaceOneModel)), + Arg.Any(), + Arg.Any()); + await repository.MediatorMock.Received(1).Publish(Arg.Any(), Arg.Any()); } [Fact] @@ -261,19 +242,14 @@ public async Task Uow_DeleteWithDomainEvent_CommitAsync() // Assert response.Should().BeTrue(); - repository.MongoDbContext.ClientSessionHandleMock.Verify( - x => x.CommitTransactionAsync(It.IsAny()), - Times.Once); - Mock.Get(repository.MongoDbContext.MongoDatabaseMock.Object.GetCollection(string.Empty)) - .Verify( - x => x.BulkWriteAsync( - It.IsAny(), - It.IsAny>>(), - It.IsAny(), - It.IsAny()), - Times.Once); - repository.MediatorMock.Verify( - x => x.Publish(It.IsAny(), It.IsAny()), - Times.Once); + await repository.MongoDbContext.ClientSessionHandleMock.Received(1) + .CommitTransactionAsync(Arg.Any()); + await repository.MongoDbContext.MongoDatabaseMock.GetCollection(string.Empty).Received(1) + .BulkWriteAsync( + Arg.Any(), + Arg.Any>>(), + Arg.Any(), + Arg.Any()); + await repository.MediatorMock.Received(1).Publish(Arg.Any(), Arg.Any()); } -} \ No newline at end of file +} diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 49d1e35..b332cb1 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,7 +1,14 @@ - + - - false - - \ No newline at end of file + + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + +