在Unity app中运行boardgame.io的尝试过程【暂时中途放弃】

为了与C/S模式运行进行对比,我们就用启用RemoteMaster的教程代码进行调试。

用vsCode调试教程Client

直接用Chrome本地打开index.html

为了调试,我们必须采用更强大的软件。这里用vsCode。先装好扩展Debugger for Chrome。

先用parcel在项目目录的dist子目录下生成全部文件。然后直接双击dist/index.html打开,chrome打开后一片空白。右键检查,发现index.html引用的js找不到,就是这一句:

<script src="/app.a6a4d504.js"></script>

加个.就可以了:

<script src="./app.a6a4d504.js"></script>

因为具体编译后产生的名字不固定,后文直接称app.*.js

vscode内置了代码格式化功能。在vscode中,可以通过按下键盘快捷键"Shift + Alt + F"来格式化选中的代码块,或者使用"Ctrl + Shift + P"快捷键调出命令面板,然后输入"Format Document"来格式化整个文件。

打开这个app.*.js,按F5运行,按提示增加launch.json,修改其url为:

"url""${workspaceFolder}/dist/index.html",

保存后再次F5,就会看到Chrome运行,并正常打开了index.html。

在app.*.js搜索字符串TicTacToeClient,跳转到对应代码,这就是我们要调试的教程代码了。

我们现在可以在这里设置断点进行调试,检查各种状态。

运行index.html+app.*.js

在TicTacToeClient构造函数设置断点。跟踪到

multiplayer: SocketIO({ server: 'localhost:8000' }),

这句话,F11进去,发现跳转到socketio-*.js的文件中去。vscode显示了这个文件的存放位置。

与之对比,用教程的npm start运行起来的chrome打开的http://localhost:1234/,检查Sources发现竟然所有文件都在

现在就尝试一下index.html+app.*.js能否正常运行:把dist下的这两个文件复制到其它任意目录,直接用Chrome打开,提示js文件找不到的错误。如前所述,需要修改index.html。然后再次直接用Chrome打开,一切正常运行!

这就说明依赖完全解除了,一个app.*.js已经包含了所有脚本。尽管其中有require("./transport-ce07b771.js");这样的代码!

现在可以直接在app.*.js中修改源代码,在教程任意地方下代码下断点调试,看自己想看的任何执行情况。

修改app.*.js 的文件名为app.js,并把index.html中的js引用改正,并且用vscode的功能对app.js进行格式化,然后把这个index.html+app.js作为模板保存下来。之后就可以在这个模板的基础上做改动。

编译后的app.js的结构分析

骨架:

parcelRequire = (function (modulescacheentryglobalName){…})({…}, {}, ["node_modules/parcel-bundler/src/builtins/hmr-runtime.js""src/app.js"], null)

这种函数被称为自运行函数。如果仅看调用就是parcelRequire = func({…}, {}, ["…", "…"], null)。func有4个参数。

func的函数体大约有100行代码。主要作用就是从func的第一个参数中分离出各个模块,并启动entry模块。

func的第一个参数modules对应的实参{…}则是整个app.js的实际内容,app.js一开头就说了:

// modules are defined as an array
// [ module function, map of requires ]

正确的意思是modules实参是个map,每个成员是模块名:array。每个array有两个成员,第一个成员是模块实体,第二个成员是require。例如:

"src/app.js": [function (require, module, exports) {
    …
    class TicTacToeClient {
      …
    }
    var gameclient = new TicTacToeClient(0);
  }, { "boardgame.io/client": "node_modules/boardgame.io/dist/esm/client.js", "boardgame.io/multiplayer": "node_modules/boardgame.io/dist/esm/multiplayer.js", "./Game": "src/Game.js" }]

require在这里又是一个模块简名到模块长名的map。

尝试在node而非浏览器中运行教程Client

最终目标是在js引擎中运行教程。现在这个阶段只能先尝试在node中运行。即js运行在node中而非浏览器中。

既然以C/S方式运行教程,那么用户界面这一块肯定是只能在控制台中考虑了。

直接在node中写教程Client

说明:这个是早期尝试,刚开始没有成功,没有搞清楚原因。后来在研究面向浏览器编译出的app.js的时候知道了原因,然后修正,然后成功。

早期不明所以,没有成功

一上来就遇到难题,node执行哪个js呢?应该是dist/index.htm所指定的js,然而dist目录比较复杂,这个目标js中的代码一团乱,看不懂,又怎么在将来从js引擎中调用呢?

想直接用源码的那个js,却发现它引用了各种js,这样直接用源码肯定是不现实的。

稍微研究了一番,觉得不用parcel,改用webpack打包试试。一试就发现webpack果然仅在dist目录中生成了一个main.js,它包含全部源码。

把教程app.js与HTML有关的语句全部去掉,然后在初始化完成后用console.log输出点信息。用node main.js运行发现没有连上服务器。复制个index.htm并把其js指向main.js,用chrome打开这个index.html,在console面板中发现能够连上服务器。

代码都是一样的,为什么main.js在node中的运行效果,与在chrome中的运行效果完全不同呢?

初步发现在node中,main.js最后一句执行完之后就没有下文了,windows控制台一直没有更新。app的onConnecting仅被调用一次(检查发现就是Client. subscribe的那一句执行之时),之后任何事件都没有了。如果用setInterval来不断检查client的状态,发现也没有任何更新。而在chrome中client是会更新的,且几次onConnecting之后,就会响应onConnected函数。

//这是在app.js中

var obj = new TicTacToeClient(0);

setInterval(() => {

      console.log('====app.js====', obj.client, obj.client.initialState);

    });

console.log('====app.js====', obj.client, obj.client.initialState);

我把类似console.log(obj.client, obj.client.initialState);这样的语句放到main.js各处检查,发现client实例对于node和chrome都是正常的,除了其中的成员initialState不同。

我们现在重点关注这个initialState。它在node中始终为null。在chrome中,以obj.client方式log出来的,一开始就存在,不是null;但是以obj.client.initialState的方式log出来的,则只有当Connected之后,才不是null。

下图演示了connected之后,obj.client.initialState从null变成了有效值:

下图是在Client类的构造函数的末尾log:this. initialState是null:

但点开this,可以看到其中的initialState不是null!!!!这也太匪夷所思了!!!

把log放到Client类构造函数的开头,仍然如此。研究了半天,终于在chrome的提示中发现了端倪:

(CSDN粘贴的图片效果太差了,没办法)

也就是说,这个Client类实例中的属性,全部都是当前的值,而不是过去的值。检查的当前是已经connected了,所以initialState有值了。

这个结论表明无论是initialState,还是client的store.getState(),都是在connected之后才有值的。所以问题仍然是node中为什么SocketIO它不继续工作。

成功的办法

知道在node中运行的时候不能用面向浏览器编译出的app.js,我们就直接在node中运行原始的app.js。

在package.json添加"type": "module"以便支持es6语法。然后在vsCode中增加node.js的启动项(lauch.json),启动程序是app.js。这样还不行,需要把app.js和game.js的import修改正确,原本的语句是编译前的,现在要正确指出来,不然node找不到。例如import { Client } from 'boardgame.io/client';必须改为import { Client } from 'boardgame.io/dist/cjs/client.js';。然后运行,就正确了,出奇简单。这个时候,使用node app.js也是能够成功执行的。

重新开始的准备工作

新建一个项目目录,直接把前面的index.html+app.js模板COPY一份到这里作为C/S方式的代码。同时为了对照调试,再COPY一份项目作为B/S方式的代码。

C/S的项目可以去掉index.html。然后在launch.json中add configuration,增加launch node.js的配置,vsCode会自动生成满足要求的配置代码,都不用修改。

打开app.js,找到TicTacToeClient类的位置,按F5运行,立即会报错,提示:

ReferenceError: document is not defined

报错位置在这一句:

const appElement = document.getElementById('app');

原因是用到了DOM,这个只能在浏览器中存在,node中是没有的。

于是我们现在应该修改TicTacToeClient类的源代码了,class TicTacToeClient声明之后的代码改为下面这样:

class TicTacToeClient {
  constructor(playerID) {    
    this.client = (0, _client.Client)({
      game: _Game.TicTacToe,
      multiplayer: (0, _multiplayer.SocketIO)({
        server: 'localhost:8000'
      }),
      playerID
    });
    this.client.start();
    this.connected = false;
    this.client.subscribe(state => this.update(state));
  }
  update(state) {
    if (state === null) {
      console.log('Connecting...');
      return;
    }
    if (!this.connected)
    {
      this.connected = true;
      console.log('Connected');
    }
  }  
}
var gameclient = new TicTacToeClient(0);

 F5运行调试,发现仅输出一句Connecting…之后就没有下文了,TicTacToeClient.update仅在this.client.subscribe的时候调用了一下,之后再也没有被调用。

为了查找问题原因,我们把B/S方式的代码也修改成这样看看效果。

node中不能而chrome中能的现象

把B/S代码修改成这样之后,在Chrome的Console面板中可以看到两次Connecting…之后就输出了Connected。在TicTacToeClient.update函数设置断点,将看到它多次被调用。而C/S的TicTacToeClient.update仅被调用一次。

我们在Transport类(注意app.js中有两个这种名称的类,要找到没有extends的那个)中设置断点。在构造函数和notifyClient函数均设置,发现C/S的这个项目,构造函数被调用,但notifyClient函数没有被调用,而B/S的那个项目,两者皆被调用。

SocketIOTransport类的继承关系分析:

class SocketIOTransport extends _transportCe07b.T

_transportCe07b.T的来源:

var _transportCe07b = require("./transport-ce07b771.js");

16657行左右:

"./transport-ce07b771.js":"node_modules/boardgame.io/dist/esm/transport-ce07b771.js"

15737行左右:

"node_modules/boardgame.io/dist/esm/transport-ce07b771.js":[function(require,module,exports) {

这下面接着就有class Transport 的定义,并且后面附了一个.T的定义:

exports.T = Transport;

可见,归根结底,SocketIOTransport类继承自Transport类。

我们要熟悉Parcel对JavaScript的这种处理结果,后面分析时要反复用到这种方式,将直接叙述继承关系,就不再赘述继承关系产生的由来了。

Transport类很简单,不用看了。还是看SocketIOTransport类。我们看到它调用了socketio的各种功能。就在connect函数调用7句socket.on(…)的7个回调函数这里设置断点吧,这相当于是socketio事件响应的第一经手处了。

B/S项目触发了’connect’和’sync’两个事件。C/S项目一个事件都没有触发。那我们就向前找找两个项目创建的socket有什么不同。

先看SocketIOTransport.connect函数在调用socket.on之前做了什么。同步调试。均执行到这一句:

this.socket = io(server + this.gameNamethis.socketOpts);

server + this.gameName的实际值都是"http://localhost:8000/default",this.socketOpts都是undefined。

io函数创建的socket,其状态都是active=true的。这就太奇怪了,一样的socket为什么C/S项目中的就不能触发事件呢?

我们在C/S项目修改TicTacToeClient类之后的代码,直接调用socketio看看是什么情况。代码如下:

this.socket = io("http://localhost:8000/default", this.socketOpts);
this.socket.on('patch', (matchID, prevStateID, stateID, patch, deltalog) => {
    console.log(matchID, prevStateID, stateID, patch, deltalog);
});
this.socket.on('update', (matchID, state, deltalog) => {
    console.log(matchID, state, deltalog);
});
this.socket.on('sync', (matchID, syncInfo) => {
    console.log(matchID, syncInfo);
});
this.socket.on('matchData', (matchID, matchData) => {
    console.log(matchID, matchData);
});
this.socket.on('chat', (matchID, chatMessage) => {
    console.log(matchID, chatMessage);
});
this.socket.on('connect', () => {
    if (this.socket) {
        const args = [this.matchID, this.playerID, this.credentials, this.numPlayers];
        this.socket.emit('sync', ...args);
    }
    console.log('connected.');
});
this.socket.on('disconnect', () => {
    console.log('disconnected.');
});
setTimeout(() => {
    this.socket.close();
}, 5000);
console.log('app start');

F5提示找不到io函数。在SocketIOTransport类的上方找到const io = _socket.default;但是这个_socket有多处定义,想想应该是SocketIOTransport类上方最近的那个,即:

var _socket = _interopRequireDefault(require("socket.io-client"));

于是在前面的代码加上:

var _socket = _interopRequireDefault(require("socket.io-client"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
const io = _socket.default;

F5提示Cannot find module 'socket.io-client'。根据对app.js的结构分析,应该把它改成长名,或者在require字符串那里添加映射。这里我们直接用长名省事:

var _socket = _interopRequireDefault(require("node_modules/socket.io-client/build/esm/index.js"));

并且可以直接缩减代码为:

const io = require('node_modules/socket.io-client/build/esm/index.js').default;

然后就可以正常调试了。调试结果与之前一样,C/S项目中的socket没有触发事件。看来我们必须先练练socketio了。

socketio单独试用证明node中能做到

另开一个项目。vscode打开该目录,terminal菜单调出终端面板,输入:

npm install socket.io

npm install socket.io-client

新建main.js,内容为:

const io = require('socket.io-client');
this.socket = io("http://localhost:8000/default", this.socketOpts);

后续内容与C/S项目中的代码一样。配置好launch.json等。

按F5测试,输出了正确的结果,如下所示:

app start

connected.

null  (第一次sync事件)

Object {state: Object, log: Array(0), filteredMetadata: Array(2), initialState: Object}(第二次sync事件)

disconnected.

现在这个项目也算是C/S项目了,可以正常工作。那为什么教程编译后的app.js中的相同代码就不能正常工作呢?

两个C/S项目对比分析

区别就在于一句。main.js中的代码为:

const io = require('socket.io-client');

app.js中的代码为:

const io = require('node_modules/socket.io-client/build/esm/index.js').default;

对main.js进行检查。用console.log(module);检查require为:

require('socket.io-client')这一句事实上在node_modules\socket.io-client\package.json之中的main属性指明了其最终指向,因此这一句可以据此修改为:

const io = require('./node_modules/socket.io-client/build/cjs/index.js');

测试能正常连接。

再次对比两个项目的不同,发现main.js用的是cjs模块,app.js用的是似乎是esm模块。app.js本身引用的的确是esm模块,但是编译应该修改为cjs语法了。难道是编译时引入了什么错误,从而在node中不能正常工作?

先跟踪进io(…)函数看看。在此设断点,F11进去,两个项目均跳到socket.io-client/build/*/index.js的lookup函数处,只不过一个是真正的目标js文件,一个是在app.js中(尽管声明是esm,但实际上也是编译后的cjs)。

app.js的socket类可以通过搜索class Socket extends _componentEmitter.Emitter找到,有两次匹配,要看socket.io的那处。在socketio-client中的原始声明是export class Socket extends Emitter。

检查两者创建的socket,也没发现有什么不同。在io函数之后,是否显示调用socket.connect();没什么不一样。根据socket的构造函数和connect函数,也没发现什么不同。

有一点仍然让人迷惑,能正常工作的socket事件是怎么触发的?这得深入socket的源码中去看看。

socketio-client的lookup函数调用了Manager类的socket函数,进一步调用了Socket类的构造函数。Socket类的构造函数最末尾又调用了其open函数,等价于调用connect函数。辗转调用了Manager类的open函数,调用堆栈如下图:

所以,Manager.open()函数才是最外层io函数的功能核心。

但奇怪的是,一开始open函数就返回了,什么也没做。于是在open函数中设置断点看看。果然,很快,它就又被调用了。追查调用堆栈,竟然还是Manager.open()函数,它里面设置了一个timer。但是这附近的代码是怎么被调用到的。

重新开始,在Manager.open函数中设置断点。终于发现前面忽视了是Manager类的构造函数引发了open函数后续代码的执行。

重新F5,发现这个Timeout并不关键。现在又发现了vsCode的强大,直接在编辑器上方选择不同的函数就可以了,不用再去搜索了

进一步研究发现,socket.io用到了engine.io,engine.io用到了xmlhttprequest-sll,也用到了websocket(目录是ws),而xmlhttprequest-sll和websocket归根结底都用到了http/https等node核心模块。

找到故障原因

我们换个方法,从上层的connect事件响应函数下断点,看调用堆栈,它是怎么被触发的。似乎最终追踪到XMLHttpRequest的open函数(cjs模块挂的函数),这个open函数的赋值又是在XMLHttpRequest这个函数本身(具体作用发生在外层require时)。于是追查open函数被谁调用,发现来自于Manager.open函数,callstack是这样的:

XMLHttpRequest在app.js却没有对应代码。我们看看丢失在哪个位置。我们就对照这个调用堆栈调试app.js这个项目,发现在polling.js的Request.create()函数中的这里:

xhr.open(this.methodthis.uritrue);

抛出了异常,错误为:xhr.open is not a function

追查这个xhr的来历,发现app.js引用的是engine.io中的某个js,其中没有open函数,而main.js引用的是xmlhttprequest-ssl的某个js,完全不一样。app.js就没有引用到任何xmlhttprequest-ssl。难道是app.js编译错误?那为什么B/S的代码就可以正常运行呢?于是对照前面的调用堆栈,又来看看B/S项目的情况。同样到这一句,但是没有抛出异常!

进一步比较发现:B/S和C/S项目中都调用了xmlhttprequest.browser.js模块(engine.io)中的XHR函数,然后B/S项目因为寄生于浏览器中,故存在XMLHttpRequest这么个东西,但是B/S项目中这个类是不存在的,故B/S项目会new XMLHttpRequest,而C/S项目不会。那为什么另一个C/S项目的main.js又有效呢?那是因为它引用的"./xmlhttprequest.js"中,定义了XHR=xmlhttprequest-ssl模块的default,所以调用的是正确的东西。

app.js对应的两个项目的"./xmlhttprequest.js"被重定义到"node_modules/engine.io-client/build/esm/transports/xmlhttprequest.browser.js",这可能是因为parcel在面向浏览器编译时做的。所以如果能够面向Server编译,将"./xmlhttprequest.js"重定向到xmlhttprequest.js,那么就正确了。

了解了一下打包能力,发现parcel是前端打包,而webpack是前后端打包,所以webpack能力更强,可以针对后端打包,应该用webpack。

进一步研究发现,针对后端打包可能也是不行的。因为像require这类函数在node中有而在浏览器中是没有的。如果针对后端打包,nodejs扩展出来的功能就无法包含在js中,这样我们将来要用的引擎就无法支持require这类nodejs扩展出来的功能。

所以,还是要针对前端打包。

把XMLHttpRequest包装进app.js?

先分析一下main.js那个C/S项目中与XMLHttpRequest有关的调用过程。

调用堆栈的最外面是在main.js的那一句:

this.socket = io("http://localhost:8000/default"this.socketOpts);

它经过层层调用,最终达到polling.js中的Request.create()函数。我们可以在create函数中的这一句下断点:

const xhr = (this.xhr = new xmlhttprequest_js_1.XHR(opts));

F11,发现会立即跳转到xmlhttprequest-ssl中的XMLHttpRequest.js,这非常奇怪,因为其中没有XHR函数。经过一番检查,发现是vsCode在这里出现了BUG。BUG的由来如下:

engine.io-client的package.json中在browser选项打包编译时单独把"./build/cjs/transports/xmlhttprequest.js"定义为"./build/cjs/transports/xmlhttprequest.browser.js",所以这是B/S项目中app.js引用到后面这个文件的由来。我们可不可以在打包的时候把这种重定义去掉,就可以把XMLHTTPRequest打进包了?

如果不是browser选项编译打包,即engine.io-client包的polling.js中的这句话const xmlhttprequest_js_1 = require("./xmlhttprequest.js");引用的是同包中的xmlhttprequest.js,在这个文件里定义了:

const XMLHttpRequestModule = __importStar(require("xmlhttprequest-ssl"));

exports.XHR = XMLHttpRequestModule.default || XMLHttpRequestModule;

可见,前面调用XHR()的那句话,最终将调用xmlhttprequest-ssl包中的XMLHttpRequest.js的默认函数,它即是es5语法下的XMLHttpRequest构造函数。

engine.io-client包的xmlhttprequest.js还导出了其它一些东西,应该是模拟支持浏览器的功能,不知道与要达到的目标有没有关系,现在暂时不考虑。

对比后文2.4.5节,这个es5的XMLHttpRequest对象居然没有timeout, withCredentials属性(这两个可能是JavaScript想怎么添附属物就怎么添,可能是配合浏览器用的,应该不需要管它)。

我们再看看这个XMLHttpRequest还依赖到什么了。依赖了fs,url, child_process,http,https,这些都是node核心模块。

初步分析了一下,发现XMLHttpRequest虽然只有不到700行代码,但是调用了node核心模块的不少功能,挺麻烦的。如果要把XMLHttpRequest打包进app.js,在node中自然是没有问题,但在Jint中,问题就大了,因为要实现更多的node核心模块,还不如为Jint提供一个XMLHttpRequest模块。

把WebSocket包装进app.js?

我们再来关心一下websocket。与XMLHttpRequest类似,在engine.io也有websocket.js等东西,它最终也是根据browser选项决定是否采用浏览器自带的WebSocket。

boardgame.io用socket.io-client与服务器通信。而socket.io-client又用到了engine.io-client,engine.io-client中的polling.js的Request在create函数中同时用到了XMLHttpRequest和WebSocket:

在XMLHttpRequest和WebSocket的构造函数中下断点,就发现XMLHttpRequest是在各个require(‘polling.js’)的地方就会导致Request.create,而WebSocket则是在Transport.open()的时候被构造。调试了一番,还是不知道XMLHttpRequest起什么作用,但WebSocket应该是主要用来传递正常数据的。在TicTacToeClient.update()函数输出有效state的时候下断点,就能从调用堆栈中发现最终这个来自于WebSocket.receiverOnMessage()函数。可见,在socket.io的整个传输过程中,XMLHttpRequest是起辅助作用的,而WebSocket才是主角。

WebSocket在底层用到了node核心模块net.Socket等,不是基于http核心模块实现的。

用Jint尝试从Unity运行简单JavaScript

JavaScript引擎的了解

因为.net的原因,优先考虑选用微软发布的js引擎。

Microsoft.JSInterop:这个可能非常好,但是只能用于.net8。反正我在.net core 3.1的console工程中用不了,因此猜测它太新,应该无法用于Unity。

有个node.js是node.js官方发布的,注意不是源代码可用的构件,没鸟用。

剩下可以考虑的:Microsoft.ClearScript.V8、V8.net、Jint。V8.net自2019年就没有更新了,可能被微软的替代了。所以几乎不用考虑它。

Microsoft.ClearScript.V8似乎依赖的东西较多。

Jint官网说了,其特性是:

Full support for ECMAScript 5.1

.NET Portable Class Library

.NET Interoperabilit

在github上,Jint说3.x版支持直到ECMAScript 2023的大部分特性。

先注意官网GitHub - sebastienros/jint: Javascript Interpreter for .NET一个说法:如果重复运行同一个脚本,则应缓存Esprima生成的scriptModule实例,并将其提供给Jint,而不是内容字符串

试用Jint

  • 最简JavaScript代码在Windows Console中运行

Jint在nuget网页中都更新到3.0.0-2023年了,但是在vs2019中的nuget面板中安装只能到2.11.58(2017年),可能因为这之后的版本写的是beta版。Jint的github主页上说了:The 3.x versions are marked as beta as they might get breaking changes while es6 features are added.所以应该安装3.0的。

手动下载下来的nuget包如何安装呢?把它放到本地目录中,并加入下图所示的程序包源就可以安装了:

https://img2023.cnblogs.com/blog/788842/202306/788842-20230613174424187-1460660027.png

其实不用这么麻烦,在vs2019的nuget面板中勾选包括预发行版就可以了:

但是.net core的console工程安装不上3.0。接着发现装Microsoft.ClearScript.V8也装不上,然后发现装什么都装不上。一番试验,终于发现是因为在前面添加了本地程序包源。去掉它就可以正常装上3.0版了。

装上后在.net core 3.1的console工程中尝试代码:

static void Main(string[] args)
{
            var engine = new Jint.Engine();
            engine.Execute("function add(a, b) { return a + b; }");
            var cc = engine.Invoke("add", 1, 2).ToObject(); // -> 3
         Console.WriteLine(cc);//Debug.Log(cc); // 这个在Unity中尝试
}

实践成功。

  • ​​​​​​​最简JavaScript代码在Unity Android中运行

然后转到Unity中尝试,直接在vs2019中安装是不行的,UnityEditor一更新就没了。正确的做法是把两个包下面的netstandard2.1下面的dll复制到unity的Plugins目录中。

然后把上述代码弄过去,放在按钮响应中。实践没有问题,可以看到输出了3。

下例在JavaScript中支持log:       

engine.SetValue("log", new Action<object>(Debug.Log));
        engine.Execute(@" function hello() { 
            log('Hello World');};
            hello();   ");

用SetValue即可让JavaScript可以回调C#!

看来还是非常好用。先把这个例子发布到Android平台中试试,需要把Debug.log换成自定义函数this.log,在这个函数中把字符串显示到Text上。

经试验,无论是mono,还是IL2CPP,都能够成功。

  • ​​​​​​​测试Jint是否支持EMCAScript标准内置对象

其实不用测,因为官网都说了,full surrport ECMAScript 5.1。

测试结果是不支持global这个变量名。并且log出的全局变量的内容与node也不一样。

用log函数监视传入的参数,发现只要是对象,传入参数就是一个System.Dynamic.ExpandoObject实例。对于undefined这样的全局变量/属性,传入参数是null。所以与node执行结果不同。

测试一例:

var a = [40, 100, 1, 5, 25, 10];

var b = {firstName:"John", lastName:"Doe", age:50, eyeColor:"blue"};

var c = function myFunction(a, b) { return a * b;};

log(typeof a[0]);

log(typeof b);

log(typeof c);

log(Math.sin(5));

log(c.prototype);

测试的输出是:

number

object

function

-0.9589242746631385

System.Dynamic.ExpandoObject

这样看来,除了global这个变量名(是Jint支持globalThis这个名字。global是node的全局对象名。EMCA标准中没有全局对象名,只有全局对象的成员,例如globalThis),对JavaScript不会有太大影响。Jint是支持EMCAScript标准内置对象的。

Jint有没有扩展内置对象呢?不知道。

  • ​​​​​​​让Jint支持浏览器引擎扩展的功能?

其实很明显,Jint默认是不支持nodejs/浏览器在EMCA标准之上多出来的任何东西的。所以,像__filename这样的全局变量是不存在的。实测的确如此。SetTimeout这样的函数是不存在的。但是作为后端的核心函数之一,必须让Jint支持。例如,前面分析到的Manager类就用到了这个函数。另外,XMLHttpReqeust或者Http也是必须支持的,否则联网就无从着落。

nodejs核心模块非常多(参考Index | Node.js v20.8.0 Documentation),根本无法全部去支持。我们也不需要这么做,仅需关注能在浏览器中正常运行的功能就可以了。例如像console.log、SetTimeout这种既在node支持也在浏览器支持的功能。

浏览器不支持require函数,所以parcel会包一层以支持require函数。

所以现在我们就不考虑支持多余的功能了,直接考虑支持boardgame.io教程Client针对浏览器生成的app.js。

试用Microsoft.ClearScript.V8

稍微了解了一下,发现Microsoft.ClearScript.V8是Microsoft.ClearScript的一个组成部分。

官方github:GitHub - microsoft/ClearScript: A library for adding scripting to .NET applications. Supports V8 (Windows, Linux, macOS) and JScript/VBScript (Windows). 说:ClearScript is a library that allows you to add scripting to your .NET applications. It supports V8 (Windows, Linux, macOS) and JScript/VBScript (Windows only). 可能是不支持AndroidIOS

用Jint尝试运行教程Client的app.js

分析需要在Jint扩展什么功能

我们的目标是基本不用修改app.js即可在Jint中运行。但是实在不行,也可以考虑仅有微小的修改就能达到目标。

console.log似乎是必须要支持的。不过我们也可以在JavaScript中用var log=console.log来代替console.log,这样,在app.js删除var log=console.log或者用var log=我们通过Jint的.net互操作自定义的函数就可以达到目标了。

SetTimeout是必须要提供的。

不这么一个一个理论分析了,直接实践,在Jint中执行app.js,它会提示哪里存在问题。

支持多模块

esprima for dotnet:GitHub - sebastienros/esprima-dotnet: Esprima .NET (BSD license) is a .NET port of the esprima.org project. It is a standard-compliant ECMAScript parser (also popularly known as JavaScript).

前面大致了解了一下Jint,没有细看,以为直接支持多模块。现在打算实现console.log才发现不是那么简单。

首先,es5没有多模块的概念,require是node.js的功能。Jint号称支持es6的import/export,但是在engine.excute(“源代码”)的时候,如果源代码直接import则会报错:Esprima.ParserException:“Line 1: Unexpected token”。

经过尝试,发现engine.AddModule仅仅是加入一个模块,实际上没有实际作用,有可能是把文本用esprima进行了语言翻译。engine.ImportModule才真正导入了一个模块。但是这样没完。比如导入了一个console模块及其log函数,但是在excute的时候源代码包含console就会报不识别的错误。

下载Jint的源代码大致看了一下,按Engine包含的函数名称看应该是支持多模块的,为什么就不行呢。Jint用esprima进行语法分析,import报错的时候却提示esprima不支持import这样的token。这与esprima的功能不符啊!

上网了解到nodejs中也有esprima的模块,调用功能分作了parseSource和parseModule两类,parseSource是不支持import这样的语句的。与此类推,esprima.net是不是也是这样的呢?Jint是不是在excute的时候也用了pareseSource呢?感觉陷入了迷茫:难道Jint真的不支持主模块调用附加模块吗?正如官方例子所示,Jint的多模块就只能用于某个变量的evaluate?

思索良久,忽然豁然开朗,可能有戏!把engine.ImportModule返回的变量赋给engine.SetValue函数,是不是就相当于var v=require(‘模块’)了呢?实测的确如此!

有了主副模块共同作用的功能,我们能干的事情就非常多了。

支持console.log

原理如前所述。代码如下:

engine.SetValue("consolelog", new Action<object>(Console.WriteLine));
            engine.AddModule("./console.js", @"
export function log() {
  for (let i=0; i<arguments.length; i++)
  {
    consolelog(arguments[i]);
  }
}");
            engine.SetValue("console", engine.ImportModule("./console.js"));

是否可以用console.log=consolelog这样一句话就可以了?实测不行。那么XMLHttpRequest.js中的XMLHttpRequest.XMLHttpRequest = XMLHttpRequest是个什么意思?

支持Timer函数

excute app.js,出现这个错误:

   at installTimerFunctions (obj, opts) <anonymous>:17911:66

   at Manager (uri, opts) <anonymous>:23029:10

   at lookup (uri, opts) <anonymous>:23400:27

引起错误的源代码是:

    const NATIVE_SET_TIMEOUT = _globalThis.globalThisShim.setTimeout;

    const NATIVE_CLEAR_TIMEOUT = _globalThis.globalThisShim.clearTimeout;

    function installTimerFunctions(obj, opts) {

      if (opts.useNativeTimers) {

        obj.setTimeoutFn = NATIVE_SET_TIMEOUT.bind(_globalThis.globalThisShim);

        obj.clearTimeoutFn = NATIVE_CLEAR_TIMEOUT.bind(_globalThis.globalThisShim);

      } else {

        obj.setTimeoutFn = _globalThis.globalThisShim.setTimeout.bind(_globalThis.globalThisShim);

        obj.clearTimeoutFn = _globalThis.globalThisShim.clearTimeout.bind(_globalThis.globalThisShim);

      }

    }

这里的if和else执行的代码竟然是完全一样的,这可能是parcel打包的结果,没有优化。

globalThisShim定义在其上方紧邻的位置,为:

exports.globalThisShim = void 0;
    const globalThisShim = (() => {
      if (typeof self !== "undefined") {
        return self;
      } else if (typeof window !== "undefined") {
        return window;
      } else {
        return Function("return this")();
      }
    })();
    exports.globalThisShim = globalThisShim;

这应该是说globalThisShim= globalThis?(例如node中)或者window(浏览器中)

针对globalThisShim的测试代码:

var v = Function("return this");

console.log(v()===globalThis);

将打印出true。所以我们提供全局的setTimeout和clearTimeout应该就能够解决问题了。

还是用Engine.SetValue函数解决。源码如下:

            engine.SetValue("setTimeout", new Func<Action, double, object>(TimerFunc.setTimeout));
            engine.SetValue("clearTimeout", new Action<System.Timers.Timer>(TimerFunc.clearTimeout));

//………
class TimerFunc
    {
        public static System.Timers.Timer setTimeout(Action callback, double interval = 1)
        {
            System.Timers.Timer timer = new System.Timers.Timer();
            timer.Interval = interval <= 0 ? 1 : interval;;
            timer.AutoReset = false;
            timer.Elapsed += (sender, e) =>
            {
                callback.Invoke();
            };
            timer.Enabled = true;
            return timer;
        }

        public static void clearTimeout(System.Timers.Timer timer)
        {
            timer.Enabled = false;
        }
    }

目前这个代码可能还不够完善,例如如果没有显示调用clearTimeout,可能没有销毁创建的timer实例。

还有,实测发现app.js有setTimeout(0)的情况,这会导致timer.Interval =0报错,所以检测到<=0就给它重新赋值成1,这里的1s是否符合要求

下面是测试代码:

engine.Execute(@"var timeout = setTimeout(()=>{console.log('timeout'); clearTimeout(timeout);}, 1000.22);

console.log('ok', 1, new Date()); ");

注意,Engine.excute函数执行一下就结束了,并不会阻塞。所以试验时,如果执行到这里就退出程序了,那么就不会看到timeout事件触发了。所以在控制台程序中我们需要Console.ReadKey();来等待timeout事件触发。

照这个思路,支持setInterval也是非常简单的事情。

发现必须要支持XMLHttpRequest和WebSocket

解决上面的问题之后,继续执行app.js,又报新的错误:cache is not defined。js调用堆栈是:

   at onError (err) <anonymous>:23402:14

   at open (fn) <anonymous>:18534:13

   at Manager (uri, opts) <anonymous>:23047:32

   at lookup (uri, opts) <anonymous>:23400:27

23047行的的代码是Manager类的构造函数内部:

这里调用的是this.open(),按理应该是:

18534行位于"node_modules/engine.io-client/build/esm/transports/polling.js"脚本的Request.create()函数内部,此处代码是:

23402行位于前面分析的lookup函数内部

看来Jint报的JavaScriptCallStack比较混乱,应该是漏掉了不少中间过程。尝试来拼凑一下本来的问题:

lookup()在23400行进行了Manager类的构造,然后执行到Manager构造函数的23047行调用了Manager的open()函数,然后在open函数应该是执行了23107行的代码this.engine = new _engine.Socket(this.uri, this.opts)总之经过层层调用才到了polling.js的Request.create()函数,然后又不知道调用到了哪里发生了异常,总之异常发生后被捕获了,但是在23400返回的cache[id]就成了空的东西了,于是23402这行就发生了错误,并且没有被捕获。

对比2.2.5找到故障原因这一小节,立刻明白是没有支持XMLHttpRequest这个东西导致发生了异常,然后被捕获,在23400返回了空的cache[id]。

xmlhttprequest.browser.js和polling.js这两个模块紧邻着被包装在app.js中。polling.js的Request.create()函数在18483行有这么一句代码:

const xhr = this.xhr = new _xmlhttprequest.XHR(opts);

这里_xmlhttprequest就是对xmlhttprequest.browser.js的require的结果。xmlhttprequest.browser.js中的XHR函数定义成这样:

function XHR(opts) {
      const xdomain = opts.xdomain;
      // XMLHttpRequest can be disabled on IE
      try {
        if ("undefined" !== typeof XMLHttpRequest && (!xdomain || _hasCors.hasCORS)) {
          return new XMLHttpRequest();
        }
      } catch (e) { }
      if (!xdomain) {
        try {
          return new _globalThis.globalThisShim[["Active"].concat("Object").join("X")]("Microsoft.XMLHTTP");
        } catch (e) { }
      }
    }

大概意思就是返回new XMLHttpRequest(),或者返回ActiveObjectX控件Microsoft.XMLHTTP的实例。网上查到一句原话:IE中使用ActiveXObject方式创建XmlHttp对象,其他浏览器如:Firefox、Opera等通过window.XMLHttpRequest来创建xmlhttp对象。这个描述其实也不准确,准确的见:滑动验证页面

很明显,在node中和Jint中都不会有这两个东西,所以最终结果比如是执行catch (e) { }什么都没有返回。然后再继续执行Request.create函数中位于18485行的xhr.open(this.method, this.uri, true);就会抛出异常。然后就是一系列错误处理,这里就不继续分析了。

在Request类中用到了XMLHttpRequest的哪些功能呢?

属性:responseText, onreadystatechange, status, readyState, timeout, withCredentials,setDisableHeaderCheck

函数:abort(),send(),setRequestHeader(), setDisableHeaderCheck()

事件:onreadystatechange()

可见还是非常多,要造这么一个XMLHttpRequest类肯定是非常麻烦的。只能看.net有没有现成的,没有就只能考虑从node.js的库中去包装一个(这样就必须修改app.js了)。

与XMLHttpRequest类似,浏览器本身也是自带了WebSocket,所以针对浏览器打包出的app.js也不包含WebSocket。node.js中的XMLHttpRequest和WebSocket最终用到node核心模块,这些核心模块要实现于Jint是很麻烦的。

小结Jint支持boardgame.io-client的方案

node.js库中的XMLHttpRequest和WebSocket所用到的node核心模块很多,包括但不限于http、net.Socket等,因此考虑把这些包进app.js是不现实的,那样的话就必须提供支持更多的node核心模块。

.net中的确是没有XMLHttpRequest这个类的,只有System.Web.HttpRequest。.net倒是有WebSocket类,只是不知道其功能与浏览器提供的WebSocket有没有什么不同。

这样看来,在app.js包进XMLHttpRequest和WebSocket模块是不行的,而在C#中实现XMLHttpRequest和WebSocket模块难度也是非常大的。

直接考虑在C#中替代app.js中的socket.io-client是不是可行呢?网上给出了socket.io for .net的包,可以考虑SocketIoClientDotNet或SocketIO4net。

在nuget中搜索socket.io,找到这两个包,同时发现微软居然也发布了一个,叫Socket.IO.Client,但是它只提供了MonoAndroid的包。

SocketIoClientDotNet似乎是支持各种平台的,但依赖的东西比较多。

注意:另外还有个SocketIoClientDotNet.Standard,作者不是同一个,下载量小一些,但是更新。最主要的区别是它所引用的EngineIoClientDotNet.Standard是支持netStandard的,而SocketIoClientDotNet引用的EngineIoClientDotNet则是区分了IOS、Android等多种平台。若SocketIoClientDotNet不能在IOS/Android上正常使用,可以考虑SocketIoClientDotNet.Standard。

从boardgame.io这一边来看(只看cjs模块),multiplayer.js用到了:

var socketio = require('./socketio-638b66b8.js');

exports.Local = socketio.Local;

exports.SocketIO = socketio.SocketIO;

类似地,react.js也用到了。这两者都是require了一下,其中的代码用的还是socketio-638b66b8.js的。socketio-638b66b8.js中才是直接用到了Socket.io的各种功能。总之,所以这些都将引用到"node_modules/socket.io-client/build/esm/index.js",而这个文件比较简短,修改起来比较简单。重点看它的导出:

export { protocol } from "socket.io-parser";

export { ManagerSocketlookup as iolookup as connectlookup as default, };

因此只要把导出这里替换掉应该就可以了。这样看来,用SocketIoClientDotNet似乎是很可行的方案。

lookup函数的主要功能应该是看指定的链接是不是已经创建过了。

另外,面向前台打包就可以了。是不是可以修改socket.io包的package.json,使得打包的时候仅包含我们修改过的/socket.io-client/build/esm/index.js就可以了?这样生成的目标app.js就完全不用改了

单独试验Socket.IO

node中socket.io连教程Server的代码分析与精简

因为用es6的类似代码在node.js中没试验成功,所以先在node.js试验。

服务器不变,仍然用教程的server。

2.2.4节是用require函数导入socket.io成功连接上server的。经过调试一下,发现它的事件流程是这样的:(1)connect事件响应,发起socket.emit('sync', ...args);(2)sync事件响应,得到数据;(3)timeout事件达到,关闭socket;(4)disconnect事件响应,程序退出。据此精简一下代码:

const io = require('socket.io-client');
var socket = io("http://localhost:8000/default");//, this.socketOpts);
socket.on('sync', (matchID, syncInfo) => {
    console.log('sync', matchID, 'syncInfo:', syncInfo);
});
socket.on('connect', () => {
    const args = [this.matchID, this.playerID, this.credentials, this.numPlayers];
    socket.emit('sync', ...args);
    console.log('connected.');
});
socket.on('disconnect', () => {
    console.log('disconnected.');
});
setTimeout(() => { socket.close();}, 3000);

这个代码是能正常工作的。去掉socket.emit('sync', ...args);也能connected。去掉url中的default,能connect但是不能sync。

把这个es5语法改成es6,即把require那句话改为:import io from 'socket.io-client';,在package.json增加"type": "module",再运行,vsCode给出的错误提示是:TypeError: Cannot read properties of undefined (reading 'matchID')。

之前以为这个错误是代码一开始就错了,经过调试发现是响应connect事件了,但是args=那一句存在问题。把这两句修改为socket.emit('sync');之后,发现正常运行了,能connect也能sync了。

这说明上面调用socket.io的过程在node.js中是完全成功的。

​​​​​​​尝试用SocketIoClientDotNet连教程Server

下面我们就来试验在C#中去连接server。先用SocketIoClientDotNet试试吧。C# console工程中导入,发现会引用不少dll。

            var socket = IO.Socket("http://localhost:8000/default");//, options);

            socket.On("connect", () => 

            {

                Console.WriteLine("connected");

            });

            socket.Connect(); 

            socket.On("disconnect", () => {

                Console.WriteLine("disconnected");

                socket.Close();

        });

就上面这么简单的代码,网上有人能连上,但是我连教程Server连不上。是不是版本原因?抑或是默认的options不同?

官网GitHub - Quobject/SocketIoClientDotNet: Socket.IO Client Library for .Net 说:This is the Socket.IO Client Library for C#, which is ported from the JavaScript client version 1.1.0.

看了一下node_modules中的socket.io的版本,是4.7.2。这样说来,很可能是版本差别太大的原因。

再查一下boardgame.io对socket.io的依赖性,目前的0.50.2版依赖的是:"socket.io": "^4.5.0",    "socket.io-client": "^4.1.3",

从这点看来,SocketIoClientDotNet应该是无法连上教程Server的。

SocketIoClientDotNet在其github主页上说了:

在这里指出的链接announce "this project is not maintained anymore" · Issue #69 · Quobject/EngineIoClientDotNet · GitHub 作者mattqs说了,他在自己的项目中用到了这个库,自己项目完成了,所以就没再更新了。他友善地给了一个链接GitHub - IBM/socket-io: A Socket.IO client for C# 我查了,这个库也没有更新了,且用法迥异,就不用研究了。

在这个链接中,有人已经指出了这个库仅支持1.7.4以下版本的server。也有人指出用socket.io-client-csharp适应了更高版本。

​​​​​​​用SocketIOClient连教程Server成功

在GitHub上搜索socket.io v4以上 for .net的库,发现了GitHub - doghappy/socket.io-client-csharp: socket.io-client implemention for .NET 声明支持v4,并且在nuget中的库支持.netstandard2.0。它在nuget中的下载量仅次于SocketIoClientDotNet。从更新来看,SocketIOClient还在维护,最近更新时间在一个月内!

仿照官方示例测试了一下,能连上了。但是它的用法与JavaScript不同。所以得先研究一番。这里发生一件怪事,就是必须new SocketIOClient.SocketIO(),直接用new SocketIO()会报SocketIO是命名空间的错误,我查了unsing,发现整个工程只有SocketIO.Core是命名空间,但是我没有using它啊。暂时放下这个问题。

下面的代码能正常取到sync的数据:

            var socket = new SocketIOClient.SocketIO("http://localhost:8000/default");           

            socket.On("sync", response =>{

                Console.WriteLine(response);

            });

            socket.OnConnected += async (sender, e) =>{

                await socket.EmitAsync("sync");

                Console.WriteLine("connected");

            };

        await socket.ConnectAsync();

再看一下SocketIOClient是由DogHappy开发维护的,它的依赖也都是这个作者在维护。估计就是因为目前C#中没有支持v2/v3/v4的socket.io库而开发的。

现在的麻烦在于,它采用了异步编程,与JavaScript中的用法还是有着较大的差别。另外,对于JavaScript中socket.on('sync', (matchID, syncInfo)=>{})的调用方法可能还需要封装才能适应,当然这个问题在SocketIoClientDotNet也存在。

尝试在Jint中支持Socket.IO

现在主要的问题就是对SocketIOClient进行封装以适应JavaScript调用了。

根据调用来封装接口,直接映射到JavaScript中的接口。测试发现connect事件响应了,但是没能正确地反馈给JavaScript。这可能与socket.on的参数有关。比如把setTimeout函数的第一个参数从Action类型改为object类型,Jint就报错了。所以我们只能再在内部再封装一次已完成JavaScript函数参数到C#函数参数的正确转换。再次仿照setTimeout函数处理,发现情况可能是调用Jint的方式哪里存在问题。

在JavaScript中调用setTimeout时无论不给参数还是给多少参数,以Action定义的callback总是正确回调。根据这个信息,仅调用一次on函数响应connect事件,这次发现居然成功了!看来是箭头函数的局部参数捕获问题。按照C#规范处理之后,果然工作正常了。sync事件也传递到JavaScript了。但是还存在一些问题。目前的问题就是Javascript中的回调参数都只能是没有,即不支持socket.on('sync', (matchID, syncInfo) => {…});这样的包含回调参数的箭头函数。

总之,现在看用SocketIOClient是没有问题的。有问题的都是在C#和Jint之间的调用与被调的处理上。另外,boardgame.io调用了不止上面这些socket.io的功能,必须支持这些被调用的全部功能。

说明一点:虽然在JavaScript直接调用SocketIOClient可能更方便,但是调试工作没办法进行。所以还是把SocketIO的接口写在C#中以便调试。

​​​​​​​解决C#和Jint之间的调用问题

1)回调Javascript存在问题不报错

经测试的确如此。方法是在回调JavaScript之前要加上try…catch…,然后就可以看到捕获的错误了。

2)回调JavaScript无法带参数

拿setTimeout作试验,发现是可以带参数的,前提是在engine.SetValue时要声明,例如这样:

engine.SetValue("setTimeout", new Func<Action<string>, double, object>(TimerFunc.setTimeout));

这要求C#中定义的TimerFunc.setTimeout也得对应起来,即:System.Timers.Timer setTimeout(Action<string> callback, double interval)

这就表明engine.SetValue("Socket", TypeReference.CreateTypeReference(engine, typeof(SocketIoClient)));这句话所声明的JavaScript要用到的C#类,其中设置回调函数的函数,它声明成什么形式,都将被Jint翻译成什么形式。

由于我们的on函数被声明成SocketIoClient on(string eventName, Action callback),故无法传递参数了。

为了适应on函数复杂的形式,继续拿setTimeout作试验,发现声明回调函数的类型为Action<string>可以兼容Action,这应该是因为Jint在处理Javascript时,只管我们C#中的声明,而对JavaScript中的声明是灵活的。当C#声明成一个,就总是把第一个参数从C#传给JavaScript;如果JavaScript声明成多个,除第一个一一对应上了,多余的就给JavaScript的回调设置成undefined;如果这时JavaScript声明成0个,那么JavaScript直接一个不用就完事了。

知道了这个原理,我们把C#中的on函数声明成on(string eventName, Action<object> callback)就可以在JavaScript中输出sync的结果了。但是还不够,现在这个结果是被赋给了matchID,而不是syncInfo。看来我们得把C#中的on函数声明成on(string eventName, Action<object, object, object, object, object> callback)的形式来支持5个参数的JavaScript回调函数。

3)从JavaScript直接访问.net的任何功能

options.AllowClr();之后打开了这个选项,但不是万能的,取决于JavaScript是否支持C#的某些特性。

例如,因为JavaScript不支持静态类,故在JavaScript中无法直接使用System.Console.WriteLine,也就是说,engine.SetValue("log", new Action<object>(Console.WriteLine));这句话无法用js中的var log = Console.WriteLine;来代替。

但是,js中这样的代码却没有任何问题:console.log(System.DateTime.Now);

比较有趣的是,engine.SetValue("console.log", new Action<object>(Console.WriteLine));之后,engine.GetValue("console.log")能够返回正确的委托函数,但是在JavaScript中还是不能直接用console.log。这是因为Jint它是把.号前面解释成了类。

关于System.Console.WriteLine,官方给的例子是支持直接在js中使用var log = System.Console.WriteLine的,不知道为什么我尝试不成功。

不仅如此,我在js中尝试new SocketIOClient.SocketIO()也是失败的,居然提示不识别SocketIOClient。

​​​​​​​支持socket.io被boardgame.io调用的全部功能

现在直接修改node_modules目录下的socket.io-client,果然就在parcel编译打包的app.js中没有了原先的socket.io、engine.io等代码了。这样的app.js的确可以不用再手工修改了。

但是载入app.js用Jint运行,发现还有不少功能还没有支持。所以必须一个个支持起来。

......未完待续

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值