一、前言

  在《上篇》中,已经把项目整体结构规划做了个大概的规划。在本文中,将使用代码的方式来一一解说各个层次。由于要搭建一个基本完整的结构,可能文章会比较长。另外,本系列主要出于实用的目的,因而并不会严格按照传统的三层那样进行非常明确的层次职能划分。

二、需求说明

  在本系列中,为方便大家理解,将以一个账户管理的小系统来进行解说,具体需求如下:

  1. 用户信息分主要信息与扩展信息,一个用户可以有(或没有)一个用户扩展信息。
  2. 记录用户的登录记录,一个用户可以有多条登录记录,但登录记录所属用户唯一。
  3. 一个用户可以有多个角色,一个角色也可以分配给多个用户。

三、架构基础

 (一) 功能返回值

  对于一个操作性业务功能(比如添加,修改,删除),通常我们处理返回值的做法是使用简单类型,通常会有如下几种方案:

  1. 直接返回void,即什么也不返回,在操作过程中抛出异常,只要没有异常抛出,就认为是操作成功了
  2. 返回是否操作成功的bool类型的返回值
  3. 返回操作变更后的新数据信息
  4. 返回表示各种结果的状态码的返回值
  5. 返回一个自定义枚举来表示操作的各种结果
  6. 如果要返回多个值,还要使用 out 来添加返回参数

  这样做有什么不妥之处呢,我们来逐一分析:

  1. 靠抛异常的方式来终止系统的运行,异常是沿调用堆栈逐层向上抛出的,会造成很大的性能问题
  2. bool值太死板,无法表示出业务操作中的各种情况
  3. 返回变更后的数据,还要与原始数据来判断才能得到是否操作成功
  4. 用状态码解决了2的问题,但各种状态码的维护成本也会非常高
  5. 用枚举值一定程序上解决了翻译的问题,但还是要把枚举值翻译成各种情况的文字描述
  6. !@#¥%……&

  综上,我们到底需要一个怎样的业务操作结果呢?

  1. 要能表示操作的成功失败(废话)
  2. 要能快速表示各种操作场景(如参数错误,查询数据不存在,数据状态不满足操作要求等)
  3. 能返回附加的返回信息(如更新成功后有后续操作,需要使用更新后的新值)
  4. 最好在调用方能使用统一的代码进行返回值处理
  5. 最好能自定义返回的文字描述信息
  6. 最好能把返回给用户的信息与日志记录的信息分开

  再综上,显然简单类型的返回值满足不了需求了,那就需要定义一个专门用来封装返回值信息的返回值类,这里定义如下:

 1     /// <summary>
2 /// 业务操作结果信息类,对操作结果进行封装
3 /// </summary>
4 public class OperationResult
5 {
6 #region 构造函数
7
8 /// <summary>
9 /// 初始化一个 业务操作结果信息类 的新实例
10 /// </summary>
11 /// <param name="resultType">业务操作结果类型</param>
12 public OperationResult(OperationResultType resultType)
13 {
14 ResultType = resultType;
15 }
16
17 /// <summary>
18 /// 初始化一个 定义返回消息的业务操作结果信息类 的新实例
19 /// </summary>
20 /// <param name="resultType">业务操作结果类型</param>
21 /// <param name="message">业务返回消息</param>
22 public OperationResult(OperationResultType resultType, string message)
23 : this(resultType)
24 {
25 Message = message;
26 }
27
28 /// <summary>
29 /// 初始化一个 定义返回消息与附加数据的业务操作结果信息类 的新实例
30 /// </summary>
31 /// <param name="resultType">业务操作结果类型</param>
32 /// <param name="message">业务返回消息</param>
33 /// <param name="appendData">业务返回数据</param>
34 public OperationResult(OperationResultType resultType, string message, object appendData)
35 : this(resultType, message)
36 {
37 AppendData = appendData;
38 }
39
40 /// <summary>
41 /// 初始化一个 定义返回消息与日志消息的业务操作结果信息类 的新实例
42 /// </summary>
43 /// <param name="resultType">业务操作结果类型</param>
44 /// <param name="message">业务返回消息</param>
45 /// <param name="logMessage">业务日志记录消息</param>
46 public OperationResult(OperationResultType resultType, string message, string logMessage)
47 : this(resultType, message)
48 {
49 LogMessage = logMessage;
50 }
51
52 /// <summary>
53 /// 初始化一个 定义返回消息、日志消息与附加数据的业务操作结果信息类 的新实例
54 /// </summary>
55 /// <param name="resultType">业务操作结果类型</param>
56 /// <param name="message">业务返回消息</param>
57 /// <param name="logMessage">业务日志记录消息</param>
58 /// <param name="appendData">业务返回数据</param>
59 public OperationResult(OperationResultType resultType, string message, string logMessage, object appendData)
60 : this(resultType, message, logMessage)
61 {
62 AppendData = appendData;
63 }
64
65 #endregion
66
67 #region 属性
68
69 /// <summary>
70 /// 获取或设置 操作结果类型
71 /// </summary>
72 public OperationResultType ResultType { get; set; }
73
74 /// <summary>
75 /// 获取或设置 操作返回信息
76 /// </summary>
77 public string Message { get; set; }
78
79 /// <summary>
80 /// 获取或设置 操作返回的日志消息,用于记录日志
81 /// </summary>
82 public string LogMessage { get; set; }
83
84 /// <summary>
85 /// 获取或设置 操作结果附加信息
86 /// </summary>
87 public object AppendData { get; set; }
88
89 #endregion
90 }

  再定义一个表示业务操作结果的枚举,枚举项上有一个DescriptionAttribute的特性,用来作为当上面的Message为空时的返回结果描述。

 1     /// <summary>
