.NET Core appsettings.json 获取数据库连接字符串_.net core sql connection string_枯叶轮回的博客-CSDN博客

mikel阅读(632)

来源: .NET Core appsettings.json 获取数据库连接字符串_.net core sql connection string_枯叶轮回的博客-CSDN博客

本文主要介绍.NET Core中,通过appsettings.json配置文件获取数据库连接字符串。

 

1、在项目的根目录中创建appsettings.json文件

{
  "MssqlConnectionString": "Server=yourip; Database=yourdbname; User Id=yourusername; Password=yourpassword; Pooling=true;",
  "Db2ConnectionString": "Database=yourdbname;UserID=yourusername;Password=yourpassword;Server=yourip:yourport",
  "SomeOtherKey": "SomeOtherValue"
}

2、安装Microsoft.Extensions.Configuration.Json的Nuget包

Install-Package Microsoft.Extensions.Configuration.Json -Version 2.2.0

3、添加AppSettingsJson类

using Microsoft.Extensions.Configuration;
using System.IO;
namespace RutarBackgroundServices.AppsettingsJson
{
    public static class AppSettingsJson
    {
        public static string ApplicationExeDirectory()
        {
            var location = System.Reflection.Assembly.GetExecutingAssembly().Location;
            var appRoot = Path.GetDirectoryName(location);
            return appRoot;
        }
        public static IConfigurationRoot GetAppSettings()
        {
            string applicationExeDirectory = ApplicationExeDirectory();
            var builder = new ConfigurationBuilder()
            .SetBasePath(applicationExeDirectory)
            .AddJsonFile("appsettings.json");
            return builder.Build();
        }

    }
}

4、使用AppSettingsJson获取连接字符串

var appSettingsJson = AppSettingsJson.GetAppSettings();
//方法一
var connectionString = appSettingsJson.GetConnectionString("MssqlConnectionString");
//方法二
var connectionString = appSettingsJson["MssqlConnectionString"];

.NET Core WebApi中实现数据库的操作(之SqlServer)_c# webapi数据库操作通用api_牛奶咖啡13的博客-CSDN博客

mikel阅读(608)

来源: .NET Core WebApi中实现数据库的操作(之SqlServer)_c# webapi数据库操作通用api_牛奶咖啡13的博客-CSDN博客

3.3.3、具体的业务逻辑
①设计业务的数据库表

 

②创建业务表的实体类

其中创建好私有的字段属性后,可以选中所有的私有字段,然后同时按下【Ctrl+R+E】键即可一次性自动生成所有字段的公有属性内容。

关于创建的实体中,如果你的属性名称与表中的字段不一致,则需要标识出来,具体内容请参考:从零开始 – 实体配置 – 《SQLSugar 5.0 文档》

/***
* Title:”.NET Core WebApi” 项目
* 主题:学生实体类
* Description:
* 功能:XXX
* Date:2021
* Version:0.1版本
* Author:Coffee
* Modify Recoder:
*/

using SQLSugar;
using System;
using System.Collections.Generic;
using System.Text;

namespace WebApiEntity
{
[SugarTable(“Test_Student”)]
public class StudentEntity
{

private int _iD = 0;
private string _name = string.Empty;
private string _number = string.Empty;
private int _age = 0;
private int _sex = 0;
private string _address = string.Empty;

/// <summary>
/// 主键
/// </summary>
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
public int ID { get => _iD; set => _iD = value; }

/// <summary>
/// 姓名
/// </summary>
public string Name { get => _name; set => _name = value; }

/// <summary>
/// 学号
/// </summary>
public string Number { get => _number; set => _number = value; }

/// <summary>
/// 年龄
/// </summary>
public int Age { get => _age; set => _age = value; }

/// <summary>
/// 性别
/// </summary>
public int Sex { get => _sex; set => _sex = value; }

/// <summary>
/// 家庭住址
/// </summary>
[SugarColumn(ColumnName = “Test_Address”)]
public string Address { get => _address; set => _address = value; }

}//Class_end

}
③创建业务的接口服务(方便业务扩展)

/***
* Title:”.NET Core WebApi” 项目
* 主题:学生服务接口
* Description:
* 功能:
*
* Date:2021
* Version:0.1版本
* Author:Coffee
* Modify Recoder:
*/

using System;
using System.Collections.Generic;
using System.Text;
using WebApiService.Common;
using WebApiEntity;

namespace WebApiService.Interfaces
{
public interface IStudentService : IBaseDbService<StudentEntity>
{
/// <summary>
/// 测试
/// </summary>
void Test();

}//Class_end

}
④实现业务服务

/***
* Title:”.NET Core WebApi” 项目
* 主题:学生服务
* Description:
* 功能:XXX
* Date:2021
* Version:0.1版本
* Author:Coffee
* Modify Recoder:
*/

using SqlSugar;
using System;
using System.Collections.Generic;
using System.Text;
using WebApiEntity;
using WebApiService.Common;
using WebApiService.Interfaces;
using WebApiUtils;

namespace WebApiService.Implements
{
class StudentService : BaseDbService<StudentEntity>, IStudentService
{
/// <summary>
/// 构造函数
/// </summary>
/// <param name=”dbType”>数据类类型</param>
public StudentService(DbType dbType=DbType.SQLServer):base(dbType)
{

}

/// <summary>
/// 测试
/// </summary>
public void Test()
{
LogHelper.Debug($”this is { this.GetType().Name} 测试”);
}
}//Class_end

}
⑤实现具体的业务逻辑内容

/***
* Title:”.NET Core WebApi” 项目
* 主题:测试学生服务【数据库操作】
* Description:
* 功能:XXX
* Date:2021
* Version:0.1版本
* Author:Coffee
* Modify Recoder:
*/

using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using WebApiEntity;
using WebApiService.Interfaces;
using WebApiUtils.Entity;

namespace WebApi_Learn.Controllers.Test
{
[Route(“api/[controller]/[action]”)]
[ApiController]
public class Test_Db_StudentService
{
#region 私有方法
//学生的接口服务
private readonly IStudentService _studentService;

#endregion

#region 公有方法

/// <summary>
/// 构造函数
/// </summary>
/// <param name=”studentService”>学生服务接口</param>
public Test_Db_StudentService(IStudentService studentService)
{
this._studentService = studentService;
}

/// <summary>
/// 插入数据
/// </summary>
/// <param name=”studentEntity”>学生实体</param>
/// <returns>返回当前学生的ID</returns>
[HttpPost, Route(“AddInfo”)]
public ActionResult<int> Insert(StudentEntity studentEntity)
{
int id = _studentService.Insert(studentEntity);

return id;
}

/// <summary>
/// 修改数据
/// </summary>
/// <param name=”studentEntity”>学生实体</param>
/// <returns>返回修改结果</returns>
[HttpPost, Route(“UpdateInfo”)]
public ActionResult<bool> Update(StudentEntity studentEntity)
{
return _studentService.Update(studentEntity);
}

/// <summary>
/// 删除数据(根据主键)
/// </summary>
/// <param name=”id”>主键</param>
/// <returns></returns>
[HttpGet]
public ActionResult<bool> Delete(int id)
{
return _studentService.Delete(id, true);
}

/// <summary>
/// 查询数据(单条数据)
/// </summary>
/// <param name=”field”>过滤字段</param>
/// <param name=”fieldValue”>过滤字段对应的值</param>
/// <returns></returns>
[HttpGet]
public ActionResult<StudentEntity> QuaryData(string field,int fieldValue)
{
StudentEntity studentEntity = new StudentEntity();

//显示字段
string strFields = “Name,Age,Test_Address”;

//根据条件查询到数据
SqlFilterEntity sqlFilterEntity = new SqlFilterEntity();
sqlFilterEntity.Append($”{field}=@{field}”);
sqlFilterEntity.Add(field,fieldValue);

studentEntity = _studentService.GetEntity(strFields, sqlFilterEntity);

return studentEntity;
}

/// <summary>
/// 查询数据(多条数据)
/// </summary>
/// <param name=”field”>字段</param>
/// <param name=”fieldValue”>字段对应的值</param>
/// <returns></returns>
[HttpGet]
public ActionResult<List<StudentEntity>> QuaryDatas(string field, string fieldValue)
{
List<StudentEntity> studentEntityList = new List<StudentEntity>();

//根据条件查询到数据
SqlFilterEntity sqlFilterEntity = new SqlFilterEntity();
sqlFilterEntity.Append($”{field}=@{field}”);
sqlFilterEntity.Add(field, fieldValue);
studentEntityList= _studentService.GetList(null, sqlFilterEntity);

return studentEntityList;
}

/// <summary>
/// 获取开始数据
/// </summary>
/// <returns></returns>
[HttpGet]
public List<StudentEntity> GetStartDatas()
{
List<StudentEntity> studentEntities = new List<StudentEntity>();
return _studentService.GetStartList(2);

}

/// <summary>
/// 分页查看
/// </summary>
/// <param name=”pageIndex”></param>
/// <param name=”pageSize”></param>
/// <param name=”strOrder”></param>
/// <returns></returns>
[HttpPost]
public List<StudentEntity> GetPageList(int pageIndex, int pageSize,
string strOrder=”Age DESC”)
{
//显示字段
string strField = “ID,Name,Age”;

//过滤条件
SqlFilterEntity sqlFilterEntity = new SqlFilterEntity();
sqlFilterEntity.Append($”Age>@Age”);
sqlFilterEntity.Add(“Age”, 21);

int totalCount=0;

return _studentService.GetPageList(pageIndex,pageSize, strField, sqlFilterEntity,strOrder,out totalCount);

}

#endregion

}//Class_end
}
3.3.4、服务的依赖注入
主要实现统一管理业务的接口与实现服务的对应关系。

/***
* Title:”.NET Core WebApi” 项目
* 主题:服务的依赖注入
* Description:
* 功能:XXX
* Date:2021
* Version:0.1版本
* Author:Coffee
* Modify Recoder:
*/

using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Text;
using WebApiService.Implements;
using WebApiService.Interfaces;

namespace WebApiService.Common.Depends
{
public class ServiceInjection
{
public static void ConfigureRepository(IServiceCollection services)
{
services.AddSingleton<IStudentService, StudentService>();

}

}//Class_end

}
对于依赖注入的简要补充如下所示:

方法 说明
Transient 每一次调用都会创建一个新的实例
Scoped 一个作用域中只实例化一个
Singleton 整个应用程序生命周期以内只创建一个实例
①在Startup类中【ConfigureServices】方法中注册【服务的依赖注入】

 

