闲话东方刚欲异闻剧情

        趁Steam版本刚发布,这几天打了30多个小时的东方刚欲异闻。作为横版苦手,终于把n难度全线打通了。为了仔细了解剧情,提高一下游戏体验,我特地每打完几面就上THBWiki看一看剧情翻译,发现这作的剧情从不同方面看也都蛮有意思的。这也是我头一回刚发布就通了关、并且仔细看了看剧情的官作。那么这就随意地闲话一下游戏剧情的内容好了。

        注意:全篇内容包含详细剧情剧透!请自行决定是否继续阅读!

        特别感谢:THBWiki以及上传剧情原文和进行翻译的小伙伴。

        另外,以下剧情概括、解读等纯属个人观点、不能当做一设结论、也不能保证正确性,只是正如标题所言的闲话罢了。当然,也欢迎各位小伙伴持不同意见来讨论。

背景

        首先,还是要交代一下故事的背景。

        地上涌出了黑色的水,这水散发着异臭,沾在身上则会难以去除。幻想乡里各路人马对这黑水都持有不同的态度。

        然而有一天起,情况发生了变化。饮用的泉水中、清澈美丽的溪流中、供人休憩的温泉中……毫不顾忌位置的黑水,开始喷涌而出。

角色剧情

        那么从每个角色的视角来看看都发生了什么吧~

        (详细的剧情对话可以移步THBWiki)

博丽灵梦

小夜时雨的巫女 / 几天不见,有些拉了啊!

博丽灵梦

        灵梦,依然是东方系列亘古不变的主角,也是这作初始自机之一。但她这次对异变的解决实在不太给力。这也不能怪灵梦不走心,毕竟对上畜生界的头头之一,还是被血池地狱强化版本,纵使是巫女也很难对付呀!

        简要剧情概括:调查黑水的来源,一路从魔法森林、地灵虹洞、旧地狱街道、旧地狱核融合炉、旧灼热地狱打到石油之海。这一路上灵梦得知了黑水其实是石油,也看见神奈子同在调查石油的来源,抵达了石油之海,自以为可以胖揍一顿尤魔,但不料打到一半被一股子上升气流直接冲回了地面。后来地面涌出的石油确实变少了,灵梦傻乎乎地觉得是自己的功劳(其实是神妈和村纱的)。

        战斗线到这里就结束了,不过芙兰C线与灵梦再次在石油之海相遇,原来是后来当石油又一次加倍泄露时,灵梦也再一次前往了石油之海。显然,这次同样也是无功而返(当然也归功于芙兰打架快)。灵梦对于异变解决当然是义不容辞,还是非常有责任心,但牵涉到畜生界和旧地狱的过往黑历史之类问题时,巫女作为一介人类,不管是力量还是智慧,还是难和活了几百几千年的各路神仙抗衡啊。

        不过当被芙兰吐槽

哼,真愚蠢

敌人难道不是必须打倒的吗

你什么时候变得那么没骨气了

曾经到我这来的你

可是有着破坏一切的眼神

        的时候,这样的灵梦看起来真的很狼狈(虽然和芙兰打完之后还是得到了芙兰的夸奖啦,毕竟尤魔的强度和吞噬一切的能力实在太bug了)。

雾雨魔理沙

湿地的魔法使 / 真的,原来魔理沙真的是配角

雾雨魔理沙

        这一作的魔理沙,确实尽心尽力有在解决异变,虽然也和灵梦一样力量不足。但除此之外,从在作品剧情中的地位来看,魔理沙也是真的非常惨。

        先看看剧情,魔理沙同样一路从魔法森林、地灵虹洞、旧地狱街道、旧地狱核融合炉、旧灼热地狱打到石油之海,也被尤魔借力用地狱的一股子上升气流直接冲回了地面,最后自我感觉非常丢人,甚至没和灵梦说遇到尤魔的事。

        之所以说魔理沙的地位非常惨,是因为,其他角色的故事线,要么就是主线,要么大体上也可以和主线说得通,算是支线。但是,魔理沙线与其他线的剧情发生了明显的冲突,战斗的时间和台词的内容明显和主线不合,从别人的故事线都难以看出魔理沙和他们打过架,几乎就是个独立的、对剧情基本没有推动的故事线。

        首先,魔理沙和村纱的战斗,在魔理沙这边是发生在淹没的地狱核融合炉,而在村纱线,是在被淹没的魔法森林。从对话看,村纱线的魔理沙并不知道地下石油的情况,应当还没有下去调查。但魔理沙线中,从地表去地下时,魔法森林还没有被淹没,很明显是矛盾的。而村纱与其他角色剧情的交互都是非常合理、符合主线剧情的,这样来看,魔理沙的线是属于平行世界了。

        同样的情况也发生在别处,比如尤魔告诉神奈子只是把灵梦吹走了;在山女和久侘歌与其他角色的台词中也看不出和魔理沙打过架等等。魔理沙线整条剧情都和主线都是游离开来的,个人看来,设计这条线存在的意义在于让玩家先与村纱和久侘歌打个照面,保证游戏最初两条线能尽量碰到所有敌机了。

        此外,在秘神对芙兰训练那段剧情里,和魔理沙的打斗也放在A线(对应最差结局),并且是一面,作为全作最简单的敌机。可怜的莎莎面子都丢光了。

魔理沙呢,要是没有个那么和我们大家水平相近的人类的话(这个游戏)就谁都谈论不起来了, 感觉她是因为这个理由而存在的。 反正是个配角。                

—— ZUN

八坂神奈子

神水满溢之湖的神明 / 她是可以成为母亲角色的人(引自日本网友)

八坂神奈子

        在Steam还没上架时,就听闻神妈这回“神德泛滥”,俘获了不少人的爱慕。剧情打下来,果然,眼光尖锐、心地善良、彬彬有礼,守矢这哪可能会缺香火钱啊!

        还是主要剧情,神奈子发现核反应炉坏了,调查得知是石油泄露,在旧地狱打探情报后,准备前往旧血池地狱一探究竟,路上还发现反应炉和灼热地狱都让村纱给水淹了,到达石油之海后,连追尤魔到血池地狱,把她胖揍了一顿(这回估计有点疼了)。但神妈也没有彻底解决问题的好手段,最终是和尤魔达成协议,让其管理石油防止泄露,仍有泄露的交给神奈子处理。

        这一路上,神奈子是处处播撒神威。一面中,山女饿极了,想要吃掉神奈子,而神妈急着要去核反应炉,还被阻拦。但打完一架,神妈却想着先为山女准备食物。不管是为了让山女填饱肚子,还是让后面过路的人能更安全一些,都太善良了。(不过能进这地灵虹洞的,哪有凡夫俗子,会被山女打爆的呀)。

        刚到达核反应炉时,碰见了正想下去调查的灵梦,神奈子出于反应炉失控和石油成因危险未知的原因,希望灵梦不要涉险,回去地面处理清洁工作。面对不理解石油燃烧和反应堆聚变的灵梦,也尽心想解释清,并且阻拦她前行。

        从旧地狱街道回来,看见自己的反应炉被水淹没,本来十分气愤的神奈子,对于始作俑者村纱的道歉和原因的解释却十分冷静,通情达理。知道是圣白莲嘱咐其前往旧灼热地狱再往下的旧血池地狱调查才出此下策之后,甚至向她提供的情报表示感谢,虽然当时都还在急匆匆的赶路,连村纱都打算把灼热地狱的水排空后再从冷却的灼热地狱下行,但神奈子直接蹚水就前行了。

        在被误解和阻拦,被迫和久侘歌战斗后,本来神奈子很有理由感到不满,可以直接弃鸡妹于不顾自个儿继续前行,但是仍然基于这里的水是三途川的水,而鸡妹负责管理三途川口岸的原因,以山之神的身份向二羽渡神请求下达正式的通行许可(原来鸡妹是叫二羽渡神啊)。

        见到尤魔后,还来不及自我介绍,也没有询问对方是谁,神奈子却先急忙询问灵梦的去向。得知灵梦被安全送回(其实就是被吹了个跑),才放下心来进行交涉。甚至对于来者不善的尤魔,神妈的交流也十分有礼貌。

        个人对神妈原本的印象,主要是在高瞻远瞩,或者说老谋深算上,对外界有了解、对能源问题有认知、对幻想乡局势中自己的势力的稳定和发展很有把握,是非常有智慧的山之神。但这一作的言语和行为,更为她增添了不少人性色彩,通情达理,心地善良,如果去守矢向早苗提亲,她也一定会许可吧

        另外,回到守矢神社后,神妈还不忘和青蛙子损一句灵梦,说灵梦以为石油得到控制是自己的功劳,非常有意思。

村纱水蜜

水难事故的化身 / 神妈、鸡妹:你礼貌吗

村纱水蜜

        船长在这次异变的处理中可谓是存在感十足。圣白莲提出的把三途川里无限的水往地狱里面灌的主意,大胆而有创意。而在实施上,便是找熟水性、有把各种底捅漏的能力的村纱在三途川底打大洞,用以淹灭核融合炉,也给灼热地狱降温,这帮助了好几位自机通过灼热地狱,虽然把神奈子的核反应炉给泡了,也让久侘歌多出来了补洞工作要做。村纱作为圣白莲势力的一份子,在异变中发挥了很大的影响力。

        村纱的主要剧情如下:命莲寺被石油污染,于是圣白莲派村纱去调查一番。事前把三途川底打了个大洞后,船长也从魔法森林、地灵虹洞、旧地狱街道、旧地狱核融合炉、旧灼热地狱、石油之海打到旧血池地狱,好好地和尤魔打了一顿,并且带着白莲的咒法,使旧血池地狱进入了休眠状态,石油也随之不再喷出。

        不得不提,三途川水的洪流确实排山倒海,不仅把灼热地狱泡了,地灵虹洞也几乎被水淹没,甚至涌出地面,把魔法森林地表都浸了个遍。这使得魔理沙奇怪为什么黑水涌完了又涌清水,小伞更是直呼要溺水了。正不愧是游戏标题中,“被水淹没的沉愁地狱”,船长也警告小伞道,“接下来要去的地方,是完全淹没在水中的沉愁地狱”。虽然沉愁地狱这个词在游戏台词里基本没再出现过,这个词语似乎不是一个专有名词,按照鸡妹在芙兰C线的台词

这前方应该是过去被遗弃的地狱……

虽说已经被遗忘了,

连忧愁都将被水淹没,