2 /// 表示业务操作结果的枚举
3 /// </summary>
4 [Description("业务操作结果的枚举")]
5 public enum OperationResultType
6 {
7 /// <summary>
8 /// 操作成功
9 /// </summary>
10 [Description("操作成功。")]
11 Success,
12
13 /// <summary>
14 /// 操作取消或操作没引发任何变化
15 /// </summary>
16 [Description("操作没有引发任何变化,提交取消。")]
17 NoChanged,
18
19 /// <summary>
20 /// 参数错误
21 /// </summary>
22 [Description("参数错误。")]
23 ParamError,
24
25 /// <summary>
26 /// 指定参数的数据不存在
27 /// </summary>
28 [Description("指定参数的数据不存在。")]
29 QueryNull,
30
31 /// <summary>
32 /// 权限不足
33 /// </summary>
34 [Description("当前用户权限不足,不能继续操作。")]
35 PurviewLack,
36
37 /// <summary>
38 /// 非法操作
39 /// </summary>
40 [Description("非法操作。")]
41 IllegalOperation,
42
43 /// <summary>
44 /// 警告
45 /// </summary>
46 [Description("警告")]
47 Warning,
48
49 /// <summary>
50 /// 操作引发错误
51 /// </summary>
52 [Description("操作引发错误。")]
53 Error,
54 }

 (二) 实体基类

  对于业务实体,有一些相同的且必要的信息,比如信息的创建时间,总是必要的;再比如想让数据库有一个“回收站”的功能,以给数据删除做个缓冲,或者很多数据并非想从数据库中彻底删除掉,只是暂时的“禁用”一下,添加个逻辑删除的标记也是必要的。再有就是想给所有实体数据仓储操作来个类型限定,以防止传入了其他非实体类型。基于以上理由,就有了下面这个实体基类:

 1     /// <summary>
2 /// 可持久到数据库的领域模型的基类。
3 /// </summary>
4 [Serializable]
5 public abstract class Entity
6 {
7 #region 构造函数
8
9 /// <summary>
10 /// 数据实体基类
11 /// </summary>
12 protected Entity()
13 {
14 IsDeleted = false;
15 AddDate = DateTime.Now;
16 }
17
18 #endregion
19
20 #region 属性
21
22 /// <summary>
23 /// 获取或设置 获取或设置是否禁用,逻辑上的删除,非物理删除
24 /// </summary>
25 public bool IsDeleted { get; set; }
26
27 /// <summary>
28 /// 获取或设置 添加时间
29 /// </summary>
30 [DataType(DataType.DateTime)]
31 public DateTime AddDate { get; set; }
32
33 /// <summary>
34 /// 获取或设置 版本控制标识,用于处理并发
35 /// </summary>
36 [ConcurrencyCheck]
37 [Timestamp]
38 public byte[] Timestamp { get; set; }
39
40 #endregion
41 }

  这里要补充一下,本来实体基类中是可以定义一个表示“实体编号”的Id属性的,但有个问题,如果定义了,就限定了Id属性的数据类型了,但实际需求中可能有些实体使用自增的int类型,有些实体使用的是易于数据合并的guid类型,因此为灵活方便,不在此限制住 Id的数据类型。