3.3.5、运行程序执行测试
比如:只显示【名称,年龄、地址】内容,查看条件是【字段为:ID;且该ID字段的值为:2021003165】信息:

 

 

参考文章:

①net core Webapi基础工程搭建(六)——数据库操作_Part 1

②net core Webapi基础工程搭建(六)——数据库操作_Part 2
————————————————
版权声明:本文为CSDN博主「牛奶咖啡13」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/xiaochenXIHUA/article/details/119574119

vue中给buttion按钮添加键盘回车(enter)事件_vue 回车 button_依旧平凡的博客-CSDN博客

mikel阅读(623)

来源: vue中给buttion按钮添加键盘回车(enter)事件_vue 回车 button_依旧平凡的博客-CSDN博客

项目中遇到点击查询按钮可以查出数据,点击回车键也能查出数据,所以就想点击回车键时调用查询方法。

以下代码可实现此功能。

首先,button上有click事件,点击可实现搜索查询;

created(){}函数里面调用回车按下的事件方法

关键的地方就是按下回车键的方法

methods:{
keyupEnter(){
document.onkeydown = e =>{
let body = document.getElementsByTagName('body')[0]
if (e.keyCode === 13 && e.target.baseURI.match(/inputbook/) && e.target === body) {
console.log('enter') // match(此处应填写文件在浏览器中的地址,如 '/home/index'),不写的话,其他页面也会有调用回车按下的方法
this.handleAddBook() //调用查询方法
}
}
},
handleAddBook(){

}
}

 

示例代码如下:

一、第一步: 给button按钮绑定@keyup.enter

  1. <div class=“btn”> <!–如果是封装过的按钮,不是原生的按钮,需要加上.native才能生效–>
  2. <Button type=“primary” @click=“handleAddBook” @keyup.enter.native=“handleAddBook”>录入</Button>
  3. </div>

 

二、第二步:浏览器url:event.target.baseURI; 获取浏览器的路径地址

  1. // 创建时
  2. created(){
  3. this.keyupEnter()//页面在创建时就调用键盘的回车事件,在结构代码中也可以不写@keyup.enter.native=”handleAddBook”
  4. },
  5. methods:{
  6. keyupEnter(){
  7. document.onkeydown = e =>{
  8. let body = document.getElementsByTagName(‘body’)[0]
  9. if (e.keyCode === 13 && e.target.baseURI.match(/inputbook/) && e.target === body) {
  10. console.log(‘enter’) // match(此处应填写文件在浏览器中的地址,如 ‘/home/index’)
  11. this.handleAddBook()
  12. }
  13. }
  14. },
  15. handleAddBook(){
  16. if(this.validate()){
  17. this._printQrcode()
  18. }
  19. }
  20. }

 

转载于:https://www.cnblogs.com/wangdashi/p/9646219.html

canvas如何监听键盘事件 - 掘金

mikel阅读(777)

来源: canvas如何监听键盘事件 – 掘金

对于canvas元素,它支持JavaScript所有鼠标事件,但是如果监听键盘事件则并不会生效。

JavaScript

复制代码
// 有效
canvas.addEventListener('click', (e) => {
  console.log('触发点击了')
})

// 无效
canvas.addEventListener('keydown', (e) => {
  console.log('触发按键了')
})

其原因在于,键盘输入事件只发生在当前拥有焦点的HTML元素上,如果没有元素拥有焦点,那么事件将会上移至windows和document对象,所以有两种常用方法来解决这个问题:

  1. 如果canvas元素和windows长宽基本一致,可以通过在windows对象上绑定键盘事件来代替对canvas元素的监听与处理。
    javascript

    复制代码
    window.addEventListener('keydown', doKeyDown, true)
    
  2. 让canvas元素处于聚焦状态,并给它绑定键盘事件
    html

    复制代码
    <canvas tabindex="0"></canvas>
    

    tabindex设置为0或更大。

下面通过示例详细的介绍第二种方法:

html

复制代码
<!-- html部分 -->
<canvas id="canvas" tabindex="0"></canvas>
javascript

复制代码
// js部分
const canvas = document.getElementById('canvas')
canvas.focus()
canvas.addEventListener('keydown', (e) => {
  console.log(`keyCode: ${e.keyCode}`)
})

这样就可以让canvas在一开始处于聚焦状态,并相应键盘输入事件。

不过tabindex聚焦的元素会有一层默认的外框,标识该元素处于聚焦状态。如果不想要显示外框,可以通过css样式去除:

css

复制代码
canvas:focus {
  outline:none;
}

可以写一个实际应用来测试,比如用键盘的上下左右或者wsad键操作一个小方块,在canvas画布中移动。

image-20210527180217366

html

复制代码
<!-- html部分 -->
<canvas id="canvas" tabindex="0"></canvas>

<!-- css部分 -->
<style>
  #canvas{
    width: 100vw;
    height: 100vh;
    background-color: #4ab7bd;
  }
  #canvas:focus{
    outline: none;
  }
</style>

<!-- js部分 -->
<script>
window.onload = function() {
  // 画布的长宽
  const canvas = document.getElementById('canvas')
  const canvasWidth = canvas.clientWidth
  const canvasHeight = canvas.clientHeight
  // 在画布上移动的方块的长宽
  const [rectWidth, rectHeight] = [40, 40]
  // 方块的横纵坐标
  let [rectX, rectY] = [0, 0]
  // 初始化
  canvas.width = canvasWidth
  canvas.height = canvasHeight
  let context = canvas.getContext('2d')
  // 给方块设置颜色和初始坐标(中心点),绘制
  context.fillStyle = 'red'
  rectX = (canvasWidth - rectWidth) / 2
  rectY = (canvasHeight - rectHeight) / 2
  context.fillRect(rectX, rectY, rectWidth, rectHeight)

  // canvas元素上监听键盘输入事件
  canvas.addEventListener('keydown', doKeyDown, true)
  canvas.focus() // 让canvas聚焦

  function clearCanvas() {
    context.clearRect(0, 0, canvasWidth, canvasHeight)
  }

  function doKeyDown(e) {
    // 获取keyCode
    const keyCode = e.keyCode ? e.keyCode : e.which

    // 向上箭头 / w,让纵坐标向上移动10
    if (keyCode === 38 || keyCode === 87) {
      clearCanvas()
      rectY -= 10
      if (rectY < 0) {
        rectY = 0
      }
      context.fillRect(rectX, rectY, rectWidth, rectHeight)
    }

    // 向下箭头 / s,让纵坐标向下移动10
    if (keyCode === 40 || keyCode === 83) {
      clearCanvas()
      rectY += 10
      if (rectY > canvasHeight - rectHeight) {
        rectY = canvasHeight - rectHeight
      }
      context.fillRect(rectX, rectY, rectWidth, rectHeight)
    }

    // 向左箭头 / a,让纵坐标向左移动10
    if (keyCode === 37 || keyCode === 65) {
      clearCanvas()
      rectX -= 10
      if (rectX < 0) {
        rectX = 0
      }
      context.fillRect(rectX, rectY, rectWidth, rectHeight)
    }

    // 向右箭头 / d,让纵坐标向右移动10
    if (keyCode === 39 || keyCode === 68) {
      clearCanvas()
      rectX += 10
      if (rectX > canvasWidth - rectWidth) {
        rectX = canvasWidth - rectWidth
      }
      context.fillRect(rectX, rectY, rectWidth, rectHeight)
    }
  }		  
}
</script>

当canvas元素处于聚焦状态时,可以监听到键盘事件,当其失去焦点时,则也会失去键盘监听。

我们可以基于此进行canvas小游戏开发,比如贪吃蛇、推箱子、走迷宫、射击、俄罗斯方块等等。

作者:有刃有鱼阮小六
链接:https://juejin.cn/post/6966986477662109709
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

使用 Vue 创建 ASP.NET Core 应用 - Visual Studio (Windows) | Microsoft Learn

mikel阅读(796)

来源: 使用 Vue 创建 ASP.NET Core 应用 – Visual Studio (Windows) | Microsoft Learn

适用范围:yesVisual Studio noVisual Studio for Mac noVisual Studio Code

在本文中,你将了解如何生成 ASP.NET Core 项目来充当 API 后端,并生成 Vue 项目来充当 UI。

目前,Visual Studio 包含支持 Angular、React 和 Vue 的 ASP.NET Core 单页应用程序 (SPA) 模板。 这些模板在 ASP.NET Core 项目中提供内置的客户端应用文件夹,其中包含每个框架的基本文件和文件夹。

可以使用本文中所述的方法创建具有以下功能的 ASP.NET Core 单页应用程序:

  • 将客户端应用放在 ASP.NET Core 项目之外的独立项目中
  • 基于计算机上安装的框架 CLI 创建客户端项目

 备注

本文介绍使用 Visual Studio 2022 版本 17.7 中的模板创建项目的过程,该版本使用 Vite CLI。

先决条件

