文档章节

RabbitMQ学习(2)

江左煤郎
 江左煤郎
发布于 01/17 23:32
字数 4968
阅读 7
收藏 0

1. 生产者客户端

void basicPublish(String exchange, String routingKey, boolean mandatory, boolean immediate, BasicProperties props, byte[] body)

    1. 在生产者客户端发送消息时,首先需要通过交换器和一个路由键来确定要发送到RabbitMQ中的有符合的队列,但是如果依据交换器和路由键无法在RabbitMQ中找到队列时,消息的处理方式取决于在生产者客户端调用发送方法时设置的参数mandatory。

参数mandatory如果设置为false(缺省该参数的重载版本也是默认为false),则表示直接丢弃该消息,如果为true,则RabbitMQ会将该消息返回给生产者客户端生产者客户端此时需要在Channel上添加一个监听器ReturnListener对象,监听该事件的发生

			channel.addReturnListener(new ReturnListener() {
				@Override
				public void handleReturn(int replyCode, String replyText, String exhange, String routingKey, BasicProperties props, byte[] body)
						throws IOException {
					String message=new String(body);
					System.out.println(message);
				}
			});

对于immediate参数,RabbitMQ3.0之后不再支持,使用会报错,必须设置为false表示不使用该功能设置,而是使用TTL和DLX的方法替代。该参数为true表示如果通过交换器和路由键匹配到的每一个队列都没有消费者存在,那么则返回给生产者,否则只要有一个队列有消费者存在,就会投递到该队列中。

    2. 备份交换器:这种交换器的作用就是用来接收无法发送到队列中的消息,也就是参数mandatory如果设置为false,但不想直接丢弃消息,也不想在源码额外添加一个监听器逻辑,则可以使用备份交换器,此时参数mandatory如果设置为false就失效了,消息仍会进入RabbitMQ的队列中,配置示例如下

channel.exchangeDeclare("alternateExchange", "fanout",true);//先声明一个备份交换器
Map<String, Object> arguments=new HashMap<String, Object>();
arguments.put("alternate-exchange", "alternateExchange");//将备份交换器添加进入主交换器的属性集合中
//声明主交换器,同时也会将读取arguments参数中的设置,将alternateExchange作为mainExchange的备胎,
//如果某个生产者客户端通过mainExchange发送的消息无法找到队列,就会进入备胎,进而保存到备胎交换器绑定的队列中
channel.exchangeDeclare("mainExchange", "direct",true,false,arguments);
//然后可以给主交换器和备份交换器各绑定几个队列,这样即使没有发送到正确的队列中,也保证消息不会丢失

注意,当消息从主交换器发送到备份交换器时,路由键不会改变,也就是说,当备份交换器接收到消息准备发送给自己所绑定的队列上时,还要保证从备份交换器到队列上的路由键与主交换器到主交换器的队列上的路由键一致(备份交换器为direct类型)或相匹配(备份交换器为topic类型),否则消息仍会丢失,所以一般建议备份交换器设置为fanout,这样无论什么路由键的消息都不会丢失。

    3. 设置消息过期时间(TTL):设置消息的过期时间,如果消息在队列内保存时间超过该阈值,就会变为死亡消息(死信),此时消息将无法被发送到当前队列的消费者客户端,有两种设置方式,一起使用时取决于时间短的哪一个,一种是在队列层面设置队列中的所有消息具有相同的过期时间,另一种就是对单条消息设置过期时间,如果设置过期时间为0,除非这条消息到达队列后会被立刻发送到消费者,否则会被丢弃

  • 设置队列中每个消息过期时间:在声明队列时,添加一对key/value参数,key为“x-message-ttl”,value为long型数据,单位为毫秒。
    Map<String, Object> arguments=new HashMap<String, Object>();
    arguments.put("x-message-ttl", 10000);
    channel.queueDeclare("queue", true, false, false, arguments);

     

  • 设置单条消息过期时间:在发送消息时,设置basicPublish方法中的 BasicProperties props参数
    channel.basicPublish(exchange, "com.rabbitmq.cn", new BasicProperties().builder().expiration("10000").build(), message.getBytes());

     

    4. 设置队列过期时间:删除一定时间内未被使用过的队列,“未被使用”指的是队列上没有任何消费者,也没有重新声明;RabbitMQ重启后会重置队列的过期时间(如果重启前没有到期删除);过期队列会被删除,但不一定立刻删除;在声明队列时,添加一对key/value参数,key为“x-expires”,value为long型数据,单位为毫秒,不能设置为0.

