5

I am writing a simple client/server chat program with Indy 10. My server (idtcpserver) sends a command to the client, and the client answers, but when more than one client is connected and the server sends a command, all the clients connected send data to the server.

How I can send a command to a specified client and not all?

Rob Kennedy
  • 161,384
  • 21
  • 275
  • 467
user1931849
  • 169
  • 4
  • 19
  • One can implement a chat relatively easily using enterprise messaging ala JMS using topics, i.e. a publish/subscribe communication mechanism between n applications. For Delphi you will though need some library to access JMS. And to be honest, yes I'm involved with such a library but there are others as well. – Jack G. Dec 27 '12 at 12:09
  • You're allowed to mention your library by name and link to it, @J.Gonzalez. You don't need to be coy; when a comment is relevant to the task at hand, it's not spam to promote yourself. – Rob Kennedy Dec 27 '12 at 14:31

2 Answers2

6

Normally in a client/server setup, the client initiates contact and the server responds. Using the events exposed by the IdTCPServer this is always context (connection) specific so you wouldn't have to do anything special.

To initiate contact from the server to the client, you would have to keep track of connected clients and use the connection of the desired client to send it a message. To do so you need a list in which to hold the connected clients and need to implement handlers for the OnConnect and OnDisconnect events.

type
  TForm1 = class(TForm)
  private
    FClients: TThreadList;

procedure TForm1.HandleClientConnect(aThread: TIDContext);
begin
  FClients.Add(aThread);
end;

procedure TForm1.HandleClientDisconnect(aThread: TIDContext);
begin
  FClients.Remove(aThread);
end;

When you want to send data to a specific client, you can do that using the normal methods for sending data over a TCP connection. But first you will need to find the specific client you need in your FClients list.

How you will identify specific clients is entirely up to you. It will depend entirely on the information you exchange between client and server when a client first connects and identifies itself. Having said that the mechanism will be the same regardless of that information.

TIDContext is the ancestor of the TIdServerContext class used by Indy to hold the connection details. You can descend from TIdServerContext to have a place where you can store your own details for a connection.

type
  TMyContext = class(TIdServerContext)
  private
    // MyInterestingUserDetails...

Tell Indy to use your own TIdServerContext descendant using its ContextClass property. You would of course need to do this before activating your server, for example in the OnCreate.

procedure TForm1.HandleTcpServerCreate(Sender: TObject);
begin
  FIdTcpServer1.ContectClass = TMyContext;
end;

And then you can use your own class everywhere you have a TIdContext parameter by casting it to your own class:

procedure TForm1.HandleClientConnect(aThread: TIDContext);
var 
  MyContext: TMyContext;
begin
  MyContext := aThread as TMyContext;
end;

Finding the connection of a specific client then becomes a matter of iterating over your FClients list and checking whether the TMyContext it contains is the one you want:

function TForm1.FindContextFor(aClientDetails: string): TMyContext;
var
  LockedList: TList;
  idx: integer;
begin
  Result := nil;
  LockedList := FClients.LockList;
  try
    for idx := 0 to LockedList.Count - 1 do
    begin
      MyContext := LockedList.Items(idx) as TMyContext;
      if SameText(MyContext.ClientDetails, aClientDetails) then
      begin
        Result := MyContext;
        Break;
      end;
    end;
  finally
    FClients.UnlockList;
  end;

Edit: As Remy points out in comments: for thread safety you should keep the list locked while writing to the client (which is not such a good thing for throughput and performance) or, in Remy's words:

"A better option is to give TMyContext its own TIdThreadSafeStringList for outbound data, and then have that client's OnExecute event write that list to the client when it is safe to do so. Other clients' OnExecute events can then push data into that list when needed."

Marjan Venema
  • 19,136
  • 6
  • 65
  • 79
  • sorry but still wont work. have you got a working example project like this? (that manage the clients) – user1931849 Dec 27 '12 at 14:35
  • 1
    Nope, sorry I don't. And saying "it still won't work" is not doing much to help me (or anyone else) to help you. – Marjan Venema Dec 27 '12 at 14:58
  • ok,for send a command to a specified client is right this code? `procedure TStringServerForm.IdTCPServer1Execute(AContext: TIdContext); var LLine: String; MyContext: TMyContext; begin MyContext:=FindContextFor('ciao'); AContext.Connection.IOHandler.WriteLn('OK'); end;'` – user1931849 Dec 27 '12 at 15:21
  • Yes, that sounds right. When you have the context for the specific client, you can write to the IO handler of its connection, something like `MyContext.Connection.IOHandler.Write(...);` – Marjan Venema Dec 27 '12 at 15:33
  • during the function FindContextFor i saw from the debug that acontext.clientdetails is empty. the problem can be here? `procedure TStringServerForm.IdTCPServer1Connect(AContext: TIdContext); var MyContext: TMyContext; lline:string; begin Memo1.Lines.Add('A client connected'); MyContext := acontext as TMyContext; MyContext.ClientDetails:=lline; LLine := AContext.Connection.IOHandler.ReadLn(TIdTextEncoding.Default); memo1.Lines.Add(LLine); FClients.Add(acontext); end;` – user1931849 Dec 27 '12 at 15:51
  • A TDictionary could be used instead of the O(N) iteration-based search – mjn Dec 27 '12 at 16:29
  • @mjn: of course, but the whole example was to get OP going on how to put a TIdServerContext to work to his advantage, not to show him how to search for things in the least time possible. – Marjan Venema Dec 27 '12 at 16:54
  • @user1931849: Clientdetails was just an example. You will have to decide what members you put in your TIdServerContext descendant and when. Generally you'd assign values in the OnConnect events, or when you first receive details from the client after it connects. If you have any additional questions you should really post them as new questions though. This site is not intended to solve programming challenges in comments. – Marjan Venema Dec 27 '12 at 16:57
  • Identifying the client to send data to is the easy part - The tough part is building an intelligent buffer on the client end which has to read (or listen for) this incoming data. – Jerry Dodge Dec 27 '12 at 19:03
  • @JerryDodge: you think I don't know that? But it seems OP stumbled at the what you call "easy part". – Marjan Venema Dec 27 '12 at 19:21
  • Exactly my point, well, I guess I've just gotten a little too used to session management that it's a no-brainer for me now. – Jerry Dodge Dec 27 '12 at 19:36
  • FYI, the example code provided is not thread-safe. `FindContextFor()` returns a `TIdContext` that could disconnect and disappear before the calling `OnExecute` event has a chance to use it. Also, this code allows multiple `OnExecute` events to write to the same `TIdContext` at the time, which will corrupt communications with that client. A better option is to implement a method that does the writing while the `Contexts` list is still locked. – Remy Lebeau Dec 27 '12 at 21:01
  • A better option is to give `TMyContext` its own `TIdThreadSafeStringList` for outbound data, and then have that client's `OnExecute` event write that list to the client when it is safe to do so. Other clients' `OnExecute` events can then push data into that list when needed. – Remy Lebeau Dec 27 '12 at 21:01
  • @RemyLebeau: Thanks! Hadn't considered thread safety any further than access to the list yet. Added a note to the post. – Marjan Venema Dec 28 '12 at 09:22