确保安装以下内容:

  • 安装了“ASP.NET 和 Web 部署”工作负载的 Visual Studio 2022 版本 17.7 或更高版本。 请转到 Visual Studio 下载页,进行免费安装。 如果需要安装工作负载,但已安装 Visual Studio,请转到“工具”>“获取工具和功能…”,这会打开 Visual Studio 安装程序。 选择“ASP.NET 和 web 开发”工作负载,然后选择“修改” 。
  • npm (https://www.npmjs.com/),随 Node.js 提供。

创建前端应用

  1. 在“开始”窗口中(选择“文件”>“开始窗口”并打开),选择“新建项目”。

    Screenshot showing Create a new project

  2. 在顶部的搜索栏中搜索“Vue”,然后选择“Vue 和 ASP.NET Core (预览版)”,并将 JavaScript 或 TypeScript 作为所选语言。

    Screenshot showing choosing a template

  3. 为项目和解决方案命名,然后选择“创建”。

    创建项目后,解决方案资源管理器应如下所示:

    Screenshot showing Solution Explorer

    独立 Vue 模板相比,可以看到一些用于与 ASP.NET Core 集成的新增和修改文件:

    • aspnetcore-https.js
    • vite.config.json(已修改)
    • HelloWorld.vue(已修改)
    • package.json(已修改)

设置项目属性

  1. 在解决方案资源管理器中,右键单击 ASP.NET Core 项目 (webapi),然后选择“属性”。

    Screenshot showing Open project properties

  2. 在“属性”页中,打开“调试”选项卡,然后选择“打开调试启动配置文件 UI”选项。 清除“启动浏览器”选项。

    Screenshot showing Debug launch profiles UI

    这会阻止打开包含源天气数据的网页。

     备注

    在 Visual Studio 中,launch.json 存储与“调试”工具栏中的“开始”按钮关联的启动设置。 目前,launch.json 必须位于 .vscode 文件夹下。

启动项目

若要启动项目,请按 F5 或选择窗口顶部的“开始”按钮 。 将显示两个命令提示符:

  • 正在运行的 ASP.NET Core API 项目
  • Vite CLI 显示一条消息,例如“VITE v4.4.9 ready in 780 ms

 备注

检查消息的控制台输出,例如指示你更新 Node.js 版本的消息。

Vue 应用随即显示,该应用通过 API 填充。 如果未显示该应用,请参阅疑难解答

发布项目

从 Visual Studio 2022 版本 17.3 开始,可以使用 Visual Studio 发布工具发布集成解决方案。

 备注

要使用发布,请使用 Visual Studio 2022 版本 17.3 或更高版本创建 JavaScript 项目。

  1. 在解决方案资源管理器中,右键单击 ASP.NET Core 项目,然后选择“添加”>“项目引用”。
  2. 选择 Vue 项目并选择“确定”。
  3. 在解决方案资源管理器中,右键单击 ASP.NET Core 项目,然后选择“卸载项目”。

    这将打开项目的 .csproj 文件。

  4. 在 .csproj 文件中,更新项目引用,然后添加 <ReferenceOutputAssembly> 并将值设置为 false

    更新引用后,它如下所示(替换你自己的项目文件夹和项目名称)。

    XML

    <ProjectReference Include="..\vueprojectfolder\vueprojectname.esproj">
        <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
    </ProjectReference>
    
  5. 右键单击 ASP.NET Core 项目并选择“重新加载项目”。
  6. 在 Program.cs 中,更新 Environment.IsDevelopment 的检查,使其类似以下内容。
    C#

    // Configure the HTTP request pipeline.
    if (app.Environment.IsDevelopment())
    {
       app.UseSwagger();
       app.UseSwaggerUI();
    }
    else
    {
       app.UseDefaultFiles();
       app.UseStaticFiles();
    }
    
  7. 若要发布,请右键单击 ASP.NET Core 项目,选择“发布”,然后选择与所需的发布场景相匹配的选项,例如 Azure、发布到文件夹等。

    与在 ASP.NET Core 项目中发布相比,该发布过程需要更多时间,因为发布时会调用 npm run build 命令。

    可以使用 VUE 项目属性中的生产环境生成命令修改 npm run build 命令。 若要进行修改,请在解决方案资源管理器中右键单击 Vue 项目并选择“属性”。

故障排除

代理错误

你可能会看到以下错误:

[HPM] Error occurred while trying to proxy request /weatherforecast from localhost:4200 to https://localhost:5173 (ECONNREFUSED) (https://nodejs.org/api/errors.html#errors_common_system_errors)

如果看到此问题,很可能前端在后端之前启动。 看到后端命令提示符启动并运行后,只需在浏览器中刷新 Vue 应用即可。

否则,如果端口正在使用中,请尝试在 launchSettings.json 和 vite.config.js 中将端口号增加 1。

隐私错误

可能会看到以下证书错误:

Your connection isn't private

尝试从 %appdata%\local\asp.net\https 或 %appdata%\roaming\asp.net\https 中删除 Vue 证书,然后重试。

验证端口

如果天气数据未正确加载,可能还需要验证端口是否正确。

  1. 确保端口号匹配。 转到 ASP.NET Core 的 webapi 项目中的 launchSettings.json 文件(在 Properties 文件夹中)。 从 applicationUrl 属性获取端口号。

    如果有多个 applicationUrl 属性,请使用 https 终结点查找一个。 它看起来应该类似于 https://localhost:7142

  2. 然后,转到 Vue 项目的 vite.config.js 文件。 更新 target 属性,以匹配 launchSettings.json 中的 applicationUrl 属性。 在进行更新时,该值应如下所示:
    JavaScript

    target: 'https://localhost:7142/',
    

过时的 Vue 版本

如果在创建项目时看到控制台消息“找不到文件 ‘C:\Users\Me\source\repos\vueprojectname\package.json’”,则可能需要更新 Vite CLI 的版本。 更新 Vite CLI 后,可能还需要删除 C:\Users\[yourprofilename] 中的 .vuerc 文件。

Docker

如果在创建 Web API 项目时启用 Docker 支持,则后端可能会开始使用 Docker 配置文件,而不侦听已配置的端口 5173。 若要解决问题,请执行以下操作:

通过添加以下属性编辑 launchSettings.json 中的 Docker 配置文件:

JSON

"httpPort": 5175, 
"sslPort": 5173  

或者,使用以下方法进行重置:

  1. 在解决方案属性中,将后端应用设置为启动项目。
  2. 在“调试”菜单中,使用“开始”按钮下拉菜单将配置文件切换到后端应用的配置文件。
  3. 接下来,在解决方案属性中,重置为多个启动项目。

后续步骤

有关 ASP.NET Core 中的 SPA 应用程序的详细信息,请参阅开发单页应用。 链接的文章为项目文件(如 aspnetcore-https.js)提供了更多上下文,尽管由于项目模板和 Vue.js 框架与其他框架之间的差异,实现的详细信息有所不同。 例如,Vue 文件包含在一个单独的项目中,而不是在 ClientApp 文件夹中。

【逆向专题】【危!!!刑】(一)使用c#+Win32Api实现进程注入到wechat - 四处观察 - 博客园

mikel阅读(499)

来源: 【逆向专题】【危!!!刑】(一)使用c#+Win32Api实现进程注入到wechat – 四处观察 – 博客园

引言

自从上篇使用Flaui实现微信自动化之后,这段时间便一直在瞎研究微信这方面,目前破解了Window微信的本地的SQLite数据库,使用Openssl,以及Win32Api来获取解密密钥,今天作为第一张,先简单写一下,获取微信的一些静态数据,以及将自己写的c语言dll通过Api注入到微信进程里面去,最后调用我们的dll的方法。话不多说,让我们开始吧。

逆向

静态数据的话,需要用到的软件 CE,全称是Cheat Engine,图标如下所示。接下来我们打开CE,可以看到左上角有一块绿色的按钮,我们点击按钮是附加进程到CE,然后我们点击附加微信到CE,在下面的图中,我们看到已经把微信进程加载到了CE里面去,然后我们要开始获取静态数据了。

 

在获取静态数据之前,我们先开始讲几个概念,就是内存的概念,我们都知道,在进程启动的时候,操作系统会给我们的进程分配虚拟内存,默认应该是4g,具体是和操作系统位数也有关系,然后在运行时也会动态的分配内存空间,我们学过计算机原理的肯定知道,我们的内存存储结构就像是一个链表或者数组,我们在给这个进程分配内存空间的时候,他的样子也是是类似数组的这种结构,首先假如我们的进程现在有一个主模块,主模块里面又有自己的方法,自己的类,属性等信息,那分配的这个主模块的内存就是一个数组,然后我们主模块有一个基础地址,你可以将这个基础地址看作是这块内存数组的索引0,而我们主模块的其他的方法,类,变量信息,都是在这个0的索引进行移动到指定的地址,这个地址指向我们的内存,这个内存存储着我们要的信息。简而言之,就是主模块是的地址就是索引0,而其他变量信息可能在5,7,9等等,我们就需要判断从0到5有多少间隔,这个就叫偏移量,我们通过属性或者方法的内存地址减去主模块的地址,这个就是我们的偏移量,借这个例子就是5-0就是5,偏移量是5。

然后我们回来,我们加载微信进程到了我们的CE之后,在wechat有一个模块叫Wechatwin,这个是window操作系统下的微信用到的主要模块,我们的和微信相关的基本都在这里,当然不包括一些resource,这个有一个专门的模块,我们在此不多赘述,所以我们假如要找我们的静态数据,例如微信昵称,微信号,或者手机号,所在地区,就需要找到我们的wechatwin的地址,这个就是这个模块的基址,然后我们需要在CE中,检索字符串找到我们要的数据,例如昵称,手机号等信息。然后用他的地址减去基址,得到偏移量。从而我们就可以在代码中获取到这些信息,接下来,我先带大家在CE中找到我们想要找的数据。

在CE上方右侧,有一个输入框,我们在这里输入我们需要检索的信息,支持的格式有byte,string,以及array,double等数据类型,我们需要找到是string,所以在ValueType那里,我们选择string。我的微信昵称是云淡风轻,所以在这里搜索云淡风轻,可以看到,就检索出来我们的昵称信息了,找到了这么多,这里我们往最下面拉,有一个绿色开头的,Address是WechatWin的,就是我们要找的地址了,其他的也有的是绿色基于Wechatwin有的不是,有的就需要一个一个测试修改数据从而得到验证了。

我们双击那条绿色记录,Wechatwin 将他加入到下面的列表去,代表我们选中的检测的内存,接下来我们验证一下,是否是找的正确的,双击Value,云淡风轻,我们修改我们的Value,将云淡风轻,改为good man 点击ok,可以在下面看到,我们的微信昵称已经同步改为了good Man,说明我们找到的是对的,接下来,我们双击Address,

 

弹出Change address姐妹,我们复制WechatWin.dll,需要我们找到我们这个模块的基址。然后在右边有一个Add Address Manually,手动添加地址,,我们把复制的WeChatWin.dll复制过去,然后点击ok,在下面的列表我们就看到了这个模块的基址,接下来,我们需要判断这个基址和昵称之间的偏移量,按照我们刚才所说的方式计算,转换16进制就是0x7ffd3d668308-0x7ffd39b40000,随便找一个16进制计算器,算下来的结果就是3B28308,也就是Address里面显示的那个,实际上CE已经给我们把偏移算出来了,接下来按照同样的方式,去搜索我们的所在地区,以及手机号,如果有的信息找不到的话,我们选择我们的昵称哪一行数据,右键,选择Browse this Memory region,在内存页显示这个内存记录,然后我们在旁边就可以看到我们的国家,以及省份地区信息了,如果有查看地址,在右侧,选择我们要复制的记录,右键,有一个goto Address,然后就导航到了我们的内存,然后复制地址即可。

 

C#代码获取数据以及远程注入

在上面我们讲了,如何使用CE,去获取我们微信的一些静态数据,接下来,我们就需要使用C#代码,去实现我们获取静态数据,以及最后写的一个远程注入,来调用我们写的一个库。首先我们需要用到的有几个Api函数,

WaitForSingleObject,等待某个句柄多长时间,在我们创建远程线程的时候需要使用这个函数来等待线程执行结束。参数是等待的句柄,我们填写我们的线程句柄。

GetProcAddress,需要使用这个函数来调用kernel32.dll的LoadLibraryA方法,来加载我们的自己写的dll,因为在每个进程启动的时候,都会去调用这个方法来加载程序所依赖的dll,还有一个方法是LoadLibraryW,和这个方法区别在于不同针对不同的编码来进行调用,W结尾主要是针对UNICODE的编码,A结尾对应Ascii编码,所以各位在调用的时候根据自己的编码去调用,如果一个找不到就试试另一个。

GetModuleHandle,这个函数是用来获取kernel32.dll,结合上面的GetProdAddress来使用。

OpenProcess,这个方法是根据指定的PID,对应就是Process类的Id,打开指定的进程,同时指定以什么权限打开这个进程,参数是三个,第一个是权限,第二个是返回值是否可以被继承,返回的进程句柄是否可以被继承,第三个参数就是我们的PID。

VirtualAllocEx,给指定的进程分配虚拟内存,第一个参数是进程的句柄,OpenProcess返回值,第二个参数指定进程内那个内存地址分配的内存,此处我们只是加载dll调用方法,并不注入到某个方法或者哪里所以是Intptr.Zero,第三个参数是,分配的内存长度,我们加载dll需要dll的路径,这里就选择路径.Length就行,字符串的长度就可以,第三个参数是内存分配的一些配置,可选值在后面会有,此处我们选择Memory_Commit,第四个参数是内存权限相关,内存是只读还是可以读写,以及用来执行代码或者怎么样,这里我们选择可以读写。

ReadProcessMemory,读指定进程的内存,第一个参数进程句柄,OpenProcess返回值,第二个参数是这个进程某个内存的地址,第三个是数据缓冲区,读取之后的内容就在这个缓冲区,我们读取这个缓冲区就可以拿到数据,第四就是缓冲区的长度,第五个就是读取的字节数量。

GetLastError,用来获取Win32api调用的时候的errorcode,错误编码,

CloseHandle,关闭某一个句柄,关闭基础,关闭线程。

WriteProcessMemory,写入内存,我们需要将我们的dll地址写入到指定内存中去,第一个参数进程句柄,OpenProcess返回值,第二个参数,要写入的内存地址基址,例如我们后期需要在某个方法进行注入,这块就需要写入这个方法的内存地址,第三个参数,写入的byte数据,第四个参数是第三个参数的长度,最后一个参数是写入的数据数量。

CreateRemoteThread,在指定的进程中创建远程线程,第一个参数 OpenProcess返回值,第二个参数是线程安全的一些特性描述,按网上所说,一般null或者 IntPtr.Zero,第三个参数设置线程堆栈大小,默认是0,即使用默认的大小,第四个参数是线程函数的地址,我们要通过这个方法去调用Kernel32的LoadLibrary方法加载我们的dll,那这个参数就填写我们的GetProcAddress返回值,第四个参数就是创建这个线程的参数,就是分配的远程内存的地址VirtualAllocEx返回值,就是说通过创建远程线程来调用LoadLibrary方法加载我们写入指定内存地址的dll库,来实现注入,是这样一个逻辑,第五个参数是线程创建的一些参数,是创建后挂起还是直接运行等,最后一个参数是输出参数,记录创建的远程线程的ID。

以上是我们所需要用到的所有的Win32Api函数,接下来我们进入代码阶段。

在下面的窗体,窗体会在加载的时候就去调用注入我们的dll,同时界面在加载的时候就获取获取我们的静态信息。我们的dll地址是E盘下面的一个dll,这个Dll使用c语言编写。在启动的时候我们去获取我们的微信进程,拿到的ID,然后去注入我们的Dll,在下面的代码里,我们判断是否模块是WechatWin.dll,如果是,就定义了phone,NickName,Provice,Area等int值,这个其实就是我们在CE拿到的静态数据的内存地址,减去我们的Wechatwin.Dll的出来的偏移量,然后定义了我们各个静态数据的缓冲区,用来读取从微信进程读取的内存数据。然后我们调用了ReadProcessMemory函数读取内存,获取我们需要的静态数据。然后使用Utf8转为字符串,显示到界面上。这就是获取静态数据的源码,然后关闭我们的进程句柄,并不是关闭微信,而是关闭我们获取的这个进程句柄。

复制代码
 string dllpath = @"E:\CoreRepos\ConsoleApplication2\x64\Debug\Inject.dll";
 var process = Process.GetProcessesByName("wechat").FirstOrDefault();
           InjectDll(process.Id, dllpath);
            var pid = OpenProcess(ProcessAccessFlags.PROCESS_ALL_ACCESS, false, process.Id);
            int bytesRead;
            int bytesWritten;
            foreach (ProcessModule item in process.Modules)
            {
                if (item.ModuleName.ToLower() == "WechatWin.dll".ToLower())
                {
                    int phone = 0x3B28248;
                    int NickName = 0x3b28308;
                    int provice = 0x3B282A8;
                    int Area = 0x3B282C8;
                    var Nickbuffer = new byte[12];

                    var Phonebuffer = new byte[11];
                    var proviceBuffer= new byte[12];
                    var areaBuffer=new byte[12];
                    ReadProcessMemory(process.Handle, item.BaseAddress + NickName, Nickbuffer, Nickbuffer.Length, out bytesRead);
                    ReadProcessMemory(process.Handle, item.BaseAddress + phone, Phonebuffer, Phonebuffer.Length, out bytesRead);
                    ReadProcessMemory(process.Handle, item.BaseAddress + provice, proviceBuffer, proviceBuffer.Length, out bytesRead);
                    ReadProcessMemory(process.Handle, item.BaseAddress + Area, areaBuffer, areaBuffer.Length, out bytesRead);
                    var Nickvalue = Encoding.UTF8.GetString(Nickbuffer); 

                    var Phonevalue = Encoding.UTF8.GetString(Phonebuffer); 
                    var Provicevalue = Encoding.UTF8.GetString(proviceBuffer); 
                    var Areavalue = Encoding.UTF8.GetString(areaBuffer); 
                    label1.Text = Nickvalue;
                    label2.Text = Phonevalue;
                    label3.Text = Provicevalue;
                    label4.Text = Areavalue;
                    var buf = Encoding.UTF8.GetBytes("我是你爹");
                    CloseHandle(process.Handle);
                }
            }
复制代码

 

 

然后我们开始看看注入DLL的代码,我们先引入了诸多函数,然后定义了OpenProcess第一个参数权限的枚举,定义了INFINITE 用来WaitForSingleObject等待指定的句柄进行某些操作的执行结束,当然有一些我没有定义完整,只定义我们此处需要的,完整的可以参考官网api去进行看。在刚进入这段代码,我们调用OpenProcess指定最高权限打开这个进程,然后获取我们的dll地址的byte数组,并将分配内存VirtualAllocEx到我们这个进程里面,同时最后两个参数代表分配内存的一些操作,例如内存是Memory_Commit,0x1000,以及内存是可以读写的0x04,分配好内存之后,我们去往我们分配好的内存写入我们的dll路径,调用WriteProcessMemory方法,传入进程句柄,内存地址,写入的数据等,在下面GetProcAddress和GetModuleHandle用来加载kernel32的LoadraryA方法句柄,最后我们调用了CreateRemoteThread函数将我们的dll注入到远程进程中去。

复制代码
 #region 32 api
        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);

        [DllImport("kernel32.dll")]
        public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);

        [DllImport("kernel32.dll")]
        public static extern IntPtr GetModuleHandle(string lpModuleName);

        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, int dwSize, int flAllocationType, int flProtect);
        [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
        public static extern bool ReadProcessMemory(
       IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int dwSize, out int lpNumberOfBytesRead
   );
        [DllImport("kernel32.dll")]
        public static extern IntPtr OpenProcess(
       ProcessAccessFlags dwDesiredAccess,
       bool bInheritHandle,
       int dwProcessId
   );
        [DllImport("kernel32.dll")]
        static extern uint GetLastError();
        [DllImport("kernel32.dll")]
        public static extern bool CloseHandle(IntPtr hObject);

        [DllImport("kernel32.dll")]
        public static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out int lpNumberOfBytesWritten);
        [DllImport("kernel32.dll")]
        public static extern IntPtr CreateRemoteThread(IntPtr hProcess, IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId);

        // 进程访问权限标志位
        [Flags]
        public enum ProcessAccessFlags : uint
        {
            PROCESS_ALL_ACCESS = 0x1F0FFF,
            PROCESS_CREATE_PROCESS = 0x0080,
            PROCESS_QUERY_INFORMATION = 0x0400,
        }
        const uint INFINITE = 0xFFFFFFFF;
        #endregionpublic  bool InjectDll(int processId, string dllPath)
        {
            IntPtr hProcess = OpenProcess(ProcessAccessFlags.PROCESS_ALL_ACCESS, false, processId);

            if (hProcess == IntPtr.Zero)
            {
                Console.WriteLine("打开失败");
                return false;
            }

            byte[] dllBytes = Encoding.UTF8.GetBytes(dllPath); ;
            IntPtr remoteMemory = VirtualAllocEx(hProcess, IntPtr.Zero, dllBytes.Length, 0x1000, 0x04);


            int bytesWritten;

            if (!WriteProcessMemory(hProcess, remoteMemory, dllBytes, dllBytes.Length, out bytesWritten))
            {
                var ooo = GetLastError();
                Console.WriteLine("写入失败");
                return false;
            }
            IntPtr loadLibraryAddr = GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryA");
            var ooaa = GetLastError();
            if (loadLibraryAddr == IntPtr.Zero)
            {
                Console.WriteLine("获取LoadraryA失败");
            }

            // 创建远程线程,在目标进程中调用 LoadLibraryA 加载 DLL
            var hRemoteThread = CreateRemoteThread(hProcess, IntPtr.Zero, 0, loadLibraryAddr, remoteMemory, 0, IntPtr.Zero);
            var ooaa1 = GetLastError();
            if (hRemoteThread == IntPtr.Zero)
            {
                Console.WriteLine("目标进程创建远程线程失败");
            }

            // 等待远程线程执行完毕
            WaitForSingleObject(hRemoteThread, 0xFFFFFFFF);


            WaitForSingleObject(hRemoteThread, INFINITE);

            CloseHandle(hRemoteThread);
            CloseHandle(hProcess);

            Console.WriteLine("注入成功");

            return true;
        }