无限的水量也非常无情呢……

        大概就是这么样的解释吧,有待各位考据。

        当然,在地底混过不少时日的村纱,对地狱的情况可相当了解。据自己交代,像“把饮泉用的长勺捅漏之类的”、“把浴桶的桶底捅漏之类的”,甚至是“把大浴场底部捅漏之类的”的恶作剧全都做过不少(竟然没有给旧地狱的鬼族和妖怪们收拾,实力还是有两把刷子)。一路轻车熟路到达石油之海,并且使用咒法使血池地狱现出真形,成为了第二个能把尤魔揍一顿的自机。

        可以说,村纱和神奈子作为两方势力分别对异变起到了不同方面的抑制,前者暂时平息了血池地狱里暴走的怨念,后者与尤魔谈判达成共同管理约定。如果没有秘神摩多罗对长远危机的操心,异变到这里就基本可以结束了。

依神女苑 & 依神紫苑

得到了一切的的石油姐妹 / 小丑竟是我自己

依神女苑 & 依神紫苑

        依神姐妹的故事线作为故事的一条副线,十分有意思。这两位贫穷神和疫病神,不过是收集到了喷涌出来的石油,尚未找到办法利用,就开始打着成为首富的想法,自封为石油王和富豪神,招摇行骗。

        依神姐妹收集到石油后,本来想在旧地狱获得更多,但发现事实并非如此,旧地狱并不能见到石油。不过她们被温泉所吸引,想要好好享受一番,于并且一度十分嚣张,不仅随地喷洒石油来炫耀,还接连在地狱干了几架。可是。后来石油不再喷出了,于是她们继续往下,穿过水正在退去灼热地狱(所以很倒霉地既和鸡妹打了架,又和阿空打了架)。中途也和神奈子战斗,还十分倒霉地被摩多罗拽去红魔馆和芙兰打了一架。最终因为实力太弱,并不能在血池地狱把尤魔揍一顿。而且或许是了解石油是血液的恶心真相、或许是被尤魔吞噬了欲望,两姐妹扫兴而归。

        两姐妹的性格也在对话中一览无余。行动的时候往往是女苑占主动,包括开口的交谈,行为的决策主要都是靠女苑,而紫苑紧随着女苑,可能是因为太饿了吧(自然,游戏里也是女苑是主机,紫苑是子机)。姐妹在得知石油的真相是被诅咒的血液后,本来在打退堂鼓,但紫苑被戳到了贫穷的痛处,发怒而连带妹妹一起打进尤魔二阶段(让玩家也再坐一次二阶段的牢)。

        而两姐妹的共同点尤其是在于贪欲,以至于被神奈子吐槽,她们和尤魔一样说出“连一滴石油都不会给你”这种话来。贪欲之强盛,连尤魔都十分欣赏(和嘴馋),于是将她们的贪欲吞噬。回到地面后,被吞噬的贪欲卷土重来,甚至继续装成拥有石油的样子招摇行骗。

        两姐妹在面对他人时也略有愚钝。举例来说,拿着石油还不知道该如何使用就想着去旧地狱温泉享受,碰见毫无干涉的久侘歌也为了“守住财产”大打出手,遇到神奈子也做出类似的事。

        另外提一下,这里的剧情和主线略有冲突。两姐妹来到灼热地狱时,久侘歌刚确认流水被控制,业火重新出现,位于神奈子和村纱到达时间的中间,但两姐妹下灼热地狱的原因是石油停止喷出,时间应当位于前两者线结束之后。而来到了石油之海,也碰见了刚和尤魔谈完了约定的神奈子,或许是谈判磨唧了太久吧。

芙兰朵露·斯卡雷特

鲜红血液的恶魔 / 芙兰好中二啊,我新老婆

芙兰朵露·斯卡雷特

        芙兰是这次异变解决的关键力量,接受了秘神摩多罗的请求,并且经过水流环境战斗的训练,最终(在C线)彻底破坏了尤魔的胃袋,使其只能重生,变成了可以沟通的尤魔。

        芙兰的故事发生在其他所有人的故事线之后。本来石油的喷发已经停止了,但秘神为了使尤魔成为地面上的公敌,四处开门让石油喷出。接着为了彻底解决尤魔,让芙兰通过她的门前往不同地方战斗,训练水流场所战斗的能力。于是,芙兰在A线(有所保留的对手)、B线(颇具本领的敌人)和C线(强敌)分别对战了不同的敌人,也引出了三个不同的结局:在A结局中,尤魔吞噬了芙兰的符卡,没有受到实质伤害,摩多罗只能暂时控制石油;在B结局中,尤魔被赶跑了,随之摩多罗也停止了令石油喷出;而在作为真结局的C结局中,芙兰狠狠揍了一顿尤魔,并在她空腹、只能吸收的时候运用破坏的能力给了她最后一击,彻底破坏了尤魔,尤魔吸收了地上的欲望后成为了可以沟通的尤魔,并且与地面商讨重新分配石油资源。

        芙兰之所以听从秘神的安排,其实是听说可以去破坏其他所有人都破坏不了的饕餮,本就按奈不住无聊,于是特别有干劲地到处战斗(大妹:我家丢了个人?)。并且,在和众敌机的交战中,芙兰狠狠的耍了一整故事条线的帅(中了一整条故事线的二)。而敌机们或者不认识芙兰,或者奇怪为什么她会出现在这里,归结成梦和幻觉,也出现了不少槽点十足的对话台词。

芙兰(见面就开打)。

勇仪(打完):混沌即安泰,温泉街今天也这么热闹,真不错。

*不愧是勇仪。

芙兰(见面就开打)。

小伞(打完):所以那也是圣大人准备的刺客吗……兴趣真奇特。

*是指芙兰中二的兴趣吗。

山女:这种紧急事态光靠地灵殿的那位处理不了呢(觉:你礼貌吗)。啊——,曾经的鬼神们,会不会回来呢。

芙兰:我是毁灭一切的破坏神!

山女(芙兰离开后):从地狱来援助了,所以已经没事了。

*被当做从新地狱搬来的救兵了。

芙兰:真亏你能乘在船上呢,乘在那种被诅咒的交通工具上。

村纱:放心吧!我的船并非是用来乘坐的交通工具。

芙兰:嚯。

村纱:是用来沉没的!

*果然。

芙兰:我可是芙兰朵露·斯卡蕾特,令小儿夜啼的吸血鬼!

女苑:我们是从泣子身上都要赚钱的石油王依神姐妹!

*笑死啦。

芙兰:(传送到被水彻底淹没的旧灼热地狱)

芙兰:呜哇啊!

芙兰:这种地方不可能的,不行不行!

芙兰:根本就是除了流水一无所有!

*可见是真的很怕水

芙兰:我赢了对吧!回去了!

久侘歌:请留步。我是庭渡久侘歌。什么解释也没有我很头疼呀。

芙兰:啊,那个就下次再说。我是芙兰朵露!令小儿夜啼的吸血鬼!(离开)

久侘歌:消失了……原来吸血鬼是那么忙碌的呀。

*五百多年下来,就只忙碌这一回。

*就算这样忙碌,也不忘记介绍自己称号啊。

        总而言之,芙兰的登场充满了中二和暴力美学,不管是台词还是战斗中的动作(七彩小陀螺!)。可惜芙兰是最后一条解锁的线,可能大部分玩家都不会打到这么后面,否则一定要圈粉无数了吧。

        接下来,是介绍非自机们。

摩多罗隐岐奈

摩多罗隐岐奈

        首先当然是这个都没从“轮椅”上站起来,就解决了异变,还让自己名正言顺地成为了石油的管理者的秘神摩多罗。摩多罗两次让石油喷出地面,前一次让几乎没能伤到尤魔的灵梦和魔理沙,十分可靠、谈判技术一流的神奈子,和强力、怀揣咒法的村纱轮番下血池地狱和尤魔战斗,了解到尤魔难以破坏后,后一次为芙兰彻底破坏尤魔后自己能作为解决异变者顺势掌管石油做铺垫。这一来一回,老谋深算。

        不过也有个小问题,为什么摩多罗不直接找芙兰把尤魔破坏,非要先把尤魔塑造成地面公敌这样的形象呢?除了需要多个自机,其他人物需要刻画这种游戏设计要求外,还有几点可能。

  1. 第一次喷发是为了让城管们下血池地狱执法,这时候摩多罗可能还不了解尤魔是难以破坏的。虽然城管们个个身怀绝技,但要面对尤魔吞噬一切的能力,彻底退治是难上加难。这时候摩多罗才想到,需要借芙兰破坏的能力。
  2. 第二次喷发情况就又有变化。虽然直接找芙兰破坏掉尤魔,当然也可以解决异变。不过变得可以沟通的尤魔,自然也是和摩多罗沟通。作为忙碌的秘神,摩多罗虽然目的是成为石油的管理者,但具体分配琐事等等可不愿意插手。而第二次更为严重的喷发后,地面上各方都对石油好好被控制住有强烈的希望,摩多罗才好趁机做个撒手掌柜,这样才有后来尤魔实际上是找灵梦谈石油具体分配情况的结局桥段,多半也是摩多罗指使的。

        为什么这次的黑幕是摩多罗不是紫妈了?或许紫妈在忙别的事情吧,或许在冬眠,或许只是贤者们分工合作的默契。不过芙兰C线结局点出了一些深意:

不过,从知晓石油所伴随的绝望未来的人来看,
由隐岐奈管理石油或许是件好事。
她是隐匿一切的秘神。
因此,石油也会再次被隐藏于地底之下吧。

摩多罗为芙兰开门前往训练场所

饕餮尤魔

饕餮尤魔

        然后是这次的大反派尤魔。尤魔是畜生界刚欲同盟的组长,饕餮的动物灵,统领着大鹫灵。

        她是贪婪的大胃王,无论实体还是灵体,有机物还是无机物,任何事物都会被她囫囵吞入肚中。非但如此,她甚至还能通过吞入腹中这一行为去理解被她吞入的事物,将其转化为自己的力量。她吞下的事物,似乎也会对她的性格造成影响。                                                                 

——东方刚欲异闻附带文档

        其实尤魔除了太贪婪以外,在剧情里看上去还是蛮可怜的。地面上石油的喷发本来和她一点关系都没有,却背了十成的黑锅。本来是在血池地狱高高兴兴地喝着石油,却被轮番找上来干架。

        不过,退治尤魔也是相当合理的。无止境喝着石油(血液)的饕餮尤魔,是吸收了血池地狱的恶意的饕餮灵个体,其行为难以捉摸,很可能在变得过于强大之后也失去了理智,把畜生界和地面都破坏得一团糟。附带文档里也提到了她的野心不止于畜生界,甚至想借机将地上的欲望也尽数纳入囊中。这也是摩多罗从长远看,不论是达成约定、还是抑制血池地狱怨灵都没法根除异变发生的种子,必须彻底破坏饕餮的原因。

        所以,重生后的尤魔也吸纳了地面上的欲望(估计也有摩多罗的作用在里面),这种欲望是带有理性价值衡量的,于是尤魔也变得可以沟通了起来,对她自己来说,也是一件好事。这或许也透露出一种希冀,现世的人们也需要控制对石油失去理智的疯狂采集,更理性地对待这种化石能源吧。

