LiteNetLib 시작
익숙하지 않다면, LiteNetLib은 게임 개발에 사용되는 경량급 C#네트워크 라이브러리로 신뢰할 수 있는 UDP 프로토콜을 실현합니다.그것은 이미 상업 게임에 성공적으로 사용되었다. 예를 들어 LiteNetLib.
본고의 코드 세션은 Godot을 사용하지만, 코드를 Unity나 다른 플랫폼으로 쉽게 개편할 수 있습니다.
7일 남았어
클라이언트 및 서버
클라이언트와 서버의 기본 클래스부터 시작합시다.만약 Godot을 사용한다면, 클라이언트를 자동으로 불러오는 데 추가해서 장면 사이에서 불러오는 상태를 유지해야 할 수도 있습니다.
서버 클래스를 위한 단독 장면을 만들어야 합니다.CLI Godot은 게임을 동시에 실행하는 두 가지 인스턴스에 사용할 수 있습니다.cd
프로젝트 디렉토리를 입력하고 실행합니다godot-mono scenes/Server.tscn
.
using Godot;
using System;
using LiteNetLib;
using LiteNetLib.Utils;
public class Client : Node, INetEventListener {
private NetManager client;
public void Connect() {
client = new NetManager(this) {
AutoRecycle = true,
};
}
public override void _Process(float delta) {
if (client != null) {
client.PollEvents();
}
}
// ... INetEventListener methods omitted
}
using Godot;
using System;
using LiteNetLib;
using LiteNetLib.Utils;
public class Server : Node, INetEventListener {
private NetManager server;
public override void _Ready() {
server = new NetManager(this) {
AutoRecycle = true,
};
}
public override void _Process(float delta) {
server.PollEvents();
}
// ... INetEventListener methods omitted
}
참고 사항:
using Godot;
using System;
using LiteNetLib;
using LiteNetLib.Utils;
public class Client : Node, INetEventListener {
private NetManager client;
public void Connect() {
client = new NetManager(this) {
AutoRecycle = true,
};
}
public override void _Process(float delta) {
if (client != null) {
client.PollEvents();
}
}
// ... INetEventListener methods omitted
}
using Godot;
using System;
using LiteNetLib;
using LiteNetLib.Utils;
public class Server : Node, INetEventListener {
private NetManager server;
public override void _Ready() {
server = new NetManager(this) {
AutoRecycle = true,
};
}
public override void _Process(float delta) {
server.PollEvents();
}
// ... INetEventListener methods omitted
}
INetEventListener
.코드 편집기가 인터페이스를 자동으로 생성하기를 원하지만, 그렇지 않으면 EventBasedNetListener
를 만들어서 NetManager
에 전달할 수 있습니다.client != null
검사합니다. private NetPeer server;
public void Connect() {
// ...
client.Start();
GD.Print("Connecting to server");
client.Connect("localhost", 12345, "");
}
public void OnPeerConnected(NetPeer peer) {
GD.Print("Connected to server");
server = peer;
}
public override void _Ready() {
// ...
GD.Print("Starting server");
server.Start(12345);
}
public void OnConnectionRequest(ConnectionRequest request) {
GD.Print($"Incoming connection from {request.RemoteEndPoint.ToString()}");
request.Accept();
}
참고 사항:server.ConnectedPeerCounts
를 통해 필요할 때 요청을 거절할 수 있다.client.Connect
과connection.AcceptIfKey
의 매개 변수를 보십시오.패킷과 통신
클라이언트와 서버 간에 통신하기 위해서는 먼저 데이터 패키지를 정의해야 합니다.
public class JoinPacket {
public string username { get; set; }
}
public class JoinAcceptPacket {
public PlayerState state { get; set; }
}
public struct PlayerState : INetSerializable {
public uint pid;
public Vector2 position;
public void Serialize(NetDataWriter writer) {
writer.Put(pid);
writer.Put(position);
}
public void Deserialize(NetDataReader reader) {
pid = reader.GetUInt();
position = reader.GetVector2();
}
}
public class ClientPlayer {
public PlayerState state;
public string username;
}
public class ServerPlayer {
public NetPeer peer;
public PlayerState state;
public string username;
}
패키지 클래스는 LiteNetLib에서 자동으로 정렬됩니다.단, 속성만 필드가 아니라 서열화되기 때문에 { get; set; }
은 필수적이다.구조도 정의하고 수동으로 실현할 수 있습니다 INetSerializable
.이것은 매우 유용하다. 왜냐하면 당신은 구조의 복제 의미를 얻을 수 있기 때문이다.
그러나 우리는 여전히 약간의 물건이 부족하다.PlayerState
및 reader.GetVector2
함수가 실제로 존재하지 않는다는 것을 알 수 있습니다.기본적으로 LiteNetLib은 대부분의 기본 데이터 형식을 서열화할 수 있지만, Godotwriter.Put(Vector2)
은 하나의 구조이기 때문에 할 수 없습니다.Vector2
및 NetDataWriter
확장 방법:
public static class SerializingExtensions {
public static void Put(this NetDataWriter writer, Vector2 vector) {
writer.Put(vector.x);
writer.Put(vector.y);
}
public static Vector2 GetVector2(this NetDataReader reader) {
return new Vector2(reader.GetFloat(), reader.GetFloat());
}
}
이제 패킷을 전송합니다.
private NetDataWriter writer;
private NetPacketProcessor packetProcessor;
private ClientPlayer player = new ClientPlayer();
public void Connect(string username) {
player.username = username;
writer = new NetDataWriter();
packetProcessor = new NetPacketProcessor();
packetProcessor.RegisterNestedType((w, v) => w.Put(v), reader => reader.GetVector2());
packetProcessor.RegisterNestedType<PlayerState>();
packetProcessor.SubscribeReusable<JoinAcceptPacket>(OnJoinAccept);
// ...
}
public void SendPacket<T>(T packet, DeliveryMethod deliveryMethod) where T : class, new() {
if (server != null) {
writer.Reset();
packetProcessor.Write(writer, packet);
server.Send(writer, deliveryMethod);
}
}
public void OnJoinAccept(JoinAcceptPacket packet) {
GD.Print($"Join accepted by server (pid: {packet.state.pid})");
player.state = packet.state;
}
public void OnPeerConnected(NetPeer peer) {
// ...
SendPacket(new JoinPacket { username = player.username }, DeliveryMethod.ReliableOrdered);
}
public void OnNetworkReceive(NetPeer peer, NetPacketReader reader, DeliveryMethod deliveryMethod) {
packetProcessor.ReadAllPackets(reader);
}
[Export] public Vector2 initialPosition = new Vector2();
private NetDataWriter writer;
private NetPacketProcessor packetProcessor;
private Dictionary<uint, ServerPlayer> players = new Dictionary<uint, ServerPlayer>();
public override void _Ready() {
writer = new NetDataWriter();
packetProcessor = new NetPacketProcessor();
packetProcessor.RegisterNestedType((w, v) => w.Put(v), reader => reader.GetVector2());
packetProcessor.RegisterNestedType<PlayerState>();
packetProcessor.SubscribeReusable<JoinPacket, NetPeer>(OnJoinReceived);
// ...
}
public void SendPacket<T>(T packet, NetPeer peer, DeliveryMethod deliveryMethod) where T : class, new() {
if (peer != null) {
writer.Reset();
packetProcessor.Write(writer, packet);
peer.Send(writer, deliveryMethod);
}
}
public void OnJoinReceived(JoinPacket packet, NetPeer peer) {
GD.Print($"Received join from {packet.username} (pid: {(uint)peer.Id})");
ServerPlayer newPlayer = (players[(uint)peer.Id] = new ServerPlayer {
peer = peer,
state = new PlayerState {
pid = (uint)peer.Id,
position = initialPosition,
},
username = packet.username,
});
SendPacket(new JoinAcceptPacket { state = newPlayer.state }, peer, DeliveryMethod.ReliableOrdered);
}
public void OnNetworkReceive(NetPeer peer, NetPacketReader reader, DeliveryMethod deliveryMethod) {
packetProcessor.ReadAllPackets(reader, peer);
}
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) {
if (peer.Tag != null) {
players.Remove((uint)peer.Id);
}
}
참고 사항:
public class JoinPacket {
public string username { get; set; }
}
public class JoinAcceptPacket {
public PlayerState state { get; set; }
}
public struct PlayerState : INetSerializable {
public uint pid;
public Vector2 position;
public void Serialize(NetDataWriter writer) {
writer.Put(pid);
writer.Put(position);
}
public void Deserialize(NetDataReader reader) {
pid = reader.GetUInt();
position = reader.GetVector2();
}
}
public class ClientPlayer {
public PlayerState state;
public string username;
}
public class ServerPlayer {
public NetPeer peer;
public PlayerState state;
public string username;
}
public static class SerializingExtensions {
public static void Put(this NetDataWriter writer, Vector2 vector) {
writer.Put(vector.x);
writer.Put(vector.y);
}
public static Vector2 GetVector2(this NetDataReader reader) {
return new Vector2(reader.GetFloat(), reader.GetFloat());
}
}
private NetDataWriter writer;
private NetPacketProcessor packetProcessor;
private ClientPlayer player = new ClientPlayer();
public void Connect(string username) {
player.username = username;
writer = new NetDataWriter();
packetProcessor = new NetPacketProcessor();
packetProcessor.RegisterNestedType((w, v) => w.Put(v), reader => reader.GetVector2());
packetProcessor.RegisterNestedType<PlayerState>();
packetProcessor.SubscribeReusable<JoinAcceptPacket>(OnJoinAccept);
// ...
}
public void SendPacket<T>(T packet, DeliveryMethod deliveryMethod) where T : class, new() {
if (server != null) {
writer.Reset();
packetProcessor.Write(writer, packet);
server.Send(writer, deliveryMethod);
}
}
public void OnJoinAccept(JoinAcceptPacket packet) {
GD.Print($"Join accepted by server (pid: {packet.state.pid})");
player.state = packet.state;
}
public void OnPeerConnected(NetPeer peer) {
// ...
SendPacket(new JoinPacket { username = player.username }, DeliveryMethod.ReliableOrdered);
}
public void OnNetworkReceive(NetPeer peer, NetPacketReader reader, DeliveryMethod deliveryMethod) {
packetProcessor.ReadAllPackets(reader);
}
[Export] public Vector2 initialPosition = new Vector2();
private NetDataWriter writer;
private NetPacketProcessor packetProcessor;
private Dictionary<uint, ServerPlayer> players = new Dictionary<uint, ServerPlayer>();
public override void _Ready() {
writer = new NetDataWriter();
packetProcessor = new NetPacketProcessor();
packetProcessor.RegisterNestedType((w, v) => w.Put(v), reader => reader.GetVector2());
packetProcessor.RegisterNestedType<PlayerState>();
packetProcessor.SubscribeReusable<JoinPacket, NetPeer>(OnJoinReceived);
// ...
}
public void SendPacket<T>(T packet, NetPeer peer, DeliveryMethod deliveryMethod) where T : class, new() {
if (peer != null) {
writer.Reset();
packetProcessor.Write(writer, packet);
peer.Send(writer, deliveryMethod);
}
}
public void OnJoinReceived(JoinPacket packet, NetPeer peer) {
GD.Print($"Received join from {packet.username} (pid: {(uint)peer.Id})");
ServerPlayer newPlayer = (players[(uint)peer.Id] = new ServerPlayer {
peer = peer,
state = new PlayerState {
pid = (uint)peer.Id,
position = initialPosition,
},
username = packet.username,
});
SendPacket(new JoinAcceptPacket { state = newPlayer.state }, peer, DeliveryMethod.ReliableOrdered);
}
public void OnNetworkReceive(NetPeer peer, NetPacketReader reader, DeliveryMethod deliveryMethod) {
packetProcessor.ReadAllPackets(reader, peer);
}
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) {
if (peer.Tag != null) {
players.Remove((uint)peer.Id);
}
}
NetDataReader
새로운 실례를 만드는 것이 아니라 같은 패키지 클래스의 실례를 다시 사용할 것입니다. 따라서 그 실례나 내용에 대한 인용을 저장하지 마십시오!이것은 제작packetProcessor.SubscribeReusable
구조의 또 다른 장점이기 때문에 우리는 그것을 쉽게 복제할 수 있다.PlayerState
를 사용하여 구조를 정의한 경우 INetSerializable
를 사용하여 등록해야 합니다.packetProcessor.RegisterNestedType<YourType>
(가장 안전한 것) 을 사용하고 싶지만, 빠른 업데이트는 DeliveryMethod.ReliableOrdered
를 사용해야 합니다. 그 중 한두 개는 버려진 패키지가 중요하지 않습니다.DeliveryMethod.Unreliable
, 서버는 적당한 packetProcessor.ReadAllPackets
로 응답합니다.설명 파일 예
재생기 업데이트
인터넷을 통해 유저 정보를 보내기 위해서 우리는 우선 유저 클래스가 필요하다.너는 이미 너의 게임이 하나 있을 것이다.
public class Player : Node2D {
[Export] public float moveSpeed = 200;
public static Player instance;
public override void _Ready() {
instance = this;
}
public override void _Process(float delta) {
Vector2 velocity = new Vector2();
if (Input.IsActionPressed("ui_left")) velocity.x -= 1;
if (Input.IsActionPressed("ui_right")) velocity.x += 1;
if (Input.IsActionPressed("ui_up")) velocity.y -= 1;
if (Input.IsActionPressed("ui_down")) velocity.y += 1;
Position += velocity * moveSpeed * delta;
}
}
어떤 비평범한 게임도 클라이언트 게이머의 모든 처리 논리가 필요하지 않기 때문에 다른 게이머를 표시하기 위해 별도의 클래스가 필요할 수 있습니다.
public class RemotePlayer : Node2D {}
이제 더 많은 패킷을 정의해 보겠습니다.
public class PlayerSendUpdatePacket {
public Vector2 position { get; set; }
}
public class PlayerReceiveUpdatePacket {
public PlayerState[] states { get; set; }
}
public class PlayerJoinedGamePacket {
public ClientPlayer player { get; set; }
}
public class PlayerLeftGamePacket {
public uint pid { get; set; }
}
// ...
public struct ClientPlayer : INetSerializable {
public PlayerState state;
public string username;
public void Serialize(NetDataWriter writer) {
state.Serialize(writer);
writer.Put(username);
}
public void Deserialize(NetDataReader reader) {
state.Deserialize(reader);
username = reader.GetString();
}
}
우리는 인터넷을 통해 발송할 것이기 때문에JoinPacket
, 우리는 그것을 구조로 전환하고 실현할 것이다JoinAcceptPacket
.
이제 이러한 패킷을 보내려면 다음과 같이 하십시오.
public override void Connect() {
// ...
packetProcessor.RegisterNestedType<ClientPlayer>();
packetProcessor.SubscribeReusable<PlayerReceiveUpdatePacket>(OnReceiveUpdate);
packetProcessor.SubscribeReusable<PlayerJoinedGamePacket>(OnPlayerJoin);
packetProcessor.SubscribeReusable<PlayerLeftGamePacket>(OnPlayerLeave);
// ...
}
public override void _Process(float delta) {
if (client != null) {
client.PollEvents();
if (Player.instance != null) {
SendPacket(new PlayerSendUpdatePacket { position = Player.instance.Position }, DeliveryMethod.Unreliable);
}
}
}
public void OnJoinAccept(JoinAcceptPacket packet) {
// ...
Player.instance.Position = player.state.position;
}
public void OnReceiveUpdate(PlayerReceiveUpdatePacket packet) {
foreach (PlayerState state in packet.states) {
if (state.pid == player.state.pid) {
continue;
}
((RemotePlayer)Player.instance.GetParent().GetNode(state.pid.ToString())).Position = state.position;
}
}
public void OnPlayerJoin(PlayerJoinedGamePacket packet) {
GD.Print($"Player '{packet.player.username}' (pid: {packet.player.state.pid}) joined the game");
RemotePlayer remote = (RemotePlayer)((PackedScene)GD.Load("res://scenes/RemotePlayer.tscn")).Instance();
remote.Name = packet.player.state.pid.ToString();
remote.Position = packet.player.state.position;
Player.instance.GetParent().AddChild(remote);
}
public void OnPlayerLeave(PlayerLeftGamePacket packet) {
GD.Print($"Player (pid: {packet.pid}) left the game");
((RemotePlayer)Player.instance.GetParent().GetNode(packet.pid.ToString())).QueueFree();
}
public override void _Ready() {
// ...
packetProcessor.RegisterNestedType<ClientPlayer>();
packetProcessor.SubscribeReusable<PlayerSendUpdatePacket, NetPeer>(OnPlayerUpdate);
// ...
}
public override void _Process(float delta) {
// ...
PlayerState[] states = players.Values.Select(p => p.state).ToArray();
foreach (ServerPlayer player in players.Values) {
SendPacket(new PlayerReceiveUpdatePacket { states = states }, player.peer, DeliveryMethod.Unreliable);
}
}
public void OnJoinReceived(JoinPacket packet, NetPeer peer) {
// ...
foreach (ServerPlayer player in players.Values) {
if (player.state.pid != newPlayer.state.pid) {
SendPacket(new PlayerJoinedGamePacket {
player = new ClientPlayer {
username = newPlayer.username,
state = newPlayer.state,
},
}, player.peer, DeliveryMethod.ReliableOrdered);
SendPacket(new PlayerJoinedGamePacket {
player = new ClientPlayer {
username = player.username,
state = player.state,
},
}, newPlayer.peer, DeliveryMethod.ReliableOrdered);
}
}
}
public void OnPlayerUpdate(PlayerSendUpdatePacket packet, NetPeer peer) {
players[(uint)peer.Id].state.position = packet.position;
}
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) {
GD.Print($"Player (pid: {(uint)peer.Id}) left the game");
if (peer.Tag != null) {
ServerPlayer playerLeft;
if (players.TryGetValue(((uint)peer.Id), out playerLeft)) {
foreach (ServerPlayer player in players.Values) {
if (player.state.pid != playerLeft.state.pid) {
SendPacket(new PlayerLeftGamePacket { pid = playerLeft.state.pid }, player.peer, DeliveryMethod.ReliableOrdered);
}
}
players.Remove((uint)peer.Id);
}
}
}
참고 사항:
public class Player : Node2D {
[Export] public float moveSpeed = 200;
public static Player instance;
public override void _Ready() {
instance = this;
}
public override void _Process(float delta) {
Vector2 velocity = new Vector2();
if (Input.IsActionPressed("ui_left")) velocity.x -= 1;
if (Input.IsActionPressed("ui_right")) velocity.x += 1;
if (Input.IsActionPressed("ui_up")) velocity.y -= 1;
if (Input.IsActionPressed("ui_down")) velocity.y += 1;
Position += velocity * moveSpeed * delta;
}
}
public class RemotePlayer : Node2D {}
public class PlayerSendUpdatePacket {
public Vector2 position { get; set; }
}
public class PlayerReceiveUpdatePacket {
public PlayerState[] states { get; set; }
}
public class PlayerJoinedGamePacket {
public ClientPlayer player { get; set; }
}
public class PlayerLeftGamePacket {
public uint pid { get; set; }
}
// ...
public struct ClientPlayer : INetSerializable {
public PlayerState state;
public string username;
public void Serialize(NetDataWriter writer) {
state.Serialize(writer);
writer.Put(username);
}
public void Deserialize(NetDataReader reader) {
state.Deserialize(reader);
username = reader.GetString();
}
}
public override void Connect() {
// ...
packetProcessor.RegisterNestedType<ClientPlayer>();
packetProcessor.SubscribeReusable<PlayerReceiveUpdatePacket>(OnReceiveUpdate);
packetProcessor.SubscribeReusable<PlayerJoinedGamePacket>(OnPlayerJoin);
packetProcessor.SubscribeReusable<PlayerLeftGamePacket>(OnPlayerLeave);
// ...
}
public override void _Process(float delta) {
if (client != null) {
client.PollEvents();
if (Player.instance != null) {
SendPacket(new PlayerSendUpdatePacket { position = Player.instance.Position }, DeliveryMethod.Unreliable);
}
}
}
public void OnJoinAccept(JoinAcceptPacket packet) {
// ...
Player.instance.Position = player.state.position;
}
public void OnReceiveUpdate(PlayerReceiveUpdatePacket packet) {
foreach (PlayerState state in packet.states) {
if (state.pid == player.state.pid) {
continue;
}
((RemotePlayer)Player.instance.GetParent().GetNode(state.pid.ToString())).Position = state.position;
}
}
public void OnPlayerJoin(PlayerJoinedGamePacket packet) {
GD.Print($"Player '{packet.player.username}' (pid: {packet.player.state.pid}) joined the game");
RemotePlayer remote = (RemotePlayer)((PackedScene)GD.Load("res://scenes/RemotePlayer.tscn")).Instance();
remote.Name = packet.player.state.pid.ToString();
remote.Position = packet.player.state.position;
Player.instance.GetParent().AddChild(remote);
}
public void OnPlayerLeave(PlayerLeftGamePacket packet) {
GD.Print($"Player (pid: {packet.pid}) left the game");
((RemotePlayer)Player.instance.GetParent().GetNode(packet.pid.ToString())).QueueFree();
}
public override void _Ready() {
// ...
packetProcessor.RegisterNestedType<ClientPlayer>();
packetProcessor.SubscribeReusable<PlayerSendUpdatePacket, NetPeer>(OnPlayerUpdate);
// ...
}
public override void _Process(float delta) {
// ...
PlayerState[] states = players.Values.Select(p => p.state).ToArray();
foreach (ServerPlayer player in players.Values) {
SendPacket(new PlayerReceiveUpdatePacket { states = states }, player.peer, DeliveryMethod.Unreliable);
}
}
public void OnJoinReceived(JoinPacket packet, NetPeer peer) {
// ...
foreach (ServerPlayer player in players.Values) {
if (player.state.pid != newPlayer.state.pid) {
SendPacket(new PlayerJoinedGamePacket {
player = new ClientPlayer {
username = newPlayer.username,
state = newPlayer.state,
},
}, player.peer, DeliveryMethod.ReliableOrdered);
SendPacket(new PlayerJoinedGamePacket {
player = new ClientPlayer {
username = player.username,
state = player.state,
},
}, newPlayer.peer, DeliveryMethod.ReliableOrdered);
}
}
}
public void OnPlayerUpdate(PlayerSendUpdatePacket packet, NetPeer peer) {
players[(uint)peer.Id].state.position = packet.position;
}
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) {
GD.Print($"Player (pid: {(uint)peer.Id}) left the game");
if (peer.Tag != null) {
ServerPlayer playerLeft;
if (players.TryGetValue(((uint)peer.Id), out playerLeft)) {
foreach (ServerPlayer player in players.Values) {
if (player.state.pid != playerLeft.state.pid) {
SendPacket(new PlayerLeftGamePacket { pid = playerLeft.state.pid }, player.peer, DeliveryMethod.ReliableOrdered);
}
}
players.Remove((uint)peer.Id);
}
}
}
계속 전진하다
이것이 바로 Lite NetLib을 사용하여 멀티플레이어 게임을 개발하는 데 필요한 모든 것이다.이 안내서는 사기나 클라이언트 예측을 방지하기 위해 권위 있는 서버를 만드는 등 더 높은 주제를 포함하지 않지만, 이러한 주제는 이 코드가 제공하는 토대에서 실현될 수 있다.
더욱 복잡한 기능의 예시를 에서 찾을 수 있습니다. 이것은 제가 LiteNetLib를 배우고 이 모든 내용을 작성하는 자원 중 하나이기 때문에 통독을 권장합니다.
LiteNetLib의 소스 코드는 대부분의 함수에 대해 문서 주석이 있기 때문에 적어도 한 번 보는 것은 좋은 생각이다.여기에는 이 라이브러리의 많은 기능과 특성을 소개하지 않았다.
본문이 당신에게 도움이 되기를 바랍니다.읽어주셔서 감사합니다!
Reference
이 문제에 관하여(LiteNetLib 시작), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다
https://dev.to/deagahelio/getting-started-with-litenetlib-2fok
텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념
(Collection and Share based on the CC Protocol.)
Reference
이 문제에 관하여(LiteNetLib 시작), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://dev.to/deagahelio/getting-started-with-litenetlib-2fok텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)