复制代码

我们看看我写的dll里面是包括了什么内容,我们的dll内容很简单,就是创建一个txt文件,然后写入一个数据就行,这里需要注意的是,在使用vs创建dll的时候 选项必须是选择的是动态链接库,这样才有DLLMain方法,这样在调用LoadraryA方法的时候才会调用我们的dll,自动调用DLLMain方法,同时里面还有一个switch case语句是进程加载线程加载,以及线程卸载,进程卸载的判断 我们可以在这里去去一些我们的逻辑判断,此处我并没有写,只是在外层创建了一个文件夹,接下来运行一下我们的winform,看看有没有获取到静态数据,以及将我们的dll注入进去。马赛克手机号。

 

 

可以看到我们启动了界面之后,查看我们的Process.Modules,可以看到我们注入的Inject.dll,那我们看看有没有创建txt呢。在下面可以看到,我们已经成功注入到微信进程并且创建了一个example.txt,并且写入的内容和上图定义的内容是一致的,到此,我们将我们dll注入到了微信进程中去了。

结语

在上面我们讲了一些如何找到静态数据,以及根据基址,偏移量在进程启动的时候找到我们想要的数据,并且将我们的dll成功注入到进程里面去,在后面,我可能还会在深入研究一下逆向,到时候会继续发文,感兴趣的朋友可以关注一波,同时,近期,还破解了微信SQLite本地数据库获取了一些内容,下面是获取的数据内容,这个我应该不会开源,但是会有一个c语言的写的解密demo开源,同时可能会分享一部分c#获取解密密钥的代码,同时也需要一些逆向的知识,win32api,这个东西由于涉及个人隐私,所以我尚不确定是否开源,因为存在有的人如果挂马,可以窃取他人的隐私,所以后续再说,同时在写的,讲的不对的地方,欢迎各位大佬指正。

 