星熊勇仪

星熊勇仪

        还是不愧是勇仪啊!混沌即安泰,面对找上门的打架,勇仪最有热情最感兴趣了。

灵乌路空

灵乌路空

        果然还是一只笨鸟头。遇到灵梦,先不看是谁,直接当不纯物开打清除;遇到打到一半逃离的依神姐妹,自己都没琢磨清楚是不是在和人战斗。

        不过虽然这样,阿空还是在很尽职尽责地清理核反应炉里的石油喔。

庭渡久诧歌

庭渡久诧歌

        鸡妹估计就十分郁闷了。鸡妹本来工作不过是在三途川边上班,守卫地狱与异界的口岸,却不料三途川被捅穿了底,只好下地底去负责修补工作。不仅如此,还不明不白地被魔理沙、神奈子、依神姐妹和芙兰轮番找上来打架。不过,作为一只快乐的鸟头,这些大概都不是问题啦。

多多良小伞

多多良小伞

        小伞终于吓到人了!倒霉的灵梦终于变成了小伞成功惊吓的对象。对于石油的喷发,小伞却十分兴奋,不仅做着自己的努力去调查,甚至想参与“石油骚乱”,想要获得石油。这是因为想到了什么新的吓人方式吗?

黑谷山女

黑谷山女

        估计哪里有山洞,山女就在哪结网吧,幻想风穴也能碰见她,地灵虹洞也能碰见她。不过想要捕捉到自机们,还是希望渺茫啊。

故事剧情

        其实故事剧情在前面的自机故事线已经交代的七七八八了。这里给大家放一张我自己的整理的剧情线示意图吧~仅供参考,如果错误和有争议的地方,请多包容。

个人整理的刚欲异闻剧情线(比较清晰的版本:http://121.5.53.81:8080/#s/7hAlk_xA)

        如果要聊本作剧情的意义,其实很容易可以理解。石油在幻想乡出现绝非偶然,众所周知,不论什么事物,幻想入则意味着它在现世将要不存在,或将要被人们遗忘了。比如说三月精里,魔法森林中一座因被外界遗忘而进入幻想乡的电波塔,或者香霖堂里在外界已经过时的各路产品。幻想乡出现石油,自然和现世化石燃料走向枯竭不无关系。或许我们身处的这个世界,暂时还看不见化石燃料枯竭的迹象,但我们很有理由相信这种情况在不久的未来会发生的。或许在东方世界的外界,这种情况已经十分严重了。

        回想起尤魔的话,未免也是一种警醒:

呵呵呵……

这石油的真实面貌是被诅咒的血液

外面的世界可是
在对此非常清楚的前提下还用个不停哦

本来,石油这种东西
就是由生物形成的产物

生命的恐惧、悲欢、憎恶、怨恨
这一切形成了这种液体的真身

        毕竟,石油是伴随着绝望的未来的。

        这些就是我对刚欲异闻剧情的闲话了,可能忽略了重点而且关注点直接跑偏,不过毕竟只是一篇文笔平平的杂谈,于是就此停笔了!谢谢各位倾听了我的闲话。

        如果想到什么新的可以聊的点子,会继续来修改这篇杂谈的。

        (另外说一句,六面道中(大地之底,刚欲之海)真的好听!)

使用gRPC进行java和python沟通

Java

getStuNo.proto

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.examples.getstuno";
option java_outer_classname = "GetStuNoProto";
option objc_class_prefix = "GSN";

package getstuno;

service GetStuNoService {
    rpc getMsg (StuNoRequest) returns (StuNoResponse){}
}

message StuNoRequest {
    string name = 1;
}

message StuNoResponse {
    string number = 1;
}

protocol buffers 是一种语言无关、平台无关、可扩展的序列化结构数据的方法,它可用于(数据)通信协议、数据存储。这里使用proto文件用于生成gRPC所需要的框架。生成方式参照https://blog.csdn.net/qq_29319189/article/details/93539198

GetStuNoServer.java

package getstuno;

import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.examples.getstuno.GetStuNoServiceGrpc;
import io.grpc.examples.getstuno.StuNoResponse;
import io.grpc.examples.getstuno.StuNoRequest;
import io.grpc.stub.StreamObserver;
import java.io.IOException;
import java.util.logging.Logger;

/**
 * Server that manages startup/shutdown of a {@code Greeter} server.
 */
public class GetStuNoServer {
    private static final Logger logger = Logger.getLogger(GetStuNoServer.class.getName());

    private Server server;

    private void start() throws IOException {
        /* The port on which the server should run */
        int port = 50051;
        server = ServerBuilder.forPort(port)
                .addService(new GetStuNoServiceImpl())
                .build()
                .start();
        logger.info("Server started, listening on " + port);
        Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                // Use stderr here since the logger may have been reset by its JVM shutdown hook.
                System.err.println("*** shutting down gRPC server since JVM is shutting down");
                GetStuNoServer.this.stop();
                System.err.println("*** server shut down");
            }
        });
    }

    private void stop() {
        if (server != null) {
            server.shutdown();
        }
    }

    /**
     * Await termination on the main thread since the grpc library uses daemon threads.
     */
    private void blockUntilShutdown() throws InterruptedException {
        if (server != null) {
            server.awaitTermination();
        }
    }

    /**
     * Main launches the server from the command line.
     */
    public static void main(String[] args) throws IOException, InterruptedException {
        final GetStuNoServer server = new GetStuNoServer();
        server.start();
        server.blockUntilShutdown();
    }

    static class GetStuNoServiceImpl extends GetStuNoServiceGrpc.GetStuNoServiceImplBase {

        @Override
        public void getMsg(StuNoRequest req, StreamObserver<StuNoResponse> responseObserver) {
            String number = "0000";
            logger.info("Received name: " + req.getName());
            if (req.getName().equals("爱丽丝")) {
                number = "1234";
            }
            StuNoResponse reply = StuNoResponse.newBuilder().setNumber(number).build();
            responseObserver.onNext(reply);
            responseObserver.onCompleted();
        }
    }
}

Server端重点实现提供的服务

GetStuNoClient.java

package getstuno;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;
import io.grpc.examples.getstuno.GetStuNoServiceGrpc;
import io.grpc.examples.getstuno.StuNoResponse;
import io.grpc.examples.getstuno.StuNoRequest;

import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * A simple client that requests a greeting from the {@link GetStuNoServer}.
 */
public class GetStuNoClient {
    private static final Logger logger = Logger.getLogger(GetStuNoClient.class.getName());

    private final ManagedChannel channel;
    private final GetStuNoServiceGrpc.GetStuNoServiceBlockingStub blockingStub;

    /** Construct client connecting to GetStuNo server at {@code host:port}. */
    public GetStuNoClient(String host, int port) {
        this(ManagedChannelBuilder.forAddress(host, port)
                // Channels are secure by default (via SSL/TLS). For the example we disable TLS to avoid
                // needing certificates.
                .usePlaintext()
                .build());
    }

    /** Construct client for accessing HelloWorld server using the existing channel. */
    GetStuNoClient(ManagedChannel channel) {
        this.channel = channel;
        blockingStub = GetStuNoServiceGrpc.newBlockingStub(channel);
    }

    public void shutdown() throws InterruptedException {
        channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
    }

    /** Say hello to server. */
    public void greet(String name) {
        StuNoRequest request = StuNoRequest.newBuilder().setName(name).build();
        StuNoResponse response;
        try {
            response = blockingStub.getMsg(request);
        } catch (StatusRuntimeException e) {
            logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus());
            return;
        }
        logger.info("Client received: " + response.getNumber());
    }

    /**
     * Greet server. If provided, the first element of {@code args} is the name to use in the
     * greeting.
     */
    public static void main(String[] args) throws Exception {
        GetStuNoClient client = new GetStuNoClient("localhost", 50051);
        try {
            /* Access a service running on the local machine on port 50051 */
            String user = "爱丽丝";
            if (args.length > 0) {
                user = args[0]; /* Use the arg as the name to greet if provided */
            }
            client.greet(user);
        } finally {
            client.shutdown();
        }
    }
}

Client端向Server端请求服务

Python

getStuNo.proto

syntax = "proto3";

package getstuno;
 
service GetStuNoService {
 rpc getMsg (StuNoRequest) returns (StuNoResponse){}
}
 
message StuNoRequest {
  string name = 1;
}
 
message StuNoResponse {
  string number = 1;
}

Python与Java的proto文件基本相同,重点在于package必须相同,否则当提供名称、类型相同的service和message时仍然无法沟通。python利用proto生成grpc框架的方法参照:
https://www.cnblogs.com/zongfa/p/12218341.html

server.py

import grpc
import getStuNo_pb2
import getStuNo_pb2_grpc
 
from concurrent import futures
import time
 
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
 
 
class GetStuNoServicer(getStuNo_pb2_grpc.GetStuNoServiceServicer):
 
  def getMsg(self, request, context):
    print("Received name: %s" % request.name)
    if request.name == '爱丽丝':
      number = '1234'
    else:
      number = '0000'
    return getStuNo_pb2.StuNoResponse(number=number)
 
 
def serve():
  server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
  getStuNo_pb2_grpc.add_GetStuNoServiceServicer_to_server(GetStuNoServicer(), server)
  server.add_insecure_port('[::]:50051')
  server.start()
  try:
    while True:
      time.sleep(_ONE_DAY_IN_SECONDS)
  except KeyboardInterrupt:
    server.stop(0)
 
if __name__ == '__main__':
  serve()

client.py

import grpc
 
import getStuNo_pb2
import getStuNo_pb2_grpc
 
def run():
  # NOTE(gRPC Python Team): .close() is possible on a channel and should be
  # used in circumstances in which the with statement does not fit the needs
  # of the code.
  with grpc.insecure_channel('localhost:50051') as channel:
    stub = getStuNo_pb2_grpc.GetStuNoServiceStub(channel)
    response = stub.getMsg(getStuNo_pb2.StuNoRequest(name='爱丽丝'))
  print("Client received: " + response.number)
 
 
if __name__ == '__main__':
  run()

python端的代码较为精简。

此时,保证了java和python端的gRPC提供的gRPC服务名,服务传输的变量名、类型,服务使用的端口相同。先启动java或python任意一个server端,再启动java或python任意一个client端,都可以正确提供gRPC服务。