四、架构分层

  具体的架构分层如下图所示:

 (一) 核心业务层

  根据 需求说明 中定义的需求,简单起见,这里只实现一个简单的用户登录功能:

  用户信息实体:

 1     /// <summary>
2 /// 实体类——用户信息
3 /// </summary>
4 [Description("用户信息")]
5 public class Member : Entity
6 {
7 /// <summary>
8 /// 获取或设置 用户编号
9 /// </summary>
10 public int Id { get; set; }
11
12 /// <summary>
13 /// 获取或设置 用户名
14 /// </summary>
15 [Required]
16 [StringLength(20)]
17 public string UserName { get; set; }
18
19 /// <summary>
20 /// 获取或设置 密码
21 /// </summary>
22 [Required]
23 [StringLength(32)]
24 public string Password { get; set; }
25
26 /// <summary>
27 /// 获取或设置 用户昵称
28 /// </summary>
29 [Required]
30 [StringLength(20)]
31 public string NickName { get; set; }
32
33 /// <summary>
34 /// 获取或设置 用户邮箱
35 /// </summary>
36 [Required]
37 [StringLength(50)]
38 public string Email { get; set; }
39
40 /// <summary>
41 /// 获取或设置 用户扩展信息
42 /// </summary>
43 public virtual MemberExtend Extend { get; set; }
44
45 /// <summary>
46 /// 获取或设置 用户拥有的角色信息集合
47 /// </summary>
48 public virtual ICollection<Role> Roles { get; set; }
49
50 /// <summary>
51 /// 获取或设置 用户登录记录集合
52 /// </summary>
53 public virtual ICollection<LoginLog> LoginLogs { get; set; }
54 }

  核心业务契约:注意接口的返回值使用了上面定义的返回值类

 1     /// <summary>
2 /// 账户模块核心业务契约
3 /// </summary>
4 public interface IAccountContract
5 {
6 /// <summary>
7 /// 用户登录
8 /// </summary>
9 /// <param name="loginInfo">登录信息</param>
10 /// <returns>业务操作结果</returns>
11 OperationResult Login(LoginInfo loginInfo);
12 }

  核心业务实现:核心业务实现类为抽象类,因没有数据访问功能,这里使用了一个Members字段来充当数据源,业务功能的实现为虚方法,必要时可以在具体的客户端(网站、桌面端,移动端)相应的派生类中进行重写。请注意具体实现中对于返回值的处理。这里登录只负责最核心的登录业务操作,不涉及比如Http上下文状态的操作。

 1     /// <summary>
2 /// 账户模块核心业务实现
3 /// </summary>
4 public abstract class AccountService : IAccountContract
5 {
6 private static readonly Member[] Members = new[]
7 {
8 new Member { UserName = "admin", Password = "123456", Email = "admin@gmfcn.net", NickName = "管理员" },
9 new Member { UserName = "gmfcn", Password = "123456", Email = "mf.guo@qq.com", NickName = "郭明锋" }
10 };
11
12 private static readonly List<LoginLog> LoginLogs = new List<LoginLog>();
13
14 /// <summary>
15 /// 用户登录
16 /// </summary>
17 /// <param name="loginInfo">登录信息</param>
18 /// <returns>业务操作结果</returns>
19 public virtual OperationResult Login(LoginInfo loginInfo)
20 {
21 PublicHelper.CheckArgument(loginInfo, "loginInfo");
22 Member member = Members.SingleOrDefault(m => m.UserName == loginInfo.Access || m.Email == loginInfo.Access);
23 if (member == null)
24 {
25 return new OperationResult(OperationResultType.QueryNull, "指定账号的用户不存在。");
26 }
27 if (member.Password != loginInfo.Password)
28 {
29 return new OperationResult(OperationResultType.Warning, "登录密码不正确。");
30 }
31 LoginLog loginLog = new LoginLog { IpAddress = loginInfo.IpAddress, Member = member };
32 LoginLogs.Add(loginLog);
33 return new OperationResult(OperationResultType.Success, "登录成功。", member);
34 }
35 }