一份关于DDD的FAQ | 码如云文档中心

mikel阅读(522)

来源: 一份关于DDD的FAQ | 码如云文档中心

前几年,我全程采用DDD开发了一个一物一码管理平台——码如云,并成功上线。在这个过程中,我们学了很多,积累了很多,也踩了不少的坑。在本文中,我希望通过FAQ(Frequently Asked Questions,常见问题)的方式给大家分享一下我对DDD的一些看法。如果对DDD落地感兴趣,可以参考我的DDD落地文章系列


问:DDD最大的受益者是谁?

答:架构师和程序员,请注意,这里的架构师是那些工作在前线,至少会参与软件模型设计的架构师,最好是依然参与代码编写工作。


问:搞DDD需要哪些条件?

答:建议先写3年左右的代码,在熟悉了面向对象技术后再学习DDD可能更加容易上手。


问:DDD为什么有时也被称为”玄学”?

答:很多对DDD的解读比较空洞,纸上谈兵,无法指导软件项目的实际落地,这种对DDD虚无缥缈的解读被有些人称为”玄学”。


问:哪些人可以做领域专家?

答:任何熟悉业务的人,比如银行的前台工作人员对于银行业务系统来说则可认为是领域专家。


问:业务分析师(BA)需要了解DDD吗?

答:不需要,BA需要做的事情是理清业务,然后将业务传递给架构师或程序员。


问:DDD的战略设计是什么?

答:DDD的战略设计只在解决一个问题,即软件的模块化划分问题。


问:DDD与微服务是什么关系?

答:没关系,业界有个说法是“DDD的限界上下文可以帮助指导微服务的划分”,但是这个说法过于笼统和牵强,基本不具现实指导意义。


问:DDD当下为什么这么火?

答:可能是跟风者比较多吧,或者是前面提到的与微服务那种牵强的关系,DDD就是一个工具而已,原本不应该这么火的。


问:DDD与TOGAF有什么关系?

答:没有关系,TOGAF主要用于企业整体架构,而DDD侧重于一个具体业务系统的软件设计和落地。


问:搞DDD必须搞事件风暴吗?

答:不用,事件风暴主要用于帮助我们了解业务流程,在实践中事件风暴很容易陷入“为了搞事件风暴而搞事件风暴”的陷阱,建议采用更加朴素的方式梳理业务。


问:DDD与面向对象是什么关系?

答:可以认为DDD是面向对象进阶,这也是为什么前面有建议说熟悉了OO之后再搞DDD。


问:DDD的战术设计包含哪些概念?

答:包含应用服务,聚合根,领域服务,实体,值对象,工厂,领域事件,资源库等。


问:为什么建议将业务概念优先建模为值对象而不是实体?

答:因为值对象是不可变的,可以大大降低系统的信息熵进而降低程序员的负担,并且可以方便逻辑推理和系统调试。


问:怎么理解聚合根?

答:聚合根表示业务中那些顶级的实体对象,其内部数据相互紧密联系,即“聚合”在一起。比如,电商系统中的“订单(Order)”,CRM系统中的“客户(Customer)”均是典型的聚合根对象。


问:不变条件是什么意思?

答:不变条件表示在聚合根中,那些具有业务互动性的业务逻辑,不变条件必须在同一个聚合根的公有方法中得到满足,否则容易导致业务逻辑的泄漏。一个老生常谈的例子是订单(Order),修改订单项内容后,订单价格也应该随之变化,因此对订单项的修改和对价格的修改应该放到同一个方法(比如updateOrderItems())中。


问:事务边界应该放在哪里?

答:应用服务,因为应用服务中的共有方法和业务用例一一对应,而业务用例又与事务一一对应。


问:什么时候应该用领域服务?

答:当将业务逻辑放在聚合根中不合适的时候,才考虑创建领域服务来存放这些业务逻辑。比如,在更新成员手机号时,需要检查手机号是否已经被他人占用,这种跨聚合根的业务逻辑无法放到某一个成员对象中,此时应该采用领域服务。


问:应用服务和领域服务的区别是什么?

答:应用服务和领域服务是很不一样的概念,应用服务是领域模型的门面,所有外部请求都由应用服务的调度编排后进入领域模型中,而领域服务是属于领域模型的一部分。应用服务不包含业务逻辑,领域服务则相反。


问:如何保证发送领域事件和更新聚合根之间一致性?

答:采用事件发送表,即先将事件保存到与聚合根相同的数据库中,这样通过数据库的本地事务即可完成它们之间的数据一致性,然后再通过一个单独的组件从事件表中加载领域事件再发送出去。更多详情,请参考这里


问:DDD和CQRS是什么关系?

答:没关系,不过在DDD项目中通常会采用CQRS,以得到更加纯粹的领域模型,当然CQRS的作用并不止于此。


问:有推荐的DDD书籍吗?

答:《领域驱动设计:软件核心复杂性应对之道》(蓝皮书),《实现领域驱动设计》(红皮书),《领域驱动设计模式、原理与实践》,《解构领域驱动设计》等。

DDD项目中使用Lombok的正确姿势 | 码如云文档中心

mikel阅读(481)

来源: DDD项目中使用Lombok的正确姿势 | 码如云文档中心

写过Java的程序员都知道,Java的语法是比较繁琐的, 各种getter、setter、equals()和hashCode()满天飞,有时甚至将真正的业务逻辑代码完全淹没,让读代码的人很难直观地了解到代码所完成的功能。于是我们有了Lombok,只需在Java类或方法上加上一些简单的注解,Lombok便会在编译时介入,并根据所添加的注解自动生成相应的字节码(比如getter和setter代码等)。

比如,对于**颜色(Color)**对象来说,当我们加上Lombok的@Data注解后,Lombok将自动为Color类生成getter、setter、toString()、eqauls()、hashCode()和构造函数等众多方法,进而让程序员将关注点放在更有价值的业务逻辑相关的代码中,岂不乐哉?

@Data
public class Color {
    int red;
    int green;
    int blue;
}

不过,也有人反对使用Lombok,其中一个很重要的原因是Lombok自动生成的setter方法只是简单的赋值操作,并没有对数据的合法性进行检查,进而可能使对象处于一种非法的状态,比如对于上例的Color对象来说,调用方可以在调用setRed()方法时,传入一个大于255的值,而我们都知道颜色的RGB值最大只能是255,因此当传入256时Color对象则不合法了。对于对业务合法性要求极高的DDD来说,使用Lombok的风险尤其突出。

于是,我们来到了一个两难的境界,一方面使用Lombok的确可以减少大量的重复性编码,另一方面Lombok又可能给程序带来非法数据的风险。要解决这个问题的,我们需要对Lombok的使用进行管控,以便可以更加安全地使用之。在日常DDD编码的过程中,我们(码如云)积累了多种Lombok使用模式,在本文中分享给大家。如果对DDD本身感兴趣,读者可以参考笔者的另一个DDD落地文章系列

DDD中包含多种概念,其中可能用到Lombok的概念有聚合根(Aggregate Root)、实体(Entity)和值对象(Value Object)等,本文将针对这些对象分别给出相应的Lombok使用建议。

禁止使用@Setter@Data #

DDD社区对setter方法可谓是深恶痛绝,其中主要有以下2个原因:

  1. setter方法只是机械式的赋值操作,无法体现代码的业务意图,而业务意图却正是DDD所强调的;
  2. setter方法只是简单的赋值操作,并没有对业务的合法性进行检查,如果使用不当可能导致Bug,如上例中的Color

Lombok中的@Data由于包含了@Setter的功能,因此也不建议使用。在不使用@Setter@Data时,实体对象可以通过业务方法予以代替,比如要更新**成员(Member)**的姓名时,可以使用一个单独编写的updateName()方法完成,在该方法中对姓名进行合法性验证后,再对name字段赋值。

//Member

public void updateName(String newName) {
    if (newName.length() > 100) {
        throw new RuntimeException("姓名不能超过100个字符。");
    }

    this.name = newName;
    raiseEvent(new MemberNameChangedEvent(this.getId(), newName));
}

可以看到,在更新Member的姓名(name)时,首先对姓名的长度进行合法性检查,如果合法才进行赋值,赋值后还需要通过raiseEvent()向外部发出领域事件,而这些操作是一个简单的setter所无法完成的。

对于值对象来说,则更不应该使用setter了,因为根据DDD原则,值对象一旦被创建便不能修改了,使用setter明显违背了这一原则。

值对象使用Lombok #

在DDD中,值对象表示那些起描述作用的对象,值对象之间通过所包含的数据内容进行相等性判断。比如,上例的Color则是一个值对象,当两个Color对象所携带的RGB值均相同时,则可认为这两个对象相等,也即可互换。值对象存在一个非常重要的约束:不变性,也即在值对象被创建出来之后,便不能再改变其状态了,如需改变,则需要创建出另一个值对象(比如下例中的changeRedTo()方法)。