Map<String, Object> arguments=new HashMap<String, Object>();
arguments.put("x-expires", 1000*3600);//表示1小时过期时间
channel.queueDeclare("queue", true, false, false, arguments);

    5. 死信队列与死信交换器(DLX):消息在一个队列中过期之后、或消息被消费者客户端拒绝后(Nack、Reject)、或队列达到最大长度时就会变为死信,私信不会被发送到所在队列消费者客户端,要么丢弃,要么交给另一个交换器,然后由该交换器路由到一个新的队列中进行特殊处理,这种专门发送死信的交换器和队列就是死信交换器和死信队列。如果消息的TTL设置为0,再搭配DLX就可以实现immediate参数相同的功能。设置死信交换器和死信队列的方式如下

			channel.exchangeDeclare("dlx_exchange", "direct");//声明一个交换器,该交换器将作为死信交换器
			channel.queueDeclare("dlx_queue", true, false, false,null);//声明一个队列,该交换器将作为死信队列
			channel.queueBind("dlx_queue", "dlx_exchange", "dlx_message");//绑定队列与交换器
			Map<String, Object> arguments=new HashMap<String, Object>();
			arguments.put("x-dead-letter-exchange", "dlx_exchange");//设置死信交换器
			arguments.put("x-dead-letter-routing-key", "dlx_message");//设置将消息通过死信交换器发送时的路由键,也可以不设置,不设置的话就是原队列的路由键,或者将死信交换器设置为fanout类型
			channel.queueDeclare("normal_queue", true, false, false, arguments);

    6. 延迟队列:延迟队列存储的是延迟消息,这些消息发送到队列中后,不能被立即消费,必须等待其延迟时间过了之后,才可以被消费。常用语定时任务,RabbitMQ并没有真正的延迟队列,而是用TTL加DLX实现这样的效果。

    7. 优先级队列:即队列中的消息具有优先级,优先级高的优先被消费者消费,当然,如果消费者消费速度大于生产者生成速度,优先级也就失去了意义。如果不设置消息的优先级,默认为0

Map<String, Object> arguments=new HashMap<String, Object>();
arguments.put("x-max-priority", 10);//设置队列中消息的最大优先级
channel.queueDeclare("queue", true, false, false, arguments);
channel.basicPublish(exchange, "com.rabbitmq.cn", new BasicProperties().builder().priority(5).build(), message.getBytes());//设置单条消息的优先级
		

    8. RPC(远程过程调用)实现:RPC即远程过程调用,直观点来说就和C/S结构差不多,有两个RabbitMQ客户端,每个客户端既是消费者也是生产者,但分为Client和Server两个类型,Client向Server发出一条消息,该消息通过RabbitMQ发送到Server处理,然后Server再响应一条消息,也通过RabbitMQ发送到Client中。RabbitMQ为RPC的实现提供了两个消息属性,在BasicProperties类的14个属性中,replyTo和correlationId属性可以实现RPC。

    9. RPC通过RabbitMQ的实现原理

  • 首先,Client端肯定要先向RabbitMQ中的一个A队列中发送一条消息,由Server接收,但是,在Client发送时必须要告诉接收这条消息的Server将响应的消息放在哪个队列,这个队列就是回调队列,由replyTo属性设置;
  • 其次,我们不可能为每一次RPC请求都声明一个队列(效率极低,而且浪费资源),每一个Client都应只声明一个回调队列,后续的RPC请求都通过该回调队列,但问题就是Server将响应消息发送到RabbitMQ中的回调队列后,这条消息发送到回调队列对应的Client中时,需要知道该响应对应的是哪一个请求,所以就需要correlationId属性来标记响应消息,将消息发送到对应的Client中后,依据correlationId来找到对应的请求。

实现示例如下