(二) 站点业务层

  站点业务契约:站点业务契约继承核心业务契约,即可拥有核心层定义的业务功能。站点登录验证使用了Forms的Cookie验证,这里的退出不涉及核心层的操作,因而核心层没有退出功能

 1     /// <summary>
2 /// 账户模块站点业务契约
3 /// </summary>
4 public interface IAccountSiteContract : IAccountContract
5 {
6 /// <summary>
7 /// 用户登录
8 /// </summary>
9 /// <param name="model">登录模型信息</param>
10 /// <returns>业务操作结果</returns>
11 OperationResult Login(LoginModel model);
12
13 /// <summary>
14 /// 用户退出
15 /// </summary>
16 void Logout();
17 }

  站点业务实现:站点业务实现继承核心业务实现与站点业务契约,负责把从UI中接收到的视图模型信息转换为符合核心层定义的参数,并处理与网站状态相关的Session,Cookie等Http相关业务。

  在这里需要注意的是,目前的项目中并没有加入IOC组件来对层与层之间进行解耦,在上层调用下层的时候,我们仍然以如下方式来进行实例化:

1 IAccountSiteContract accountContract = new AccountSiteService();

  这会造成层与层之间紧耦合,在后面的文章中,会加入.NET自带的MEF组件进行层之间的解耦,到时层对象实现化的工作将由MEF来完成,就需要把 AccountSiteService 类的可访问性由 public 修改为 internal,以防止出现上面的实例化代码出现。

 1     /// <summary>
2 /// 账户模块站点业务实现
3 /// </summary>
4 public class AccountSiteService : AccountService, IAccountSiteContract
5 {
6 /// <summary>
7 /// 用户登录
8 /// </summary>
9 /// <param name="model">登录模型信息</param>
10 /// <returns>业务操作结果</returns>
11 public OperationResult Login(LoginModel model)
12 {
13 PublicHelper.CheckArgument(model, "model");
14 LoginInfo loginInfo = new LoginInfo
15 {
16 Access = model.Account,
17 Password = model.Password,
18 IpAddress = HttpContext.Current.Request.UserHostAddress
19 };
20 OperationResult result = base.Login(loginInfo);
21 if (result.ResultType == OperationResultType.Success)
22 {
23 Member member = (Member)result.AppendData;
24 DateTime expiration = model.IsRememberLogin
25 ? DateTime.Now.AddDays(7)
26 : DateTime.Now.Add(FormsAuthentication.Timeout);
27 FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(1, member.UserName, DateTime.Now, expiration,
28 true, member.NickName, FormsAuthentication.FormsCookiePath);
29 HttpCookie cookie = new HttpCookie(FormsAuthentication.FormsCookieName, FormsAuthentication.Encrypt(ticket));
30 if (model.IsRememberLogin)
31 {
32 cookie.Expires = DateTime.Now.AddDays(7);
33 }
34 HttpContext.Current.Response.Cookies.Set(cookie);
35 result.AppendData = null;
36 }
37 return result;
38 }
39
40 /// <summary>
41 /// 用户退出
42 /// </summary>
43 public void Logout()
44 {
45 FormsAuthentication.SignOut();
46 }
47 }

(三) 站点展现层

  MVC控制器:Action提供统一风格的代码来对业务操作结果OperationResult进行处理

 1     public class AccountController : Controller
2 {
3 public AccountController()
4 {
5 AccountContract = new AccountSiteService();
6 }
7
8 #region 属性
9
10 public IAccountSiteContract AccountContract { get; set; }
11
12 #endregion
13
14 #region 视图功能
15
16 public ActionResult Login()
17 {
18 string returnUrl = Request.Params["returnUrl"];
19 returnUrl = returnUrl ?? Url.Action("Index", "Home", new { area = "" });
20 LoginModel model = new LoginModel
21 {
22 ReturnUrl = returnUrl
23 };
24 return View(model);
25 }
26
27 [HttpPost]
28 public ActionResult Login(LoginModel model)
29 {
30 try
31 {
32 OperationResult result = AccountContract.Login(model);
33 string msg = result.Message ?? result.ResultType.ToDescription();
34 if (result.ResultType == OperationResultType.Success)
35 {
36 return Redirect(model.ReturnUrl);
37 }
38 ModelState.AddModelError("", msg);
39 return View(model);
40 }
41 catch (Exception e)
42 {
43 ModelState.AddModelError("", e.Message);
44 return View(model);
45 }
46 }
47
48 public ActionResult Logout( )
49 {
50 string returnUrl = Request.Params["returnUrl"];
51 returnUrl = returnUrl ?? Url.Action("Index", "Home", new { area = "" });
52 if (User.Identity.IsAuthenticated)
53 {
54 AccountContract.Logout();
55 }
56 return Redirect(returnUrl);
57 }
58
59 #endregion
60 }

  MVC 视图:

@model GMF.Demo.Site.Models.LoginModel
@{
ViewBag.Title = "Login";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h2>Login</h2>
@using (Html.BeginForm()) {
@Html.AntiForgeryToken()
@Html.ValidationSummary(true)
<fieldset>
<legend>LoginModel</legend>
<div class="editor-label">
@Html.LabelFor(model => model.Account)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Account)
@Html.ValidationMessageFor(model => model.Account)
</div>
<div class="editor-label">
@Html.LabelFor(model => model.Password)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Password)
@Html.ValidationMessageFor(model => model.Password)
</div>
<div class="editor-label">
@Html.LabelFor(model => model.IsRememberLogin)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.IsRememberLogin)
@Html.ValidationMessageFor(model => model.IsRememberLogin)
</div>
@Html.HiddenFor(m => m.ReturnUrl)
<p>
<input type="submit" value="登录" />
</p>
</fieldset>
}
<div>
@Html.ActionLink("Back to List", "Index", "Home")
</div>
@section Scripts {
@Scripts.Render("~/bundles/jqueryval")
}

  至此,整个项目构架搭建完成,运行结果如下:
  

在本篇中,网站的Controller是依赖于站点业务实现与核心业务实现的,在下一篇中,将使用.net 4.0自带的MEF作为IOC对层与层之间的依赖进行解耦。

五、源码下载

  GMFrameworkForBlog.zip

  为了让大家能第一时间获取到本架构的最新代码,也为了方便我对代码的管理,本系列的源码已加入微软的开源项目网站 http://www.codeplex.com,地址为:

  https://gmframework.codeplex.com/

  可以通过下列途径获取到最新代码:

  • 如果你是本项目的参与者,可以通过VS自带的团队TFS直接连接到 https://tfs.codeplex.com:443/tfs/TFS17 获取最新代码
  • 如果你安装有SVN客户端(亲测TortoiseSVN 1.6.7可用),可以连接到 https://gmframework.svn.codeplex.com/svn 获取最新代码
  • 如果以上条件都不满足,你可以进入页面 https://gmframework.codeplex.com/SourceControl/latest 查看最新代码,也可以点击页面上的 Download 链接进行压缩包的下载,你还可以点击页面上的 History 链接获取到历史版本的源代码
    • 如果你想和大家一起学习MVC,学习EF,欢迎加入Q群:5008599(群发言仅限技术讨论,拒绝闲聊,拒绝酱油,拒绝广告)
    • 如果你想与我共同来完成这个开源项目,可以随时联系我。

