我在做一款浏览器端的3D可视化项目时,遇到一个绕不开的需求:玩家的存档记录、操作日志和实时排行榜数据要落到数据库里。项目本身是Unity WebGL打包后运行在浏览器中的,数据后端选定的是ASP.NET Web API,数据库用的是SqlServer。这个组合听起来很常规,但真正动手之后才发现,WebGL端连数据库这件事,和平时做客户端原生开发完全是两码事。这篇文章把我从架构设计到最终调通的全过程写出来,包括环境准备、接口封装、跨域安全、以及我在实测里踩过的几个坑,希望能给准备走这条路的读者省点时间。
1. 为什么Unity WebGL不能直连SqlServer:架构要想清楚
1.1 浏览器沙箱到底拦了什么
很多刚接触WebGL的同学第一反应是:我在Unity里能用SqlConnection连数据库,为什么打包成WebGL就不行了?
这个问题的答案要从Unity WebGL的运行时机制说起。Unity WebGL不是把C#编译成IL再交给.NET运行时执行,而是通过Emscripten把整个游戏逻辑编译成JavaScript和WebAssembly,跑在浏览器的沙箱环境里。沙箱环境下,网页代码拿不到操作系统的原生网络套接字,不能像桌面客户端那样随便建立一个到某个IP端口的TCP连接。SqlClient驱动连SqlServer走的是TDS协议,底层依赖原生套接字和完整的.NET网络栈,这些在浏览器里都不存在。所以第一个原因很直接:技术上根本做不到。
第二个原因是安全模型。浏览器有一套同源策略,你部署在A域名上的网页,默认情况下不能随便向B域名的接口发请求,更不能让页面里的脚本拿到数据库连接串之后直连数据库。如果允许这么做,任何网页都能通过浏览器去扫内网、连数据库,后果不堪设想。
第三个原因是连接字符串的泄露风险。如果真把连接串写进Unity代码再打包成WebGL,发布出去的JS文件里会明文存在。任何人打开浏览器按一下F12,就能看到你的服务器地址、库名、用户名和密码。这已经不是“能不能连”的问题,而是“连了等于裸奔”。
所以对于Unity WebGL项目来说,直连数据库这条路被堵死不是坏事,它逼着你走一条更安全、更清晰的架构路径。
1.2 中间层数据桥梁的基本模型
最终采用的架构是一个经典的三层模型:
Unity WebGL前端 -> HTTP/HTTPS请求 -> ASP.NET Web API -> Dapper/ADO.NET -> SqlServer -> JSON返回 -> Unity解析展示
整个链路中,Unity端只和HTTP接口打交道。它发送请求,拿到JSON,然后反序列化成自己的数据模型。数据库连接串、SQL语句、账号密码全部留在服务端,WebGL客户端完全看不到。
这个模型有几个实际优势。首先是安全,敏感数据不出服务器,连接串不会暴露;其次是解耦,前端数据结构可以随时调整,只要API入参出参不变,前端和后端各改各的都能跑;最后是可扩展,后续如果要做原生客户端版本、微信小游戏版本甚至H5版本,它们都只需要复用同一套API。
当时也考虑过WebSocket方案,用来做实时排行榜推送。WebGL下WebSocket确实可用,但它的容错处理比REST复杂,断线重连、心跳机制都要自己实现,对早期项目来说维护成本偏高。最终决定还是以REST为主,如果有实时需求再做单独WebSocket服务,两个服务职责分离,出问题容易排查。
1.3 为什么选ASP.NET Web API
也想过用Node.js写接口、用PHP、甚至用云函数,但最终选了ASP.NET Web API。理由主要有三点:
第一,团队本来就熟悉C#,Unity端和服务端能共用一套DTO设计思路。虽然实际上因为序列化字段命名问题还是要单独处理,但心智负担比再引入一门后端语言小得多。
第二,微软官方对SqlServer的支持最成熟。连接字符串、驱动、监控工具都是现成的,出了任何问题搜资料都不难,第三方生态也很完整。
第三,ASP.NET Core本身跨平台,后续从Windows服务器迁移到Linux服务器成本很低,不绑定特定操作系统。
下面的内容都基于“ASP.NET Core Web API + Dapper + SqlServer”这套组合展开。服务端和前端两部分我会分别讲清楚。
2. 服务端准备:搭一个能跑的Web API并接上数据库
2.1 环境与项目创建
我用的环境是Visual Studio 2022,安装了“ASP.NET和Web开发”工作负载。数据库是SQL Server 2019。Unity版本是2022 LTS。
创建项目的步骤:
- 打开VS,新建项目,选择“ASP.NET Core Web API”模板。
- 框架选择.NET 8,认证类型选择“无”。
- 不勾选“配置HTTPS”以外的多余选项,项目模板自带的WeatherForecast控制器先留着。
创建完之后,先跑一遍确认默认端口能打开Swagger页面。Swagger在开发阶段挺好用,它能直接看到接口接收什么参数、返回什么结构,后面Unity端联调之前,我会先用Swagger把接口行为固定住。
然后安装NuGet包:
Install-Package Dapper Install-Package System.Data.SqlClientDapper是我专门选的,不是EF Core。这个项目的数据库操作很轻,几张小表,主要是排行榜查询、玩家数据查改、日志写入。Dapper写SQL直观,性能也好,映射能力足够用。如果项目里有几十张表、大量复杂关联查询和迁移需求,EF Core会更合适。技术选型没有绝对标准,适合自己的团队规模和项目复杂度才是关键。
2.2 连接串与仓储层写法
打开appsettings.json,把连接串放进去。注意,这是开发阶段的本地连接串,最终发布到服务器后会被环境变量替换掉。
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "ConnectionStrings": { "DefaultConnection": "Server=localhost;Database=GameDB;User Id=game_api;Password=DevOnlyPwd;TrustServerCertificate=True;" } }然后建仓储类。我习惯在项目里建一个Data目录,把对数据库的操作都收拢到Service层,Controller不直接碰SqlConnection。
示例:查询积分榜前N名。
public class LeaderboardService { private readonly string _connStr; public LeaderboardService(IConfiguration config) { _connStr = config.GetConnectionString("DefaultConnection"); } public async Task<IEnumerable<PlayerScore>> GetTopPlayersAsync(int topN) { const string sql = @" SELECT PlayerName, Score, UpdatedAt FROM PlayerScores ORDER BY Score DESC OFFSET 0 ROWS FETCH NEXT @TopN ROWS ONLY"; using var conn = new SqlConnection(_connStr); return await conn.QueryAsync<PlayerScore>(sql, new { TopN = topN }); } }PlayerScore是一个简单的DTO类,属性名和表字段名保持一致,Dapper会自动映射。这里没有用SELECT *,只取需要的字段,明确知道这条SQL会返回什么,也防止表结构变更时隐形炸掉。
2.3 控制器接口设计与Swagger验证
控制器里只做参数校验和结果包装,不写业务逻辑。
[ApiController] [Route("api/[controller]")] public class LeaderboardController : ControllerBase { private readonly LeaderboardService _leaderboardService; public LeaderboardController(LeaderboardService leaderboardService) { _leaderboardService = leaderboardService; } [HttpGet("top/{topN}")] public async Task<IActionResult> GetTopPlayers(int topN) { if (topN <= 0 || topN > 100) { return BadRequest(new { code = 1, message = "topN必须在1到100之间", data = (object)null }); } var data = await _leaderboardService.GetTopPlayersAsync(topN); return Ok(new { code = 0, message = "success", data }); } }别忘了在Program.cs里注册服务:
builder.Services.AddSingleton<IConfiguration>(builder.Configuration); builder.Services.AddScoped<LeaderboardService>();为什么用AddScoped而不是AddSingleton?因为Scoped是每次请求创建一个实例,能避免单例对象长期持有过期连接串或数据库上下文,也避免一些线程安全问题。Web API的并发模式下,Singleton持有SqlConnection这类非线程安全对象很容易出诡异错误,Scoped更省心。
烧录控制器之后,用Swagger直接调用 GET /api/Leaderboard/top/10,能看到JSON数组返回,说明服务端到数据库这条链路已经通了。
有个细节值得强调:接口的返回结构最好统一包一层。比如下面这样:
{ "code": 0, "message": "success", "data": [] }Unity端只需要解析固定的外层壳,data里面放业务数据。后面加错误码、加提示信息都方便,不会把解析逻辑分散得到处都是。
3. Unity WebGL端请求封装:从UnityWebRequest到回调
3.1 选型分析:为什么不用HttpClient
Unity开发者的直觉是用System.Net.Http的HttpClient,但WebGL平台是个例外。
WebGL构建下,HttpClient走的是浏览器内部的XMLHttpRequest或Fetch桥接。大部分时候能用,但高级特性可能不稳定,比如自定义超时精度、重定向处理、取消机制等等。还有一个更麻烦的点:HttpClient不能天然配合MonoBehaviour的协程生命周期。场景切换、对象销毁时,挂起的请求怎么处理要自己管,很容易漏。
更稳妥的是Unity自带的UnityWebRequest。它在WebGL平台有专门实现,能正确处理浏览器端的跨域请求,和协程结合得非常自然。Unity官方也推荐优先用它。
3.2 一个通用的Get/Post封装类
我写了一个HttpUtil静态类,统一管理请求超时、公共Header和返回体解析。核心思路是用协程加yield return机制等待请求完成,然后把结果通过回调函数抛给业务层。
using System; using System.Collections; using System.Text; using UnityEngine; using UnityEngine.Networking; public static class HttpUtil { private const float DefaultTimeout = 20f; public static void GetAsync<T>(string url, Action<T> onSuccess, Action<string> onError) where T : class { GameRoot.Instance.StartCoroutine(GetCoroutine(url, onSuccess, onError)); } public static void PostAsync<T>(string url, string jsonBody, Action<T> onSuccess, Action<string> onError) where T : class { GameRoot.Instance.StartCoroutine(PostCoroutine(url, jsonBody, onSuccess, onError)); } private static IEnumerator GetCoroutine<T>(string url, Action<T> onSuccess, Action<string> onError) where T : class { using var request = UnityWebRequest.Get(url); request.timeout = DefaultTimeout; yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { onError?.Invoke(request.error); yield break; } try { var json = request.downloadHandler.text; var result = JsonUtility.FromJson<T>(json); onSuccess?.Invoke(result); } catch (Exception ex) { onError?.Invoke("解析失败: " + ex.Message); } } private static IEnumerator PostCoroutine<T>(string url, string jsonBody, Action<T> onSuccess, Action<string> onError) where T : class { var payload = Encoding.UTF8.GetBytes(jsonBody); using var request = new UnityWebRequest(url, "POST"); request.uploadHandler = new UploadHandlerRaw(payload); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); request.timeout = DefaultTimeout; yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { onError?.Invoke(request.error); yield break; } var responseJson = request.downloadHandler.text; var result = JsonUtility.FromJson<T>(responseJson); onSuccess?.Invoke(result); } }几个关键点:
- 协程必须挂在一直存在的对象上。我项目里做了一个GameRoot单例,专门负责承载所有全局协程,避免场景对象销毁导致请求中断。
- using var request保证请求结束时释放底层资源。WebGL里资源泄漏会直接影响页面稳定性,这个不能省。
- request.error只有在失败时才有效,不要一开始就访问request.responseCode。
- 设置Header必须在调用SendWebRequest之前,这一点在WebGL平台尤其严格。
3.3 Newtonsoft.Json与Unity内置JsonUtility的选择
这里有个天坑。Unity内置的JsonUtility存在两个硬伤:一是字段名必须和C#类成员严格匹配,二是不能直接反序列化字典,以及顶层是数组的JSON。
API返回的data经常是一个数组,JsonUtility直接跪。所以我的做法分两种场景:简单的单对象返回用JsonUtility,够用;包含数组、字典或复杂嵌套的情况用Newtonsoft.Json。
Unity 2022之后,官方包管理器可以直接安装com.unity.nuget.newtonsoft-json包,不用再费劲去网上找dll塞Plugins目录。这个包和构建环境结合得好,AOT裁剪问题也被官方处理过,优先级很高。
使用Newtonsoft.Json反序列化列表的写法:
var response = JsonConvert.DeserializeObject<ApiResponse<List<PlayerScore>>>(json);注意C#属性名和JSON字段名的匹配问题。Web API默认返回的JSON字段名是按C#属性名原样输出的,比如PlayerName。Newtonsoft在反序列化时默认大小写敏感匹配。我的习惯是:服务端在返回时统一用驼峰命名(playerName),Unity端DTO里的字段也用小写开头,两边从一开始就对齐,省得后面改字段时到处改。
Unity端的DTO长这样:
[Serializable] public class ApiResponse<T> { public int code; public string message; public T data; } [Serializable] public class PlayerScore { public string playerName; public int score; public DateTime updatedAt; }服务端也返回同样结构的对象,两边约定清晰,解析时就省心很多。
3.4 回调线程问题与协程生命周期
UnityWebRequest的回调跑在主线程上,这是它的优势。回调里可以直接操作UI和游戏对象,不用像HttpClient那样担心跨线程访问Unity对象的问题。
但协程有个生命周期坑:如果发起请求的MonoBehaviour被销毁,协程也会被中止,回调就不会触发。严格来说不是“不会触发”,而是协程被Unity在对象销毁后自行中断,回调逻辑自然执行不到。
解决方式上面提过,用全局单例GameRoot.StartCoroutine发起所有网络请求。这样请求生命周期不绑定某个场景物体,哪怕切场景也没关系,回调会继续执行,然后由回调里自行判断当前游戏状态是否有效。
另外,请求结束拿到JSON之后,建议先检查code是否为0,再做业务处理。不要把错误处理散落在各个业务代码里,而是封装在HttpUtil里统一判断:
private static void HandleResponse<T>(ApiResponse<T> response, Action<ApiResponse<T>> onSuccess, Action<string> onError) where T : class { if (response == null) { onError?.Invoke("返回数据为空"); return; } if (response.code != 0) { onError?.Invoke(response.message); return; } onSuccess?.Invoke(response); }这样业务层只需要处理“拿到数据”这一种情况,错误都被提前拦掉了。
4. CORS、安全与发布:跑通本地只是开始
4.1 跨域问题到底长什么样
本地第一次联调时,WebGL页面跑在http://localhost:5500,API跑在http://localhost:5000,浏览器控制台直接报错,内容大致是这样:
Access to XMLHttpRequest at 'http://localhost:5000/api/...' from origin 'http://localhost:5500' has been blocked by CORS policy原因就是同源策略。浏览器认为协议、域名、端口三者中任何一个不同,就已经跨域了。WebGL游戏部署到线上之后,页面和API几乎不可能完全同源,所以服务端必须明确告诉浏览器:我允许哪些来源访问这个接口。
ASP.NET Core配置CORS很简单,在Program.cs里加:
builder.Services.AddCors(options => { options.AddPolicy("AllowUnityWebGL", policy => { policy.WithOrigins( "http://localhost:5500", "https://your-game-domain.com") .AllowAnyHeader() .AllowAnyMethod(); }); }); // 位置要在UseRouting和UseAuthorization之间,取决于具体版本 app.UseCors("AllowUnityWebGL");两个容易踩的坑:
- 开发阶段图省事可以用AllowAnyOrigin(允许所有来源),线上必须收紧到具体域名,否则等于向所有网站开放你的接口,任何人都能在自己的页面上调用你的API。
- 如果WebGL请求要携带Cookie或Authorization头,需要同时AllowCredentials;但AllowCredentials和AllowAnyOrigin不能同时使用,必须指定域名。这点配置错了会报一个很麻烦的CORS错误,浏览器控制台的提示往往不够直观。
4.2 连接字符串和密钥的存放
开发阶段连接串放在appsettings.json没问题。生产环境里,普遍做法是配置操作系统环境变量,代码读取时会自动覆盖JSON里的值。
具体操作:
- Windows服务器:在IIS应用池的“环境变量”或系统环境变量中添加 ConnectionStrings__DefaultConnection。
- Linux服务器:在部署脚本里export对应的环境变量。
关键点是,这个环境变量的名字里有两个下划线,ASP.NET Core的配置系统会自动把__映射为配置层级中的冒号,所以 ConnectionStrings__DefaultConnection 对应的就是 ConnectionStrings:DefaultConnection。这个细节很容易写错。
还有一条建议:SqlServer账号要单独建一个低权限账号,只给业务需要的表的SELECT、INSERT、UPDATE权限,不要用sa账号跑业务接口。万一接口出漏洞,影响面会小很多。
4.3 SQL注入防护与DTO校验
WebGL端传过来的参数,比如topN,服务端拼接SQL时必须参数化。Dapper天然支持匿名对象参数化,前面代码里new { TopN = topN }就是参数化的写法。如果你用ADO.NET原生写法,务必用SqlParameter,绝对不能用字符串拼接。
参数校验也不能省。数值参数要限范围,字符串参数要限制长度。这么做不只是防注入,也是防止恶意调用把数据库资源打满。比如topN如果允许传100000,一条SQL就能把服务器内存吃光。我在生产环境里见过这样的攻击,接口直接卡死。
如果接口需要身份验证,JWT是成本比较低的选择。流程是:
- 登录接口验证玩家身份,签发JWT。
- Unity端每次登录拿到token,后续请求在Authorization头里带上。
- Web API在需要认证的控制器或方法上打[Authorize]标签。
UnityWebRequest设置Header的写法:
using var request = new UnityWebRequest(url, "POST"); request.uploadHandler = new UploadHandlerRaw(payloadBytes); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Authorization", "Bearer " + token); request.SetRequestHeader("Content-Type", "application/json");这一步做掉之后,接口就不再是裸奔状态。
4.4 部署到IIS的配置要点
Web API部署到IIS,需要提前确认的事不少:
- 服务器上安装.NET 8 Hosting Bundle,这是运行ASP.NET Core应用的必要运行时。
- 站点应用池的托管模式设置为“无托管代码”。ASP.NET Core是自托管,不依赖IIS的托管运行时,这点和传统ASP.NET站点很不一样。
- 给应用池账号授予网站目录的读写权限,否则日志写不进去。
- 如果API和WebGL页面在同一个域下的不同路径部署,注意请求前缀别写错;如果完全在不同域名,服务端代码里的CORS配置必须包含实际域名。
到这里服务端基本站稳了,接下来是Unity端发布之后的排错,这部分坑其实最多。
5. 我实测中踩过的坑与最终调通的经验清单
5.1 500错误的定位思路
第一次从WebGL发Post请求,服务端一口一个500,回看Swagger里同样的参数却完全正常。这个问题排查了很久,后来发现是内容格式问题。
UnityWebRequest如果手动拼JSON字符串,必须确保Content-Type是application/json。我一开始没设置这个Header,服务端ModelBinding解析不了请求体,抛了空引用异常,返回500。这种错误在浏览器Network面板里能看到Payload有内容,但服务端读不到,其实就是请求头不对。
排查50x类错误,我遵循的顺序是:
- 先用Postman或Swagger确认同样参数的请求能通,排除SQL和业务逻辑问题。
- 打开浏览器开发者工具Network面板,对比失败请求和成功请求的Headers、Payload差异。
- 看服务端控制台或日志,直接看异常堆栈,不要只看响应文本里的那行错误信息。
- 检查统一返回包装,错误信息是否被外层成功响应吞掉了。
排查这类问题,最重要的不是拿到答案,而是建立自己的排查顺序。顺序对了,大部分问题几分钟内就能定位。
5.2 中文乱码与时间格式坑
WebGL端POST提交中文昵称,存进SqlServer之后变成问号。这个问题的根因往往是数据库和请求编码不一致。
解决方式要四层都检查:
- 数据库表字段必须用NVARCHAR,不要用VARCHAR。SQL Server里N前缀的字段才能正确存储Unicode字符。
- Web API要确保请求头Content-Type带charset=utf-8。
- Unity端上传的payload用UTF-8编码的字节数组,而不是默认的某种系统编码。
- 返回给前端的JSON同样要保证UTF-8。
时间格式方面,Newtonsoft默认序列化DateTime是ISO 8601字符串,Unity端解析时要注意时区换算。我的处理是:服务端统一存UTC时间,返回ISO字符串带Z后缀,Unity端拿到后调用ToLocalTime()转本地时间。千万别在两端各自用DateTime.Now,否则同一份数据在不同时区会出现8小时偏差。
5.3 发布后的路由404问题
本地好好的,发布到IIS之后请求接口404,这种情况很常见。
一个原因是子路径问题。如果API站点绑定在子应用路径,比如https://域名/playerapi/,那么前端请求就应该是:
https://api.example.com/playerapi/api/Leaderboard/top/10UnityWebRequest里必须写完整URL,别指望相对路径能帮你自动拼对。
另一个隐蔽问题是服务器上如果没有正确配置ASP.NET Core模块,请求会被IIS静态文件处理器接管,直接返回404。安装Hosting Bundle之后,发布出来的web.config里应该有ASP.NET Core Module的handler配置,如果这个文件被别人改过或者损坏,就会出现这种“本地正常、线上404”的现象。
5.4 请求超时与重试策略
WebGL在弱网条件下,UnityWebRequest默认20秒超时不一定够用。建议根据接口特点分开设置:
- 简单查询接口,超时15秒。
- 包含SQL统计或第三方调用的接口,超时30秒以上。
- 上传类的操作,单独评估。
Unity端要做超时重试,但必须用指数退避,不要用死循环。第一次失败等2秒再试,第二次等4秒,最多重试3次。原因是,如果接口本身已经慢了,客户端不断重试只会把服务端越拖越垮。
还有一个很容易混淆的点:UnityWebRequest在WebGL下result值为ConnectionError时,有可能是浏览器层面的网络异常,也有可能是CORS拦截。CORS拦截的报错往往表现为“Failed to fetch”或“NetworkError”,但其实是服务端跨域配置问题。这种时候去服务端日志看,反而看不到任何请求到达,因为请求在浏览器层面就被拦掉了。这个排查点我印象很深。
6. 后续再往前走:缓存、日志与请求管理的常规做法
6.1 接口性能优化
排行榜和静态配置类数据,建议在API层做内存缓存或者Redis缓存,别每次请求都打数据库。WebGL项目一旦上线,玩家同时在线,接口访问量往往比你预估的高一个量级。缓存能挡掉大部分重复查询。
分页查询用OFFSET FETCH或者键集分页,不要一次性把大数组丢给前端。Unity WebGL跑在浏览器里,内存是共享资源,一个巨大的JSON数组会让页面直接卡死。
实践中我建议普通列表接口限制最大返回条数,比如100条。超过100条就走分页。这样既能保护服务器内存,也不影响玩家体验。
6.2 日志与监控
生产环境一定要保留请求日志和SQL慢查询日志。我习惯在API中间件里记录RequestId、耗时、状态码,SqlServer端开启慢查询跟踪。这样线上问题基本能定位到是哪一层出的问题。
日志格式尽量统一,时间戳、接口路径、参数、耗时、异常堆栈都要记录。WebGL端也可以暴露出一个内部控制台,把HttpUtil的每次请求打到Unity的Debug日志里,方便在玩家设备上排查。
6.3 Unity端的统一请求管程
如果项目接口数量上来了,建议把HttpUtil扩展成带队列和优先级的请求管理器。比如排行榜请求的优先级高,日志上报的优先级低。低优先级的请求在页面繁忙时可以延迟发送,避免多个协程同时并发大量请求卡死浏览器。
WebGL是单线程页面环境,并发过高会直接影响游戏帧率。我曾经在项目里无脑并发发起几十个请求,结果页面掉帧严重,后来改成队列加限流才好转。
另外,对于同一个接口的短时间重复请求,可以做客户端去重。比如玩家快速点击排行榜刷新按钮,在第一次请求还没回来之前,忽略后续相同参数的请求,只保留一个。这个逻辑放在HttpUtil里,所有接口都自动生效。
最后再多说一句:WebGL项目里网络层是全局最容易被低估的部分。我现在的习惯是任何接口改动,都先在浏览器Network面板过一遍,确认Header、Payload、返回码完全符合预期,再进Unity端联调。这个习惯帮我少刷了非常多无意义的排查时间。