//Color

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class Color {
    int red;
    int green;
    int blue;

    public Color changeRedTo(int red) {
        if (red > 255) {
            throw new RuntimeException("Invalid Red value.");
        }
        return Color.builder()
                .red(red)
                .green(this.green)
                .blue(this.blue)
                .build();
    }
}

在使用Lombok时,通过@Value表示一个值对象,@Value并不包含生成setter方法。另外,通过@Builder自动生成Builder方法。

需要注意的是,我们通过access = PRIVATE将全参构造函数设置为了私有的,外部无法访问,因为我们只希望外部通过一种方式创建值对象——Builder。这样做其实还可以解决Lombok无法解决的一个问题:在本例中,假设Color对象使用的是@AllArgsConstructor(access = PUBLIC)生成的全参构造函数,那么调用方可以通过以下方式创建一个Color对象:

Color aColor = new Color(12, 23, 34);

此时,如果我们将Color中的redgreen字段调换一下顺序,由于redgreen均是int类型,那么通过@AllArgsConstructor所生成的全参构造函数的签名其实是无变化的,这也意味着上面通过构造函数创建Color的代码是不会产生编译错误的,但是其所表示的业务含义已经随着字段顺序的变化而变化了,如果程序员自己不知道需要做相应的修改,那么Bug也就因此而生。

因此,在使用Lombok时,我们更推荐使用Builder进行对象的创建,而不是全参构造函数。具体落地时,由于@Value会自动引入@AllArgsConstructor,因此需要通过@AllArgsConstructor(access = PRIVATE)将其显式地隐藏起来。

聚合根使用Lombok #

聚合根可以说是DDD中最重要的概念了,它表示领域模型中那些最重要的实体性对象(比如电商系统中的订单Order,CRM系统中的客户Customer等),其他DDD概念都围绕着聚合根展开。

聚合根是有生命周期的对象,通常会被持久化到数据库中,也就是说通常有2种情况涉及到聚合根的创建:

  1. 业务上从无到有新建一个聚合根对象;
  2. 从数据库中加载一个既有的聚合根对象。

对于从业务上新建来说,新建过程是一个显著的业务过程,并且一般不需要全参数构造函数,而是基于场景所需数据完成创建。因此,此时对聚合根的新建过程通常采用我们自己编写的构造函数完成创建。

对于从数据库加载聚合根对象来说,由于Spring Data框架会自动调用无参构造函数,因此可以通过Lombok的@NoArgsConstructor(access = PRIVATE)自动生成。

另外,由于外部通常会获取聚合根中的各种数据,因此可以使用比较安全的@Getter向外暴露各个字段。

//Member

@Getter
@NoArgsConstructor(access = PRIVATE)
public class Member extends AggregateRoot {
    private String name;//姓名
    private List<Roles> roles; //角色

    public Member(String name) {
        if (name.length() > 100) {
            throw new RuntimeException("Name must be less than 100 characters in length.");
        }
        this.name = name;
    }
}

可以看到,对聚合根而言,我们只使用了Lombok的@GetterNoArgsConstructor注解,并且NoArgsConstructor所生成的无参构造函数被声明为了私有的,因此其作用只是便于框架调用而不是编码时直接调用。

事实上,在聚合根中使用Lombok所得到的好处并不多,反而有可能带来一定风险。一是无参的构造函数通过自己手动编写也非常简单;二是在使用@Getter时可能将诸如List之类的容器字段一并暴露出去,使得外部可以直接操作这些容器字段,违背了聚合根的基本原则——外部只能通过聚合根完成对其内部状态的改变,而不能直接操作聚合根内部的字段。因此,对于Memberroles字段来说,比使用@Getter更安全的方式是返回一个不变的List容器:

    public List<Role> getRoles(){
        return List.copyOf(this.roles); //通过copyOf()返回一个不变的List容器
    }

不过,这个问题也可以通过将roles字段本身建模为不变对象来解决,比如使用Guava的ImmutableList,这样外部即便通过@Getter拿到了roles字段,在向其中添加数据元素时,程序也将报错。

码如云,我们做了妥协,也即依然在聚合根中使用了@Getter方法,因为我们的程序员能够自觉的遵守聚合根的各种编码原则。

命令对象和查询对象 #

在DDD中,命令对象(Command)用于封装外部向系统发起的一次请求,其中包含了请求所需数据;而查询对象(Query)则用于向外部返回查询的结果数据。在技术层面,这两种对象都属于值对象类型,因此可以使用与值对象相同的Lombok注解。

//命令对象:CreateMemberCommand

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class CreateMemberCommand implements Command {
    @NotBlank
    @Size(max = MAX_GENERIC_NAME_LENGTH)
    private final String name;
    
}

总结 #

在本文中,我们对DDD中的各种对象使用Lombok进行了针对性的管控,从而减少了产生Bug的风险。当然,这些管控手段不见得适合于所有的项目,但是与这些实践手法本身相比,我们更希望传达的一个思想是:使用Lombok可以,但是要慎重。你得知道一个简单的Lombok注解可能给程序带来的风险,进而通过自己的手段进行规避,并形成一定的原则和套路,以让团队的所有成员通过一致的方式使用Lombok。

整洁架构能有多整洁? | 码如云文档中心

mikel阅读(566)

来源: 整洁架构能有多整洁? | 码如云文档中心

前段时间,我将一个中大型后端项目顺利地从Spring Boot 2.5 迁移到了Spring Boot 3,整个过程仅花了一天左右时间,在小酌庆祝之余,我开始思考此次迁移之所以能够顺利进行的原因,此时一个概念立即闪现脑海——整洁架构

下来我对项目代码做了个统计,发现了以下两组比较重要的数据:

  1. 在整个代码库中,领域模型(对应上图中的Entities)的代码占比为55%,应用服务(对应上图中的Use Cases)的占比为9%。EntitiesUse Cases合起来可以看作是整个软件系统的业务核心,也就是说业务核心代码占到了整个代码库的一大半。(事实上,我们采用了CQRS架构将写操作和读操作分离了,上述统计数字仅仅统计了写操作部分,如果再加上读操作部分,业务核心的代码占比将更大)
  2. 在整个业务核心(EntitiesUse Cases)中,我们对Spring框架的所有依赖仅包含4个注解:@Transactional@Component@Document@TypeAlias。其中,@Transactional用于数据库事务,@Component用于依赖注入,@Document@TypeAlias用于MongoDB持久化。

这里简单介绍一下项目背景,该项目叫码如云,是一个基于二维码的一物一码管理平台。在技术上,码如云是一个无代码平台,技术栈主要有Java、Spring和MongoDB等。码如云全程采用整洁架构和DDD的思想完成开发,对DDD感兴趣的读者可以参考笔者的DDD落地文章系列

码如云的源代码是开源的,可以通过以下方式访问:

码如云源代码:https://github.com/mryqr-com/mry-backend

在本文中,我将分享码如云在采用整洁架构的过程中的所使用的主要实践方式与原则。

将业务代码内聚在核心模块中 #

无论是大家所熟悉的“低耦合,高内聚”原则,还是DDD中的聚合根概念,都要求与业务逻辑相关的代码具有高度的内聚性,有内聚就有了边界,以此表明业务逻辑与基础设施之间的边界,使得业务逻辑和基础设施可以单独演进,互不影响。

这里所说的业务代码包含了上图中的EntitiesUse Cases,它们所组成的核心模块可以类比于计算机的CPU,其中Entities(领域模型)对应CPU内部的各种二极管和三极管等核心电路,而Use Cases(应用服务)则对应CPU的管脚。CPU的内部电路通过管脚与外界交互,同理,领域模型通过应用服务与外界交互,外界无需关心领域模型的内部是如何实现的,只需通过业务用例向应用服务发起命令请求即可。

要达到高内聚的目的,在实际编码时,领域模型不应该是只有getter和setter的数据容器,而应该是富含业务行为的领域对象,这也是为什么在码如云我们可以做到项目中的大部分代码都集中在核心模型中的原因。

通过分包形成清晰的逻辑边界 #

分包有助于对代码进行归类和分层,以辅助业务复杂度和技术复杂度的分离,边界清晰了,架构自然就整洁了。总结下来,我们形成了以下几种归类,并以此为边界进行分包。

首先,基于DDD社区建议的“先业务,后技术”分包原则,对整个系统按照不同的业务板块进行分包,在码如云中,app(应用)、assignment(任务)等对应不同的业务板块,因此分别为它们创建分包。

然后,针对某个业务板块,再进行技术性分包。以app为例,其下包含以下几种子分包:

  • domain:用于存放项目中最核心的领域模型,包含所有的业务逻辑,对应文首图中的Entities
  • command:用于存放应用逻辑(即DDD中的应用服务),对应文首图中的Use Cases
  • eventhandler:事件处理类,在EDA(Event Driven Architecture,事件驱动架构)中用于处理领域事件
  • infrastructure:与app模块相关的基础设施,比如数据库访问等
  • query:用于数据查询,在CQRS架构中,查询逻辑与业务逻辑分离,因此为查询单独建立分包

对于其它业务板块(比如assignment)而言,其下的分包结构均与app相同。需要注意的是,在这些子分包下,还可以再次进行子包划分,比如可以将一些相互联系紧密的类放到一个子包下。

接收请求时,尽可能早地脱离技术框架 #

为了将技术性代码与业务性代码更好的区分开来,我们建议在接收到请求时,尽可能早地脱离技术框架,否则可能导致业务逻辑分散在系统各个地方,使业务和技术产生强耦合,架构也不再整洁。

比如,在Spring中,请求首先到达Controller,Controller虽然比Servlet更加上层,但是依然是一种基础设施,并且与Spring框架直接相关。在码如云,Controller被建模成了非常薄的一层,薄到仅有一行代码:

//MemberController
@PutMapping(value = "/me/mobile")
public void changeMyMobile(@RequestBody @Valid ChangeMyMobileCommand command,
                           @AuthenticationPrincipal User user) {
    memberCommandService.changeMyMobile(command, user);
}

可以看到,对于“修改手机号”用例而言,MemberController.changeMyMobile()方法在接收到请求后,立即将请求代理给了memberCommandService.changeMyMobile(),然后结束了Controller的使命。MemberCommandService是一个应用服务,表示请求已经进入到了业务的核心。

调用第三方时,尽可能晚地依赖技术框架 #

与接收请求的处理相反,在向外部调用第三方时,我们希望尽可能晚地依赖技术框架,不过所达到的目的却是相同的。比如,在向外发送领域事件时,首先在领域模型中定义一个与基础设施无关的DomainEventPublisher接口:

public interface DomainEventPublisher {
    void publish(List<String> eventIds);
}