MVC 实用构架实战(一)——项目结构搭建的更多相关文章

  1. MVC实用构架设计(三)——EF-Code First(6):数据更新最佳实践

    前言 最近在整理EntityFramework数据更新的代码,颇有体会,觉得有分享的价值,于是记录下来,让需要的人少走些弯路也是好的. 为方便起见,先创建一个控制台工程,使用using(var db ...

  2. NET 项目结构搭建

    NET 项目结构搭建 我们头开始,从简单的单项目解决方案,逐步添加业务逻辑的约束,从应用逻辑和领域逻辑两方面考虑,从简单的单个项目逐步搭建一个多项目的解决方案.主要内容:(1)搭建应用逻辑和领域逻辑都 ...

  3. vue2项目结构搭建

    vue2项目结构初搭建与项目基本流程 一.开始项目结构搭建的重要性 决定项目是否能够健康成长 决定了项目是否利于多人协作开发 决定了项目是否利于后期维护 决定了项目是否性能良好 决定了代码是否重用率降 ...

  4. SpringMVC+Spring+mybatis项目从零开始--分布式项目结构搭建

    转载出处: SpringMVC+Spring+mybatis+Redis项目从零开始--分布式项目结构搭建 /** 本文为博主原创文章,如转载请附链接. **/ SSM框架web项目从零开始--分布式 ...

  5. 使用.NET 6开发TodoList应用(2)——项目结构搭建

    为了不影响阅读的体验,我把系列导航放到文章最后了,有需要的小伙伴可以直接通过导航跳转到对应的文章 : P TodoList需求简介 首先明确一下我们即将开发的这个TodoList应用都需要完成什么功能 ...

  6. 架构系列:ASP.NET 项目结构搭建

    我们头开始,从简单的单项目解决方案,逐步添加业务逻辑的约束,从应用逻辑和领域逻辑两方面考虑,从简单的单个项目逐步搭建一个多项目的解决方案.主要内容:(1)搭建应用逻辑和领域逻辑都简单的单项目 (2)为 ...

  7. vue2.0 仿手机新闻站(二)项目结构搭建 及 路由配置

    1.项目结构 $ vue init webpack-simple news $ npm install vuex vue-router axios style-loader css-loader -D ...

  8. 25、Flask实战第25天:项目结构搭建

    创建一个虚拟环境bbs,并安装flask框架 #cmd进入DOS窗口 mkvirtualenv bbs pip install flask 在本地磁盘D新建项目目录:bbs 打开pycharm,创建f ...

  9. 13: vue项目结构搭建与开发

    vue其他篇 01: vue.js安装 02: vue.js常用指令 03: vuejs 事件.模板.过滤器 目录: 1.1 初始化项目 1.2 配置API接口,模拟后台数据 1.3 项目整体结构化开 ...

随机推荐

  1. 微信小程序测试指南

    [本文出自天外归云的博客园] 微信小程序本地部署测试方法 下载微信开发者工具 让小程序管理员将测试人员的微信号添加开发者权限 本地设置hosts为测试环境hosts 打开微信web开发者工具并扫码登录 ...

  2. remmina如何上传文件到linux服务器

    1.安装filezilla 2. remmina中选择列表中的主机,点击“外部工具 -> filezilla-xfp.sh” 3. 弹出filezilla,输入主机,端口,用户名,密码,连接, ...

  3. Viewing the interface of your Swift code,查看Swift代码的头文件的三种方法

      Technical Q&A QA1914 Viewing the interface of your Swift code Q:  How do I view the interface ...

  4. Java多线程系列——线程阻塞工具类LockSupport

    简述 LockSupport 是一个非常方便实用的线程阻塞工具,它可以在线程内任意位置让线程阻塞. 和 Thread.suspend()相比,它弥补了由于 resume()在前发生,导致线程无法继续执 ...

  5. 根据IP获取国家

    国外的还算比较权威的IP地址库,而且免费,每天调用次数不超过1000免费.超过另收费. public string Ip2Country(string ip) { try { string url = ...

  6. c# 调用非托管c++dll 参数问题(转)

    在C#中调用C(C++)类的DLL的时候,有时候C的接口函数包含很多参数,而且有的时候这些参数有可能是个结构体,而且有可能是结构体指针,那么在C#到底该如何安全的调用这样的DLL接口函数呢?本文将详细 ...

  7. Scala学习笔记——断言和单元测试

    1.断言 assert(conditon)将在条件不成立的时候,抛出assertionError assert(conditon,explanation)讲在条件不成立的时候,抛出explanatio ...

  8. SQL SERVER EXPRESS 连接字符串

    Microsoft SQL Server Express Edition 为生成应用程序提供了一个简单的数据库解决方案.SQL Server Express Edition 支持完整的 SQL Ser ...

  9. 手动添加jar包到本地maven仓库(已测)ok

    很多时候我们需要的jar包在maven仓库上没有 我们可以自己找到这个jar包,但是怎么通过在pom文件配置使用呢? 需要我们手动将本地的jar包添加到本地maven仓库. 前提是已经配置好maven ...

  10. SpringBoot Docker Mysql安装,Docker安装Mysql

    SpringBoot Docker Mysql安装,Docker安装Mysql ================================ ©Copyright 蕃薯耀 2018年4月8日 ht ...