6

The only way a command could be sent to all connected clients is if your code is looping through all of the clients sending the command to each one. So simply remove that loop, or at least change it to only send to the specific client that you are interested in.

The best place to send a command to a client, to avoid corrupting the communications with that client due to overlapping commands, is from within that client's own OnExecute event, eg:

procedure TForm1.IdTCPServer1Execute(AContext: TIdContext);
begin
  ...
  if (has a command to send) then
  begin
    AContext.Connection.IOHandler.WriteLn(command here);
    ...
  end;
  ...
end;

If you need to send commands to a client from other threads, then it is best to give that client its own queue of outbound commands and then have that client's OnExecute event send the queue when it is safe to do so. Other threads can push commands into the queue when needed.

type
  TMyContext = class(TIdServerContext)
  public
    ClientName: String;
    Queue: TIdThreadSafeStringList;
    constructor Create(AConnection: TIdTCPConnection; AYarn: TIdYarn; AList: TThreadList = nil); override;
    destructor Destroy; override; 
  end;

constructor TMyContext.Create(AConnection: TIdTCPConnection; AYarn: TIdYarn; AList: TThreadList = nil);
begin
  inherited Create(AConnection, AYarn, AList);
  Queue := TIdThreadSafeStringList.Create;
end;

destructor TMyContext.Destroy;
begin
  Queue.Free;
  inherited Destroy;
end;

procedure TForm1.FormCreate(Sender: TObject);
begin
  IdTCPServer1.ContextClass := TMyContext;
end;

procedure TForm1.SendCommandToClient(const ClientName, Command: String);
var
  List: TList;
  I: Ineger;
  Ctx: TMyContext;
begin
  List := IdTCPServer1.Contexts.LockList;
  try
    for I := 0 to List.Count-1 do
    begin
      Ctx := TMyContext(List[I]);
      if Ctx.ClientName = ClientName then
      begin
        Ctx.Queue.Add(Command);
        Break;
      end;
    end;
  finally
    IdTCPServer1.Context.UnlockList;
  end;
end;

procedure TForm1.IdTCPServer1Connect(AContext: TIdContext);
var
  List: TList;
  I: Ineger;
  Ctx, Ctx2: TMyContext;
  ClientName: String;
begin
  Ctx := TMyContext(AContext);
  ClientName := AContext.Connection.IOHandler.ReadLn;
  List := IdTCPServer1.Contexts.LockList;
  try
    for I := 0 to List.Count-1 do
    begin
      Ctx2 := TMyContext(List[I]);
      if (Ctx2 <> Ctx) and (Ctx.ClientName = ClientName) then
      begin
        AContext.Connection.IOHandler.WriteLn('That Name is already logged in');
        AContext.Connection.Disconnect;
        Exit;
      end;
    end;
    Ctx.ClientName = ClientName;
  finally
    IdTCPServer1.Context.UnlockList;
  end;
  AContext.Connection.IOHandler.WriteLn('Welcome ' + ClientName);
end;

procedure TForm1.IdTCPServer1Disconnect(AContext: TIdContext);
var
  Ctx: TMyContext;
begin
  Ctx := TMyContext(AContext);
  Ctx.ClientName = '';
  Ctx.Queue.Clear;
end;

procedure TForm1.IdTCPServer1Execute(AContext: TIdContext);
var
  Ctx: TMyContext;
  Queue: TStringList;
begin
  Ctx := TMyContext(AContext);
  ...
  Queue := Ctx.Queue.Lock;
  try
    while Queue.Count > 0 do
    begin
      AContext.Connection.IOHandler.WriteLn(Queue[0]);
      Queue.Delete(0);
      ...
    end;
    ...
  finally
    Ctx.Queue.Unlock;
  end;
end;
Remy Lebeau
  • 555,201
  • 31
  • 458
  • 770