该接口的实现类AsynchronousDomainEventPublisher如下:

public class AsynchronousDomainEventPublisher implements DomainEventPublisher {
    private final DomainEventDao domainEventDao;
    private final DomainEventSender domainEventSender;
    private final TaskExecutor taskExecutor;

    @Override
    public void publish(List<String> eventIds) {
        if (isNotEmpty(eventIds)) {
            taskExecutor.execute(() -> {
                List<DomainEvent> domainEvents = domainEventDao.byIds(eventIds);
                domainEvents.forEach(domainEventSender::send);
            });
        }
    }

}

可以看到,即便是在实现类中,我们也看不出底层使用的消息中间件到底是Kafka,还是RabbitMQ,或者其他,为此需要进一步查看接口DomainEventSender 的具体实现类KafkaDomainEventSender

public class KafkaDomainEventSender implements DomainEventSender {
    private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
    private final CommonProperties commonProperties;

    public void send(DomainEvent event) {
        kafkaTemplate.send(commonProperties.getEventTopic(), event);
    }
}

在整个过程中,直到KafkaDomainEventSender才真正对基础设施产生耦合,这也意味着,如果以后需要替换消息队列的话,只需要新增一个DomainEventSender的实现类即可,其他地方可以保持不变。

保证核心模型中立于基础设施 #

核心模型如果太多地依赖于基础设施(比如Spring框架),将导致整个软件架构混乱不清,整洁架构也无从谈起。正如前文所说,核心模型就像一个CPU,将其放到不同品牌的电脑主板中均可使用,并且无需额外的适配,软件的核心模型也应该以此为目标,使之中立于具体的技术框架和基础设施。

在具体实践中,我们主要将Spring当做一个IoC容器来使用,而刻意不使用或者少使用诸如Spring Data和Spring Events这样的框架设施。(声明一下:在基础设施层,虽然我们没有全面使用Spring Data,但是依然使用了Spring Data所提供的比较底层的设施类MongoTemplate;不过在处理领域事件时,我们一点都没有使用Spring的ApplicationEvent,而是全部自己建模,对此请参考我们的领域事件一文)

了解你的线程模型 #

软件有单线程模型和多线程模型之分,不同的线程模型可能对我们的编码和架构有很大的影响。有时,在多线程模型中可用的设施在单线程下可能不再适用。举个例子,在使用Spring Security时,在任何时候都可以通过SecurityContextHolder.getContex()全局方法获取到当前的用户信息,这得益于Java中的ThreadLocal类将用户信息存放在了当前线程的一个全局变量中。这种方式对于Spring MVC这种采用多线程模型的框架来说是可用的,但是对于某些基于单线程的桌面应用则不再可用。假设有一天你需要从Web应用迁移到桌面版应用程序,而又如果你的代码中包含大量的对SecurityContextHolder.getContex()的调用,可能就比较麻烦了。虽然这种迁移可能几乎不会发生,但是却可以从侧面反映出软件架构本身的整洁性和鲁棒性。

在码如云,我们并未采用SecurityContextHolder.getContex(),而是采用了中立于线程架构的方式:将用户上下文显式的传递。

//MemberController
@PutMapping(value = "/me/mobile")
public void changeMyMobile(@RequestBody @Valid ChangeMyMobileCommand command,
                           @AuthenticationPrincipal User user) {
    memberCommandService.changeMyMobile(command, user);
}

可以看到,在MemberController.changeMyMobile()中,我们将从Spring Security中获取到的用户user传递给了应用服务MemberCommandServiceMemberCommandService可以直接访问user而不用调用SecurityContextHolder.getContext()。这样的好处是,一来可以中立于线程模型,二则减少了应用服务对Spring框架的依赖。不过,这种做法也是有代价的,即需要将user在多个方法之间传递,这个多余的方法参数即增加了编码成本,也被一些人认为是对方法签名的污染。总之,没有唯一正确的答案,It depends!只是无论选择哪种方式,我们都需要对程序所处的线程环境心知肚明。

不用追求100%的整洁架构 #

如果严格按照Robert C. Martin在《架构整洁之道》一书中的讲述,我们可能需要分别为domaininfrastucture等分包单独创建子项目并生成各自的jar文件,因为只有这样才能满足该书中所声称的各个组件单独部署和维护的目的。

然而,在实际编码时,我们并不建议这么做的,因为这样做的代价太大,收益却太小。此外,我们也并不建议核心模型要完全地与技术框架解耦,而是尽量少地依赖于技术框架,少到对技术框架的依赖不至于影响我们对业务逻辑的实现即可。

举个例子,前文提到,码如云的核心模型依赖了Spring的4个注解,由于这些依赖都是通过打在类或方法上的注解形式存在,在我们实现业务逻辑的过程中,并不会受到这些注解的影响,因此我们认为这些依赖是可以接受的。此外,假设未来我们要从Spring迁移到Google的Guice框架,此时要对这4个注解进行替换的话,成本不会太高,方案如下:

Spring注解 Guice替换方案 替换成本
@Transactional Guice有自己的Tranctional注解
@Component 使用JSR-330的@Named
@Document@TypeAlias 自己通过MongoDB的Driver实现

可以看到,如果要将码如云从Spring迁移到Guice,对于核心模型来讲,@Transactional@Component注解可以通过简单的文本替换即可完成迁移,只是@Document@TypeAlias需要多花些功夫。

总结 #

整洁架构是一种非常值得采纳的编程思想,对于系统的解耦和维护有实实在在的指导价值,不过我们也没有必要教条式地追求100%的整洁架构,而是在这种思想的引导下,选择适合于自己的整洁架构方案。

后端开发就是CRUD?没那么简单! | 码如云文档中心

mikel阅读(470)

来源: 后端开发就是CRUD?没那么简单! | 码如云文档中心

作为一个后端开发者,不时都能听到这么一种论调:后端开发没什么技术含量,就是CRUD而已。此时,我一般会嘴角抿抿,心里呵呵。

事实上,从某种程度上说这种说法并没错,我们甚至还可以进一步去挖掘一下其背后更深层次的本质:软件就是一个I/O系统,后端开发就是对数据的I/O处理而已,只需能把数据存起来再放出去即可,的确说不上什么高端可言。此外,在国内的大多数程序员所从事的细分行业只能说是“应用软件开发”或者“业务软件开发”,说白了这些成天处理业务逻辑的软件都没什么难的,就是一些低级逻辑而已,这也是为什么很多非计算机专业的学生都可以成功转行为程序员的原因(之一)。

然而,同样一个业务功能,分别让两个工作经验不同的程序员去实现,他们的代码可能完全不一样。有时,经验少的程序员写100行代码就能实现的一个功能,老程序员却需要写500行,因为后者考虑到了对各种边界条件的处理,缓存的使用以及对性能的顾及等。又有时,经验少的程序员写了500行代码实现的一个功能,老程序员只花了100行就实现了,因为后者使用了更加优秀的算法或者采用了能使代码变得更加简洁的工具和原则等。

李书福说:“造车就是一个沙发加四个车轮”。他说的没错,因为这是汽车的某种本质。然而,真正要造好一台汽车,却需要考虑舒适性、加速性、NVH、操控性、通过性等诸多方面的因素。软件也一样,简单的CRUD操作纵然能够满足基本的I/O需求,但是在具体落地时我们还要考虑很多原则和因素以让人能够更好地掌控软件系统,其中包含但不限于:高内聚低耦合、关注点分离、依赖倒置、非功能性需求等等。这里所涉及到的一个基本命题是:软件代码首先是给人脑看的,其次才是给电脑执行的。

在本文中,我们将以一个真实的软件项目 —— 码如云https://www.mryqr.com)为例,系统性的讲解后端在处理请求的过程中所需要顾及的方方面面,你会发现后端开发绝非单纯的CRUD这么简单。

码如云是一个基于二维码的一物一码管理平台,技术上是一个无代码平台,全程采用DDD思想进行开发,对DDD感兴趣的读者可以参考笔者的DDD落地系列文章

接下来,我们将围绕以下业务用例展开讨论:在码如云中,成员(Member)可以更新自己的手机号码,但如果所使用的手机号已经被他人占用,则禁止更新。

整个请求处理的流程如下图所示:

概括来看,整个请求处理流程和我们通常的实践并没有太大的区别。首先,请求到达MemberController,这是Spring MVC处理请求的第一站;然后MemberController调用MemberCommandService完成该业务用例,调用时传入请求数据对象ChangeMyMobileCommand,这里的MemberCommandService在DDD中被称为应用服务MemberCommandService通过MemberRepository获取到对应的Member对象,再通过MemberDomainService(在DDD中被称为领域服务)完成对Member的手机号更新;最后MemberCommandService 调用MemberRepository.save()将更新后的Member对象保存到数据库。

MemberController #

在整个请求处理的过程中,首先通过MemberController接收请求:

@PutMapping(value = "/me/mobile")
@ResponseStatus(OK)
public void changeMyMobile(@RequestBody @Valid ChangeMyMobileCommand command,
                           @AuthenticationPrincipal User user) {
    memberCommandService.changeMyMobile(command, user);
}

源码出处:com/mryqr/core/member/MemberController.java

这里,MemberController.changeMyMobile()方法一共只有5行代码,可不要小瞧这5行代码,在实际编码时我们却需要考虑多个方面的因素:

  1. Spring MVC的Controller是框架直接相关的,DDD讲求业务复杂度与技术复杂度的分离,我们希望自己的代码实现能够尽快的脱离技术框架,因此MemberController只起到了简单的代理作用,也即把请求代理给应用服务MemberCommandService
  2. 对URL的设计是有讲究的,MemberController采用了REST风格的URL,通过HTTP的PUT方法完成对mobile资源(me/mobile)的更新,更多关于REST URL的内容,请参考这里
  3. 同样基于REST原则,更新资源后应该返回HTTP的200状态码,这里通过@ResponseStatus(OK)完成(Spring MVC默认返回的即是200)。
  4. 对于接收到的数据请求对象ChangeMyMobileCommand需要加上@Valid以做数据验证,否则后续对ChangeMyMobileCommand中的各种JSR-303验证将失效。
  5. MemberController需要返回void,也即不返回任何数据,这是因为基于CQRS的原则,任何写数据的操作不能同时查询数据,反之亦然。

ChangeMyMobileCommand #

