保存成功
保存失败,请重试
提交成功

如何实现微信公众号本地调试

向作者提问
李熠,中国石油规划总院高级开发工程师,对Java编码规范和编码技巧有着独特的见解,热衷微服务架构,曾作为中小型企业CTO,带领过超过30人的技术团队。 著有《Spring Cloud实战演练》一书
查看本场Chat

前言

本文的本章标题是如何实现微信公众号本地化调试,其实本文的主题远不止这些,作者写本文的目的是为了解决一些大型企业或者大型国企对于网络环境的限制,以及我们如何突破这些限制而完成我们的工作。

本文以微信公众号调试为例,告诉大家,如何在内网环境进行微信公众号的调试,包括一些企业对一些端口比如 3389 的禁用,如何绕过这些限制,进而远程控制服务器。

我们开发微信公众号,需要在公众号后台设置一些安全域名,而微信公众号规定:必须有已经备案的域名,并且只支持 80 和 443 端口。

如果我们在内网环境,公众号是无法调用回来的,而一些大型企业往往会有一个称之为安全域的服务器,只有该服务器可以对外访问,并且映射域名,其有一个内网 IP 和访问到我们的内网。而对于前后端架构来说,前端部署在安全域,通过域名来访问,但是当用户在外网访问前端界面时,如果指定到内网的后端接口地址,是访问不通的。

这时,我们就想到需要在安全域部署一个代理服务器转到内网地址。对于一些大型企业尤其是国企,是禁止使用类似 nginx 的反向代理软件的。鉴于这种情况,我们首先想到的就是自己实现一个代理功能。

基本实现原理为

利用 netty 的长连接机制在安全域服务器和内网服务器之间建立一个长连接通道,通过 TCP 协议接受外部的请求,将外部的请求流原样发送给内网服务器,而内网服务器处理完请求后,再将返回的数据发送给安全域的代理应用,代理应用再原样返回给客户端,这样我们就能利用在安全域部署的代理服务器间接地请求到内网地址。

说了这么多废话,那到底是怎样实现的呢,下面我们就来看一看代理服务器的核心代码(全部代码已上传,请自行下载):

1.先创建一个 maven 工程,并添加依赖:
<properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <lombok.version>1.18.0</lombok.version>
    </properties>


    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.0.3.RELEASE</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <!--web组件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--测试组件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!--配置处理组件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
        </dependency>

        <!--热部署组件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
        </dependency>
        <!--网络应用程序框架 -->
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
2.创建 SpringBoot 启动类:
@SpringBootApplication
public class StartProgram {

    public static void main(String[] args) {
        SpringApplication.run(StartProgram.class, args);
    }
}
3.创建配置文件 application.yml:
app-config:
  #尝试重连间隔时间(单位:毫秒)
  interval: 1000
  proxy[0]:
    host: 127.0.0.1
    port: 8080
  port: 8088
server:
    # WebServer bind port
    port: 9010
4.开始编写基于 netty 的核心代码,在应用启动后,需要同时启动 netty 代理服务器

连接代码如下:

@Async("frontendWorkTaskExecutor")
    public Future<Boolean> initProxyServer() {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(frontendPipeline)
                    .childOption(ChannelOption.AUTO_READ, false);
            ChannelFuture f = b.bind(appConfig.getPort()).sync();
            System.out.println("启动代理服务,端口:" + ((InetSocketAddress) f.channel().localAddress()).getPort());
            f.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            LOGGER.debug("代理服务关闭!");
        } catch (Exception e) {
            LOGGER.error("代理服务启动失败!", e);
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
        return new AsyncResult<>(true);
    }

然后编写接收客户端的请求 Handler 和从目标服务器返回的数据 Handler:

@Component
@Scope(scopeName = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ProxyFrontendHandler extends SimpleChannelInboundHandler<byte[]> {

    private static final Logger log = LoggerFactory.getLogger(ProxyFrontendHandler.class);

    // 代理服务器和目标服务器之间的通道(从代理服务器出去所以是outbound过境)
//    private volatile ChannelGroup allChannels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
    @Autowired
    private AppConfig appConfig;

    private volatile Queue<ChannelGroup> queue;

    private volatile boolean frontendConnectStatus = false;


    /**
     * Closes the specified channel after all queued write requests are flushed.
     */
    public static void closeOnFlush(Channel ch) {
        if (ch.isActive()) {
            ch.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
        }
    }

    /**
     * 当客户端和代理服务器建立通道连接时,调用此方法
     *
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        frontendConnectStatus = true;
        SocketAddress clientAddress = ctx.channel().remoteAddress();
        log.info("客户端地址:" + clientAddress);
        List<Proxy> proxy = appConfig.getProxy();
        if(null == queue){
            queue = new ArrayBlockingQueue<>(proxy.size());
        }
        /**
         * 客户端和代理服务器的连接通道 入境的通道
         */
        Channel inboundChannel = ctx.channel();
        proxy.stream().forEach(item -> createBootstrap(inboundChannel, item.getHost(), item.getPort()));
    }

    /**
     * 在这里接收客户端的消息 在客户端和代理服务器建立连接时,也获得了代理服务器和目标服务器的通道outbound,
     * 通过outbound写入消息到目标服务器
     *
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    public void channelRead0(final ChannelHandlerContext ctx, byte[] msg) throws Exception {

        log.info("客户端消息");
        ChannelGroup channels = queue.poll();
        channels.writeAndFlush(msg).addListener((ChannelGroupFutureListener)future -> ctx.channel().read());
        queue.add(channels);

    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        log.info("代理服务器和客户端断开连接");
        frontendConnectStatus = false;
        ChannelGroup channels = queue.poll();
        if(null != queue){
            channels.close();
            queue.add(channels);
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        log.error("发生异常:", cause);
        ctx.channel().close();
    }

    public void createBootstrap(final Channel inboundChannel, final String host, final int port) {
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(inboundChannel.eventLoop());
            bootstrap.channel(NioSocketChannel.class);
            bootstrap.handler(new BackendPipeline(inboundChannel, ProxyFrontendHandler.this, host, port));
            ChannelFuture f = bootstrap.connect(host, port);
            f.addListener((ChannelFutureListener)future -> {
                if (future.isSuccess()) {
                    ChannelGroup allChannels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
                    allChannels.add(future.channel());
                    queue.offer(allChannels);
                } else {
                    if (inboundChannel.isActive()) {
                        log.info("Reconnect");
                        final EventLoop loop = future.channel().eventLoop();
                        loop.schedule(()->ProxyFrontendHandler.this.createBootstrap(inboundChannel, host, port), appConfig.getInterval(), TimeUnit.MILLISECONDS);
                    } else {
                        log.info("notActive");
                    }
                }
                inboundChannel.read();
            });

        } catch (Exception e) {

        }
    }

    public boolean isConnect() {
        return frontendConnectStatus;
    }

}
public class ProxyBackendHandler extends SimpleChannelInboundHandler<byte[]> {
    private static final Logger LOGGER = LoggerFactory.getLogger(ProxyBackendHandler.class);

    private Channel inboundChannel;
    private ProxyFrontendHandler proxyFrontendHandler;
    private String host;
    private int port;

    public ProxyBackendHandler(Channel inboundChannel, ProxyFrontendHandler proxyFrontendHandler, String host,
            int port) {
        this.inboundChannel = inboundChannel;
        this.proxyFrontendHandler = proxyFrontendHandler;
        this.host = host;
        this.port = port;
    }

    // 当和目标服务器的通道连接建立时
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        LOGGER.info("服务器地址:" + ctx.channel().remoteAddress());
    }

    /**
     * msg是从目标服务器返回的消息
     *
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    public void channelRead0(final ChannelHandlerContext ctx, byte[] msg) throws Exception {
        LOGGER.info("服务器返回消息");
        /**
         * 接收目标服务器发送来的数据并打印 然后把数据写入代理服务器和客户端的通道里
         */
        // 通过inboundChannel向客户端写入数据
        inboundChannel.writeAndFlush(msg).addListener((ChannelFutureListener)future -> {
            if (!future.isSuccess()) {
                future.channel().close();
            }
        });
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        LOGGER.info("关闭服务器连接");
        if (proxyFrontendHandler.isConnect()) {
            proxyFrontendHandler.createBootstrap(inboundChannel, host, port);
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        LOGGER.error("发生异常:", cause);
        ctx.channel().close();
    }
}

我们通过 ProxyFrontendHandler 将接收到的客户端请求发送给目标服务器,而在 ProxyBackendHandler 将目标服务器返回的数据拿到返回给客户端。通过这样的一个流程,就能实现外网客户端请求到内网地址的这样一个需求。

5.编译并打包工程,在安全域服务器启动:
java -jar --server.port=9999 proxy.jar --app-config.port=8383 --app-config.proxy[0].host=10.120.133.39 --app-config.proxy[0].port=3389

其中,server.port 为应用启动端口,app-config 为代理服务器启动端口,app-config.proxy[0] 为目标服务器的IP和端口,可以指定多个。

因为我们通过 netty 实现的是一个 TCP 长连接,他的作用不止于转发 http 请求,他可以代理任何一个网络请求,比如公司对 3389 端口禁用了,而远程服务器的端口为 3389,那么,我们可以找一个能连接上 3389 端口的主机,将代理服务器部署到该电脑上,设置代理服务器端口,如上面指定的为 8383,我们就可以通过 8383 间接地连接到目标服务器。


本文首发于GitChat,未经授权不得转载,转载需与GitChat联系。

互动评论
评论
PHP1101 年前
是不是还有其他简单的方法?比如ngrok、还有国人实现的一个类似软件。
评论
李熠lynn(作者)1 年前
有是有,但大多要收费,免费的也不稳定,得靠自己😂😂
评论
查看更多
微信扫描登录