Asp.Net6.0学习笔记
原文链接:https://blog.csdn.net/jh_negit/article/details/130867719
1.项目
1.1创建
-
打开VS,选择新建项目,选择Asp.Net Core 空或Web应用,点击下一步;
-
配置项目名称、路径等信息,下一步;
-
选择框架版本,将身份验证类型设为无,取消配置HTTPS(H),创建
1.2启动
1.2.1VS启动
1.2.2控制台启动
-
打开项目路径bin->debug->net6.0(具体.net版本)文件夹,在地址栏中输入cmd;
-
打开控制台窗口,输入命令;
命令有两个:- 默认端口:
dotnet AspNetCore6.PracticalDemo.dll
- 指定端口:
dotnet AspNetCore6.PracticalDemo.dll --urls="http://*:5177"; dotnet AspNetCore6.PracticalDemo.dll --urls=http://localhost:5177--port=5177
- 默认端口:
- 说明:
AspNetCore6.PracticalDemo.dll:项目生成的dll文件;
5177:访问的端口号
1.3发布
1.3.1IIS
-
右键项目->发布->选择文件夹;
-
打卡IIS->网站->添加网站,输入指定内容,点击确定;其中物理路径为项目发布路径;
-
※注意事项:
发布后如果无法访问需要先安装对应的SDK和Hosting Bundle,安装后在控制面板会有对应的Service安装程序,IIS中的模块会新增AspNetCoreModuleV2模块。
2.MVC
2.2视图
2.2.1布局视图
类似与模板,可包含其他视图;
2.2.1.1使用
- 新建布局视图
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<title>@ViewBag.Title</title>
</head>
<body>
<div>
@RenderBody()
</div>
</body>
</html>
- 删除子页面中包含之外的内容;
- 标记子页面所使用的布局页;
@{
Layout = "~/Views/Shared/_Layout.cshtml";
ViewBag.Title = "学生详细信息";
}
<div>
姓名:@Model.Student.Name
</div>
<div>
邮箱:@Model.Student.Emial
</div>
<div>
班级名称:@Model.Student.ClassName
</div>
2.2.1.2Section使用
- 在布局页定义RenderSection;
@RenderSection("Scripts",false)
- 在子页面定义具体的引用。
@section Scripts{
<script src="~/js/MyScript.js"></script>
}
@section 后的Scripts必须与@RenderSection中的参数Scripts命名一致
2.2.2视图开始
视图开始的意义在于将子页面中的公共部分抽离到开始视图中,如子页面引用布局视图的代码,开始视图遵循就近原则,子页面会优先加载离自己最近的开始视图。
@{
Layout = "_Layout";
}
2.2.3视图导入
视图导入用于导入子页面公共命名空间以及其他指令,视图导入遵循就近原则,子页面会优先加载离自己最近的开始视图。
@using StudentManagement.Models
@using StudentManagement.ViewModels
2.3路由
MVC中路由有两种:常规路由和属性路由;
- 常规路由
默认使用app.UseMvcWithDefaultRoute()添加路由规则:Home/Index/1;
可以手动配置为自己所需要的路由规则:
app.UseMvc(route => {
route.MapRoute("Default", "{Controller=Home}/{Action=Index}/{id?}");
});
Core3.1及之后版本:
app.UseRouting();
app.UseEndpoints(routes =>
{
routes.MapControllerRoute("default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
- 属性路由
2.4文件上传
- 在视图页添加上传文件控件;
@model StudentCreateViewModel
<form enctype="multipart/form-data" asp-controller="home" asp-action="create" method="post" class="mt-3">
<div class="form-group row mt-3">
<label asp-for="Photo" class="col-sm-2 col-form-label"></label>
<div class="col-sm-10">
<div class="custom-file">
<input asp-for="Photo" class="custom-control custom-file-input" />
</div>
</div>
</div>
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-primary">创建</button>
</div>
</div>
</form>
- 在控制器中处理将图片文件保存到指定路径;
public IActionResult Create(StudentCreateViewModel model)
{
if (ModelState.IsValid)
{
string uniqueFileName = null;
if (model.Photo != null)
{
// wwwroot\images路径
string uploadFile = Path.Combine(_hostingEnvironment.WebRootPath, "images");
//生成图片唯一值
uniqueFileName = Guid.NewGuid() + "_" + model.Photo.FileName;
//新文件全路径
string newFileName = Path.Combine(uploadFile, uniqueFileName);
//将图片复制到新文件中
using(FileStream stream=new FileStream(newFileName, FileMode.Create))
{
model.Photo.CopyTo(stream);
}
Student newStudent = new Student
{
Name = model.Name,
Email = model.Email,
ClassName = model.ClassName,
PhotoPath = uniqueFileName
};
_studentRepository.Add(newStudent);
return RedirectToAction("Details", new { id = newStudent.Id });
}
}
return View();
}
- 如果上传图片后,进程退出则按以下步骤设置即可;
a) 打开工具-选项-项目和解决方案
b) 选择web项目,将浏览器窗口关闭时停止调试程序,在调试停止时关闭浏览器取消选中;
3 中间件
3.1 静态文件
app.UseStaticFiles();
3.2 默认文件
- 使用默认文件
app.UseDefaultFiles();
- 修改默认文件
DefaultFilesOptions defaultFilesOptions = new DefaultFilesOptions();
defaultFilesOptions.DefaultFileNames.Clear();
defaultFilesOptions.DefaultFileNames.Add("MyPage.html");
app.UseDefaultFiles(defaultFilesOptions);
3.3 404找不到页面
3.3.1 添加中间件
当找不到路由时,加载默认错误页面
app. UseStatusCodePages();
3.3.2重定向
- UseStatusCodePagesWithRedirects
找不到路由时重定向到指定的路由,该中间件会覆盖原始请求路径;
app. UseStatusCodePagesWithRedirects ("/Error/{0}");
- UseStatusCodePagesWithReExecute
找不到路由时重定向到指定的路由,该中间件不会覆盖原始请求路径,建议使用该中间件;
app. UseStatusCodePagesWithReExecute ("/Error/{0}");
3.3.3 添加Error控制器及方法
public class ErrorController: Controller
{
[Route("/Error/{statusCode}")]
public IActionResult HttpStatusCodeHandler(int statusCode)
{
switch (statusCode)
{
case 404:
ViewBag.ErrorMessage = "抱歉,你访问的页面不存在";
break;
}
return View("NotFound");
}
}
3.3.4添加404页
3.4错误页面
当发生异常时会跳转到指定error视图中。
- 添加UseExceptionHandler中间件
app.UseExceptionHandler("/Error");
- 添加Error控制器及方法
public class ErrorController: Controller
{
[AllowAnonymous]//允许匿名访问(即不需要登录也可以访问)
[Route("Error")]
public IActionResult Error()
{
var exceptionHandle= HttpContext.Features.Get<IExceptionHandlerPathFeature>();
ViewBag.ExceptionPath = exceptionHandle.Path;//异常路径
ViewBag.ErrorMessage= exceptionHandle.Error.Message;// 异常信息
ViewBag.ErrorStackTrace= exceptionHandle.Error.StackTrace;// 异常堆栈
return View();
}
}
3.添加具体Error页面
注入服务
- 中文乱码
builder.Services.AddControllersWithViews().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All);
});
- Session
builder.Services.AddSession();
工具
客户端工具LibMan
轻量级的客户端管理工具,可以从CND下载客户端和框架;
注:必须在VS2017版本15.8及以上可以使用。
安装Bootstrap
- 右键wwwroot文件夹,选择新建-客户端;
- 打开添加客户端窗口,输入twitter-bootstrap,安装;
- 等待下载完成即可。
TagHelper
引入
在视图中引入TagHelper:
@addTagHelper *,Microsoft.AspNetCore.Mvc.TagHelpers
使用
- asp-controller:控制器名
- asp-action:方法名
- asp-route-id:参数
- asp-append-version
属性设置为true时,会为图片标记一个特有的hash值,作用是当本机的值变化时才从服务器中重新读取图片。 - asp-for
<label asp-for="Name"> </label>
默认会生成Id=Name,Name=Name,该Name为Model中的具体属性;
- asp-items
asp-items="Html.GetEnumSelectList<ClassNameEnum>()"
用于生成ClassNameEnum类型的的选择项;
- asp-validation-summary
用于模型验证,值分别为All、ModelOnly和None;
注解
HttpGet和HttpPost
该注解用于请求时设置请求方法为Get或者Post方式,当一个控制器中有两个同名方法,但请求方式不同时则用该注解,默认为HttpGet。
[HttpGet]
public IActionResult Create()
{
return View();
}
[HttpPost]
public RedirectToActionResult Create(Student student)
{
_studentRepository.Add(student);
return RedirectToAction("Details",new { id = student.Id });
}
Required
用于模型验证。
依赖注入
注入实例
Singleton
services.AddSingleton<IStudentRepository, SQLStudentRepository>();
Scoped
services.AddScoped<IStudentRepository, SQLStudentRepository>();
Transient
services.AddTransient<IStudentRepository, SQLStudentRepository>();
热更新
当cshtml页更改后实时更新网站中的内容而不用重新启动项目;
services.AddMvc().AddRazorOptions(options => options.AllowRecompilingViewsOnFileChange = true);
EF Core
SqlServer
使用
-
右键项目,选择Nuget管理,搜索Microsoft.EntityFrameworkCore.SqlServer;
-
选择指定的版本,安装;
-
新建Context类继承DbContext;
public class AppDbContext:DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options):base(options)
{
}
public DbSet<Student> Students { get; set; }//模型对应表文件
}
对于该类中使用的每个实体都需要添加DbSet 属性,将此属性查询和保存TEntity的实例
4. 连接SqlServer
//注入DbContext
services.AddDbContextPool<AppDbContext>(
options => options.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=MyDB-2;Trusted_Connection=True")
) ;
Trusted_Connection=True表示使用Windows认证。
连接字符串建议存放在appsettings.json或其他配置文件中,使用如下
services.AddDbContextPool<AppDbContext>(
options => options.UseSqlServer(Configuration.GetConnectionString("StudentDBConnection"))
) ;
该服务必须在builder.Build()之前注册,否则会报错
在appsetting.json文件中添加以下连接字符串
"ConnectionStrings": {
"StudentDBConnection": "Server=(localdb)\\mssqllocaldb;Database=StudentDB;Trusted_Connection=True"
//连接本地数据库
BlogDBConnection": "Data Source=DESKTOP-EK117L1\\MYSQLSERVER;Database=MyBlog;uid=sa;pwd=123456;Encrypt=True;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=true"
}
数据库迁移
-
get-help about_entityframeworkcore
使用该命令获取命令帮助。 -
Add-Migration
使用该命令添加数据库迁移(默认执行最新的迁移文件),输入Name(迁移名称,自定义) ,命令执行成功后会在项目中生成快照。 -
update-database
更新到数据库; -
删除已更新至数据库的内容
添加种子数据
- 重写DbContext中的OnModelCreating方法;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Student>().HasData(
new Student
{
Id = 1,
Name = "孙悟空",
ClassName = ClassNameEnum.FirstGrade,
Email = "wukong@163.com"
}
);
}
- 在Nugget控制台中执行add-Migration seedStudentsTabls命令,seedStudentsTabls为自定义名称,用于表示当前迁移的作用名称
- 在Nugget控制台中执行update-database命令,更新数据库数据。
数据操作
新增
public Student Add(Student student)
{
_dbContext.Students.Add(student);
_dbContext.SaveChanges();
return student;
}
删除
public Student Delete(int id)
{
var student=_dbContext.Students.Find(id);
if (student != null)
_dbContext.Students.Remove(student);
return student;
}
修改
public Student Update(Student uStudent)
{
var stu = _dbContext.Students.Attach(uStudent); ;
stu.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
_dbContext.SaveChanges();
return uStudent;
}
查询
public IEnumerable<Student> GetAllStudents()
{
return _dbContext.Students;
}
日志
ILogger
使用步骤
- 在需要使用日志的地方注入ILogger;
在Error控制器中注入ILogger接口;
public class ErrorController: Controller
{
private ILogger<ErrorController> _logger;
public ErrorController(ILogger<ErrorController> logger)
{
_logger = logger;
}
}
- 写日志;
在指定方法中写入日志
[Route("Error")]
public IActionResult Error()
{
_logger.LogError("发生了一个异常,请检查!");
return View();
}
Nlog
使用步骤
-
打开NuGet扩展工具,搜索NLog.Web.AspNetCore,选择需要的版本点击安装;
-
新建nlog.config文件;
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- 要写入的目标内容 -->
<targets>
<!-- 将日志写入文件的具体位置 -->
<target name="allfile" xsi:type="File"
fileName="D:\Cache\StudentManagement\Logs\nlog-all-${shortdate}.log"/>
</targets>
<!-- 将日志程序名称映射到目标的规则 -->
<rules>
<!--记录所有日志,包括Microsoft级别-->
<logger name="*" minlevel="Information" writeTo="allfile" />
</rules>
</nlog>
- 在Program.cs中注册NLog;
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args).ConfigureLogging(
(hostingContext, logging) =>{
//覆盖原有的日志记录启用NLog
logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"));
logging.AddConsole();
logging.AddDebug();
logging.AddEventSourceLogger();
//启动NLog作为记录日志的程序之一
logging.AddNLog();
})
.UseStartup<Startup>();
- 注入ILogger接口
public class ErrorController: Controller
{
private ILogger<ErrorController> _logger;
public ErrorController(ILogger<ErrorController> logger)
{
_logger = logger;
}
}
- 写日志
_logger.LogInformation("这是NLogger信息日志");
AOP-Filter
共包含六种:权限验证、资源缓存、方法前后的记录、结果生成前后扩展、响应结果的补充、异常处理
权限验证
角色授权
- 配置鉴权和授权
builder.Services.AddAuthentication(option =>
{
option.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;//Cookie方式
option.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
option.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
option.DefaultForbidScheme = CookieAuthenticationDefaults.AuthenticationScheme;
option.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme,option=> {
//如果未找到用户Cookie,鉴权失败,跳转至指定Action中
option.LoginPath = "/Account/Login";
//如果用户登录成功,但是角色没有指定权限则跳转至该Action中
option.AccessDeniedPath = "/Error/NoAuthority";
});
- 添加鉴权和授权中间件
app.UseAuthentication();//鉴权
app.UseAuthorization();//授权
如果路由配置app.UseRouting()和app.UseEndpoints(...)同时存在,则该中间件必须放在app.UseRouting()和app.UseEndpoints(...)之间,否则会报错
- 标记授权的Action
//Authorize:表示用户必须登录
//Roles= "Admin":标记用户角色必须为Admin时才可以访问
[Authorize(AuthenticationSchemes= CookieAuthenticationDefaults.AuthenticationScheme, Roles= "Admin")]
public IActionResult Index(){
return View();
}
- 鉴权不通过跳转的指定Action
[HttpPost]
public async Task<IActionResult> Login(User model)
{
if (ModelState.IsValid)
{
if (model.Name == "chongzi" && model.Password == "1")
{
//以下Claim可以写入任意数据
var claims = new List<Claim>()//用于鉴别当前用户相关信息
{
new Claim("Userid","1"),
new Claim(ClaimTypes.Role,"Admin"),//标记该用户角色为Admin
new Claim(ClaimTypes.Name,$"{model.Name}-来自于Cookies'"),//标记该用户名
new Claim(ClaimTypes.Email,$"18829206496@163.com"),//标记该用户邮箱
new Claim("password",model.Password),
new Claim("Account","Administrator"),
new Claim("role","admin")
};
ClaimsPrincipal userPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Customer"));
HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, userPrincipal, new AuthenticationProperties
{
ExpiresUtc = DateTime.UtcNow.AddMinutes(1)//过期时间为1分钟
}).Wait();
var user = HttpContext.User;
return base.Redirect("/Home/Index");
}
}
return View();
}
- 如果登录成功,但是角色不匹配则跳转至AccessDeniedPath 指定的路径
注意事项
- 当同时标记多个Authorize时,若有多个角色Role,则用户必须同时拥有指定角色,才可以正常访问;
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme, Roles = "Admin")]
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme, Roles = "Teacher")]
- 当只标记一个Authorize时,用户只需满足其中某一个角色都可以正常访问。
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme, Roles = "Admin,Teacher")]
策略授权
- 添加策略
builder.Services.AddAuthorization(option =>
{
//定义名为MyPolicy的策略
option.AddPolicy("MyPolicy", policy =>
{
policy.RequireRole("Admin,User,Teacher");//验证角色必须包含Admin或User或Teacher
policy.RequireUserName("chongzi1");//验证角色名字必须为chognzi
policy.RequireAssertion(handler =>//可添加不同逻辑
{
return handler.User.HasClaim(c => c.Type == ClaimTypes.Role);//是否存在角色
});
});
});
- 在Action中标注
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme,Policy = "MyPolicy")]
public IActionResult Index44()
{
return new JsonResult("/Home/Index4 View");
}
数据库或其他第三方授权
- 添加服务接口
public interface IUserService
{
bool Validata(string id, string qq);
}
- 定义类实现服务接口
public class UserService : IUserService
{
public bool Validata(string id, string qq)
{
//各种业务逻辑验证,如数据库、QQ、微信或其他第三方
return true;
}
}
- 定义Requirement类并实现IAuthorizationRequirement接口
public class QQEmailRequirement: IAuthorizationRequirement
{
}
- 定义Handler类实现AuthorizationHandler
public class QQHandler : AuthorizationHandler<QQEmailRequirement>
{
private IUserService _UserService;
public QQHandler(IUserService userService)
{
_UserService = userService;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, QQEmailRequirement requirement)
{
string id = context.User.Claims.First(c => c.Type == "Userid").Value;
string qq = context.User.Claims.First(c => c.Type == "QQ").Value;
if(_UserService.Validata(id, qq))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
- 注入需要的依赖
builder.Services.AddTransient<IUserService, UserService>();
builder.Services.AddTransient<IAuthorizationHandler, QQHandler>();
- 添加策略授权
builder.Services.AddAuthorization(option =>
{
option.AddPolicy("MyPolicy", policy =>
{
policy.AddRequirements(new QQEmailRequirement());
});
});
资源缓存
主要用于做缓存服务。
IResourceFilter
流程
- 自定义ResourceFilte类继承Attribute并实现IResourceFilter接口
public class MyResourceFilterAttribute : Attribute, IResourceFilter
{
//用于存储缓存内容
private static Dictionary<string, object> _DicCache = new Dictionary<string, object>();
//在访问资源之前调用
public void OnResourceExecuting(ResourceExecutingContext context)
{
string key = context.HttpContext.Request.Path;//当前请求路径
if(_DicCache.ContainsKey(key) )
{
//如果执行到此处,则直接返回到调用的地方,不会往后执行
context.Result =(IActionResult)_DicCache[key];
}
Console.WriteLine("Exceuted MyResourceFilterAttribute.OnResourceExecuting");
}
//在访问资源之后调用
public void OnResourceExecuted(ResourceExecutedContext context)
{
string key = context.HttpContext.Request.Path;//当前请求路径
_DicCache[key]= context.Result;
Console.WriteLine("Exceuted MyResourceFilterAttribute.OnResourceExecuted");
}
}
- 在指定Action中标注该特性
public class ResourceController: Controller
{
public ResourceController()
{
Console.WriteLine("ResourceController被构造了");
}
[MyResourceFilter]
public IActionResult Index()
{
Console.WriteLine("Exceuted ResourceController.Index");
return new JsonResult(new[] { "你好呀", "虫子不吃鸟" });
}
}
执行顺序
- MyResourceFilterAttribute.OnResourceExecuting();
- ResourceController构造方法;
- 指定Action:Index;
- MyResourceFilterAttribute.OnResourceExecuted();
IAsynResourceFilter
异步缓存
流程
- 自定义ResourceFilte类继承Attribute并实现IAsyncResourceFilter接口
public class MyAsyncResourceFilterAttribute : Attribute, IAsyncResourceFilter
{
//用于存储缓存内容
private static Dictionary<string, object> _DicCache=new Dictionary<string, object>();
public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
{
Console.WriteLine("Exceuted MyAsyncResourceFilterAttribute.OnResourceExecutionAsync Before");
string key = context.HttpContext.Request.Path;//当前请求路径
if (_DicCache.ContainsKey(key))
{
//如果执行到此处,则直接返回到调用的地方,不会往后执行
context.Result = (IActionResult)_DicCache[key];
}
else
{
ResourceExecutedContext resource = await next.Invoke();
_DicCache[key] = resource.Result;
Console.WriteLine("Exceuted MyAsyncResourceFilterAttribute.OnResourceExecutionAsync After");
}
}
}
- 在指定Action中标注该特性
public class ResourceController : Controller
{
public ResourceController()
{
Console.WriteLine("ResourceController被构造了");
}
[MyAsyncResourceFilter]
public IActionResult Index()
{
Console.WriteLine("Exceuted ResourceController.Index");
return new JsonResult(new[] { "你好呀", "虫子不吃鸟" });
}
}
执行顺序
- await next.Invoke()执行之前
- ResourceController构造方法;
- 指定Action:Index;
- await next.Invoke()执行之后
方法前后的记录
结果生成前后扩展
响应结果的补充
异常处理
反向代理
Ninginx
使用步骤
-
下载
Ninginx下载地址:http://nginx.org/en/download.html - 修改端口号
-
默认端口号为80,将其修改为其他值,打开下载路径\conf\nginx.conf文件:
在Server节点中修改listen的值即可 -
转发
- 单个转发
在server-location节点中添加 :proxy_pass http://localhost:9002;
- 多个转发
- 添加服务列表,在http节点之中,server节点之前添加以下内容
net6WebApi:服务列表名称,自定义upstream net6WebApi{ server localhost:9002 weight:1; server localhost:9003; server localhost:9004; }
weight:设置该服务器的请求权重,默认为轮询策略 - 在server-location节点中添加:
proxy_pass http://net6WebApi;
- 添加服务列表,在http节点之中,server节点之前添加以下内容
- 单个转发
-
启动
在nginx.exe所在目录中运行cmd,输入start nginx即可启动
指令
- 启动
start nginx
- 热加载
nginx -s reload
- 停止
nginx -s quit
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了