命令对象ChangeMyMobileCommand用于封装请求数据,之所以称之为命令(Command)是因为一个请求就像外界向软件系统发起了一次命令一样,这里的Command正是来自于CQRS中的“C”。

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class ChangeMyMobileCommand implements Command {
    @Mobile
    @NotBlank
    private final String mobile;

    @NotBlank
    @VerificationCode
    private final String verification;

    @NotBlank
    @Password
    private final String password;

    @Override
    public void correctAndValidate() {
        //用于JSR-303无法完成的验证逻辑,但是又不能包含业务逻辑
    }
}

源码出处:com/mryqr/core/member/command/ChangeMyMobileCommand.java

ChangeMyMobileCommand 对象主要充当数据容器的作用,其中一个比较重要的任务是完成数据的初步验证。具体实践时需要考虑以下几个方面:

  1. Command对象通常是不变的(Immutable),在编码时应将建模为一个值对象,为此我们使用了Lombok中的@Value@Builder@AllArgsConstructor(access = PRIVATE)达到此目的。
  2. 对Command对象中的每一个字段,都需要判断是否需要做验证,有些字段可以通过简单的JSR-303内建注解完成验证,比如mobile字段中的@NotBlank,而更复杂的验证则需要自行实现JSR-303的ConstraintValidator接口,比如mobile字段的@Mobile注解。
  3. 对于Command对象,还需要特别注意其中的容器类字段,比如ListSet等,需要对这些字段做非null检查(@NotNull),以消除后续代码在引用这些字段时有可能的空指针异常NullPointerException
  4. 对于更加复杂的验证,比如需要对多个字段进行关联性验证,通过自定义JSR-303可能比较麻烦,此时可以自定义Command接口,通过实现该接口的correctAndValidate()方法完成验证目的。
  5. 对于字符串类字段来说,任何时候都需要通过@Size注解对其长度进行限制,除非其他注解中已经包含了此限制。

MemberCommandService #

应用服务(ApplicationService或者CommandService)是领域模型的门面,任何对领域模型的请求都需要通过应用服务中的公有方法完成。更多关于应用服务的讲解,请参考我们DDD文章系列中的这一篇

@Transactional
public void changeMyMobile(ChangeMyMobileCommand command, User user) {
    mryRateLimiter.applyFor(user.getTenantId(), "Member:ChangeMyMobile", 5);

    String mobile = command.getMobile();
    verificationCodeChecker.check(mobile, command.getVerification(), CHANGE_MOBILE);

    Member member = memberRepository.byId(user.getMemberId());
    memberDomainService.changeMyMobile(member, mobile, command.getPassword());
    memberRepository.save(member);
    log.info("Mobile changed by member[{}].", member.getId());
}

源码出处:com/mryqr/core/member/command/MemberCommandService.java

在DDD中,应用服务应该是很薄的一层,因为它不能包含业务逻辑,而主要是起协调的作用,另外事务边界、鉴权等操作也会放在应用服务中。在实现时,应该考虑以下几个方面:

  1. 应用服务不能包含业务逻辑,这也是很多CRUD程序员经常犯的一个错误。举个例子,在本例中,如果成员的手机号已经被占用,则禁止更新手机号,这是一个典型的业务逻辑,因此不应该在MemberCommandService 中完成,而应该放到领域模型中。通常来说,应用服务遵循请求处理“三部曲”原则:(1)获取需要处理的领域对象(本例中的Member),(2)对领域对象进行处理(memberDomainService.changeMyMobile()),(3)将更新后的领域对象保存回数据库(memberRepository.save())。
  2. 应用服务中的公共方法应该与业务用例一一对应,而每个业务用例又对应一个数据库事务,因此应用服务应该是事务的边界,也即Spring的@Transactional注解应该打在应用服务的公用方法上。
  3. 与Controller一样,应用服务中负责写操作的方法不能返回查询数据,而负责查询的方法不能更改数据。
  4. 应用服务应该是独立于技术框架(本例的Spring)的,如果把领域模型比作CPU中的芯片,那么应用服务便是CPU引脚,整个CPU放到不同的电脑主板(类比到技术框架)中均能正常使用。不过,在实际的编码过程中,我们做了一些妥协,比如在本例中,@Transactional 则是来自于Spring的,不过总的原则是不变的,即应用服务(以及其所包围着的领域模型)尽量少地依赖于技术框架。
  5. 一些非业务性的功能也应该在应用服务中完成,比如对请求的限流(本例中的mryRateLimiter ),限流处理原本可以放到技术框架中统一处理的,不过由于码如云是一个SaaS软件,需要对不同的租户单独限流,因此我们将其放在了应用服务这一层。
  6. 一般来讲,对权限的检查也可以放在应用服务中;不过不同的人对此有不同的看法,有人认为权限也属于业务逻辑,因此应该放到领域模型中,而另外有人认为权限不是业务逻辑,应该被当做一个单独的关注点来处理。在码如云,我们选择了后者,并且将对权限的处理放到了应用服务中。

MemberRepository #

资源库(Repository)的、可以认为是对数据库的封装和抽象,有些类似于DAO(Data Access Object),不过它们最大的区别是资源库是与DDD中的聚合根一一对应的,只有聚合根对象才“配得上”拥有资源库,而DAO则没有此限制。更多关于资源库的内容,可以参考这里

public interface MemberRepository {
    boolean existsByMobile(String mobile);
    Member byId(String id);
    Optional<Member> byIdOptional(String id);
    Member byIdAndCheckTenantShip(String id, User user);
    boolean exists(String arId);
    void save(Member member);
    void delete(Member member);
}

源码出处:com/mryqr/core/member/domain/MemberRepository.java

在实现资源库时,应该考虑以下几个方面:

  1. 只对聚合根对象创建相应的资源库,并且其操作的对象是以聚合根为单位的。
  2. 资源库不能包含太多的查询方法,大量的查询操作可能意味着对领域模型的污染,此时可以考虑通过CQRS将查询操作绕过资源库单独处理。
  3. 资源库通常分为接口类和实现类,接口类是属于领域模型的一部分,而实现类则应该放到基础设施中,落地时接口类应该放到domain分包下,而实现类应该放到infrastructure分包下,这也意味着,资源库的实现是“可插拔”的,即如果将来要从MySQL迁移到MongoDB,那么只需要新添加一个基于MongoDB的资源库实现类即可,其他地方可以不变。
  4. 资源库中不能包含业务逻辑,其完成的功能只限于将数据从内存同步到数据库,或者反之。

MemberDomainService #

与应用服务不同的是,领域服务(DomainService)属于领域模型的一部分,专门用于处理业务逻辑,通常被应用服务所调用。在本例中,我们使用MemberDomainService 对“手机号是否已经被占用”进行检查:

public void changeMyMobile(Member member, String newMobile, String password) {
    if (!mryPasswordEncoder.matches(password, member.getPassword())) {
        throw new MryException(PASSWORD_NOT_MATCH, "修改手机号失败,密码不正确。", "memberId", member.getId());
    }

    if (Objects.equals(member.getMobile(), newMobile)) {
        return;
    }

    if (memberRepository.existsByMobile(newMobile)) {
        throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS, "修改手机号失败,手机号对应成员已存在。",
                mapOf("mobile", newMobile, "memberId", member.getId()));
    }

    member.changeMobile(newMobile, member.toUser());
}

源码出处:com/mryqr/core/member/domain/MemberDomainService.java

在实践时,使用领域服务应该考虑到以下几个方面:

  1. 领域服务不是必须有的,而是只有当领域模型(准确的讲是聚合根)无法完成某些业务逻辑时才出现的,是“不得已而为之”的结果。在本例中,检查“手机号是否被占用”需要进行跨聚合(Member)的操作,光凭当事的Member是无法做到这一点的,此外这种检查有属于业务逻辑的一部分,因此我们创建一种可以处理业务逻辑的服务(Service)类来解决,这个服务类即是领域服务。在很多项中,应用服务和领域服务揉杂在一起,功能倒是实现了,但是各组件之间的耦合也加深了,导致的结果是软件在未来的演进中将变得越来越复杂,越来越困难。
  2. 领域服务的职责最多只到更新领域模型在内存中的状态,而不包含保存领域模型的职责,比如在本例中,MemberDomainService 并不调用memberRepository.save(member)来保存Member,而是由应用服务MemberCommandService负责完成。这样做的好处是将领域服务建模为一个仅仅操作领域模型的“存在”,使其职责更加的单一化。

Member #

领域对象(Domain Object)是业务逻辑的主要载体,同时包含了业务数据和业务行为。在本例中,Member对象则是一个典型的领域对象,在DDD中,Member也被称为聚合根对象。Member对象实现修改手机号的代码如下:

public void changeMobile(String mobile, User user) {
    if (Objects.equals(this.mobile, mobile)) {
        return;
    }

    this.mobile = mobile;
    this.mobileIdentified = true;
    raiseEvent(new MobileChangedEvent(this.getId(), mobile));
}

源码出处:com/mryqr/core/member/domain/Member.java

在实现领域对象时,应该考虑以下几个方面:

  1. 忘掉数据库,不要预设性地将领域模型中的字段与数据库中的字段对应起来,只有这样才能够做到架构的整洁性以及基础设施中立性,正如Bob大叔所说,数据库是一个细节
  2. 领域模型应该保证数据一致性,比如在修改订单项时,订单的价格也应该相应的变化,那么此时所有相关的处理逻辑均应该在同一个方法中完成。在本例中,手机号修改了之后,应该同时将Member标记为“手机号已记录”状态(mobileIdentified ),因此对mobileIdentified 的修改应该与对mobile的修改放在同一个chagneMyMobile()方法中。在DDD中,这也称为不变条件(Invariants)。
  3. 在实现领域逻辑的过程中,还会随之产生领域事件(Domain Event),由于领域事件也是领域模型的一部分,因此一种做法是领域对象在完成业务操作之后,还应发出领域事件,即本例中的raiseEvent(new MobileChangedEvent(this.getId(), mobile));更多关于领域事件的内容,请参考这里
  4. 领域对象不能持有或引用其他类型的对象,包括应用服务,领域服务,资源库等,因为领域对象只是根据业务逻辑的运算完成对业务数据的更新,也即领域对象应该建模为POJO(Plain Old Java Object)。
  5. 同理于应用服务,Member.changeMobile()方法是个写操作,不能返回任何数据。

总结 #

在文本中我们看到,哪怕是一个诸如“用户修改手机号”这样简单的需求,在整个实现过程中需要考虑的点也达到了将近30个,真实情况只会多不会少,比如我们可能还需要考虑性能、缓存和认证等众多非功能性需求等。因此,后端开发绝非CRUD这么简单,而是需要将诸多因素考虑在内的一个系统性工程,还是那句话,有讲究的编程并不是一件易事。