Frp内网穿透参数设置

Frp内网穿透的教程各大博客都能找到,但具体参数很少有讲清楚的。这里个人对已学习的部分参数做笔记以供参考。这里的需求是一台内网设备映射多个端口至一台公网设备。

服务端(公网设备)

在Frp穿透中,公网设备是服务端,配置在frps.ini中设置

[common]
bind_port = 7000
vhost_http_port = 8080

[common]

  • bind_port是必须设置的端口,这个参数的端口在公网设备使用、用来在公网设备和内网设备沟通。
  • vhost_http_port是当有http映射时,如果设置,作为默认的端口,这个参数的端口在公网设备使用、用来其他任意客户端打开映射的网站时访问的端口,即访问:公网设备IP:vhost_http_port。

需要开放的端口

  • bind_port(这里为7000)
  • 所有其他客户端访问公网ip时需要访问的端口,包括公网设备的frps.ini中的vhost_http_port和内网设备的frpc.ini中的所有remote_port(后面介绍)。

客户端(内网设备)

在Frp穿透中,内网设备是客户端,配置在frpc.ini中设置

[common]
server_addr = 106.x.x.x
server_port = 7000

[web]
type = http
local_ip = 127.0.0.1
local_port = 9091
custom_domains = 106.x.x.x

[ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 22
remote_port = 8081

[common]

  • server_addr填写公网设备的IP。
  • server_port填写公网设备用于与内网设备沟通的端口,即和服务端的frps.ini的bind_port相同。

[web]

  • type是映射类型,这里需要映射网站,填写http。
  • 在http映射中,需要设置本地架设网站的ip和端口(即在本机浏览器上访问网站的ip和端口),这个端口是网站后端监听的端口(这里例子为9091),而其他客户端要访问网站,需要的是外网设备ip和映射端口(这里例子为106.x.x.x:8080),这个端口需要在本地开放。
  • local_ip是网页IP,通常是127.0.0.1,即本机。
  • local_port是网页端口。
  • custom_domains是网页的域名,必须填写;如果没有域名,可以填写外网设备的IP。

[ssh]

  • type是映射类型,这里需要映射ssh,填写tcp。
  • local_ip是本地SSH的IP,通常是127.0.0.1,即本机。
  • local_port是本地SSH的端口,通常是22。
  • remote_port是本地该端口映射到外网设备后,其他客户端需要访问本地时需要访问的端口,即其他客户端连接ssh实际需要访问的端口(这里例子为106.x.x.x:8081),这个端口需要在外网设备开放。

CSAPP:ShellLab 实现原理整理

CSAPP附带实验中的ShellLab要求实现一个Unix下运行的Shell程序,并且能接受fg、bg、job、quit等指令和ctrl-c、ctrl-z的信号。这里并不从解题思路,而是从该程序的运行过程进行整理。

首先,不带参数启动时,Shell进程的main函数将编写好的handler注册到各个Signal上,然后通过一个不断循环的while来读取指令,在没有异常的情况下送到eval函数进行指令的解析。

int main(int argc, char **argv) // 未经修改的main函数
{
    char c;
    char cmdline[MAXLINE];
    int emit_prompt = 1; /* emit prompt (default) */

    /* Redirect stderr to stdout (so that driver will get all output
     * on the pipe connected to stdout) */
    dup2(1, 2);

    /* Parse the command line */
    while ((c = getopt(argc, argv, "hvp")) != EOF) {
        switch (c) {
        case 'h':             /* print help message */
            usage();
	    break;
        case 'v':             /* emit additional diagnostic info */
            verbose = 1;
	    break;
        case 'p':             /* don't print a prompt */
            emit_prompt = 0;  /* handy for automatic testing */
	    break;
	default:
            usage();
	}
    }

    /* Install the signal handlers */

    /* These are the ones you will need to implement */
    Signal(SIGINT,  sigint_handler);   /* ctrl-c */
    Signal(SIGTSTP, sigtstp_handler);  /* ctrl-z */
    Signal(SIGCHLD, sigchld_handler);  /* Terminated or stopped child */

    /* This one provides a clean way to kill the shell */
    Signal(SIGQUIT, sigquit_handler); 

    /* Initialize the job list */
    initjobs(jobs);

    /* Execute the shell's read/eval loop */
    while (1) {

	/* Read command line */
	if (emit_prompt) {
	    printf("%s", prompt);
	    fflush(stdout);
	}
	if ((fgets(cmdline, MAXLINE, stdin) == NULL) && ferror(stdin))
	    app_error("fgets error");
	if (feof(stdin)) { /* End of file (ctrl-d) */
	    fflush(stdout);
	    exit(0);
	}

	/* Evaluate the command line */
	eval(cmdline);
	fflush(stdout);
	fflush(stdout);
    } 

    exit(0); /* control never reaches here */
}

接着,在eval函数中,先判断命令是否为内建指令,若不是内建指令,则是需要创建一个新的子进程。子进程依靠fork创建,依靠execve切换进程的内容,同时利用pid来判断在父进程还是子进程中,从而分别执行不同的代码。在父进程内部,同时需要创建一个jobs表来记录子进程。

在这里,有两个情况需要加锁:一是父进程在整个从fork到等待子进程被回收的过程,此操作内需要发送SIGCHLD信号,保证前台只有一个子进程;而是在父进程修改jobs表前需要加锁,此操作是保证jobs表不发生冲突。

void eval(char *cmdline) 
{
    int bg;
    pid_t pid;
    sigset_t mask, mask_all, prev;
    char buf[MAXLINE];
    char *argv[MAXARGS];

    strcpy(buf, cmdline);  // 读指令至buffer
    bg = parseline(buf, argv); // 是否后台运行
    if (argv[0] == NULL) // 空行
    {
        return;
    }

    if (!builtin_cmd(argv)) // 不是内建指令
    {
        sigemptyset(&mask);
        sigaddset(&mask, SIGCHLD);
        sigfillset(&mask_all);
        sigprocmask(SIG_BLOCK, &mask, &prev); // 添加阻塞
        if ((pid = fork()) == 0) // 子进程
        {
            sigprocmask(SIG_SETMASK, &prev, NULL); // 子进程的阻塞解除
            if (setpgid(0, 0) < 0) // 初始化唯一的pid
            {
                printf("Setpgid Error!\n");
                exit(0);
            }
            if (execve(argv[0], argv, environ) < 0) // 转换进程
            {
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }
        else // 父进程
        {
            sigprocmask(SIG_BLOCK, &mask_all, NULL);
            if (bg) 
            {
                addjob(jobs, pid, BG, cmdline); // 添加至job表
            }
            else
            {
                addjob(jobs, pid, FG, cmdline); // 添加至job表
            }
            sigprocmask(SIG_SETMASK, &prev, NULL); // 对job操作完后即可解阻塞

            if (bg)
            {
                printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
            }
            else
            {
                waitfg(pid);
            }
        }
    }
    return;
}

在waitfg函数中等待前台子进程结束的方式,这里简单地采用了sleep+轮询

void waitfg(pid_t pid) // 等待前台子进程
{
    while (pid == fgpid(jobs))
    {
        sleep(1);
    }
    return;
}

若指令是内建指令,则通过buildin_cmd函数来进行解析

int builtin_cmd(char **argv) 
{
    if (!strcmp(argv[0],"quit"))
    {
        exit(0);
    }
    if (!strcmp(argv[0],"jobs"))
    {
        listjobs(jobs); // 输出jobs表
        return 1;
    }
    if (!strcmp(argv[0],"bg") || !strcmp(argv[0],"fg"))
    {
        do_bgfg(argv);
        return 1;
    }
    
    return 0;
}

其中quit为结束shell,jobs为输出jobs表,而bg或fg指令为将参数(pid或%jobid)所对应的进程切换到后台/前台运行。

因此,需要编写do_bgfg函数来具体执行bg和fg指令,并且发送绑定了handler的信号给子进程。

void sigchld_handler(int sig) 
{
	int old_errno = errno;	// 保存原errno
	pid_t pid;
	sigset_t mask, prev;
	int state;	// waitpid的状态
	struct job_t *job;
	
	sigfillset(&mask);
	while ((pid = waitpid(-1, &state, WNOHANG | WUNTRACED)) > 0) // while保证回收每个子进程
    {
		sigprocmask(SIG_BLOCK, &mask, &prev); // 阻塞所有信号
		if (WIFEXITED(state)) // 正常终止
        {
			deletejob(jobs, pid);
		}
        else if (WIFSIGNALED(state)) // 信号导致终止
        {
			printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, WTERMSIG(state));		
			deletejob(jobs, pid);
		}
        else if (WIFSTOPPED(state)) // 子进程停止
        {
			job = getjobpid(jobs, pid);
            if (job == NULL)
            {
                printf("Error: (%d): No such process\n",pid);
            }
			else
            {
                job->state = ST;
            }
			printf("Job [%d] (%d) stopped by signal %d\n", job->jid, pid, WSTOPSIG(state));
		}
		sigprocmask(SIG_SETMASK, &prev, NULL);	// 解锁
	}
	errno = old_errno; // 复原errno
}

对SIGCHLD信号,需要判断发送的原因。子进程正常终止、收到信号而终止、或者STOP时都会发送该信号。因此若正常终止或信号导致终止,则从jobs表中删除;若子进程停止,则只需要将job的状态改为STOP。

同样,需要在修改前后对信号加阻塞。

void sigint_handler(int sig) // 响应ctrl-c
{
    int old_errno = errno;
    pid_t pid = fgpid(jobs);
    if (pid != 0)
    {
        kill(-pid, sig);
    }
    errno = old_errno;
    return;
}

void sigtstp_handler(int sig) // 响应ctrl-z
{
    int old_errno = errno;
    pid_t pid = fgpid(jobs);
    if (pid != 0)
    {
        kill(-pid, sig);
    }
    errno = old_errno;
    return;
}

最后是ctrl-c和ctrl-z的相应。这里都只需要从jobs表中找到该job,并且将ctrl-c或ctrl-z的signal发送给子进程即可。

配合其他的辅助函数,这个程序便可以实现简单的shell功能,与参考文件中的示例shell输出无差。

./sdriver.pl -t trace01.txt -s ./tsh -a "-p"
#
# trace01.txt - Properly terminate on EOF.
#
./sdriver.pl -t trace01.txt -s ./tshref -a "-p"
#
# trace01.txt - Properly terminate on EOF.
#
./sdriver.pl -t trace02.txt -s ./tsh -a "-p"
#
# trace02.txt - Process builtin quit command.
#
./sdriver.pl -t trace02.txt -s ./tshref -a "-p"
#
# trace02.txt - Process builtin quit command.
#
./sdriver.pl -t trace03.txt -s ./tsh -a "-p"
#
# trace03.txt - Run a foreground job.
#
tsh> quit
./sdriver.pl -t trace03.txt -s ./tshref -a "-p"
#
# trace03.txt - Run a foreground job.
#
tsh> quit
./sdriver.pl -t trace04.txt -s ./tsh -a "-p"
#
# trace04.txt - Run a background job.
#
tsh> ./myspin 1 &
[1] (19812) ./myspin 1 &
./sdriver.pl -t trace04.txt -s ./tshref -a "-p"
#
# trace04.txt - Run a background job.
#
tsh> ./myspin 1 &
[1] (19817) ./myspin 1 &
./sdriver.pl -t trace05.txt -s ./tsh -a "-p"
#
# trace05.txt - Process jobs builtin command.
#
tsh> ./myspin 2 &
[1] (19824) ./myspin 2 &
tsh> ./myspin 3 &
[2] (19826) ./myspin 3 &
tsh> jobs
[1] (19824) Running ./myspin 2 &
[2] (19826) Running ./myspin 3 &
./sdriver.pl -t trace05.txt -s ./tshref -a "-p"
#
# trace05.txt - Process jobs builtin command.
#
tsh> ./myspin 2 &
[1] (19834) ./myspin 2 &
tsh> ./myspin 3 &
[2] (19836) ./myspin 3 &
tsh> jobs
[1] (19834) Running ./myspin 2 &
[2] (19836) Running ./myspin 3 &
./sdriver.pl -t trace06.txt -s ./tsh -a "-p"
#
# trace06.txt - Forward SIGINT to foreground job.
#
tsh> ./myspin 4
Job [1] (19846) terminated by signal 2
./sdriver.pl -t trace06.txt -s ./tshref -a "-p"
#
# trace06.txt - Forward SIGINT to foreground job.
#
tsh> ./myspin 4
Job [1] (19853) terminated by signal 2
./sdriver.pl -t trace07.txt -s ./tsh -a "-p"
#
# trace07.txt - Forward SIGINT only to foreground job.
#
tsh> ./myspin 4 &
[1] (19860) ./myspin 4 &
tsh> ./myspin 5
Job [2] (19862) terminated by signal 2
tsh> jobs
[1] (19860) Running ./myspin 4 &
./sdriver.pl -t trace07.txt -s ./tshref -a "-p"
#
# trace07.txt - Forward SIGINT only to foreground job.
#
tsh> ./myspin 4 &
[1] (19874) ./myspin 4 &
tsh> ./myspin 5
Job [2] (19876) terminated by signal 2
tsh> jobs
[1] (19874) Running ./myspin 4 &
./sdriver.pl -t trace08.txt -s ./tsh -a "-p"
#
# trace08.txt - Forward SIGTSTP only to foreground job.
#
tsh> ./myspin 4 &
[1] (19886) ./myspin 4 &
tsh> ./myspin 5
Job [2] (19888) stopped by signal 20
tsh> jobs
[1] (19886) Running ./myspin 4 &
[2] (19888) Stopped ./myspin 5 
./sdriver.pl -t trace08.txt -s ./tshref -a "-p"
#
# trace08.txt - Forward SIGTSTP only to foreground job.
#
tsh> ./myspin 4 &
[1] (19898) ./myspin 4 &
tsh> ./myspin 5
Job [2] (19900) stopped by signal 20
tsh> jobs
[1] (19898) Running ./myspin 4 &
[2] (19900) Stopped ./myspin 5 
./sdriver.pl -t trace09.txt -s ./tsh -a "-p"
#
# trace09.txt - Process bg builtin command
#
tsh> ./myspin 4 &
[1] (19912) ./myspin 4 &
tsh> ./myspin 5
Job [2] (19914) stopped by signal 20
tsh> jobs
[1] (19912) Running ./myspin 4 &
[2] (19914) Stopped ./myspin 5 
tsh> bg %2
[2] (19914) ./myspin 5 
tsh> jobs
[1] (19912) Running ./myspin 4 &
[2] (19914) Running ./myspin 5 
./sdriver.pl -t trace09.txt -s ./tshref -a "-p"
#
# trace09.txt - Process bg builtin command
#
tsh> ./myspin 4 &
[1] (19926) ./myspin 4 &
tsh> ./myspin 5
Job [2] (19928) stopped by signal 20
tsh> jobs
[1] (19926) Running ./myspin 4 &
[2] (19928) Stopped ./myspin 5 
tsh> bg %2
[2] (19928) ./myspin 5 
tsh> jobs
[1] (19926) Running ./myspin 4 &
[2] (19928) Running ./myspin 5 
./sdriver.pl -t trace10.txt -s ./tsh -a "-p"
#
# trace10.txt - Process fg builtin command. 
#
tsh> ./myspin 4 &
[1] (19942) ./myspin 4 &
tsh> fg %1
Job [1] (19942) stopped by signal 20
tsh> jobs
[1] (19942) Stopped ./myspin 4 &
tsh> fg %1
tsh> jobs
./sdriver.pl -t trace10.txt -s ./tshref -a "-p"
#
# trace10.txt - Process fg builtin command. 
#
tsh> ./myspin 4 &
[1] (19955) ./myspin 4 &
tsh> fg %1
Job [1] (19955) stopped by signal 20
tsh> jobs
[1] (19955) Stopped ./myspin 4 &
tsh> fg %1
tsh> jobs
./sdriver.pl -t trace11.txt -s ./tsh -a "-p"
#
# trace11.txt - Forward SIGINT to every process in foreground process group
#
tsh> ./mysplit 4
Job [1] (19968) terminated by signal 2
tsh> /bin/ps a
  PID TTY      STAT   TIME COMMAND
 1054 tty1     Ss+    0:00 /sbin/agetty --noclear tty1 linux
 8356 pts/2    Ss+    0:00 /bin/bash
18883 pts/0    Ss     0:00 -bash
19786 pts/0    S+     0:00 sh run.sh
19964 pts/0    S+     0:00 make test11
19965 pts/0    S+     0:00 /usr/bin/perl ./sdriver.pl -t trace11.txt -s ./tsh -a -p
19966 pts/0    S+     0:00 ./tsh -p
19973 pts/0    R      0:00 /bin/ps a
21890 pts/1    Ss+    0:00 /bin/bash
./sdriver.pl -t trace11.txt -s ./tshref -a "-p"
#
# trace11.txt - Forward SIGINT to every process in foreground process group
#
tsh> ./mysplit 4
Job [1] (19978) terminated by signal 2
tsh> /bin/ps a
  PID TTY      STAT   TIME COMMAND
 1054 tty1     Ss+    0:00 /sbin/agetty --noclear tty1 linux
 8356 pts/2    Ss+    0:00 /bin/bash
18883 pts/0    Ss     0:00 -bash
19786 pts/0    S+     0:00 sh run.sh
19974 pts/0    S+     0:00 make rtest11
19975 pts/0    S+     0:00 /usr/bin/perl ./sdriver.pl -t trace11.txt -s ./tshref -a -p
19976 pts/0    S+     0:00 ./tshref -p
19985 pts/0    R      0:00 /bin/ps a
21890 pts/1    Ss+    0:00 /bin/bash
./sdriver.pl -t trace12.txt -s ./tsh -a "-p"
#
# trace12.txt - Forward SIGTSTP to every process in foreground process group
#
tsh> ./mysplit 4
Job [1] (19992) stopped by signal 20
tsh> jobs
[1] (19992) Stopped ./mysplit 4 
tsh> /bin/ps a
  PID TTY      STAT   TIME COMMAND
 1054 tty1     Ss+    0:00 /sbin/agetty --noclear tty1 linux
 8356 pts/2    Ss+    0:00 /bin/bash
18883 pts/0    Ss     0:00 -bash
19786 pts/0    S+     0:00 sh run.sh
19986 pts/0    S+     0:00 make test12
19987 pts/0    S+     0:00 /usr/bin/perl ./sdriver.pl -t trace12.txt -s ./tsh -a -p
19990 pts/0    S+     0:00 ./tsh -p
19992 pts/0    T      0:00 ./mysplit 4
19993 pts/0    T      0:00 ./mysplit 4
20000 pts/0    R      0:00 /bin/ps a
21890 pts/1    Ss+    0:00 /bin/bash
./sdriver.pl -t trace12.txt -s ./tshref -a "-p"
#
# trace12.txt - Forward SIGTSTP to every process in foreground process group
#
tsh> ./mysplit 4
Job [1] (20007) stopped by signal 20
tsh> jobs
[1] (20007) Stopped ./mysplit 4 
tsh> /bin/ps a
  PID TTY      STAT   TIME COMMAND
 1054 tty1     Ss+    0:00 /sbin/agetty --noclear tty1 linux
 8356 pts/2    Ss+    0:00 /bin/bash
18883 pts/0    Ss     0:00 -bash
19786 pts/0    S+     0:00 sh run.sh
20001 pts/0    S+     0:00 make rtest12
20002 pts/0    S+     0:00 /usr/bin/perl ./sdriver.pl -t trace12.txt -s ./tshref -a -p
20005 pts/0    S+     0:00 ./tshref -p
20007 pts/0    T      0:00 ./mysplit 4
20008 pts/0    T      0:00 ./mysplit 4
20011 pts/0    R      0:00 /bin/ps a
21890 pts/1    Ss+    0:00 /bin/bash
./sdriver.pl -t trace13.txt -s ./tsh -a "-p"
#
# trace13.txt - Restart every stopped process in process group
#
tsh> ./mysplit 4
Job [1] (20020) stopped by signal 20
tsh> jobs
[1] (20020) Stopped ./mysplit 4 
tsh> /bin/ps a
  PID TTY      STAT   TIME COMMAND
 1054 tty1     Ss+    0:00 /sbin/agetty --noclear tty1 linux
 8356 pts/2    Ss+    0:00 /bin/bash
18883 pts/0    Ss     0:00 -bash
19786 pts/0    S+     0:00 sh run.sh
20012 pts/0    S+     0:00 make test13
20013 pts/0    S+     0:00 /usr/bin/perl ./sdriver.pl -t trace13.txt -s ./tsh -a -p
20018 pts/0    S+     0:00 ./tsh -p
20020 pts/0    T      0:00 ./mysplit 4
20021 pts/0    T      0:00 ./mysplit 4
20025 pts/0    R      0:00 /bin/ps a
21890 pts/1    Ss+    0:00 /bin/bash
tsh> fg %1
tsh> /bin/ps a
  PID TTY      STAT   TIME COMMAND
 1054 tty1     Ss+    0:00 /sbin/agetty --noclear tty1 linux
 8356 pts/2    Ss+    0:00 /bin/bash
18883 pts/0    Ss     0:00 -bash
19786 pts/0    S+     0:00 sh run.sh
20012 pts/0    S+     0:00 make test13
20013 pts/0    S+     0:00 /usr/bin/perl ./sdriver.pl -t trace13.txt -s ./tsh -a -p
20018 pts/0    S+     0:00 ./tsh -p
20030 pts/0    R      0:00 /bin/ps a
21890 pts/1    Ss+    0:00 /bin/bash
./sdriver.pl -t trace13.txt -s ./tshref -a "-p"
#
# trace13.txt - Restart every stopped process in process group
#
tsh> ./mysplit 4
Job [1] (20037) stopped by signal 20
tsh> jobs
[1] (20037) Stopped ./mysplit 4 
tsh> /bin/ps a
  PID TTY      STAT   TIME COMMAND
 1054 tty1     Ss+    0:00 /sbin/agetty --noclear tty1 linux
 8356 pts/2    Ss+    0:00 /bin/bash
18883 pts/0    Ss     0:00 -bash
19786 pts/0    S+     0:00 sh run.sh
20031 pts/0    S+     0:00 make rtest13
20032 pts/0    S+     0:00 /usr/bin/perl ./sdriver.pl -t trace13.txt -s ./tshref -a -p
20035 pts/0    S+     0:00 ./tshref -p
20037 pts/0    T      0:00 ./mysplit 4
20038 pts/0    T      0:00 ./mysplit 4
20041 pts/0    R      0:00 /bin/ps a
21890 pts/1    Ss+    0:00 /bin/bash
tsh> fg %1
tsh> /bin/ps a
  PID TTY      STAT   TIME COMMAND
 1054 tty1     Ss+    0:00 /sbin/agetty --noclear tty1 linux
 8356 pts/2    Ss+    0:00 /bin/bash
18883 pts/0    Ss     0:00 -bash
19786 pts/0    S+     0:00 sh run.sh
20031 pts/0    S+     0:00 make rtest13
20032 pts/0    S+     0:00 /usr/bin/perl ./sdriver.pl -t trace13.txt -s ./tshref -a -p
20035 pts/0    S+     0:00 ./tshref -p
20046 pts/0    R      0:00 /bin/ps a
21890 pts/1    Ss+    0:00 /bin/bash
./sdriver.pl -t trace14.txt -s ./tsh -a "-p"
#
# trace14.txt - Simple error handling
#
tsh> ./bogus
./bogus: Command not found.
tsh> ./myspin 4 &
[1] (20055) ./myspin 4 &
tsh> fg
fg command requires PID or %jobid argument
tsh> bg
bg command requires PID or %jobid argument
tsh> fg a
fg: argument must be a PID or %jobid
tsh> bg a
bg: argument must be a PID or %jobid
tsh> fg 9999999
(9999999): No such process
tsh> bg 9999999
(9999999): No such process
tsh> fg %2
%2: No such job
tsh> fg %1
Job [1] (20055) stopped by signal 20
tsh> bg %2
%2: No such job
tsh> bg %1
[1] (20055) ./myspin 4 &
tsh> jobs
[1] (20055) Running ./myspin 4 &
./sdriver.pl -t trace14.txt -s ./tshref -a "-p"
#
# trace14.txt - Simple error handling
#
tsh> ./bogus
./bogus: Command not found
tsh> ./myspin 4 &
[1] (20075) ./myspin 4 &
tsh> fg
fg command requires PID or %jobid argument
tsh> bg
bg command requires PID or %jobid argument
tsh> fg a
fg: argument must be a PID or %jobid
tsh> bg a
bg: argument must be a PID or %jobid
tsh> fg 9999999
(9999999): No such process
tsh> bg 9999999
(9999999): No such process
tsh> fg %2
%2: No such job
tsh> fg %1
Job [1] (20075) stopped by signal 20
tsh> bg %2
%2: No such job
tsh> bg %1
[1] (20075) ./myspin 4 &
tsh> jobs
[1] (20075) Running ./myspin 4 &
./sdriver.pl -t trace15.txt -s ./tsh -a "-p"
#
# trace15.txt - Putting it all together
#
tsh> ./bogus
./bogus: Command not found.
tsh> ./myspin 10
Job [1] (20097) terminated by signal 2
tsh> ./myspin 3 &
[1] (20101) ./myspin 3 &
tsh> ./myspin 4 &
[2] (20103) ./myspin 4 &
tsh> jobs
[1] (20101) Running ./myspin 3 &
[2] (20103) Running ./myspin 4 &
tsh> fg %1
Job [1] (20101) stopped by signal 20
tsh> jobs
[1] (20101) Stopped ./myspin 3 &
[2] (20103) Running ./myspin 4 &
tsh> bg %3
%3: No such job
tsh> bg %1
[1] (20101) ./myspin 3 &
tsh> jobs
[1] (20101) Running ./myspin 3 &
[2] (20103) Running ./myspin 4 &
tsh> fg %1
tsh> quit
./sdriver.pl -t trace15.txt -s ./tshref -a "-p"
#
# trace15.txt - Putting it all together
#
tsh> ./bogus
./bogus: Command not found
tsh> ./myspin 10
Job [1] (20122) terminated by signal 2
tsh> ./myspin 3 &
[1] (20127) ./myspin 3 &
tsh> ./myspin 4 &
[2] (20129) ./myspin 4 &
tsh> jobs
[1] (20127) Running ./myspin 3 &
[2] (20129) Running ./myspin 4 &
tsh> fg %1
Job [1] (20127) stopped by signal 20
tsh> jobs
[1] (20127) Stopped ./myspin 3 &
[2] (20129) Running ./myspin 4 &
tsh> bg %3
%3: No such job
tsh> bg %1
[1] (20127) ./myspin 3 &
tsh> jobs
[1] (20127) Running ./myspin 3 &
[2] (20129) Running ./myspin 4 &
tsh> fg %1
tsh> quit
./sdriver.pl -t trace16.txt -s ./tsh -a "-p"
#
# trace16.txt - Tests whether the shell can handle SIGTSTP and SIGINT
#     signals that come from other processes instead of the terminal.
#
tsh> ./mystop 2
Job [1] (20147) stopped by signal 20
tsh> jobs
[1] (20147) Stopped ./mystop 2
tsh> ./myint 2
Job [2] (20156) terminated by signal 2
./sdriver.pl -t trace16.txt -s ./tshref -a "-p"
#
# trace16.txt - Tests whether the shell can handle SIGTSTP and SIGINT
#     signals that come from other processes instead of the terminal.
#
tsh> ./mystop 2
Job [1] (20163) stopped by signal 20
tsh> jobs
[1] (20163) Stopped ./mystop 2
tsh> ./myint 2
Job [2] (20168) terminated by signal 2

函数调用关系观察:深度优先搜索解决八皇后问题

代码:

#include <iostream>
#include <vector>
using namespace std;

void dfs(int x, const int size, int* a, int* row, int* col, int* ld, int* rd, int& total);
int main()
{
	const int size = 10000;
	int row[size] = { 0 };
	int col[size] = { 0 };
	int ld[2 * size] = { 0 };
	int rd[2 * size] = { 0 };
	int a[size] = { 0 };
	int total = 0;
	dfs(0, size, a, row, col, ld, rd, total);
	cout << "总方案数: " << total;
	return 0;
}

void dfs(int x, const int size, int* a, int* row, int* col, int* ld, int* rd, int & total)
{
	//cout << x;
	if (x == size)
	{
		/*
		cout << "正确解法:";
		for (int i = 0; i < size; i++)
		{
			cout << a[i] << " ";
		}
		cout << endl;
	*/
		total++;
		return;
	}
	cout << "正在测试第 " << x + 1 << " 行的情况\n";
	for (int y = 0; y < size; y++)
	{
		//cout << "正在测试第 " << x + 1 << " 行第 " << y + 1 << " 列的情况:";
		if (!row[x] && !col[y] && !ld[x - y + size] && !rd[x + y])
		{
			//cout << "第 " << x + 1 << " 行第 " << y + 1 << " 列的情况是成功的,此时为:";
			//cout << "成功\n";
			a[x] = y + 1;

			row[x] = 1;//封闭行
			col[y] = 1;//封闭列
			ld[x - y + size] = 1;//封闭左对角线
			rd[x + y] = 1;//封闭右对角线
			/*
			for (int i = 0; i < size; i++)
			{
				cout << a[i] << " ";
			}
			cout << endl;
			*/
			dfs(x + 1, size, a, row, col, ld, rd, total);//进行下一层回溯

			row[x] = 0;//还原行
			col[y] = 0;//还原列
			ld[x - y + size] = 0;//还原左对角线
			rd[x + y] = 0;//还原右对角线
		}
		else
		{
			//cout << "  但是在第 " << x + 1 << " 行第 " << y + 1 << " 列的情况是失败的\n";
			//cout << "失败\n";
		}
	}
}
  1. 观察函数栈的调用:方法一:设置断点,在每个断点处检查变量的值;方法二:设置断点,并且在函数调用时和返回后都进行输出
  2. 程序以八皇后问题为例,这里使用的方法二,运行之后可以看出进行深度优先搜索时函数调用栈满足后进先出的原则,
  3. 将八皇后的棋盘大小设置为10000,在测试第2869~2875行的情况时发生了溢出,每次发生溢出时深度不完全相同

如何在宝塔上搭建express后端

问题

按照网络上的关于在宝塔上搭建express后端的方法,在我的环境下是有问题的。先简单叙述网上其他博文中的办法:先新建站点,然后上传express后端到站点目录下,然后再pm2管理器中启动,最后在站点的配置文件加上几行:

location / {
	    proxy_pass http://127.0.0.1:3000; // 监听的本地端口
}

然而这样会导致一个情况:只有根目录下的网页可以访问到,于是会导致如图情况:

连页面请求的css、js等文件都请求不到。

解决方案

研究了宝塔端pm2后,终于发现了正确的搭建方式:先在pm2中启动express后端,再在pm2中添加映射。具体的方式如下:

1. 上传写好的express后端

上传到一个方便管理的目录下

2. 在pm2中启动express

填写好文件夹地址、启动文件和项目名,点击添加,pm2可以自动在正确的端口启动express。这里使用了express-generator,所以启动文件是bin/www

3. 添加映射

点击上图中需要添加映射的项目右边的“映射”

填入需要添加映射的站点即可,宝塔会自动创建一个网站,在左侧“网站”面板中可以管理。

这时候所有的资源都请求得到了。

原因猜测

原方法访问不到的原因必然是仅仅加上那几行,Nginx无法正确映射所有的express框架中访问的资源的url。而直接使用pm2的自动配置映射的功能,可以自动完成对Nginx的配置,比较省时。

树莓派云课堂开发总结

完成功能

  1. 网站界面:Bootstrap4+jQuery+Vue.js
  2. 局域网搭建:hostapd+dhcpch+dnsmasq
  3. 局域网登录功能:Express+jQuery+MongoDB
  4. 局域网登录状态保存:express-session+cookie-parser
  5. 文件上传、下载和推送:bootstrap-fileinput+formidable+fs+socket.io
  6. 习题创建、完成和查看统计:MongoDB+Echart
  7. 云端电子白板:canvas+websocket
  8. 课堂直播:obs/ffmpeg+node-media-server+flv.js
  9. 直播弹幕:canvas+websocket

页面展示

更多计划

  1. 完善已有的功能,增加灵活性,增强用户体验
  2. 添加更多功能,使课堂更加方便
  3. 完善权限系统,界定超级管理员,管理员,可创建班级、可管理班级和无特殊权限的教师,学生等角色的权限功能

心得

js已经成为了一门应用场景非常广泛的语言,从服务器、到web客户端、以及窗口程序,都能使用js完成。nodejs的广泛使用,AngularJS、vue.js等新框架的诞生,mvvm、mvw等模式的融入,正在不断使js符合最新的设计理念。electron等打包方案使js在不同领域展现其独特性和适用性。Webpack、gulp等前端工具使js最初作为一项脚本语言而能开发大型项目成为可能。掌握好js及基于js的各种工具,非常有益。

课堂直播弹幕功能

首先利用vue的循环语句来绑定html中的列表元素

<div class="card-body" id="danmaku">
    <li v-for="chat in chats">
        {{ chat.name }}: {{ chat.content }}
    </li>
</div>
new Vue({
    el: '#danmaku',
    data: {
        chats: [
            { name: "学生1", content: "233"},
            { name: "学生2", content: "233"},
            { name: "学生3", content: "233"},
            { name: "学生4", content: "233"}
        ]
    }
})

然后增加弹幕发送框,利用v-model绑定输入框

var msg = new Vue({
        el: '#send',
        data: {
            message: ''
        }
    })

    $('#btn-send').on('click', () => {
        addDanmaku('‘你’', msg.message);
    })

function addDanmaku(name, content) {
    while (chats.length >= 15) {
        chats.shift();
    }
    chats.push({
        name: name,
        content: content
    })
    msg.message = '';
}

接着使用websocket进行所有客户端弹幕的同步

// 客户端js
var socket = io('ws://' + window.location.host);
socket.on('down', function(data) {
        addDanmaku(data.name, data.content);
    })

socket.emit('up', {
    name: name, 
    content: msg.message
});
// 服务端js
// 接收弹幕
socket.on('up', (chat) => {
    socket.broadcast.emit('down', chat);
})

最后添加利用canvas绘制弹幕的功能,学习了一下他人的思路,先对canvas创建一个弹幕对象,设置canvas的属性并创建容器,然后利用setInterval不断更新更新弹幕文本的位置,并重绘canvas。控制绘图的draw函数:

// 绘制弹幕
        this.draw = function () {
            if (this.interval != "") return; // 如果已经有重绘,则返回
            var _this = this; // 传入this(弹幕对象)
            this.interval = setInterval(function () { // 每20毫秒进行一次绘制
                _this.ctx.clearRect(0, 0, _this.width, _this.height); // 先擦除画布
                _this.ctx.save();   // 然后将当前画布保存
                for (var i = 0; i < _this.msgs.length; i++) {
                    if (!(_this.msgs[i] == null)) {
                        if (_this.msgs[i].left==null) { // 新创建的弹幕,不存在作为左坐标left属性
                            _this.msgs[i].left = _this.width; // 新弹幕的位置在最右边
                            _this.msgs[i].top = parseInt(Math.random() * 200) + 30; // 设置弹幕的高度
                            _this.msgs[i].speed = 4; // 设置弹幕的速度
                            _this.msgs[i].color = _this.colorArr[Math.floor(Math.random() * _this.colorArr.length)]; // 设置弹幕的颜色 
                        }else{
                            if(_this.msgs[i].left < -200){
                                _this.msgs[i]=null;  // 弹幕离开屏幕后删除
                            }else {
                                _this.msgs[i].left = parseInt(_this.msgs[i].left - _this.msgs[i].speed); // 弹幕移动
                                _this.ctx.fillStyle = _this.msgs[i].color; // 在画板上设置颜色
                                _this.ctx.fillText(_this.msgs[i].msg, _this.msgs[i].left, _this.msgs[i].top); // 在画板上重绘弹幕
                                _this.ctx.restore(); // 写入画板
                            }
                        }
                    }
                }
            }, 20);
        };

并且同时将创建弹幕的函数调用放在addDanmuku函数中,这样不论是自己发弹幕还是接收弹幕,都可以看见飘过的弹幕

基于Express框架+obs/ffmpeg进行云课堂直播(node-media-server+flv.js)

云课堂中,直播功能非常重要,可以很大程度上弥补单纯web端功能的不足,尤其是串流电脑屏幕时,可以将使用任何教学软件的过程串流给客户端,效果拔群。

推流方式选择

一、Web端推流

Web端推流在现在使用得很少,一般基于rtmp-streamer模块进行,同时需要swf插件。并且现在各大浏览器正在陆续抛弃FlashPlayer,因此不采用这种方式进行推流。

二、利用ffmpeg推流

ffmpeg是非常常用的开源的视频音频处理程序,在很多软件中有使用。如果在js使用,可以利用封装了ffmpeg命令行调用的fluent-ffmpeg模块进行调用。然而ffmpeg在多个平台需要不同的本地客户端,并且配置环境变量,或者临时写入路径,因此主要有两个方法:

  1. 随网站载入,下载ffmpeg执行文件,临时存储。但由于平台多样、ffmpeg程序体积大的问题并不合适。
  2. 在网站上预先提供ffmpeg下载和安装,但由于更新版本、构建等问题,加以该云课堂需要脱离外部网络运行,这个方法的使用也有缺陷。

在章节最后一节,尝试了将ffmpeg打包成用户界面更舒适的应用,用于显示器推流。

另外,直播推流对系统性能的开销很大,出于对优化和实际应用场景等方面考虑,既然需要本地预先安装推流客户端,不如选择更加成熟的软件。

三、利用OBS(Open Broadcaster Software)推流

OBS是一个免费、开源的视频录制、推流软件,其优化力度大、对系统性能占用小、支持复杂灵活的场景,并且具有直接推流至某一服务器的完善功能,因此使用OBS对课堂直播进行推流。

OBS的使用

obs的使用非常灵活,可以捕捉各种各样的来源,包括屏幕捕获、窗口捕获、媒体源、浏览器、图片、文字等,并且可以实时查看当前的捕获情况。

obs的推流需要一个服务器地址,将视频流发送至服务端,因此服务端需要一个服务来提供地址获取视频

服务端监听:node-media-server模块

首先安装模块

sudo npm install node-media-server –save

然后创建一个调用模块的脚本

// myscripts\stream.js
const NodeMediaServer = require('node-media-server');
 
const config = {
  rtmp: {
    port: 1935,
    chunk_size: 60000,
    gop_cache: true,
    ping: 60,
    ping_timeout: 30
  },
  http: {
    port: 8000,
    allow_origin: '*'
  }
};

function run() {
    console.log('开始监听推流');
    var nms = new NodeMediaServer(config);
    nms.run();
}

module.exports.run = run;

因为框架中运行服务器的是www文件,因此把启动对串流的监听也放在www中

// bin\www
// 启动推流监听
var stream = require('../myscripts/stream').run;
stream();

客户端拉流:flv.js

flv.js是一个开源的脚本,可以不借助flash在H5的video标签中播放flv。

<head>
    <script src="https://cdn.bootcss.com/flv.js/1.4.0/flv.min.js"></script>
    <!-- ...... -->
</head>
<body>
    <video id="videoElement" width="1280px" controls></video>
</body>

紧接着对这个vedio添加来源

// stream.js
$(document).ready(() => {
    if (flvjs.isSupported()) {
        var url = 'http://' + document.domain + ':8000/live/test.flv';
        var videoElement = document.getElementById('videoElement');
        var flvPlayer = flvjs.createPlayer({
            type: 'flv',
            url: url
        });
        flvPlayer.attachMediaElement(videoElement);
        flvPlayer.load();
        flvPlayer.play();
    }
});

注意url中的“test”便是直播名,同时在串流时作为串流的秘钥使用

启动直播功能

启动express框架,便能看见后台的消息:

开始监听推流
2020-6-20 0:05:50 13172 [INFO] Node Media Server v2.1.9
2020-6-20 0:05:50 13172 [INFO] Node Media Rtmp Server started on port: 1935
2020-6-20 0:05:50 13172 [INFO] Node Media Http Server started on port: 8000
2020-6-20 0:05:50 13172 [INFO] Node Media WebSocket Server started on port: 8000

对于OBS串流,这个时候需要在obs里填写串流信息。此时在本机上进行测试,域名填写localhost。

服务器:rtmp://localhost/live
串流秘钥:test

在obs中开始串流,便能在网站上查看到直播了。

优化开播设置

直接在课程界面添加服务器和串流秘钥即可

$(document).ready(() => {
    //请求所有需要的数据
    $.get("/users/process_getdata", (data) => {
        var name = data.username;
        var classname = data.classname;
        //修改称呼
        let text = $("#welcome").text();
        $("#welcome").text(text + name);
        let key = "stream-key-" + classname;
        $('#stream-url').val('rtmp://' + document.domain + '/live');
        // 串流服务器url
        $('#stream-key').val(key);
        // 串流秘钥
    }, "json");
})

非常简约

附:利用ffmpeg推流

ffmpeg功能非常强大,同时只需要命令行就可以进行推流。为了提高用户体验,我们将操作打包成一个nodejs应用。

首先为了使用ffmpeg推流,需要一个如下的指令启动ffmpeg,并且将屏幕流推送到服务器:

ffmpeg -f gdigrab -i desktop -vcodec libx264 -preset:v ultrafast -tune:v zerolatency -f flv rtmp://127.0.0.1/live/stream-key-0.flv

当然可以使用js直接执行这个命令。当然也可以使用fluent-ffmpeg模块,它将ffmpeg命令行以js形式转译,便于执行命令,调用ffmpeg的方式如下:

const ffmpeg = require('fluent-ffmpeg');
const ffmpegPath = __dirname+"\\..\\libs\\ffmpeg.exe";
var command = null;
function run(outputPath) {
    command = ffmpeg()
    .setFfmpegPath(ffmpegPath)
    .input('desktop')
    .inputFormat('gdigrab')
    .addOptions([
        '-vcodec libx264',
        '-preset ultrafast',
        '-acodec libmp3lame',
        '-pix_fmt yuv422p'
    ])
    .format('flv')
    .output(outputPath, {
        end: true
    })
    .on('start', function (commandLine) {
        console.log('[' + new Date() + '] Vedio is Pushing !');
        console.log('commandLine: ' + commandLine);
    })
    .on('error', function (err, stdout, stderr) {
        console.log('error: ' + err.message);
    })
    .on('end', function () {
        console.log('[' + new Date() + '] Vedio Pushing is Finished !');
    });
    command.run();
}

function stop() {
    command.kill();
}

直接运行脚本里的run函数,是可以成功推流的。然后编写一个简单的html页面:

为了将node程序打包,这里需要使用electron和打包工具asar,并初始化项目

cnpm install electron -g
cnpm install electron-prebuilt -g
cnpm install electron-packager -g
cnpm install electron-builder -g
cnpm install asar -g
npm init

初始化完项目后,只需要在index.html中编写主窗体,然后在js中加上对fluent-ffmpeg的引用,并且可以直接在本地访问ffmpeg.exe。引入非原生模块需要重新构建模块:

cnpm install electron-rebuild –save-dev
.\node_modules.bin\electron-rebuild.cmd

在页面脚本中使用vue,并且调用run和stop两个函数

$(document).ready(() => {
    var data = {
        isStreaming: false,
        btnMessage: '开始串流',
        url: '',
        key: ''
    }

    var buttonApp = new Vue({
        el: "#btn",
        data: data
    })
    
    var inputApp = new Vue({
        el: "#inputs",
        data: data
    })
    
    $('#btn').on('click', () => {
        if (data.isStreaming == false) {
            if (!data.url) {
                alert('请输入服务器!');
            } else if (!data.key) {
                alert('请输入串流秘钥!');
            } else {
                data.isStreaming = true;
                var path;
                if (data.url[data.url.length - 1] != "/") {
                    path = data.url + "/" + data.key + '.flv';
                } else {
                    path = data.url + data.key + '.flv';
                }
                data.btnMessage = "串流中:" + path;
                const ipc = require('electron').ipcRenderer; 
                run(path);
            }
        } else {
            data.isStreaming = false;
            data.btnMessage = "开始串流";
            stop();
        }
    })
})

由于构件electron需要的部分github资源由于网络原因无法下载,这里使用wpf,用类似的逻辑,命令行启动ffmpeg并推流。

可以看见使用ffmpeg也可以正常地推流桌面,但是同样需要下载客户端,相比较于使用obs,这个方法需要大量的功能改进,例如获得摄像头名称并直播摄像头、电脑音频和麦克风音频选择、桌面推流来源选择等。

基于Express实现云课堂远程共享白板(canvas+websocket)

利用canvas搭建白板

首先在html中设置一个白板区域,添加一个清除白板的按钮

<canvas id="whiteboard" width="1280px" height="720px" class="whiteboard-canvas"></canvas>
<div>
    <button id="clear" class="btn-warning">清除画板</button>
</div>

接着在js中创建一个App对象,用于管理所有白板相关的属性和方法,并且添加处理鼠标事件的方法。

App.whiteboard = $('#whiteboard');
App.ctx = App.whiteboard[0].getContext("2d");
// 标记是否开始绘图
App._startedDrawing = false;
// 鼠标事件关联绘图:下笔、移动、提笔
App.whiteboard.on('mousedown mouseup mousemove', null, function(e) {
    // 如果没有开始画画并且事件不是下笔 则不开始画画
    if (!App._startedDrawing && e.type != "mousedown") return;
    App._startedDrawing = true;
    // 获得偏移坐标
    var offset = $(this).offset();
    // 所有需要的数据
    var data = {
        x: (e.pageX - offset.left),
        y: (e.pageY - offset.top),
        type: e.handleObj.type,
        color: App.ctx.strokeStyle,
        imageData: App.whiteboard[0].toDataURL()
    }
    // 呈现在自己的画板上
    App.draw(data);
})
// 鼠标事件关联清除画板
$('#clear').on('click', function() {
    App.clear();
})

然后写draw函数,将传入的data在画布上展现出来;以及clear函数,用于清空画板

// 绘图操作 
App.draw = function (data) {
    var originalColor = App.ctx.strokeStyle;
    App.ctx.strokeStyle = data.color;
    if (data.type == "mousedown") {
        App.ctx.beginPath();
        App.ctx.moveTo(data.x, data.y)
    } else if (data.type == "mouseup") {
        App.ctx.stroke();
        App.ctx.closePath();
        App._startedDrawing = false;
        App.socket.emit('save-data', App.whiteboard[0].toDataURL());
    } else {
        App.ctx.lineTo(data.x, data.y);
        App.ctx.stroke();
    }
    App.ctx.strokeStyle = originalColor;
};
App.clear = function () {
    App.ctx.clearRect(0, 0, App.whiteboard[0].width, App.whiteboard[0].height);
};

这个时候,就完成了鼠标路径到画布显示的路径

利用websocket同步画板

这里在服务端和客户端均是利用socket.io来实现websocket。首先在客户端js中写好连接websocket,发送笔画、接受笔画的方法。

// 建立websocket
App.socket = io('http://' + window.location.host);
// 初始化图片和色彩
App.socket.on('setup', function (color, dataUrl) {
    App.ctx.strokeStyle = color;
    if (dataUrl) {
        // 从url加载图片
        var imageObj = new Image();
        imageObj.onload = function () {
            App.ctx.drawImage(this, 0, 0);
        };
        imageObj.src = dataUrl;
    }
});
// 接受笔画信息
App.socket.on('draw', App.draw);
// 接受清除画板请求
App.socket.on('clear', App.clear);
// 发送笔画至服务端
App.socket.emit('do-the-draw', data);
// 发送画完一笔的信息至服务端
App.socket.emit('save-data', App.whiteboard[0].toDataURL());
// 发送清除画板请求
App.socket.emit('clear');

在客户端,由于socket.io是基于server进行websocket操作的,而server的架设在bin\www文件中,因此为了能够在路由中实现websocket,需要完成一个router.js→app.js→www的过程。

// eoutes\canvas.io
var imageData;
// 对不同用户给不同的颜色
var colors = [ "#CFF09E", "#A8DBA8", "#79BD9A", "#3B8686", "#0B486B" ];
var i = 0;
var IO = null;
// 这里定义了一个io函数
router.io = function(io) {
    io.on('connection', (socket) => {
        if (i == 5) i = 0;
      socket.emit('setup', colors[i++], imageData);
  
      socket.on('do-the-draw', (data) => {
        // 用户画了一笔
        socket.broadcast.emit('draw', data);
        imageData = data.imageData;
      })
  
      socket.on('clear', function() {
        // 用户清除画板
        socket.broadcast.emit('clear');
        imageData = null;
      })
  
      socket.on('save-data', (data) => {
        // 用户画完一笔
        imageData = data;
      })
    });
    IO = io;
    return io;
}

这里在路由中定义了一个对传入io进行socket操作的函数,并且将这个函数传出出去。

// app.js
// 引入路由
var canvasRouter = require('./routes/canvas')
// 引入路由中的io函数
app.canvasIO = canvasRouter.io;
// 便于在路由中响应http请求
app.use('/canvas', canvasRouter);

而app.js获得router传出的这个函数,并再次传出去

// bin\www
// 架设server
var server = http.createServer(app);
// 引入socket.io
var io = require('socket.io')(server);
// 将io传入app.js的canvasIO函数中,最终传入canvas.js的io函数中
app.canvasIO(io);

www获得app.js传出的对io进行操作的函数,并且传入server的io。这个时候,不同客户端之间就可以实时共享白板了。

添加上传画布背景功能

为了便于对某一张图片进行标记和讲解,需要添加一个上传画布背景的功能。

选用的依然是bootstrap-fileinput插件,在html中插入上传框,在上传初始化中将上传的文件格式限制为’jpg’, ‘gif’, ‘png’, ‘jpeg’。上传后,在路由中接受文件并暂存为临时文件。

//上传背景图片
router.post('/upload', function(req, res) {
    var form = new formidable.IncomingForm();
	// ...
        // formidable对象的临时路径等属性的初始化
	form.parse(req, function(err, fields, files) {
        var filepath = '';
		for (var key in files) {
                        // ...
			// 获取到临时存放的文件地址
		}
		var fileExt = filepath.substring(filepath.lastIndexOf("."));
        // 文件类型校验
		if ((".jpg.png.bmp.jpeg").indexOf(fileExt.toLowerCase()) == -1) {
                        // ...
			// 格式不对的操作
		} else {
            // ...
            //  提供格式正确的返回值,并且利用socket向所有客户端发送背景临时文件的地址
        }
    });
})

然后在客户端js中加入更换图片背景的方法

// 接受图片背景
App.socket.on('drawImage', (data) => {
    var imgObj = new Image();
    imgObj.src = '/canvas/image?path=' + data.data; // 通过url获得服务端图像临时文件
    imgObj.onload = function() {
        App.ctx.drawImage(this, 0, 0, 1280, 720); // canvas 绘制图像
    }
    $("#downblock").hide();
    // 或者直接将图像作为背景,但不推荐
    //$('#whiteboard').css("background", "url(" + data.data + ") no-repeat");
})

通过上传图片→分发图片→绘制图片的过程,所有的客户端,不论是已经打开的,还是后续打开的,都可以在画布上看见新的图片。

切换黑白板功能

切换黑白板功能只需要在预先存好黑白两个底色,然后在路由中存储一个是否为白色的变量。每次客户端点击切换黑白板的时候,将请求emit到服务端,然后服务端修改这个变量,并且在emit到所有客户端即可

保存画板功能

保存画板需要用到canvas的一个toDataURL方法,然后将获得的url下载下来

// 保存画板
$(document).ready(() => {
    $('#save').click(() => {
        downLoad(whiteboard.toDataURL("image/png"));
    })
})

function downLoad(url){
    // 创建一个新的元素,将href值设为url,并点击它
}

通过点击新建的下载元素,可以通过canvas的url,将画布转为png文件下载下来

同样地,这个简单的共享白板也可以添加更多优化,例如撤销恢复功能,添加线条、方框、箭头等基本元素的功能,以及对学生访问白板的权限管理等。