public class Client {
	private static final String req_queue="req_queue";
	public static Connection con=null;
	public static Channel channel=null;
	public static final String host="192.168.10.128";
	public static final int port=5672;
	private static final String USER="root";
	private static final String PASSWORD="123456";
	private static final String req_exchange="req_exchange";
	private static final String req_routingKey="req_routingKey";
	private static Channel channel1 = null;
	static{
		ConnectionFactory factory=new ConnectionFactory();
		factory.setHost(host);
		factory.setPort(port);
		factory.setUsername(USER);
		factory.setPassword(PASSWORD);
		try {
			con=factory.newConnection();
			channel=con.createChannel();//该信道用来向请求队列发送消息
			channel1=con.createChannel();//该信道用来从回调队列消费响应消息
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (TimeoutException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}
	public static void main(String[] args) {
		try {
			call();
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (TimeoutException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}
	
	public static void call() throws IOException, TimeoutException{
		String callback_queue=channel.queueDeclare().getQueue();//由RabbitMQ创建一个回调队列
		Scanner scan=new Scanner(System.in);
		String corId=UUID.randomUUID().toString();//通过UUID来保证每条消息标记的特有
		//创建消费者,接收响应消息
		channel1.basicQos(4);//设置队列中最大未收到消费者端确定消费的消息数量
		Consumer consumer = new DefaultConsumer(channel1) {
			@Override
			public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body)
					throws IOException {
				channel1.basicAck(envelope.getDeliveryTag(), false);
				if (properties.getCorrelationId().equals(corId)) {
					System.out.println("已接收响应消息:" + new String(body, "utf-8"));
				}
			}
		};
		channel1.basicConsume(callback_queue,false, consumer);
		
		//发送请求消息
		BasicProperties props=new BasicProperties().builder().replyTo(callback_queue).correlationId(corId).build();
		while(true){
			String message=scan.nextLine();//从系统输入框输入消息
			channel.basicPublish(req_exchange, req_routingKey, props, message.getBytes());
		}
		
	}
}
public class Server {
	private static final String req_queue = "req_queue";
	public static Connection con = null;
	public static Channel channel = null;//该信道用来消费请求队列中的消息
	private static Channel channel1 = null;//该信道用来向回调队列发送响应消息
	public static final String host = "192.168.10.128";
	public static final int port = 5672;
	private static final String USER = "root";
	private static final String PASSWORD = "123456";
	private static final String req_exchange = "req_exchange";
	private static final String req_routingKey = "req_routingKey";
	static {
		ConnectionFactory factory = new ConnectionFactory();
		factory.setUsername(USER);
		factory.setPassword(PASSWORD);
		try {
			
			con = factory.newConnection(new Address[]{new Address(host, port)});
			channel = con.createChannel();
			channel1=con.createChannel();
			channel.queueDeclare(req_queue, true, false, false, null);// 声明请求消息队列
			channel.exchangeDeclare(req_exchange, "direct");
			channel.queueBind(req_queue, req_exchange, req_routingKey);
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		} catch (TimeoutException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}
	public static void main(String[] args) {
		try {
			get();
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}
	public static void get() throws IOException{
		channel.basicQos(4);
		Consumer consumer=new DefaultConsumer(channel){
			@Override
			public void handleDelivery(String consumerTag, Envelope envelope, BasicProperties properties, byte[] body)
					throws IOException {
				channel.basicAck(envelope.getDeliveryTag(), false);
				String message=new String(body,"utf-8");
				System.out.println(message);
				//接收到消息,处理然后发送响应消息
				channel1.basicPublish("", properties.getReplyTo(), properties, "yes".getBytes());
				
			}
		};
		channel.basicConsume(req_queue,false, consumer);
	}
}

    10. 持久化:当RabbitMQ因为一些异常状况导致关闭或宕机时,通过持久化处理可以防止数据丢失。RabbitMQ中的持久化主要有三种,分别是交换器持久化、队列持久化和消息持久化。

  • 交换器持久化:在声明交换器时设置durable参数值为true即可,如果不设置一个交换器的持久化,在RabbitMQ重启后,交换器会丢失,就无法在使用了,但不会影响消息数据
    Exchange.DeclareOk exchangeDeclare(String exchange,
                                                  String type,
                                                  boolean durable,
                                                  boolean autoDelete,
                                                  boolean internal,
                                                  Map<String, Object> arguments) throws IOException;

     

  • 队列的持久化:在声明队列时,设置durable参数值为true即可,如果不设置一个队列的持久化,RabbitMQ重启后,队列会丢失,同时里面的消息数据也会丢失
    Queue.DeclareOk queueDeclare(String queue, boolean durable, boolean exclusive, boolean autoDelete,
                                     Map<String, Object> arguments) throws IOException;

     

  • 消息的持久化:队列的持久化能够保证自身不会丢失,但是里面的消息数据就不一定了,如果消息不是持久化的,RabbitMQ重启后消息仍然会丢失,要设置消息的持久化,就在发送消息时(basicPublish)设置props参数中的deliveryMode属性值为2即可。但是要注意,一定要将队列和消息同时设置持久化才能保证消息的持久化,对于消息的是否持久化一定要考虑清楚,如果消息不需要很高的可靠性,那么持久化一般不要使用,否则会严重影响RabbitMQ的性能,因为消息数据写入磁盘的速度远低于写入内存,如果大量的消息都需要写入磁盘,那么将会严重影响RabbitMQ的吞吐量
    void basicPublish(String exchange, String routingKey, BasicProperties props, byte[] body) throws IOException;
    
    props=new AMQP.BasicProperties().builder().deliveryMode(2).build();

实际上,即使交换器、队列、消息都正常的设置为持久化之后,仍然不能保证不会丢失:

首先,消息到达RabbitMQ之后,并不是立刻就持久化,会在很短一段时间后持久化,所以如果在这段时间内消息数据还未存盘RabbitMQ宕机或重启就会发生数据丢失,解决办法就是将RabbitMQ进行集群化部署,使用RabbitMQ的镜像队列机制,在主节点挂掉之后,从节点迅速顶替(和Redis一样),虽然仍然无法保证数据不会丢失,但极大的提高了可靠性和高可用性;

另一种情况就是消费消息时,设置autoAck参数为true,这样就会导致消费者可能还没来得及处理消息就发生了宕机,这也算数据丢失。在生产者客户端,还有消息发送事务机制和消息发送确认机制,来保证消息正确发送到RabbitMQ中。

在生产者端,如果在发送消息后(注意,这里消息发送的目标交换器和队列是在RabbitMQ中存在的,也就是说如果消息),消息还未到达RabbitMQ时,RabbitMQ就异常挂掉,那么此时消息就会丢失,所以消息发送事务机制和消息发送确认机制就为了避免这种情况的发生

    11. 消息发送事务机制:相关方法有三个,通过Channel对象的txSelect()方法(开启事务)、txCommit()方法(提交事务)、txRollback()方法(回滚事务)来实现,开启事务之后(一个信道中事务只需要开启一次就行),就可以发送消息到RabbitMQ,发送完毕后,提交事务,如果提交成功,那么消息一定成功的到达了RabbitMQ,如果在发送消息或提交时因为RabbitMQ的异常导致发生异常导致无法接收消息,可以将该异常捕获然后进行事务回滚,同时也可以将消息保存或者进行重发,如果开启事务模式之后发送消息,不进行事务提交或者事务回滚了,那么消息不会被发送,和数据库中的事务机制类似。如果需要批量发送消息,也可以一次性发送,然后再提交事务。消息事务机制确实可以保证消息的正确发送,但在提交或回滚时会比较影响性能,而且如果本来有一部分消息是可以成功发送的,一旦事务回滚仍然会发送失败,重新发送所有消息太浪费。

			try {
				channel.txSelect();
				channel.basicPublish(exchange_demo, "com.rabbitmq.cn", MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
				channel.txCommit();
			} catch (Exception e) {
				channel.txRollback();
			}
//批量发送消息
			channel.txSelect();
			//如果需要的话,可以事先将批量消息放在一个缓存容器中
			for (int i = 0; i <= 10; i++) {
				try {
					channel.basicPublish(exchange_demo, "com.rabbitmq.cn", MessageProperties.PERSISTENT_TEXT_PLAIN,
							(i + "").getBytes());
					if (i == 10) {
						channel.txCommit();
						//发送成功,清空缓存容器
					}
					TimeUnit.SECONDS.sleep(2);
				} catch (Exception e) {
                    //发送失败,将缓存容器中的消息准备重新发送
					System.out.println("事务回滚");
					channel.txRollback();
					e.printStackTrace();
				}
			}

    12. 消息发送方确认机制:首先,生产者中通过Channel对象的confirmSelect()方法,将信道设置为confirm模式,一旦信道进入confirm模式,在该信道上发送的所有消息都会有一个唯一的ID值(从1开始,每发送一条就会+1),当消息被发送到匹配的队列后RabbitMQ就会返回一个确认消息(包括消息ID)给生产者,如果消息和队列时持久化的,那么确认消息会在持久化完成之后在发送给生产者。发送方确认机制有一个最大的好处就是可以实现异步确认,相较于事务机制,具有更好的性能。但是,该机制不会和事务机制一样具有原子性,即在批量发送时要么全部发送失败要么全部发送成功,而是可能会一半消息发送成功一般消息发送失败的情况。

  • 普通确认模式:该模式和事务机制相似,发送消息,然后等待确认消息返回。注意调用waitForConfirms方法(该方法也是一个同步阻塞方法,必须等待RabbitMQ响应之后才能继续执行)时必须保证信道以被置为confirm模式。waitForConfirms方法原理是其维护着一个SortedSet容器,发送的每一个消息的ID(deliveryTag)都会进入该容器中,调用waitForConfirms方法时,如果RabbitMQ返回的确认接收的消息(该消息中会包含发送给RabbitMQ的原消息的ID),那么就删除SortedSet容器中的一条ID(multiple为false)或者删除该ID和排在该ID之前的所有ID(multiple为true),否则就不删除
    			//发送一条消息
    			String message="Hello World";
    			channel.confirmSelect();
    			try {
    				channel.basicPublish(exchange_demo, "com.rabbitmq.cn", MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
    				if(!channel.waitForConfirms()){
    					System.out.println("消息发送失败");
    					//在这里可以进行其他操作,比如将发送失败的消息放进另一个队列里保存
    				}
    			} catch (Exception e) {
    				// TODO: handle exception
    			}

     

  • 批量确认模式:和事务机制相同,也是批量发送消息之后,在进行确认;该方式比事务机制批量提交性能更好,因为如果需要重新发送未发送成功的消息,可以添加一个缓存容器(比如ArrayList)来保存那些发送失败的消息,这样发送成功的消息就不用二次发送,而且本身waitForConfirms方法的耗时比事务提交或回滚要更少
  • 异步确认模式:应该使用该方式,需要在Channel中添加一个confirm监听器ConfirmListener对象,ConfirmListener接口中只有两个方法,handleNack(long deliveryTag, boolean multiple)和handleAck(long deliveryTag, boolean multiple),分别用来处理消息被拒绝接收和消息确认接收的方法,这两个方法和waitForConfirms()方法一样,也需要维护一个SortedSet消息ID集合,原理和上面说的waitForConfirms()方法一样
    			SortedSet<Long> set=new TreeSet<Long>();
    			channel.confirmSelect();
    			channel.addConfirmListener(new ConfirmListener() {
    
    				@Override
    				public void handleNack(long deliveryTag, boolean multiple) throws IOException {
    					// TODO Auto-generated method stub
    					System.out.println("被拒绝的消息ID为:"+deliveryTag);
    					if(multiple){
    						set.headSet(deliveryTag-1).clear();//删除该消息ID以及之前的所有ID
    					}else{
    						set.remove(deliveryTag);//删除该消息ID
    					}
    					//可以在这里添加消息重新发送的场景
    				}
    				
    				@Override
    				public void handleAck(long deliveryTag, boolean multiple) throws IOException {
    					// TODO Auto-generated method stub
    					System.out.println("被确认接收的消息ID为:"+deliveryTag);
    					if(multiple){
    						set.headSet(deliveryTag-1).clear();//删除该消息ID以及之前的所有ID
    					}else{
    						set.remove(deliveryTag);//删除该消息ID
    					}
    				}
    			});
    			while(true){
    				long nextId=channel.getNextPublishSeqNo();//获取下一个id值
    				channel.basicPublish(exchange_demo, "com.rabbitmq.cn", MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
    				set.add(nextId);
    			}

     

注意,发送端消息确认机制和事务确认机制,不能共存,也就是说同一个Channel上不能同时开启事务模式(txSelect方法)和发送端确认模式(confirmSelect方法);本身事务机制和发送者确认机制的性能差距较大(事务机制完整执行一次提交或回滚命令的耗时长的多),而且发送者确认模式支持异步处理,这就使发送者确认模式具有更好的性能,也更常用。

© 著作权归作者所有

共有 人打赏支持
上一篇: RabbitMQ学习(3)
下一篇: RabbitMQ学习(1)
江左煤郎
粉丝 26
博文 86
码字总数 220642
作品 0
西安
后端工程师
私信 提问
Rabbitmq学习之路2-rabbitmqctl

学习rabbitmq,原理之后第一个要掌握的就是rabbitmqctl这个命令的用法了,rabbitmq的管理功能最全的就是rabbitmqctl命令了,当然还有HTTP API和UI两种管理手段。 rabbitmqctl的使用方法: ra...

China_OS
2013/12/21
0
0
rabbitmq-server 安装

一,安装rabbitmq-server 1.安装erlang wget https://packages.erlang-solutions.com/erlang-solutions-1.0-1.noarch.rpm rpm -Uvh erlang-solutions-1.0-1.noarch.rpm rpm --import https:/......

丿小贰丶
2018/05/08
0
0
zabbix自动发现rabbitmq

参考文档 http://blog.csdn.net/qq29778131/article/details/52537288?ticket=ST-77459-cUGNcZF1BJBtNuZoZe1i-passport.csdn.net #python脚本 一,实现功能 实现自动发现rabbitmq queue,并监......

typuc
2018/06/26
0
0
RabbitMQ 3.6.3 RC 2 发布

RabbitMQ 3.6.3 RC2 发布了,RabbitMQ 是由 LShift 提供的一个 Advanced Message Queuing Protocol (AMQP) 的开源实现,由以高性能、健壮以及可伸缩性出名的 Erlang 写成,因此也是继承了这些...

oschina
2016/07/01
782
2
#DDBMS#rabbitmq安装

考虑了下,决定以可靠性为前提,在DDBMS低层支撑架构中用上rabbitmq,像OpenStack一样以RPC为主交互 安装rabbitmq: apt-get install rabbitmq-server 安装pika库: pip install pika 相关的...

Hochikong
2015/02/28
0
0

没有更多内容

加载失败,请刷新页面

加载更多

Objective-C中的associated object释放时机问题

如果对象A持有对象B,B作为A的associated object,并且表面上B没有其他被强引用的地方,那么对象A被释放时,对象B一定会同时释放吗?大部分情况下是,但真有不是的时候。最近实现代码的时候不...

阿里云官方博客
15分钟前
0
0
12_第一个Flutter程序

使用 package 在这一步中,你将开始使用一个名为 english_words 的开源软件包,其中包含数千个最常用的英文单词以及一些实用功能。 你可以 在 pub.dartlang.org 上找到 english_words 软件包...

口十耳
43分钟前
6
0
【Maven冷知识】Compiler插件

很多同学在pom的配置中都喜欢加上这样一段配置信息: 从配置信息上看,这是maven对Java源代码进行的编译配置,采用了Java 7 进行编译,但是为什么要加上这段配置呢?不加有没有什么影响?很多...

算法与编程之美
48分钟前
2
0
磊哥测评之数据库SaaS篇:腾讯云控制台、DMC和小程序

本文由云+社区发表 作者:腾讯云数据库 随着云计算和数据库技术的发展,数据库正在变得越来越强大。数据库的性能如处理速度、对高并发的支持在节节攀升,同时分布式、实时的数据分析、兼容主...

腾讯云加社区
50分钟前
2
0
Visual Studio系列教程:使用XAML工具创建用户界面(二)

Visual Studio是一款完备的工具和服务,可帮助您为Microsoft平台和其他平台创建各种各样的应用程序。在本系列教程中将介绍如何为图像编辑创建基本的用户界面,有任何建议或提示请在下方评论区...

ymy_666666
51分钟前
2
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部